Repository navigation
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
|
AFAICT ChainRules is the wrong package for this change, it's rather that ChainRulesCore is missing a |
|
I opened JuliaDiff/ChainRulesCore.jl#724 |
|
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 |
|
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
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 What's left here: rebase on |
devmotion
left a comment
There was a problem hiding this comment.
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 arithmeticThe 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.
| # 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. |
There was a problem hiding this comment.
With JuliaDiff/ChainRulesCore.jl#724, broadcasting over a Tangent throws instead of collecting it. ChainRules supports both versions, so maybe:
| # 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` |
| # Zygote passes the cotangent of a tuple as `Tangent{Any}`, which broadcasting used to | ||
| # `collect` into a `Vector`, losing the element types. |
There was a problem hiding this comment.
Same as above:
| # Zygote passes the cotangent of a tuple as `Tangent{Any}`, which broadcasting used to | |
| # `collect` into a `Vector`, losing the element types. |
| 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))) |
There was a problem hiding this comment.
Maybe also cover thunked cotangents and the multi-argument path (bc_many_back), which is also inferable:
| @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)} |
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>
When a broadcast over tuples is differentiated, the cotangent of the (tuple) output arrives as a
Tangent. Zygote builds it asTangent{Any}, sinceZBackdoesn't see the primal. ChainRulesCore defines nobroadcastableforTangent, so the broadcast pullbacks hit Base'scollectfallback. They then work with aVectorof whatever eltypecollectfinds, instead of a tuple: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))constructsGet(i)from a runtimei.This PR:
Tangent{<:Any, <:Tuple}cotangent, in all four broadcast pullbacks;unzip(::Tuple)dispatch to the existingunzip(xs, ::Val{N})generated method, as the array path already does.Runtime results don't change; inference improves:
Downstream impact
In QuantumKitHub/BlockTensorKit.jl#86,
Zygote.gradientofx -> (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 aVector{<:AdjointTensorMap{...}}with an abstract eltype, and inferring the downstream code on that type took minutes once BlockTensorKit was loaded. With this PR,back_genericinfers 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@inferredcheck for path 2.@inferredtests forunzip/unzip_broadcaston tuples.The
@inferredtests fail onmain. 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
mainand finished it. Some parts of the description above are now out of date:unzipchange landed separately in Makeunzipof tuples inferable and validate its input #859 and is no longer part of this PR.unbroadcastfor vectors #857 will take 1.74.15.StructuralTangentthrow anArgumentErrorinstead of falling back tocollect. With it, every rule that broadcasts over a tuple cotangent has to unwrap it first.Additional changes
*,^2,/: these fused rules now also broadcast over the backing tuple of aTangent{<:Any,<:Tuple}cotangent. With CRC#724,Zygote.gradient(t -> (y = t .* 2; y[1] + y[2]), (1.0, 2.0f0))threw before this change.-,conj,real,imag: these applied the function to the whole cotangent.real(::Tangent),conj(::Tuple)and-(::Tuple)have no methods, so for examplereal.(conj.(t))failed in Zygote even onmain. They now broadcast over the unwrapped cotangent and project it withunbroadcast. Previously,imagalso returned an unprojectedTuple.+, binary-,T.,float.,complex.: these now unwrap the cotangent before callingunbroadcast. That call failed for a lazy argument, e.g.float.(real.(t))(ndims(::Tangent)).@thunkfrom the fused rules that have only one differentiable input. Their only cotangent is always needed, so the thunk just adds overhead.Tests
test_rrulewith tuple arguments for*(tuple×number, number×tuple, tuple×tuple),^2,/,conj,realandimag.Tangent{Any}cotangents on split paths 2, 3 and 4. These include thunked cotangents and multi-argumentatanwith@inferred.Tangent{Any}and plainTuplecotangents, and for lazyBroadcastedarguments.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)
unbroadcast, which sums over broadcast dimensions, projects, and turns tuple cotangents intoTangents. 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])gave10.0 - 2.0imfor the real2.0.x,real.(x)now projects its cotangent the same way the scalar rule does (real(ΔΩ) + zero(z)). This costs one allocation, the same asimag.andconj.._realusesreal(dz)for arrays, which doesn't copy a real cotangent, andreal.(dz)for tuples.test_rrulefor a real scalar times a complex array, and for a complex array divided by a real scalar. Both fail onmain.🤖 Generated with Claude Code