Extract derivatives at the indices of x, and store those indices in the config - #840
Extract derivatives at the indices of x, and store those indices in the config#840devmotion wants to merge 4 commits into
x, and store those indices in the config#840Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #840 +/- ##
==========================================
+ Coverage 90.68% 91.54% +0.85%
==========================================
Files 11 11
Lines 1052 1076 +24
==========================================
+ Hits 954 985 +31
+ Misses 98 91 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Looks like your PRs need some JET related adjustments |
| ##################### | ||
|
|
||
| function extract_gradient!(::Type{T}, result::DiffResult, y::Real) where {T} | ||
| # Derivatives are only computed with respect to the structurally non-zero entries of `x`, since only |
There was a problem hiding this comment.
Having these huge comments on almost every function getting changed is not great. It's a good practice to read through the comments and really think about if they have a reasonable noise-to-signal ratio and are written so a human actually understands them.
There was a problem hiding this comment.
I know 🙂
I noticed a few more issues (eg #842) so I'm still iterating quite a bit on the PR locally (actually it has diverged completely). It's not useful at all to spend time on reviewing it right now.
4b46775 to
d412246
Compare
… the config Since #739 only the structurally non-zero entries of an input are seeded, but extraction was not updated to match, so the derivatives were written to positions taken from the result container instead of from `x`. `extract_gradient!`/`extract_gradient_chunk!` now take the positions of `x`. Entries that receive no derivative are zeroed, which is their derivative, and the sweep does that once up front: those entries -- the ones belonging to the structural zeros of `x` -- belong to no chunk in particular. The `DiffResult` method splits on mutability, since an immutable result cannot be written to entry by entry, and it dispatches on `DiffResult` rather than `MutableDiffResult`, because a `StaticArray` gradient buffer makes the result immutable even when the buffer itself can be written to entry by entry, as an `MVector` can. Fixes #838, where a dense result got the derivatives at linear positions `1:structural_length(x)` and a `DiffResults.GradientResult` threw, and with it the mis-scattered gradient of `hessian!(::DiffResult, ...)`. The Jacobian is indexed by the linear indices of `x`: column `j` holds `∂f(x)[i]/∂x[j]`, as documented, with hard zeros in the columns of the structural zeros. Its allocations therefore use `length(x)` rather than `structural_length(x)`, which is what `reshape_jacobian` expected all along, so chunk mode stops throwing. Fixes #839. This changes the shape of the result for structured inputs: `jacobian` gains the zero columns and `hessian` inherits both conventions through `jacobian(∇f, x)`, becoming `length(x) x length(x)` with hard-zero rows and columns instead of mixing linear and structural indices. That also makes `hessian!(DiffResults.HessianResult(x), ...)` work. For a `Diagonal` the result now scales with `length(x)`, so differentiating with respect to the diagonal vector is the better choice there. `reshape_jacobian` keeps the short-circuit `extract_jacobian!` was given in #797, with an explicit size check in place of the one `reshape` performed on the way past: since 1.12 `reshape` can no longer return its argument, so every chunk-mode `jacobian!` allocated an `Array` wrapper. Both modes now reject a wrongly shaped matrix result with the same error. Also drops the `map!` that `vector_mode_jacobian(f!, ...)` ran before `extract_jacobian!`, which reads only `ydual`, and that the `map!` after it repeats. Seeding, meanwhile, took the positions to seed from the config's work buffer while extraction took them from `x`, so reusing a config with an input of the same length and a different structure seeded one set of positions and extracted another -- and left the buffer entries outside the input's structure uninitialized for the target function to read. Fixes #842. `structural_eachindex` and `structural_columns` are replaced by `structural_indices`, which returns the linear indices of the seeded entries in seeding order, and each config stores one per work buffer it owns, built from the buffer rather than from `x`. Every API entry then calls `checkstructure` next to `checktag`: it compares `structural_kind`, which is O(1) and a compile-time constant, so it can run unconditionally, and no size or count comparison could replace it -- `LowerTriangular(n, n)` and `UpperTriangular(n, n)` agree on `size` and on `structural_length` alike. Storing the positions also removes the `Iterators.drop` walk that re-traversed them from the front to reach each chunk, three times per gradient chunk. That walk was 35-49% of `gradient!` for an `UpperTriangular` input, and dense inputs paid it too, `Iterators.drop` having no range specialization: `gradient!` is now 1.4-1.9x faster and `jacobian!` 1.2-1.5x, dense and structured alike. Because the positions are linear indices, the Jacobian's structured and dense extraction paths collapse into the one fused broadcast the dense path already used, and neither chunk extractor takes `x` any more. The unassigned-entry branch of seeding is fixed with them, the second half of #842: it called `Base._unsetindex!(duals, idx)`, for which Base has no `CartesianIndex` method at all and whose `AbstractArray` fallback for a linear index recurses forever. `adjoint`, `transpose`, `PermutedDimsArray` and `view` inputs work now, their buffers being plain `Array`s, and the three wrappers `similar` preserves raise an `ArgumentError` naming the entry instead of a `MethodError` or a `StackOverflowError`, since only an `Array` can hold an unassigned entry at all. The new config type parameter is not shimmed: code that pins the parameter count of these types has to be updated. A config for an offset-indexed input now throws at construction rather than at the first `gradient`/`jacobian` call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d412246 to
8e906dd
Compare
x, not of the resultx, and store those indices in the config
`structural_kind` returned the wrapper type itself, with `nothing` for the
dense case. It names an index set rather than a wrapper, so it now returns one
of four singletons under a `StructuralKind` supertype: the fallback is typed,
the dense case reads as "every position" rather than as an absence, and there
is no `Type{UnionAll}` dispatch.
With the kinds named, `check_structural_indices` can ask the question it
actually needs: does the result store every position `x` does? That is a subset
relation, not equality -- an `UpperTriangular` result can hold the gradient of a
`Diagonal` input -- so `structural_issubset` spells the four pairs out. This
also lets the check catch a structured result for a dense `x` on the ForwardDiff
side, where it used to fall through to `setindex!` on a triangular wrapper.
The size check goes with it. It gained nothing: the case it targeted, a result
with the wrong number of entries, is already a length mismatch, and it forbade
for a structured `x` what the dense path has always allowed -- a result of the
same length and a different shape.
Also here:
- `vector_mode_gradient!` and `vector_mode_jacobian!` reject a wrongly shaped
`f(x)` up front, as the other six sweeps do, instead of failing later with a
`MethodError`.
- `extract_gradient!(::ImmutableDiffResult, ::Dual, ...)` checks the buffer it
copies the partials into wholesale, which is only correct when every entry of
`x` is seeded.
- `zero_unseeded!` and `zero_unseeded_columns!` return `nothing`, since a caller
reassigning their result would silently drop it for an immutable buffer.
- `_seed_zero_partials!`, the un-windowed `seed!` body, `check_matching_size` and
`input_indices` fold into their callers.
- Comments trimmed to what the code does not already say.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of the three is a pre-existing gap that only entered the diff because
threading `indices` through changed its signature:
- `structural_issubset(::MainDiagonal, ::LowerTriangle)`: the case of a result
whose structure contains that of `x` ran `UpperTriangular` alone, so the
structured gradient testset now runs both triangles.
- `eltype(::Type{DerivativeConfig})`: `DerivativeTest.jl` never asked a config
for its element type, unlike `GradientTest.jl` and `JacobianTest.jl`.
- `extract_jacobian(::Type, ::AbstractArray, ::StaticArray)`: `_diff` returns a
`StaticArray`, so the `@generated` method always won and the whole fallback
body was dead. An `f` returning an `Array` takes it, and gets an `Array`
Jacobian.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extension has no config to cache the structural positions in, so it derives them per extraction call. Nothing pinned that down: the one static allocation test nests `jacobian`, which the `@generated` methods answer without ever reaching the extraction in `src`. `gradient!`/`jacobian!` do reach it, so they are measured here, into an `Array` as well as a mutable static buffer -- extraction writes the result through a view, and a `view` of an `MArray` at a `UnitRange` is no static array. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #838, fixes #839 and fixes #842 — the same omission from #739 three times over: seeding became structure-aware, extraction and the config did not.
gradient!wrote to the wrong entries (#838)extract_gradient!/extract_gradient_chunk!took their positions from the result, while the seeds are laid out along the structural positions ofx. They now take the positions ofx, which the config holds (see #842 below), and entries that receive no derivative are zeroed — that is their derivative.This also fixes the mis-scattered gradient of
hessian!(::DiffResult, f, x), whose bufferDiffResults.HessianResultallocates densely even for a structuredx— the case from the #837 review.Chunked
jacobianthrew (#839)The result was allocated with
structural_length(x)columns whilereshape_jacobianasked forlength(xdual). The allocation was the side that was wrong: the Jacobian is indexed by the linear indices ofx— columnjholds∂f(x)[i]/∂x[j], exactly as the docstring says — with hard zeros in the columns of the structural zeros.Reusing a config across structures was silently wrong (#842)
seed!derived the positions to seed from the config's work buffer, while extraction derived them fromx. A config reused with an input of the same length and a different structure therefore seeded one set of positions and extracted another — silently — and left the buffer entries outside the input's structure uninitialized for the target function to read (UndefRefErrorfor a non-bits element type, garbage forFloat64).Taking the positions from
xinstead would leave those buffer entries never written at all. Sostructural_indicesis derived from the buffer and stored on the config — a newindicesfield, one position vector per buffer a config owns — and every API entry now checks the config against the arrays it is used with, callingcheckstructurenext tochecktag. A size or count comparison could not replace that check:LowerTriangular(n, n)andUpperTriangular(n, n)agree onsizeand onstructural_length, yet their positions are disjoint off the diagonal.Separately, seeding's unassigned-entry branch only ever worked for a dense
Array:Base._unsetindex!has noCartesianIndexmethod at all, and itsAbstractArrayfallback for a linear index recurses forever. With the positions now linear,adjoint,transpose,PermutedDimsArrayand non-stridedviewinputs behave like aMatrix, their buffers being plainArrays; the wrapperssimilarpreserves raise anArgumentErrornaming the entry, in place of aMethodError(the triangles) or aStackOverflowError(Diagonal).It is also faster
The lazy position iterators were walked from the front to reach each chunk (
Iterators.drop, which has no range specialization), three times per gradient chunk. Storing them turns that intoview(indices, index:(index + count - 1)). Dense inputs paid the same walk overeachindex(duals, x), so they speed up too — best-of-200gradient!/jacobian!, µs:xgradient!jacobian!MatrixMatrixMatrixUpperTriangularUpperTriangularUpperTriangularTriangular buffers are now walked by linear index rather than by
CartesianIndex, which is also what lets a position double as a Jacobian column number.Breaking changes
The result conventions follow #839/#837 — index the result by the indices of
x, hard zeros off the structure — which is whatgradient(f, x)has always returned. Consequences:jacobiangains the zero columns:(2, 6)→(2, 9)forUpperTriangular(3×3).hessianinherits both conventions throughjacobian(∇f, x)and becomeslength(x) × length(x)with hard-zero rows and columns, instead of mixing linear rows with structural columns:(9, 6)→(9, 9).hessian!(DiffResults.HessianResult(x), f, x)starts working (it allocateslength(x)^2), as doesjacobian!(DiffResults.JacobianResult(y, x), ...).Diagonalthe Jacobian/Hessian now scale withlength(x) = n², so differentiating with respect to the diagonal vector is the better choice there.gradient!requires the result to have the same length asxand to store every entryxdoes:gradient!(zeros(6), f, UpperTriangular(3×3))throwsDimensionMismatchrather than packing the derivatives in structural order, that order being an internal detail, and a structured result for a densexthrowsArgumentError. Shape is not constrained beyond the length, sozeros(9)for a3×3input and anUpperTriangularresult for aDiagonalone both work.jacobian!into a matrix whose shape is notlength(y) × length(x)now throwsDimensionMismatchin chunk mode, where it used to be reshaped whenever the total length happened to match. Vector mode already used such a result as is; the two modes now agree.GradientConfig/JacobianConfigwith a differently structured input silently computes wrong derivatives #842 above).indicesfield, deliberately not shimmed, since pinning the parameter count means depending on internals. Two known cases: SimpleNonlinearSolve constructsForwardDiff.JacobianConfig{tag,V,N,typeof(duals)}(seeds, duals)directly, a hard error, and Pumas types two struct fields asJacobianConfig{TJ,V,NJ,DJ}/GradientConfig{TG,Dual{…},NG,DG}, which stop being concrete.gradient/jacobiancall, sincestructural_indicesrequires one-based indexing.Smaller changes
Zeroing belongs to the sweep, not to a chunk. The chunk extractors recognised the first chunk by
index == 1and used it to zero the whole result — neither part of extracting one chunk nor something a chunk can decide, the entries at stake belonging to no chunk in particular. Both sweeps now zero once up front.reshape_jacobianallocated a wrapper on every chunked call. It reshaped the result even when that was already a matrix, and since 1.12reshapecan no longer return its argument. It now shares the short-circuitextract_jacobian!was given in #797, with an explicit size check stricter than whatreshapeperformed on the way past — that only ruled out a wrong total length, so a matrix of the right length and the wrong shape was silently reinterpreted.xlast, one job per function.extract_gradient!andextract_jacobian!takexafter the derivatives, like every other internal function pairing an output with an input; the chunk extractors drop it, since they only write positions, which they now receive.extract_jacobian!duplicated the structured branch ofextract_jacobian_chunk!atoffset == 0and now delegates to it, and its redundantnargument is gone. Both gradient sweeps reject an array-valuedfup front, as the Jacobian sweeps do, sogradienton one points atjacobianinstead of raising aMethodErrorfromzero(::Type{<:AbstractArray}).Tests
New testsets in
GradientTest.jl(#838),JacobianTest.jl(#839) andHessianTest.jl(both, inherited) cover the three wrappers × sizes × every relevant chunk size against a dense result, a result shaped likex,DiffResults.GradientResult/JacobianResult/HessianResult, a denseDiffResultgradient buffer, and both Jacobian forms. Results are prefilled withNaNso untouched entries fail rather than pass, and the expected nonzero positions are written out by hand so a bug in the position mapping cannot hide inside the reference; against master the Hessian testset gets 0 passed, 3 failed, 4 errored. For #842, a config-reuse testset in each ofDerivativeTest.jl,GradientTest.jl,JacobianTest.jlandHessianTest.jlruns the full four-kinds × four-kinds grid in both modes — including theLowerTriangular/UpperTriangularpair no size or count check can catch — and asserts the error messages, not just the types.AllocationsTest.jlasserts zero allocations outright for all four input types in both modes, which is also what pins the stored positions to being indexed through stack-allocated views.Further test detail
The Hessian test differentiates a function whose second derivative is
1 + (a == b)on the structural entries, so the reference is exact and the hard-zero rows and columns are asserted rather than approximated. The config-reuse testsets also assert that a wrong length still fails as aDimensionMismatchand that the fitting config still returns the right answer.DerivativeTest.jlhad no structure coverage at all before, so it also pins the value and the derivative for a structuredy.The unassigned-entry cases are covered end-to-end in
GradientTest.jland per-operation inSeedTest.jl, which now pinsstructural_indicesagainst the index sets it writes out by hand, asserts that a window overrunning the end is aBoundsErrorrather than a silently truncated chunk, and covers all fourArray-buffer input kinds plus the three wrappers.The first version of the allocation test failed on Julia ≤ 1.10 for
Diagonal, but none of the allocations came from extraction: the target function reduced with the no-functionsum(z), andBase._sum(::Diagonal, ::Colon)allocates there — 32 bytes even for aDiagonal{Float64}. Reducing withsum(f, z)measures ForwardDiff rather than LinearAlgebra. (On arm64 macOS with Julia 1.10.12 the olderTest jacobian! allocationstestset reports 16 bytes both here and on569af35; they come from the target function's own broadcast overVector{Dual}, and 1.12 reports zero.)Also asserted: the out-of-place
gradientreturns the structure ofx; a structured result cannot hold the gradient of a densex, while a result whose structure contains that ofxcan; a wrongly shapedjacobian!result and agradient!result of the wrong length both throw; chunk mode fills theMVectorgradient buffer of anImmutableDiffResult; the structured Jacobian and Hessian testsets run a chunk size leaving a partial final chunk, which every size they had divided; and the JET tests cover a structured input.Beyond the suite: a sweep over the three wrappers ×
n ∈ {1,2,3,5,8}× every chunk size × every result container agrees with the closed-form expectation, with the Hessian symmetric and its structural rows/columns exactly zero; empty inputs, flatVectorresults andBigFloatinputs with unassigned entries behave. On the GPU side the dense path is unchanged:partials_wrapis kept, andresult[:, cols] .= …targets a contiguous view of the whole array there,colsbeing aUnitRangeover every column. AJLArrayrun fails identically on this branch and on master, at the scalar indexing inseed!that #816 addresses.🤖 Generated with Claude Code