Uh oh!
There was an error while loading. Please reload this page.
Apply the second-round symmetric-Hessian review: output shape, shape check, chunk-size independence - #841
Closed
devmotion wants to merge 11 commits into
Closed
Conversation
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 `x` and walk `structural_eachindex(x, result)`. Entries that receive no derivative are zeroed, which is their derivative; in chunk mode the first chunk does it. The `DiffResult` method splits on mutability, since an immutable result cannot be written to entry by entry (and only occurs for `StaticArray` inputs, all of whose entries are structural). 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. `structural_linearindices` maps a structural position to a linear index of `x` without materializing anything, and the single-broadcast path is kept when every index of `x` is structural, so no path allocates more than before. 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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vered
The new allocation test failed on Julia <= 1.10 for `Diagonal` inputs, but
none of the allocations came from extraction: the target function reduced
with the no-function `sum(z)`, and `Base._sum(::Diagonal, ::Colon)` allocates
there (32 bytes even for a `Diagonal{Float64}`). Reducing with `sum(f, z)`
instead measures ForwardDiff rather than LinearAlgebra, and extraction turns
out to be allocation-free for every input type on both 1.10 and 1.12.
That left the chunk-mode comparison against a dense input, which was hiding a
real cost: `reshape_jacobian` reshapes the result even when it already is a
matrix, and since 1.11 `reshape` can no longer return its argument, so every
chunk-mode `jacobian!` allocated an `Array` wrapper. `extract_jacobian!` had
been given that short-circuit in #797; `reshape_jacobian` now shares it, with
an explicit size check in place of the one `reshape` performed on the way
past, and `extract_jacobian!` calls it instead of repeating the ternary. Both
modes now reject a wrongly shaped matrix result with the same error, and the
test can assert zero allocations outright.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`extract_gradient_chunk!`/`extract_jacobian_chunk!` recognised the first chunk by `index == 1` and used it to zero the whole result, which is neither part of extracting one chunk nor something a chunk can decide: the entries at stake -- those of the structural zeros of `x` -- belong to no chunk in particular. Both sweeps now do it once up front, and the chunk functions only write their chunk. The gradient's half is shared with `extract_gradient!` as `zero_unseeded!`, which also fixes the condition. `structural_length(x) != length(x)` zeroed a result that is itself structured, whose every stored entry the sweep goes on to write; comparing against `structural_length(result)` skips that, and leaves the mismatched-structure cases erroring at the same write as before. The Jacobian keeps its own test, since its result is not shaped like `x` and what has to be covered there is columns. Also drops the `map!` that `vector_mode_jacobian(f!, ...)` ran before `extract_jacobian!`, which reads only `ydual`, and that the `map!` after it repeats. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hessian` inherits both conventions through `jacobian(gradient(f), x)`, so it changes shape for a structured `x` and `hessian!(DiffResults.HessianResult(x), f, x)` starts working, none of which was asserted. The new testset pins the value, the gradient and the Hessian of 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 checked rather than approximated. Against master it fails everywhere: the smaller chunks throw, and the full-length one gets the wrong shape and cannot take a `HessianResult` at all. Also: the out-of-place `gradient` is shaped like `x`, so its zeros are the structural ones and its type is worth asserting; a structured result cannot hold the gradient of a dense `x`, which now throws where it used to write to the wrong entries; a Jacobian result that is not a matrix is reshaped; and the `f!` form takes a `JacobianResult` too. Both structured testsets take the chunk sizes from `length(sidx)` rather than `ForwardDiff.structural_length`, keeping the reference data in the test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`extract_gradient!`, `extract_gradient_chunk!`, `extract_jacobian!` and
`extract_jacobian_chunk!` take `x` after the derivatives, like every other internal
function that pairs an output with an input: `reshape_jacobian(result, ydual, x)`, which
`extract_jacobian!` calls on its first line, `extract_value!(T, out, y, ydual)`, and the
`extract_jacobian(T, ydual, x::StaticArray)` of the StaticArrays extension. `x` is
annotated `AbstractArray` there, so a call written against an older signature fails on
the method rather than inside `structural_length`.
`extract_jacobian!` duplicated the structured branch of `extract_jacobian_chunk!` at
`offset == 0` and now delegates to it, and both Jacobian sweeps zero through one helper.
`structural_linearindices(x)` becomes `structural_columns(out, x)`: what a caller needs
are the columns of `out` that receive a derivative, and every method returns those,
`axes(out, 2)` or a subset of it, without materializing anything. It checks its
arguments the way `structural_eachindex` does, whose three bare `DimensionMismatch()`
now name the sizes they expected.
The chunk mode gradient sweep rejects an array-valued `f` up front, as the Jacobian
sweep does, in place of the `dual::AbstractArray` methods of `extract_gradient_chunk!`:
`zero_unseeded!` runs before them and would fail on `zero(::Type{<:AbstractArray})`
before dispatch got there. `zero_unseeded!` also dispatches on `DiffResult` rather than
`MutableDiffResult`, since a `StaticArray` gradient buffer makes the result immutable
even when the buffer itself can be written to entry by entry, as an `MVector` can.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>…atic buffer A wrongly shaped `jacobian!` result and a `gradient!` result that is not shaped like `x` both throw now, the latter for a dense `x` too, and neither was asserted. Nor was the `MVector` gradient buffer of an `ImmutableDiffResult`, which chunk mode writes entry by entry. The structured Jacobian and Hessian testsets only ran chunk sizes that divide the number of seeded entries, so the final chunk was always a full one; they gain a size that leaves a remainder. `SeedTest` pins the columns `structural_columns` returns against the index sets it already writes out by hand, since the Jacobian misplaces its derivatives if they disagree with `structural_eachindex`. The allocation test measures a dense result as well, which is the case that zeroes before extracting, and the JET tests cover a structured input, which reaches the generator branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`extract_gradient_chunk!` takes `x` — last, since 27033bb — and no longer zeroes what it does not write, so the Hessian's gradient extraction has to thread `x` through and the sweep has to call `zero_unseeded!` itself, as `chunk_mode_gradient_expr` does. `test/HessianTest.jl` conflicted, both sides having added a structured-input testset in the same place. #840's is kept: its objective couples the entries, so a wrong row or column offset cannot hide, and it pins the result shape `hessian` has on master. The one dropped here asserted the shape this branch introduced instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both axes of a Hessian belong to the entries of `x`, the way the columns of a Jacobian do, so `structural_columns` supplies the positions for both and the result is `length(x)` by `length(x)` with hard zeros in the rows and columns of the structurally zero entries. Without this the sweep numbered its rows and columns by seeding position, which is a shape and an order that only ForwardDiff knows, and which #838/#839 had just removed from `gradient` and `jacobian`. The sweep indexes into the positions of two blocks at once and re-reads each row block once per column block, so unlike `jacobian!` it cannot walk them lazily and materializes them once per call instead. For everything but the triangular wrappers they are a range already and `_indexable` is a no-op. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Writing the result entry by entry lost the validation the broadcast used to do on the way past, so a matrix of the wrong size was partially filled and returned instead of rejected: `hessian!(fill(NaN, 4, 4), f, rand(3))` came back with a row and a column of `NaN`. `reshape_hessian` mirrors #840's `reshape_jacobian`, down to short-circuiting on a matrix rather than letting `reshape` allocate a wrapper for it on 1.11 and later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The diagonal blocks read the nested partials at `outer <= inner` and the off-diagonal ones at `outer > inner`, which are equal in exact arithmetic and not in floating point, so the Hessian depended on the chunk size in the last bit: at n = 16, every chunk size below 16 disagreed with vector mode and with the `StaticArray` path, which reads `outer <= inner` as well. Swapping which layer the column block seeds costs nothing — the same evaluations, the same seed writes — and makes all of them bitwise equal. `seed_hessian_chunk!` built the zero partials of both layers whether or not it needed them, which an isbits `V` optimizes away and `BigFloat` does not: 288 bytes per call at N = 3, now zero when both seeds are supplied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@## kc/symmetric_hessian #841 +/- ##
========================================================
+ Coverage 91.30% 91.91% +0.61%
========================================================
Files 11 11 Lines 1115 1162 +47 ========================================================
+ Hits 1018 1068 +50 + Misses 97 94 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The sweep needs no random access into the positions its blocks are seeded at. For a fixed column block the row blocks are walked in order, and within a block each row resumes the column walk from the state saved at the start of the block, so the walk costs what `seed_hessian_chunk!` already pays to seed the same block and no more. A diagonal block reads its columns from wherever its rows have got to, which is exactly the upper triangle it wants. So the lazy positions of the triangular wrappers never have to be materialized, and `hessian!` is allocation-free for every input kind, as `gradient!` and `jacobian!` are. Three comments, while nearby: - Reading every block at the same nesting order is what fixes the entries of `H` across chunk sizes, but it does not fix them for an `f` that branches on the seeds: `<` on `Dual` breaks ties on the partials, so the two layers of `max(x[1], x[2])` at an exact tie resolve to different arguments. The off-diagonal comment names the mechanism rather than the consequence. - `zero_unseeded_columns!` reads `ydual` for its value type, so the sweep, which zeroes rows as well as columns, can hand it a scalar dual where a Jacobian hands it an array of them. Say so at both ends. - `length(x)` belongs to the one generated body that allocates a result. Tests: - The zero partials of a `Float64` `x` are isbits and free to build, so pin the seed combination that needs neither layer's on a `BigFloat` one, where building them costs 288 bytes at N = 3. - The structured testset's `f` has an exactly representable Hessian, so its `==` says nothing about floating point. Cover the three wrappers in the chunk size independence testset, against their largest chunk. - Cover the sweep's own `HESSIAN_ERROR`, an `f` that ignores a non-empty `x`, and a `DiffResult` holding a Hessian that is not a matrix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 freeto 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.
Stacked on #837, applying my second round of review as code rather than as prose, since you suggested that would save us both a round-trip. Merge it into
kc/symmetric_hessianand it becomes part of #837. Draft until #840 lands, since it depends on it.Read commits 1–4; commit 0 is a merge of #840. Because the base is
kc/symmetric_hessian, the Files view shows #840's diff as well as mine — the per-commit view does not. Your two commits are untouched and never rewritten.Why #840 has to come first
#840 (fixing #838/#839, the two issues I filed off the last review) settles that
gradient,jacobianandhessianresults for a structuredxare indexed by the linear indices ofx, with hard zeros at the structurally zero entries — sohessian(f, ::UpperTriangular{3×3})is 9×9, not 6×6 — and pins it with aHessianTest.jltestset. On masterhessianinherits that throughjacobian(∇f, x); the new kernel numbers rows and columns by seeding position instead, an order only ForwardDiff knows. That testset fails with 16 failures on the merge commit and passes from commit 1 on.The merge is textually clean apart from one testset collision, but not semantically: #840 gave
extract_gradient_chunk!anxargument (last, as of its27033bb) and stopped it zeroing what it does not write. Both are fixed inside the merge commit so that every commit builds.One thing the merge deletes from my review: I said
extract_hessian_gradient_chunk!must not delegate toextract_gradient_chunk!, because that read positions offresult. #840 fixed that, so delegating is now the right thing to do and the gradient ofhessian!(HessianResult(x), f, x)lands correctly for free.The commits
x. Both axes of a Hessian belong to entries ofxthe way a Jacobian's columns do, so Extract derivatives at the indices ofx, and store those indices in the config #840'sstructural_columnssupplies the positions for both and no new mapping is needed.0c04e90,hessian!(fill(NaN, 4, 4), f, rand(3))returns with a row and column ofNaNwhere master threw.reshape_hessianmirrors Extract derivatives at the indices ofx, and store those indices in the config #840'sreshape_jacobianrather than introducing a different helper.outer <= inner, off-diagonal ones atouter > inner— equal in exact arithmetic, not in floating point. Swapping which layer the column block seeds costs nothing and makes them all bitwise equal, which also makes the existingsymmetric_static == hessian(symmetry_f, x)assertion meaningful rather than passing only because n = 9 stays underDEFAULT_CHUNK_THRESHOLD. The swap also moves the cheaper of the two seedings into the hot loop, since the inner zero is aPartials{N,V}and the outer one aPartials{N,Dual{T,V,N}}: forBigFloatat N = 3 the call that runsO(nblocks²)times drops from 192 to 96 bytes, the one that runsO(nblocks)times rises from 96 to 192. Same commit:seed_hessian_chunk!built both layers' zero partials whether or not it needed them (free for isbits, 288 → 0 bytes per call forBigFloatat N = 3), thePartials{0}andseed_zero_partials!-MethodErrorcomments, and the tests.seed_hessian_chunk!already pays to seed the same block and no more. A diagonal block reads its columns from wherever its rows have got to, which is exactly the upper triangle it wants. The lazy positions of the triangular wrappers therefore never have to be materialized, andhessian!is allocation-free for every input kind, asgradient!andjacobian!are. Results are bitwise unchanged and JET stays clean. Same commit: the comment fixes below and the remaining test gaps.Everything else from the review is a test: #837's structured testset is replaced by #840's coupled one (the old
dot(weights, abs2.(x))has a strictly diagonal Hessian, so it could not detect a wrong offset), plus the extension'sreshapebranch and bothHESSIAN_ERRORthrows, empty inputs, the mixed seed forms inSeedTestasserting which layer got which seed, and theAllocationsTestrename.Three comments that were not quite right
His read at; the result follows only for anfthat does not branch on the seeds.<onDualis lexicographic — values first, partials on a tie — so at the outer layer a tie is broken by comparing innerDuals and at the inner layer by comparing plain numbers.f(z) = max(z[1], z[2]) * z[3]atz = [1, 1, 2]is still chunk-size dependent for that reason. The comment now names the mechanism.zero_unseeded_columns!is called from the sweep with a scalar dual where its only other callers pass an array of them; it works because it readsydualfor its value type andeltypeis the identity on numbers. Stated at the helper and at the call site.length(x)was emitted into both generated bodies and read by the one that allocates a result.Verified
Lower/UpperTriangular/Diagonal× n ∈ {3,5,8} × every chunk size) against a reference that differentiates the vector of structural entries and scatters it: exact symmetry, exactly-zero non-structural rows and columns.SVectorpath; the three wrappers bitwise identical across every chunk size, on anfwhose Hessian is not exactly representable. Both are now testsets rather than side checks.x, and store those indices in the config #840's branch for the three wrappers: same shapes, agreeing to better than 1e-12 relative — the two compute the Hessian by different algorithms, so last-bit differences are expected.hessian!allocates nothing, for dense inputs and all three wrappers, at every chunk size.seed_hessian_chunk!allocation test is on aFloat64x, whose zero partials are isbits and free to build whether or not they are needed, so it cannot see commit 3's laziness. It is now also pinned on aBigFloatx; reverting the laziness makes that one assertion fail at 288 bytes and leaves theFloat64loop passing, which is how I checked it bites.hessian!either way, forf = sum(abs2, ·).Left for you
DimensionMismatchare all user-visible, so1.5.0rather than a patch — but that is your release, and Extract derivatives at the indices ofx, and store those indices in the config #840 is breaking for structured inputs on its own.devmotion/structured-extraction-838-839, or merging Extract derivatives at the indices ofx, and store those indices in the config #840 intokc/symmetric_hessiandirectly, would make exploit symmetry in the hessian #837's own diff readable again. Both are your call; I didn't want to touch your branch.jacobian's docstring deserves the same note aboutDiagonalcostinglength(x)²that I added tohessian's — Extract derivatives at the indices ofx, and store those indices in the config #840's to fix, not this PR's.require_one_based_indexingis not applied toDiffResultHessian buffers. Pre-existing and shared withjacobian!; not widened into here.🤖 Generated with Claude Code