JuliaDiff / JuliaDiff/ForwardDiff.jl
3-arg `hypot` flattens `Dual`s with different tags, and rejects non-`Type` tags
Nobody has claimed this yet.
- Dominant language
- Julia
- Stars
- 1k
- Forks
- 160
- PR merge metrics
- No merged PRs in 30d
Description
The 3-argument hypot is wired into @define_ternary_dual_op, but all seven bodies delegate to a single helper that ignores the tag the macro hands it:
https://github.com/JuliaDiff/ForwardDiff.jl/blob/master/src/dual.jl#L601-L619
@inline function calc_hypot(x, y, z, ::Type{T}) where T
vx = value(x)
vy = value(y)
vz = value(z)
h = hypot(vx, vy, vz)
p = (vx / h) * partials(x) + (vy / h) * partials(y) + (vz / h) * partials(z)
return Dual{T}(h, p)
end
This causes the bug below. (A second claim about non-Type tags in the original text turned out not to be a bug; it is struck through.)
Duals with different tags are silently flattened
The one-argument value/partials strip a Dual layer unconditionally, without consulting the tag. So when the three arguments carry different tags, all three are flattened and their partials are summed into whichever single tag the dispatch selected. The other perturbations are annihilated from the result, and the surviving tag's partial is polluted with their contributions.
Reachable through the ordinary API, with no error:
julia> using ForwardDiff
julia> inner(x) = ForwardDiff.derivative(y -> hypot(x, y, 1.0), 2.0);
julia> ForwardDiff.derivative(inner, 3.0)
0.0
The correct value is -2x/(x²+5)^{3/2} = -0.11454053224818188 at x = 3 (finite differences agree: -0.11454053222559324). The 0.0 arises because inner(::Dual{Tout}) returns a plain Float64 — the outer perturbation is gone, so the outer pass sees no dependence on x at all. The inner derivative is wrong too: it returns 1.3363062095621219 instead of 0.5345224838248488.
Every other ternary op avoids this by giving each of the seven slots its own body that only unwraps arguments known to carry the tag T, e.g.
calc_fma_xy(x::Dual{T}, y::Dual{T}, z::Real) # z used raw
Dual{Tz}(fma(x, y, value(z)), partials(z)) # z_body: only z unwrapped
Since Dual <: Real, an inner-tagged Dual arriving as z::Real flows through the arithmetic untouched and the nesting survives.
Not affected: 2-argument hypot (handled by the DiffRules binary loop), single-tag gradient, and hessian of a 3-argument hypot — in the latter the nesting is in the same variable, so all three arguments share the same tag structure and take the single-tag path. Only genuine cross-tag combinations break.
2. Non-Type tags throw a MethodError (not a bug)
Type tags throw a MethodErrorcalc_hypot(x, y, z, ::Type{T}) where T requires the tag to be a Type, unlike calc_fma_xyz which uses an unconstrained where {T,N}, so hypot(Dual{:t}(...), ...) throws a MethodError where fma/muladd succeed.
Correction: tags are always expected to be Types, so the ::Type{T} constraint is intentional and this is not a bug. The differing behaviour between hypot and fma/muladd for Symbol tags is not something that needs fixing. Leaving the original text struck through since it motivated part of the discussion below.
Why the helper exists
Worth recording so a fix doesn't regress it: calc_hypot is not just arity plumbing. hypot exists to avoid forming x², and the helper preserves that by calling Base's hypot on the values and applying the analytic ∂h/∂xᵢ = xᵢ/h:
julia> ForwardDiff.gradient(v -> hypot(v[1],v[2],v[3]), [1e200,1e200,1e200])
3-element Vector{Float64}:
0.5773502691896257 # 1/√3 ✓
0.5773502691896257
0.5773502691896257
julia> ForwardDiff.gradient(v -> sqrt(v[1]^2+v[2]^2+v[3]^2), [1e200,1e200,1e200])
3-element Vector{Float64}:
0.0 # squares overflow ✗
0.0
0.0
So the helper cannot simply be dropped and left to compose.
Test coverage
test/DualTest.jl:596-597 are the only 3-argument hypot tests, and both use arguments sharing a single tag, so they pass on the current code. The cross-tag combination has never been tested.
Not part of this
Differentiating hypot at the origin gives NaN, and nansafe_mode does not help: the NaN originates in the coefficient vx/h = 0/0, while the NaN-safe guard is iszero(partial) and only rescues a zero partial multiplied by a non-finite coefficient. The 2-argument DiffRules path behaves identically. hypot is the Euclidean norm and genuinely has no gradient at the origin, so this is a separate question about choosing a subgradient convention, not a tagging bug.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/dual.jl:601-619 by tracing calc_hypot through @define_ternary_dual_op and compare it with the other ternary-operation bodies. Add a cross-tag regression alongside test/DualTest.jl:596-597, then run the Dual tests and verify that nested differentiation returns the stated derivative while the large-value gradient behavior remains covered.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- julia
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100