Skip to content

Apply the second-round symmetric-Hessian review: output shape, shape check, chunk-size independence - #841

Closed
devmotion wants to merge 11 commits into
kc/symmetric_hessianfrom
devmotion/symmetric-hessian-output
Closed

Apply the second-round symmetric-Hessian review: output shape, shape check, chunk-size independence#841
devmotion wants to merge 11 commits into
kc/symmetric_hessianfrom
devmotion/symmetric-hessian-output

Conversation

@devmotion

@devmotiondevmotion commented Aug 18, 2026

Copy link
Copy Markdown
Member

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_hessian and 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, jacobian and hessian results for a structured x are indexed by the linear indices of x, with hard zeros at the structurally zero entries — so hessian(f, ::UpperTriangular{3×3}) is 9×9, not 6×6 — and pins it with a HessianTest.jl testset. On master hessian inherits that through jacobian(∇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! an x argument (last, as of its 27033bb) 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 to extract_gradient_chunk!, because that read positions off result. #840 fixed that, so delegating is now the right thing to do and the gradient of hessian!(HessianResult(x), f, x) lands correctly for free.

The commits

  1. Index the Hessian by the linear indices of x. Both axes of a Hessian belong to entries of x the way a Jacobian's columns do, so Extract derivatives at the indices of x, and store those indices in the config #840's structural_columns supplies the positions for both and no new mapping is needed.
  2. Restore the result-shape check. Writing entry by entry lost the validation the old broadcast did on the way past: on 0c04e90, hessian!(fill(NaN, 4, 4), f, rand(3)) returns with a row and column of NaN where master threw. reshape_hessian mirrors Extract derivatives at the indices of x, and store those indices in the config #840's reshape_jacobian rather than introducing a different helper.
  3. Read the same triangle at every chunk size. Diagonal blocks read the nested partials at outer <= inner, off-diagonal ones at outer > 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 existing symmetric_static == hessian(symmetry_f, x) assertion meaningful rather than passing only because n = 9 stays under DEFAULT_CHUNK_THRESHOLD. The swap also moves the cheaper of the two seedings into the hot loop, since the inner zero is a Partials{N,V} and the outer one a Partials{N,Dual{T,V,N}}: for BigFloat at N = 3 the call that runs O(nblocks²) times drops from 192 to 96 bytes, the one that runs O(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 for BigFloat at N = 3), the Partials{0} and seed_zero_partials!-MethodError comments, and the tests.
  4. Walk the structural positions instead of indexing them. The sweep needs no random access: 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 block's start rather than restarting it, 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. The lazy positions of the triangular wrappers therefore never have to be materialized, and hessian! is allocation-free for every input kind, as gradient! and jacobian! 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's reshape branch and both HESSIAN_ERROR throws, empty inputs, the mixed seed forms in SeedTest asserting which layer got which seed, and the AllocationsTest rename.

Three comments that were not quite right

  • The off-diagonal comment said reading one triangle means "the result does not depend on the chunk size", unconditionally. What commit 3 fixes is the nesting order every entry of H is read at; the result follows only for an f that does not branch on the seeds. < on Dual is lexicographic — values first, partials on a tie — so at the outer layer a tie is broken by comparing inner Duals and at the inner layer by comparing plain numbers. f(z) = max(z[1], z[2]) * z[3] at z = [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 reads ydual for its value type and eltype is 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

  • Full suite 10111/10111, zero failures.
  • 130 structured configurations (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.
  • n = 16 bitwise identical across chunk sizes 1, 2, 3, 5, 7, 11, 16 and to the SVector path; the three wrappers bitwise identical across every chunk size, on an f whose Hessian is not exactly representable. Both are now testsets rather than side checks.
  • Against Extract derivatives at the indices of 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.
  • The four-combination seed_hessian_chunk! allocation test is on a Float64x, 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 a BigFloatx; reverting the laziness makes that one assertion fail at 288 bytes and leaves the Float64 loop passing, which is how I checked it bites.
  • Writing both triangles inside the extraction loop rather than mirroring one at the end: 1.3–2.2× faster in 7 of 8 configurations over n ∈ {64, 256, 1024, 2048} × N ∈ {8, 64}, since the value is still in a register for the second store while a second pass re-reads n²/2 entries. Extraction is 0.11–1.6% of hessian! either way, for f = sum(abs2, ·).

Left for you

🤖 Generated with Claude Code

devmotionand others added 10 commits August 17, 2026 17:24
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

codecovBot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.19355% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.91%. Comparing base (0c04e90) to head (6279027).

Files with missing linesPatch %Lines
ext/ForwardDiffStaticArraysExt.jl83.33%1 Missing ⚠️
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.
📢 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.

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>
@devmotion

Copy link
Copy Markdown
MemberAuthor

Superseded by #843, which carries #837's commits on top of #840 instead of merging #840 into a branch based on #837, so the diff shows the Hessian work alone.

Sign up for freeto 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.

1 participant

@devmotion