Skip to content

Exploit symmetry in the Hessian, on top of #840 - #843

Open
devmotion wants to merge 7 commits into
devmotion/structured-extraction-838-839from
devmotion/symmetric-hessian-on-840
Open

Exploit symmetry in the Hessian, on top of #840#843
devmotion wants to merge 7 commits into
devmotion/structured-extraction-838-839from
devmotion/symmetric-hessian-on-840

Conversation

@devmotion

@devmotiondevmotion commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fixes#253. Fixes#836.

Carries #837's two commits unmodified on top of #840, and answers its second review round.
Supersedes #841. kc/symmetric_hessian is untouched.

Based on #840 because the sweep needs its structural positions: structural_indices cached on
the config, and an extract_gradient_chunk! that takes its positions from x rather than from
the result (#838). On master the same review asks would need three new helpers that #840 already
has, all of which would then conflict with it.

Breaking

For structured inputs the result is now length(x) by length(x), both axes indexed by the
linear indices of x, with zeros in the rows and columns of the structurally zero entries. So
hessian(f, UpperTriangular(rand(3, 3))) is 9x9 with rows and columns 2, 3, 6 zero. That is the
Jacobian's convention, and it makes DiffResults.HessianResult(x) the right buffer, so
hessian!(::DiffResult, f, x) works for structured inputs instead of erroring. It also makes
hessian(f, ::Diagonal) quartic in the size of the diagonal.

HessianConfig holds iseeds, oseeds, duals and indices instead of a JacobianConfig and
a GradientConfig. The sweep never reads the Jacobian config's buffer, which only existed because
the Hessian used to be jacobian(gradient(f), x). Dropping it takes HessianConfig(f, x) from
1.472 MB to 1.368 MB at length(x) == 1000, and the result-aware constructor (which built two
dead buffers) from 1.576 MB. Separate commit, easy to drop.

Fixes

The result was not reproducible across chunk sizes: diagonal blocks read one triangle,
off-diagonal blocks the other, and the two round differently. At n = 16 every chunk size below
16 differed from single-block by ~1e-16. Every chunk size is now bitwise equal to the StaticArrays
path.

hessian! had stopped checking the result shape when the sweep replaced extract_jacobian!, so
hessian!(fill(NaN, 4, 4), f, rand(3)) silently left row and column 4 untouched.
reshape_hessian restores it, mirroring reshape_jacobian.

Smaller items: seed_hessian_chunk! no longer builds a zero for a layer that was given seeds (288
to 0 bytes per call for BigFloat at N = 3), and the extension's reshape branch, its two
HESSIAN_ERROR throws and empty inputs are now covered.

Numbers

Suite 10420/10420. #837's table re-measured against the old algorithm rebuilt on this branch,
hessian! into a preallocated matrix:

nrosenbrockackleyallocations
10 (single chunk)1.13x1.17x0 vs 19856 B
301.58x1.57x0 vs 28224 B
1001.86x1.86x0 vs 142624 B

Three review items done differently

  • reshape_hessian checks size like reshape_jacobian rather than axes, plus one
    require_one_based_indexing for the DiffResult buffer that no entry point checks.
  • The Diagonal cost is not in the docstring. Neither gradient nor jacobian documents
    structured inputs, and the new shape sentence (worded like jacobian's) makes it derivable.
  • SeedTest does not gain the mixed seed forms. Which layer a seed lands in is enforced by the
    types, and both mixed forms run in every multi-block sweep, so the bitwise chunk-size test
    already covers them. That differs from seed_zero_partials!, whose testset exists because
    over-clearing is invisible through the public API.

KristofferCand others added 7 commits August 16, 2026 10:05
instead of relying on the jacobian of gradient for the hessian
explicitly seed dual numbers and only calculate the upper triangular part when chunking, gives ~2x speedup as input length becomes big
…#840
The symmetric sweep of #837 was written against `structural_eachindex`, which #840
replaced with `structural_indices`: linear indices of `x`, in seeding order, cached
per work buffer on the config. Every conflict resolves to that API.
Three consequences beyond the mechanical rename:
* The result is `length(x)` by `length(x)`, both axes indexed by the linear indices
of `x`, as #840 already established for the Jacobian and for the Hessian it
computes from one. The rows and columns of the structurally zero entries are
zeroed by `zero_unseeded_columns!`, and the gradient buffer by `zero_unseeded!`.
* `extract_hessian_gradient_chunk!` can keep delegating to
`extract_gradient_chunk!`, which now takes its positions from `x` rather than
from the result -- that was #838, and it is what made
`DiffResults.HessianResult`'s dense gradient buffer scatter its derivatives.
* The entry points call `checkstructure` themselves. The sweep no longer goes
through `gradient` and `jacobian`, so a config built for a differently
structured input would otherwise no longer be caught (#842).
The structured testset gains `n = 5`, the flat-vector result and the default chunk
from #837's own, which it otherwise supersedes.
`hessian!`'s dropped result shape check and the sweep's chunk-size dependence are
left for the commits that follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`HessianConfig` wrapped a `JacobianConfig` and a `GradientConfig` because the Hessian
was `jacobian(gradient(f), x)`: the outer sweep seeded the Jacobian config's buffer
and the inner `gradient` seeded the gradient config's. The symmetric sweep seeds both
layers of the one nested buffer, so it reads four things -- the two seed tuples, the
nested buffer and its positions -- and never touches the Jacobian config's buffer
again after the constructor derives the nested element type from it.
Holding those four directly drops the dead buffer, and `checkstructure(cfg, x)` now
resolves through the generic `AbstractConfig` method like every other config's.
At `length(x) == 1000` and a chunk size of 12:
HessianConfig(f, x) 1.472 MB -> 1.368 MB
HessianConfig(f, result, x) 1.576 MB -> 1.368 MB
The result-aware constructor allocated two dead buffers rather than one, since it
built the `f!(y, x)` `JacobianConfig`. Nothing about the work buffers depends on
`result`, so it forwards to the plain constructor and the two now return the same
type -- which is what the tests asserting the two configs interchangeable already
implied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The symmetric sweep writes the result entry by entry, which lost the validation that
`reshape_jacobian` and the broadcast in `extract_jacobian!` used to provide. A vector
result was still checked by `reshape`, a matrix one no longer was:
hessian!(fill(NaN, 4, 4), f, rand(3)) # no error, row/col 4 left NaN
hessian!(fill(NaN, 4, 4), f, SVector(1., 2., 3.))
`reshape_hessian` mirrors `reshape_jacobian`, down to its `DiffResult` method, so the
`DiffResult` path is checked too -- it holds a buffer no entry point ever passes to
`require_one_based_indexing`, hence the extra call here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diagonal blocks read `outer <= inner`, off-diagonal blocks read `outer > inner`, and the
StaticArrays path reads `outer <= inner` throughout. Reading an entry with i in the outer
layer rounds differently from reading it with j there, so the result was not reproducible:
at `n = 16`, chunk sizes 1, 2, 3, 5, 7, 11 each differed from chunk 16 and from the
`SVector` path by ~1e-16.
Swapping which layer block q carries fixes it. Same number of evaluations and seed
writes, and q is still seeded once outside the loop; afterwards every chunk size is
bitwise identical to the `SVector` path.
`log(sum(exp, z))` is the objective in the new test because its mixed partials actually
round differently in the two orders -- `sum(z)^3`, `exp(sum(z))`, `prod(z)` and
`sum(sin, z) * sum(cos, z)` all give bitwise equal results either way, so none of them
would have caught this.
It also makes `symmetric_static == hessian(symmetry_f, x)` robust rather than accidental:
that only passed because `n = 9` is below `DEFAULT_CHUNK_THRESHOLD`, so the array path
ran a single block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`seed_hessian_chunk!` materialised both zeros even when both seeds were supplied. Free
for an isbits value type, but for `BigFloat` at `N = 3` it cost 288 bytes per call,
identical for all four seed combinations. Each `=== nothing` is a compile-time constant,
so deciding per layer folds away:
iseeds oseeds before after
given given 288 0
given - 288 192
- given 288 96
- - 288 288
Also documents the result shape, in the wording `jacobian` uses for its own, and adds a
sentence on why the clearing calls cannot go through `seed_zero_partials!` and why the
`Partials{0}` method of `extract_hessian` is load-bearing -- for a constant `f` the
generic method builds a `0 × length(x)` result, not `length(x) × length(x)`.
New coverage: the extension's `reshape` branch and its two `HESSIAN_ERROR` throws, and
empty inputs on both paths.
Not added: the mixed seed forms in `SeedTest`. Which layer a seed lands in is enforced by
the types -- `oseeds` only fits the outer `Dual` -- and both mixed forms run in every
multi-block sweep, so the bitwise chunk-size test covers them with a failure mode that a
unit test would only relocate. This is unlike `seed_zero_partials!`, whose testset exists
because over-clearing is invisible through the public API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecovBot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.23%. Comparing base (ae5bdb8) to head (a82a9e6).

Additional details and impacted files
@@ Coverage Diff @@## devmotion/structured-extraction-838-839 #843 +/- ##
===========================================================================
+ Coverage 91.54% 92.23% +0.69% 
===========================================================================
Files 11 11 Lines 1076 1159 +83 ===========================================================================
+ Hits 985 1069 +84 + Misses 91 90 -1 

☔ 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.

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.

2 participants

@devmotion@KristofferC