Skip to content

Keep tuple structure of broadcast cotangents - #858

Open
lkdvos wants to merge 7 commits into
JuliaDiff:mainfrom
lkdvos:tuple-tangent-broadcast
Open

lkdvos wants to merge 7 commits into
JuliaDiff:mainfrom
lkdvos:tuple-tangent-broadcast

Conversation

@lkdvos

@lkdvos lkdvos commented Oct 29, 2026 •

Copy link
Copy Markdown

When a broadcast over tuples is differentiated, the cotangent of the (tuple) output arrives as a Tangent. Zygote builds it as Tangent{Any}, since ZBack doesn't see the primal. ChainRulesCore defines no broadcastable for Tangent, so the broadcast pullbacks hit Base's collect fallback. They then work with a Vector of whatever eltype collect finds, instead of a tuple:

julia> using ChainRules, ChainRulesCore

julia> Base.Broadcast.broadcastable(Tangent{Any}(1.0, [2.0]))
2-element Vector{Any}:
 1.0
  [2.0]

The element types are lost, and downstream code gets inferred on abstract eltypes. In addition, unzip(::Tuple) isn't inferable: ntuple(i -> map(Get(i), xs), length(x1)) constructs Get(i) from a runtime i.

This PR:

  • broadcasts over the backing tuple of a Tangent{<:Any, <:Tuple} cotangent, in all four broadcast pullbacks;
  • makes unzip(::Tuple) dispatch to the existing unzip(xs, ::Val{N}) generated method, as the array path already does.

Runtime results don't change; inference improves:

using ChainRules, ChainRulesCore
using Base.Broadcast: broadcasted
struct Cfg <: RuleConfig{Union{HasReverseMode}} end
ChainRulesCore.rrule_via_ad(::Cfg, f, args...) = rrule(f, args...)

_, bk = rrule(Cfg(), copy∘broadcasted, Broadcast.BroadcastStyle(Tuple), log, (1.0, 2.0f0))
Base.infer_return_type(bk, Tuple{Tangent{Any, Tuple{Float64, Float32}}})
# before: Tuple{NoTangent, NoTangent, Any, Vararg{Any}}
# after:  Tuple{NoTangent, NoTangent, NoTangent, Tangent{Tuple{Float64, Float32}, Tuple{Float64, Float32}}}

Base.infer_return_type(ChainRules.unzip, Tuple{Tuple{Tuple{Int, Float64}, Tuple{Int, Float64}}})
# before: Tuple{Tuple{Any, Any}, Tuple{Any, Any}}
# after:  Tuple{Tuple{Int64, Int64}, Tuple{Float64, Float64}}

Downstream impact

In QuantumKitHub/BlockTensorKit.jl#86, Zygote.gradient of x -> (a, b = adjoint.(x); real(dot(a, a) + dot(b, b))) on a tuple of TensorKit tensors goes through the generic path (split_bc_pullbacks). The per-element tangents ended up in a Vector{<:AdjointTensorMap{...}} with an abstract eltype, and inferring the downstream code on that type took minutes once BlockTensorKit was loaded. With this PR, back_generic infers concrete tangent types and the first gradient takes 5.5 s (>150 s before; 6.7 s without BlockTensorKit loaded).

This is independent of #857, which avoids inferring the dead reduction in unbroadcast(::Tuple, ::AbstractVector). That case still occurs for a tuple broadcast against a vector, e.g. (a, b) .* [1, 2]. Both PRs bump the version to 1.74.9, so whichever is merged second needs a trivial rebase.

Tests

  • Tangent{Any} cotangents for tuple broadcasts on split paths 2 and 4, including an @inferred check for path 2.
  • @inferred tests for unzip / unzip_broadcast on tuples.

The @inferred tests fail on main. The full test suite passes locally on Julia 1.13.3.

Update (2026-11-11, by @devmotion)

This PR stopped receiving updates, so I merged main and finished it. Some parts of the description above are now out of date:

Additional changes

  • *, ^2, /: these fused rules now also broadcast over the backing tuple of a Tangent{<:Any,<:Tuple} cotangent. With CRC#724, Zygote.gradient(t -> (y = t .* 2; y[1] + y[2]), (1.0, 2.0f0)) threw before this change.
  • Unary -, conj, real, imag: these applied the function to the whole cotangent. real(::Tangent), conj(::Tuple) and -(::Tuple) have no methods, so for example real.(conj.(t)) failed in Zygote even on main. They now broadcast over the unwrapped cotangent and project it with unbroadcast. Previously, imag also returned an unprojected Tuple.
  • +, binary -, T., float., complex.: these now unwrap the cotangent before calling unbroadcast. That call failed for a lazy argument, e.g. float.(real.(t)) (ndims(::Tangent)).
  • Thunks: removed @thunk from the fused rules that have only one differentiable input. Their only cotangent is always needed, so the thunk just adds overhead.

Tests

  • test_rrule with tuple arguments for * (tuple×number, number×tuple, tuple×tuple), ^2, /, conj, real and imag.
  • Tangent{Any} cotangents on split paths 2, 3 and 4. These include thunked cotangents and multi-argument atan with @inferred.
  • Value checks for the fused rules with Tangent{Any} and plain Tuple cotangents, and for lazy Broadcasted arguments.

I ran the broadcast tests and the Zygote snippets from the review against CRC#724 locally. All 154 two-step chains of fused broadcasts on (1.0, 2.0f0) now differentiate with Zygote.

Projection (follow-up commit)

  • All fused rules now map their cotangent to each argument with unbroadcast, which sums over broadcast dimensions, projects, and turns tuple cotangents into Tangents. This fixes * with a scalar argument and the divisor in /, which returned a complex cotangent for a real scalar: rrule(broadcasted, *, 2.0, [1.0 + 2im, 3.0 - 1im]) gave 10.0 - 2.0im for the real 2.0.
  • For complex x, real.(x) now projects its cotangent the same way the scalar rule does (real(ΔΩ) + zero(z)). This costs one allocation, the same as imag. and conj.. _real uses real(dz) for arrays, which doesn't copy a real cotangent, and real.(dz) for tuples.
  • New tests: test_rrule for a real scalar times a complex array, and for a complex array divided by a real scalar. Both fail on main.

🤖 Generated with Claude Code

The cotangent of a tuple-valued broadcast arrives as a `Tangent`, which
Zygote constructs as `Tangent{Any}`. Broadcasting over it falls back to
`collect`, so the pullbacks produced `Vector`s with abstract element types
instead of tuples. Broadcast over the backing tuple instead.

Also make `unzip(::Tuple)` inferable by dispatching to the `Val{N}` method,
as is already done for arrays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.66%. Comparing base (d9ef0f7) to head (f1f3b78).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #858   +/-   ##
=======================================
  Coverage   96.65%   96.66%           
=======================================
  Files          28       28           
  Lines        3615     3625   +10     
=======================================
+ Hits         3494     3504   +10     
  Misses        121      121           

☔ 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.

@devmotion

Copy link
Copy Markdown
Member

AFAICT ChainRules is the wrong package for this change, it's rather that ChainRulesCore is missing a broadcastable definition for Tangent.

@devmotion

Copy link
Copy Markdown
Member

I opened JuliaDiff/ChainRulesCore.jl#724

@lkdvos

lkdvos commented Oct 29, 2026

Copy link
Copy Markdown
Author

Thanks for picking this up! I have to say I'm not too familiar with where these things go so I do appreciate the help

@devmotion

Copy link
Copy Markdown
Member

I'm reopening this because I was wrong when I closed it: ChainRules is the right place for this change after all.

I tried to add a broadcastable definition for Tangent to ChainRulesCore in JuliaDiff/ChainRulesCore.jl#724, but no definition there is correct for all Tangents:

  • With P === Any, as Zygote produces, the primal type is unknown, so ChainRulesCore cannot tell whether the tangent should act as a container or a scalar.
  • A structural tangent of an array-like primal, such as Tangent{SVector}(data=...), would have to be a container, so treating it as a scalar is wrong. ChainRules' pullback then silently returns NoTangent()s.
  • Even a tuple-backed tangent has two possible shapes. In a tuple-valued broadcast it is a container, but in a 0-dim broadcast it is a single element.

Only the code doing the broadcast knows which shape is meant, here the broadcast rule, which has the primal output. So #724 now makes broadcasting over structural tangents an ArgumentError, like Base does for NamedTuple and Dict, rather than silently collecting them. That means this PR is no longer just an inference improvement: the broadcast rules must unwrap the cotangent before #724 can be released.

What's left here: rebase on main, since the unzip change and the version bump conflict with the merged #859. Ideally, also run the tests against JuliaDiff/ChainRulesCore.jl#724.

@devmotion devmotion left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reran this branch against JuliaDiff/ChainRulesCore.jl#724. rulesets/Base/broadcast passes, and Zygote gradients through log.(x) and (sin∘cos).(x) work for tuples. The four split paths look correct to me, and a thunked cotangent in back_generic is fine too, because back_generic(dys::AbstractThunk) unthunks first.

However, the fused rules broadcast over the cotangent as well, and they accept tuples: NumericOrBroadcast includes NTuple{<:Any,Number}. With #724 they throw for Tangent cotangents. With ChainRulesCore 1.26.8 they work:

julia> Zygote.gradient(t -> (y = t .* 2; y[1] + y[2]), (1.0, 2.0f0))
ERROR: ArgumentError: broadcasting over structural tangents is not supported; use `map` or arithmetic

The same happens for t .^ 2, t ./ 2 and imag.(t). .+, .-, float., T., conj. and complex. are fine, because they don't broadcast over the cotangent. I checked locally that unwrapping the cotangent in these four rules fixes it:

     function bc_times_back(Δraw)
-        Δ = unthunk(Δraw)
+        Δ = _bc_cotangent(unthunk(Δraw))
@@
     function bc_square_back(dy_raw)
-        dx = @thunk ProjectTo(x)(2 .* unthunk(dy_raw) .* conj.(x))
+        dx = @thunk ProjectTo(x)(2 .* _bc_cotangent(unthunk(dy_raw)) .* conj.(x))
@@
     function bc_divide_back(dz_raw)
-        dz = unthunk(dz_raw)
+        dz = _bc_cotangent(unthunk(dz_raw))
@@
-    bc_imag_back(dz) = (NoTangent(), NoTangent(), @thunk(im .* real.(unthunk(dz))))
+    bc_imag_back(dz) = (NoTangent(), NoTangent(), @thunk(im .* real.(_bc_cotangent(unthunk(dz)))))

The test suite passes on #724 only because the fused-rule tests don't cover tuples for these rules. Could you add tests like these to the "fused rules" testset? With #724, each of them errors without the change above and passes with it:

test_rrule(copy∘broadcasted, *, Tuple(rand(3)), rand())
test_rrule(copy∘broadcasted, *, rand(), Tuple(rand(3)))
test_rrule(copy∘broadcasted, *, Tuple(rand(3)), Tuple(rand(3)))
test_rrule(copy∘broadcasted, Base.literal_pow, ^, Tuple(rand(3)), Val(2))
test_rrule(copy∘broadcasted, /, Tuple(rand(3)), rand())

imag with a tuple fails test_rrule with or without the change, because bc_imag_back returns an unprojected Tuple (a Vector on main). That's a separate issue, so I left it out of the list above.

Also, main is already at 1.74.9 from #859, which hasn't been released yet, so VersionVigilante wants 1.74.10.

Comment thread src/rulesets/Base/broadcast.jl Outdated
Comment on lines +5 to +6
# The cotangent of a tuple-valued broadcast is a `Tangent` (`Tangent{Any}` from Zygote). Broadcasting
# over that would `collect` it into a `Vector`, losing the element types, so use its backing tuple.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With JuliaDiff/ChainRulesCore.jl#724, broadcasting over a Tangent throws instead of collecting it. ChainRules supports both versions, so maybe:

Suggested change
# The cotangent of a tuple-valued broadcast is a `Tangent` (`Tangent{Any}` from Zygote). Broadcasting
# over that would `collect` it into a `Vector`, losing the element types, so use its backing tuple.
# The cotangent of a tuple-valued broadcast can be a `Tangent`

Comment thread test/rulesets/Base/broadcast.jl Outdated
Comment on lines +201 to +202
# Zygote passes the cotangent of a tuple as `Tangent{Any}`, which broadcasting used to
# `collect` into a `Vector`, losing the element types.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above:

Suggested change
# Zygote passes the cotangent of a tuple as `Tangent{Any}`, which broadcasting used to
# `collect` into a `Vector`, losing the element types.

Comment thread test/rulesets/Base/broadcast.jl Outdated
y4, bk4 = rrule(CFG, copy∘broadcasted, BT1, sin∘cos, x) # split 4
dx4 = bk4(Tangent{Any}(1.0, 1.0f0))
@test dx4[4] isa Tangent{typeof(x), typeof(x)}
@test all(ChainRulesCore.backing(dx4[4]) .≈ (-cos(cos(1.0)) * sin(1.0), -cos(cos(2.0f0)) * sin(2.0f0)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe also cover thunked cotangents and the multi-argument path (bc_many_back), which is also inferable:

Suggested change
@test all(ChainRulesCore.backing(dx4[4]) .≈ (-cos(cos(1.0)) * sin(1.0), -cos(cos(2.0f0)) * sin(2.0f0)))
@test all(ChainRulesCore.backing(dx4[4]) .≈ (-cos(cos(1.0)) * sin(1.0), -cos(cos(2.0f0)) * sin(2.0f0)))
@test bk2(@thunk(Tangent{Any}(1.0, 1.0f0))) == dx2
@test bk4(@thunk(Tangent{Any}(1.0, 1.0f0))) == dx4
_, bkm = rrule(CFG, copy∘broadcasted, BT1, atan, x, x) # split 2, several arguments
dxm = @inferred bkm(Tangent{Any}(1.0, 1.0f0))
@test dxm[4] isa Tangent{typeof(x), typeof(x)}
@test dxm[5] isa Tangent{typeof(x), typeof(x)}

devmotion and others added 5 commits November 11, 2026 14:44
The fused rules for `*`, `^2`, `/` and `imag` broadcast over the cotangent too,
so they need `_bc_cotangent` as well. Bump the version to 1.74.14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `+`, binary `-`, `T.`, `float.` and `complex.` pass the cotangent to
  `unbroadcast`, which fails for a `Tangent` cotangent when an argument is a
  lazy `Broadcasted` (e.g. `float.(real.(x))`), so unwrap it first.
- Unary `-`, `conj`, `real` and `imag` applied their function to the whole
  cotangent (`real(::Tangent)`, `conj(::Tuple)` etc. have no method) and
  `imag` returned an unprojected `Tuple`. Broadcast over the unwrapped
  cotangent and project with `unbroadcast`.
- Drop the `@thunk` from the single-input fused rules (unary `-`, `^2`,
  `T.`, `float.`, `conj`, `real`, `imag`, `complex`): their only cotangent
  is always needed, so the thunk is pure overhead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `*` with a scalar argument and `/` (cotangent of the divisor) returned a
  complex cotangent for a real scalar; they now go through `unbroadcast`,
  which projects it.
- `^2` uses `unbroadcast` like the other rules (no change in behaviour).
- `real` dispatches on the cotangent: `real(dz)` for arrays doesn't copy a
  real cotangent, `real.(dz)` for tuples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants