Repository navigation
Unthunk the cotangent in reduce(vcat/hcat), hvcat and vect pullbacks - #861
Merged
Merged
Conversation
These pullbacks index the cotangent once per input array. Indexing an `AbstractThunk` forwards to `unthunk`, which recomputes the whole array on every access, so the cost was O(length(As) * length(dY)). Unthunk once at the top of each pullback, as the other `cat` pullbacks already do. Also bumps the patch version. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #861 +/- ##
==========================================
+ Coverage 96.63% 96.64% +0.01%
==========================================
Files 28 28
Lines 3598 3609 +11
==========================================
+ Hits 3477 3488 +11
Misses 121 121 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
devmotion
reviewed
Nov 2, 2026
devmotion
left a comment
Member
There was a problem hiding this comment.
Thanks, the fix looks good. Unthunking once at the top matches hcat/vcat/cat/stack. It leaves plain-array and ZeroTangent cotangents alone, and it handles InplaceableThunk.
Two suggestions on the tests:
- Rather than using
@allocatedthresholds as a stand-in, consider counting unthunks directly. That checks the property itself, gives the same result on every Julia version and coverage setting, and doesn't need the 1600-input or 200-argumenthvcatcases:This would replace the "cost does not grow with the number of inputs" testset and the@testset "$name" for (name, (Y, back)) in cases dY = rand(size(Y)...) expected = back(dY) n = Ref(0) counted() = (n[] += 1; copy(dY)) for dȲ in (@thunk(counted()), InplaceableThunk(dx -> dx .+= dY, @thunk(counted()))) n[] = 0 @test back(dȲ) == expected @test n[] == 1 end end
allocs/thunk/inplaceablehelpers. plain(dY) = dYcan go;expected = back(dY)does the same.
Possible follow-up: hvcat_pullback could return one InplaceableThunk per argument, as hcat/vcat/cat do, so slices for non-differentiable arguments are never computed.
…, count unthunks in tests - `vect` pullbacks for numbers/arrays and for arbitrary arguments indexed the cotangent once per argument, unthunking each time. - `hvcat_pullback` returns an `InplaceableThunk` per argument, as the `hcat`/`vcat`/`cat` pullbacks do. - Tests count unthunks directly instead of checking allocations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `Vector` input of `reduce(vcat/hcat)` with matrices received an `n×1` `Matrix` cotangent, and the cotangent's element type was not projected onto the inputs' (e.g. complex cotangents for real inputs). Project each slice with `ProjectTo`, as the `hcat`/`vcat`/`cat` pullbacks do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reduce(vcat/hcat) and hvcat pullbacksreduce(vcat/hcat), hvcat and vect pullbacks
Member
|
I pushed two commits that address my review and the |
`d in (1, 2)` is not constant-folded inside the `ntuple` closure on Julia 1.10, so the inferred index type is a `Union`. Since the pullback now returns thunks that capture the index, the `Union` leaked into its return type. `d <= 2` is folded, like the `d == 1`/`d == 2` checks in `vcat`/`hcat`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
devmotion
approved these changes
Nov 3, 2026
Member
Author
|
thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #860.
The pullbacks of
reduce(vcat, As),reduce(hcat, As)(reduce_vcat_pullback,reduce_hcat_pullback_1,reduce_hcat_pullback_2) andhvcat(hvcat_pullback) index the cotangent once per input. Indexing anAbstractThunkcallsunthunkeach time, so a thunked cotangent (e.g. fromsum's pullback in Zygote, FluxML/Zygote.jl#1686) is recomputedlength(As)times. This callsunthunkonce at the top of each, ashcat/vcat/cat/stackalready do.I used
dY = unthunk(ȳ)(renaming the argument) rather thandY = unthunk(dY), matching the neighbouring pullbacks and avoiding rebinding a variable captured by thedoblocks.hvcat_pullbackhas the same pattern and is included.Measured on Julia 1.13.2 (min of 5 runs), thunked cotangent
@thunk(ones(size(Y))):reduce(vcat), 1600 ×Vector(10)InplaceableThunkreduce(vcat), 800 ×Matrix(10×2)reduce(hcat), 1600 ×Vector(10)reduce(hcat), 800 ×Matrix(10×2)hvcat, 200 ×Vector(10)Added a testset in
test/rulesets/Base/array.jlchecking that@thunkandInplaceableThunkcotangents give the same result as the plain array for all the variants above (vectors / matrices / mixed), and that@allocated back(thunk) < 10 * sizeof(Y)at 1600 inputs (the quadratic path allocates aboutlength(As) * sizeof(Y)). Without the fix, the 10 allocation checks fail and the 14 equality checks pass; with it all 24 pass, also under--check-bounds=yes --code-coverage=user.Tests:
test/runtests.jl rulesets/Baseon Julia 1.13.2 passes (16978 pass, 52 broken as before). Not run locally on 1.10 (left to CI).Bumps the patch version to 1.74.10, as recent PRs do.
Noticed but left alone:
vectpullbacks indexȳ[n]per element and are equally quadratic in the number of arguments (N=256: 0.8 ms vs 3 µs), but N is a compile-time argument count, so I did not touch them.src/rulesets/Base/either alreadyunthunkor access the cotangent once.🧔♂️🤖 Assisted by AI
Follow-up edits (after review)
Addressing the review in 72b132c, 5f925a1 and 975805c:
vect: the projected (Vararg{Union{Number,AbstractArray{<:Number}}}) andVararg{Any}pullbacks now callunthunkonce instead of indexingȳ[n]per argument. The uniformVararg{T}method converts withNTuple{N}(ȳ), which iterates and so unthunks only once, and is unchanged. Numeric arguments of a single type, e.g.vect(1.0, 2.0), dispatch to the projected method. Closes Rule forvectunthunks many times #676hvcat:hvcat_pullbackreturns oneInplaceableThunkper argument, likehcat/vcat/cat, so slices for arguments that don't need a tangent are never computed. Its index computation usesd <= 2instead ofd in (1, 2), which Julia 1.10 doesn't constant-fold, so the returned thunks infer concretely there too (975805c).reduce(vcat/hcat)projection: the pullbacks now project each slice withProjectTo.(As), as thehcat/vcat/catpullbacks do. Before, aVectorinput among matrices got ann×1Matrixcotangent, and the element type wasn't projected (e.g. complex cotangents forFloat32inputs). The projectors are built by broadcasting:map(ProjectTo, As)doesn't infer concretely, andmapover the existingadjstest input (a reshapedAdjoint) callsadjointon the stored results. The twotest_rrule(reduce, *, mix)calls onVector{AbstractVecOrMat}inputs now usecheck_inferred=false. Their correctly typed cotangents (oneMatrix, oneVector) can't be inferred concretely. Homogeneous inputs still pass the inference checks.@allocatedthreshold testset (1600 inputs, 200-argumenthvcat) and theplain/thunk/inplaceable/allocshelpers are replaced by a testset that counts how often a@thunk/InplaceableThunkcotangent is evaluated (exactly once) and compares unthunked results against the plain-array cotangent. It covers the 7reduce/hvcatcases above plus the threevectmethods. Against the source onmain, 18 of its 40 checks fail (every count check except the uniformvectones).reduce cat: cotangents are projectedchecks that the cotangents ofFloat32vector/matrix inputs keep the inputs' types for aComplexF64cotangent. All 3 of its checks fail before 5f925a1.vect,hvcat,reduce hcatandreduce vcattestsets, pass locally (Julia 1.13.2).Updated benchmarks
Pullback
back(dȲ)onmain(a441bd5) and this PR, Julia 1.13.1 (aarch64, macOS), minimum time and memory from BenchmarkTools.@thunkis@thunk(ones(size(Y)))and plain isones(size(Y)).InplaceableThunk(dx -> dx .+= 1, @thunk(ones(size(Y))))cotangents give the same numbers as@thunkto within noise.@thunk, main@thunk, PRreduce(vcat), 1600 ×Vector(10)reduce(vcat), 800 ×Matrix(10×2)reduce(hcat), 1600 ×Vector(10)reduce(hcat), 800 ×Matrix(10×2)reduce(hcat), 10 ×Vector(10)hvcat, 200 ×Vector(10)vect, 256 ×Float64vect,Symbol+ 255 ×Float64ProjectTocalls added toreduce(vcat/hcat)cost at most about 1.5 µs with 800–1600 inputs and nothing measurable with 10.hvcatreturns lazyInplaceableThunks, so its rows measurebackalone. Unthunking all 200 outputs takes about another 9 µs and 34 KiB, the same per-argument cost as the existinghcatrule.