Precompute and caching: the design principle#
Where derived data may be computed once and reused during optimization, where it must not be, and why — the first-principles audit (2026-08-20) behind the geometry’s
precompute, the regularizeraux=threading, and the decisions recorded at the bottom. Written after the SF-T3 build, whose per-frame companion made the question concrete; the principle is general.
The economics, and the real cost axis#
Precompute-and-store pays when (cost of X) × (reuses within X's lifetime) exceeds computing it
once plus carrying it. In this library the carrying cost is not memory — sweeps, companions,
and masks are tiny next to a matvec’s working set. The real cost is staleness risk and state
channels: a cache that can be stale is the classic source of silent wrongness, and a cache in the
wrong place breaks the execution model. So the design question is never “should we precompute?”
(usually yes) but “where does the cached object live, and what invalidates it?”
Three mechanisms, in increasing order of danger#
Lazy caches on immutable objects —
functools.cached_propertyon the frozen dataclasses (T3Frame.orthogonality_residual,T3Tangent.gauge_residual). Invalidation is impossible because mutation is impossible. Safe, but limited to things derivable from one object alone.Explicit aux threading at scope boundaries — compute X at the moment its inputs settle and pass it along as a value: the sampling
sweep, the SF-T3 companion (the geometry’sprecompute→LocalModel.geom_aux/ the frontend models’geometry_aux), the packed sample. This is the functionally pure form of caching: reuse follows provenance (the same object flowing through the loop), invalidation is structural (a new frame simply is a new aux), and it composes with jit (the aux is a pytree leaf, likesweep).Hidden caches on data objects — a memo dict on a frame, keyed by whatever. In a jax-pytree world this is wrong by construction, not by convention: flatten/unflatten drops or duplicates hidden state, a trace poisons it with tracers, and it silently perturbs recompile keys. We re-derived this from first principles during the audit (deliberately setting the house convention aside) and landed in the same place: mechanism 2 is the only cache that is correct by construction here. A related non-mechanism: value-memoization (hash the arrays, cache by content) — rejected outright; hashing costs what it saves, float-equality semantics are a swamp, and reuse should follow provenance, not value coincidence.
The scope ladder#
Match each cached object to the widest scope whose invalidation is structural — keyed by an object identity/value the loop already threads:
scope |
invalidated by |
lives as |
examples |
|---|---|---|---|
per-problem |
new data/sample |
|
packed sample/data, ω, canonical |
per-rank-level |
a genuine rank change |
value-hashed jit aux / closure statics |
uniform masks, compilations |
per-frame (outer step) |
the iterate moving |
a leaf beside |
the sampling sweep, the SF-T3 companion |
per-call |
— |
nothing cached |
the matvec itself |
The refined form of “if we can precompute, we should”:
Precompute at the widest structurally-invalidated scope, through a channel the loop already threads. Once the channel exists, hoisting one more item is nearly free — do it. Creating a NEW state channel for a sub-percent win is where to stop. Never hide state in pytree data objects.
The audit’s findings (2026-08-20)#
Fixed — the regularizer leak. Regularizer.hessian/quadratic called geom.project(frame, p)
without aux, inside gn_hessian/gn_quadratic — i.e. once per CG matvec. For the shared
geometries that recomputed the whole companion (a lossless right sweep + d small GEMMs + the
group SVDs) per matvec — exactly the serial-small-SVDs pattern precompute exists to avoid,
leaking back through one seam. (The original “accepted cost” note was mis-scoped: acceptable per
model, not per matvec.) The protocol now threads aux=None through
gradient/hessian/quadratic, the backend LocalModel and both frontend models pass their
stored companion, and a regression test asserts zero companion rebuilds across repeated regularized
matvecs. This also pre-builds the channel a future shared-geometry Grasedyck–Kramer
SingularValueRegularizer needs — its natural input is the companion’s svd_s, a fourth
consumer of the same per-frame object (projection, retraction, regularizer, spectrum diagnostics).
Recorded, not built — retract→frame fusion. The manifold retraction’s internal T3-SVD returns
a left-orthogonal point, and the outer loop’s next act is frame(x), whose first stage is a
left-orthogonalization (t3_orthogonal_representations even has an unused
already_left_orthogonal=True fast path). Deeper: frame construction computes the centers HH
and discards them; the SF-T3 companion then reproduces them by the bit-identical re-sweep. A fused
retract → (point, frame, companion) seam would save roughly one lossless sweep plus a third of
frame construction per outer step — low single-digit percent of a Newton step (gradient + 10–30
matvecs), but a real fraction of a first-order manifold step, where frame construction is ~⅓ of
the work. Cost: a new fused seam, and the loss of a genuine virtue of the current split — the
companion derives from any frame however obtained (transport targets, user-built frames), one
code path. Verdict: build only if first-order manifold fitting becomes a hot path.
Examined and left alone:
Retaining
HH/Son the frame — mechanism 3; see above. The companion’s re-sweep is the pure-form equivalent at the cost of one cheap sweep per outer step (and its bit-identity with the construction is what makes the⟨O, H⟩pairing exact — seesharing_internals.md).Continuation reading
κ_goff the last companion instead of a fresh groupedt3svd— true thatsvd_siss_g, but continuation is a between-solves decision (once per level), the fresh call also supplies the TT spectra, and the coupling buys nothing measurable.Sharing-specific sampling precompute — probe vectors differ per mode, so tied factors share no contractions; nothing to hoist.
Standalone frontend calls (
geom.project(v)outside a model) recompute the companion by design — theaux=Nonefallback keeps every entry point correct without a model; interactive use is not the hot path.
Checklist for adding a precomputed object#
Name its exact invalidation scope; find the existing channel at that scope (
Problemfield / value-hashed aux / a leaf besidesweep). If none exists, that is a design decision, not a refactor — stop and weigh it.Keep an
aux=Nonerecompute fallback so standalone calls stay correct (the shared-geometry pattern — now the geometry’sgroupsfield).The object flows as a pytree leaf if it holds arrays; only hashable statics go in aux keys.
Audit every consumer seam — the regularizer leak lived precisely in the one seam nobody re-checked after the channel was added.