Skip to content

hessian: the two nested dual layers share a tag, so results depend on the code path and chunk size #845

Description

@devmotion

ForwardDiff.hessian evaluates f on nested duals that carry the same tag at both layers, Dual{T,Dual{T,V,N},N}HessianConfig passes one tag = Tag(f, V) to both of its inner configs (src/config.jl#L221-L227) and Base.eltype reflects that (src/config.jl#L256).

The two layers are therefore type-indistinguishable, so ForwardDiff.value / ForwardDiff.partials called inside f cannot tell them apart, and the tag-ordering machinery (, DualMismatchError) has nothing to compare. Which layer gets peeled then depends on how a given code path happens to nest its evaluations.

1. One function, three Hessians and two gradients

The layer is dropped from an intermediate here: ForwardDiff.value(z[1]) is order 1, while f's return value is a full order-2 Dual{T,Dual{T,V,N},N}. Extraction therefore has both layers to read and nothing about the result type looks wrong — the damage is that the one-layer intermediate is re-absorbed at the inner layer on its way back up, the shared tag making the two indistinguishable.

using ForwardDiff, StaticArrays, DiffResults
f(z) = sum(abs2, z) + ForwardDiff.value(z[1]) * z[2]
x  = [1.0, 2.0, 3.0]
sx = SVector(1.0, 2.0, 3.0)

ForwardDiff.hessian(f, x)                                                           # H[1,2]=1.0  H[2,1]=0.0
ForwardDiff.hessian(f, x, ForwardDiff.HessianConfig(f, x, ForwardDiff.Chunk{1}()))  # H[1,2]=0.0  H[2,1]=0.0
ForwardDiff.hessian(f, sx)                                                          # H[1,2]=0.0  H[2,1]=1.0

DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(x),  f, x))     # [4.0, 5.0, 6.0]
DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(sx), f, sx))    # [2.0, 5.0, 6.0]

Three Hessians and two gradients for the same function, varying with the chunk size, the array type and the result type.

2. A silently zeroed gradient

Here the output itself is order 1, so extraction has no outer layer to read, and the surviving first-order partials are discarded rather than used:

g(z) = ForwardDiff.value(sum(abs2, z))
DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(x),  g, x))   # [2.0, 4.0, 6.0]
DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(sx), g, sx))  # [0.0, 0.0, 0.0]
ForwardDiff.gradient(z -> sum(abs2, z), x)                                        # [2.0, 4.0, 6.0]

hessian!(::ImmutableDiffResult, f, ::StaticArray) builds dualize(T, dualize(T, x)) and extracts with nested value(T, ·). Because both layers share T, value(T, ·) peels a lone Dual{T,V,N} down to a plain Float64, and the following partials(T, ·, i) hits the zero(x) fallback. The zero Hessian is correct — no second-order information survived — but the gradient should be [2.0, 4.0, 6.0].

Cause and suggested fix

The two layers need distinct tags: an inner T = Tag(f, V) and an outer TO derived from it, so that T ≺ TO. Then value(TO, ::Dual{T}) correctly leaves an inner-layer value alone, and partials(TO, ::Dual{T}) is zero. Both cases above come out right with no special-casing, and the result stops depending on the chunk size.

The paths that already use two tags give the consistent answer today: hessian(f, ::StaticArray) goes through jacobian(Base.Fix1(gradient, f), x), which builds a fresh tag for each level.

Version: ForwardDiff master (v1.4.5), Julia 1.12.7, StaticArrays 1.9.19.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions