From 1d7e74d8d60cf42988d5ce3daa24ed51cb7ce6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller-Widmann?= Date: Tue, 1 Sep 2026 16:01:41 +0200 Subject: [PATCH] Extract a gradient the result cannot carry as zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- src/gradient.jl | 13 ++++++---- test/GradientTest.jl | 56 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/gradient.jl b/src/gradient.jl index a5ef3dac..55f528da 100644 --- a/src/gradient.jl +++ b/src/gradient.jl @@ -49,21 +49,24 @@ gradient(f, x::Real) = throw(DimensionMismatch("gradient(f, x) expects that x is # result extraction # ##################### +# Without the perturbation the gradient is zero everywhere. function extract_gradient!(::Type{T}, result::DiffResult, y::Real) where {T} - result = DiffResults.value!(result, y) + v = value(T, y) + result = DiffResults.value!(result, v) grad = DiffResults.gradient(result) - fill!(grad, zero(y)) + fill!(grad, zero(v)) return result end -function extract_gradient!(::Type{T}, result::DiffResult, dual::Dual) where {T} +function extract_gradient!(::Type{T}, result::DiffResult, dual::Dual{T}) where {T} result = DiffResults.value!(result, value(T, dual)) result = DiffResults.gradient!(result, partials(T, dual)) return result end -extract_gradient!(::Type{T}, result::AbstractArray, y::Real) where {T} = fill!(result, zero(y)) -function extract_gradient!(::Type{T}, result::AbstractArray, dual::Dual) where {T} +extract_gradient!(::Type{T}, result::AbstractArray, y::Real) where {T} = + fill!(result, zero(value(T, y))) +function extract_gradient!(::Type{T}, result::AbstractArray, dual::Dual{T}) where {T} idxs = structural_eachindex(result) for (i, idx) in zip(1:npartials(dual), idxs) result[idx] = partials(T, dual, i) diff --git a/test/GradientTest.jl b/test/GradientTest.jl index bf121239..73fe356e 100644 --- a/test/GradientTest.jl +++ b/test/GradientTest.jl @@ -301,6 +301,62 @@ end @test grad == SVector{3}(der, der, der) end +# https://github.com/JuliaDiff/ForwardDiff.jl/issues/847 +@testset "a result that does not carry the perturbation" begin + xs = ([1.0, 2.0, 3.0], SVector(1.0, 2.0, 3.0), MVector(1.0, 2.0, 3.0)) + + @testset "no perturbation: $(nameof(typeof(x)))" for x in xs + f = Returns(2.0) + @test all(iszero, ForwardDiff.gradient(f, x)) + @test all(iszero, ForwardDiff.gradient!(fill(NaN, 3), f, x)) + result = ForwardDiff.gradient!(DiffResults.GradientResult(collect(x)), f, x) + @test DiffResults.value(result) == 2.0 + @test all(iszero, DiffResults.gradient(result)) + end + + @testset "an enclosing tag only: $(nameof(typeof(x)))" for x in xs + # `f` does not depend on `z`, so its result carries the `derivative` tag alone + ForwardDiff.derivative(1.0) do a + f = z -> a * 2.0 + @test all(iszero, ForwardDiff.gradient(f, x)) + + # a buffer that can hold the enclosing tag is written in full, one that cannot errors + g = fill(a * 111.0, 3) + ForwardDiff.gradient!(g, f, x) + @test all(iszero, g) + @test_throws MethodError ForwardDiff.gradient!(fill(111.0, 3), f, x) + + result = ForwardDiff.gradient!(DiffResults.GradientResult(fill(a * 111.0, 3)), f, x) + @test DiffResults.value(result) === a * 2.0 + @test all(iszero, DiffResults.gradient(result)) + return zero(a) + end + end + + @testset "an enclosing tag only, chunk size = $c" for c in (1, 2, 3) + x = [1.0, 2.0, 3.0] + ForwardDiff.derivative(1.0) do a + f = z -> a * 2.0 + cfg = ForwardDiff.GradientConfig(f, x, ForwardDiff.Chunk{c}()) + @test all(iszero, ForwardDiff.gradient(f, x, cfg)) + g = fill(a * 111.0, 3) + ForwardDiff.gradient!(g, f, x, cfg) + @test all(iszero, g) + return zero(a) + end + end + + # a tag with no relation to the config's is a mismatch, not a derivative that vanishes + @testset "an unrelated tag, chunk size = $c" for c in (1, 2, 3) + x = [1.0, 2.0, 3.0] + f = z -> Dual{OuterTestTag}(sum(z), 1.0) + cfg = ForwardDiff.GradientConfig(f, x, ForwardDiff.Chunk{c}(), TestTag()) + @test_throws ForwardDiff.DualMismatchError ForwardDiff.gradient(f, x, cfg) + @test_throws ForwardDiff.DualMismatchError ForwardDiff.gradient!(fill(NaN, 3), f, x, cfg) + @test_throws ForwardDiff.DualMismatchError ForwardDiff.gradient!(DiffResults.GradientResult(x), f, x, cfg) + end +end + @testset "NaN-safe mode" begin # issue #774 f = x -> log(zero(x[1]) + x[2])