Skip to content

Extract a gradient the result cannot carry as zero - #848

Open
devmotion wants to merge 1 commit into
masterfrom
dmw/extract_gradient
Open

Extract a gradient the result cannot carry as zero#848
devmotion wants to merge 1 commit into
masterfrom
dmw/extract_gradient

Conversation

@devmotion

Copy link
Copy Markdown
Member

Fixes #847.

extract_gradient! dispatched on a bare ::Dual. A Dual{S,V,N} with S ≺ T carries no T-perturbation, so its gradient is zero everywhere and the ::Real method is what it needs — but it is a Dual, so it took the ::Dual method, where npartials reports the N of the wrong layer. Whenever that N was smaller than structural_length(x) the tail of the result was never written:

julia> x = [1.0, 2.0, 3.0];

julia> ForwardDiff.derivative(1.0) do a
           out = fill(a * 111.0, 3)               # a buffer with recognisable junk
           ForwardDiff.gradient!(out, z -> a * 2.0, x)
           @show ForwardDiff.value.(out)          # [0.0, 111.0, 111.0]
           return zero(a)
       end

The allocating form returned uninitialized memory, which is often zero and so hides the bug, and the DiffResult form passed partials(T, dual) — a scalar Dual rather than a Partials — to DiffResults.gradient! and errored.

gradient! into a plain result was affected for a StaticArray too, since it shares extract_gradient! through vector_mode_gradient!. Only the allocating gradient was correct, because its @generated extract_gradient is built from length(x) and uses the indexed partials(T, y, i).

Fix

Dispatch on Dual{T}, as #844 does for the Hessian on Dual{TO,<:Dual{T}}, so a result carrying only an enclosing tag falls through to the ::Real method, which already fills the whole result. Dual{T} <: Real, so this is a strict narrowing with no new ambiguity.

The two ::Real methods take their value from value(T, y) rather than y. For a plain Real and for a Dual{S} with S ≺ T that is the identity, and it keeps rejecting a tag with no such relation, which the ::Dual methods did through partials(T, dual, i). Chunk mode rejects it regardless — from partials(T, dual, i), from similar(x, valtype(T, ydual)), and from extract_value!'s value(T, d) for a DiffResult — so vector mode has to agree or the result would depend on the chunk size.

Chunk mode needed no change: extract_gradient_chunk! is bounded by chunksize rather than npartials, and partials(T, ::Dual{S}, i) already answers zero for every position. jacobian and derivative are likewise unaffected — extract_jacobian! broadcasts over all n columns and extract_derivative produces a single partials(T, y, 1).

Tests

A new testset over Vector, SVector and MVector, covering the allocating, AbstractArray and DiffResult forms:

  • no perturbation at all — pins the ::Real path this PR touches.
  • an enclosing tag only — the three reproducers from gradient! leaves part of the result unwritten when f does not depend on its argument #847, plus @test_throws MethodError for a Float64 buffer that cannot hold the enclosing tag, mirroring HessianTest's assertion.
  • an enclosing tag only, chunk size = 1, 2, 3 — vector and chunk mode agree.
  • an unrelated tag, chunk size = 1, 2, 3 — a regression guard rather than a test of the fix: it passes both before and after, and is what would catch a later simplification back to plain zero(y) turning a DualMismatchError into a silent zero gradient.

Against the unfixed source the testset gives 6 failures and 3 errors, on the array-buffer cases for all three input types and on chunk size 3 — the only chunk size that takes the vector-mode path for a length-3 input. With the fix the whole suite passes (Derivative, Gradient, Jacobian, Hessian, Confusion, Misc, Dual, Partials, Seed, Allocations).

Not addressed here

Two adjacent gaps, both pre-existing and neither introduced nor worsened by this change:

  • gradient! never checks the result's size, unlike hessian!'s reshape_hessian. ForwardDiff.gradient!(zeros(5), sum, [1.0, 2.0]) writes two entries and leaves three stale. The ::Real and ::Dual branches already disagreed about this — fill! covers the whole buffer — and this PR only routes one more case to the fill! side. A structural_length(result) == structural_length(x) check would close it, but it would reject calls that are accepted today.
  • gradient! into an ImmutableDiffResult (DiffResults.GradientResult(::SVector)) hits fill! on an SVector and errors; there is no gradient!(::ImmutableDiffResult, f, ::StaticArray) specialisation the way there is for hessian!. Making fill! work alone would not help, since gradient! discards what vector_mode_gradient! returns, so the functional update would be dropped. The new tests keep their DiffResult assertions on mutable buffers.

🤖 Generated with Claude Code

`extract_gradient!` dispatched on a bare `::Dual`. A `Dual{S,V,N}` with
`S ≺ T` carries no `T`-perturbation, so its gradient is zero everywhere and
the `::Real` method is what it needs -- but it *is* a `Dual`, so it took the
`::Dual` method, where `npartials` reports the `N` of the wrong layer.
Whenever that `N` was smaller than `structural_length(x)` the tail of the
result was never written:

    ForwardDiff.derivative(1.0) do a
        out = fill(a * 111.0, 3)
        ForwardDiff.gradient!(out, z -> a * 2.0, [1.0, 2.0, 3.0])
        @show ForwardDiff.value.(out)   # [0.0, 111.0, 111.0]
        return zero(a)
    end

The allocating form returned uninitialized memory, and the `DiffResult` form
passed `partials(T, dual)` -- a scalar `Dual` rather than a `Partials` -- to
`DiffResults.gradient!` and errored. `gradient!` into a plain result was
affected for a `StaticArray` too, since it shares `extract_gradient!`; only
the allocating `gradient`, whose `@generated extract_gradient` is built from
`length(x)`, was correct.

Dispatch on `Dual{T}`, as the Hessian does on `Dual{TO,<:Dual{T}}`, so a
result carrying only an enclosing tag falls through to the `::Real` method,
which already fills the whole result.

The two `::Real` methods take their value from `value(T, y)` rather than `y`.
For a plain `Real` and for a `Dual{S}` with `S ≺ T` that is the identity, and
it keeps rejecting a tag with no such relation, which the `::Dual` methods did
through `partials(T, dual, i)`. Chunk mode rejects it regardless, from
`partials(T, dual, i)` and from `similar(x, valtype(T, ydual))`, so vector
mode has to agree or the result would depend on the chunk size.

Chunk mode needed no change: `extract_gradient_chunk!` is bounded by
`chunksize` rather than `npartials`, and `partials(T, ::Dual{S}, i)` already
answers zero for every position.

Fixes #847.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.07%. Comparing base (b742809) to head (1d7e74d).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #848      +/-   ##
==========================================
+ Coverage   90.68%   91.07%   +0.38%     
==========================================
  Files          11       11              
  Lines        1052     1053       +1     
==========================================
+ Hits          954      959       +5     
+ Misses         98       94       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gradient! leaves part of the result unwritten when f does not depend on its argument

1 participant