Batching and stacking in T3Toolbox — the complete reference#
Batch/stack axes —
stack_shape, theC/W/Kblocks,K-stacked (tangent-stacked) tangents,vmap/jitover these objects — are the most subtle part of the library. This file is the source of truth for what the conventions are and why; the “Start here” section is enough for most use. (Rules for extending the batching machinery, and the decision history, live incontributor/batching_internals.md.)
Start here — the mental model in one screen#
New to stacking here? Read this section; it is enough to use the library. The numbered sections below are the full reference.
Stacking = leading batch axes on the cores, so one object holds many. Every TuckerTensorTrain /
T3Frame / T3Variations stores its cores as core.shape == stack_shape + (tensor/rank axes). With
stack_shape = (2, 3) you carry a 2×3 grid of T3s/bases/variations in one object, and operations
vectorize over them.
There are three kinds of batch — three “blocks” — that batch different things, on different arrays. Keeping them straight is the whole game; conflating them is the #1 source of confusion:
C— the frame/core stack. A batch of whole T3s / base points on the manifold. Lives on every core (it isstack_shape). Mnemonic: on every Core.W— the probe stack. A batch of probe-vector sets. Lives on the probe vectorswwonly, not the cores. Mnemonic: the w vectors.K— the tangent stack. A batch of tangent vectors attached at the same base point. Lives on the variation cores only. Mnemonic: the k tangent vectors at each frame.
One object can carry more than one block at once. Order is always frame-inner: W + K + C + (axes)
(C innermost, adjacent to the indices; W/K outermost). Why that order? See §3 — it is exactly
what lets a shared frame broadcast over its batch for free.
One concrete example (the picture that makes it click)#
Take d = 3 modes, a batch of 2 base points (C = (2,)), with 3 tangent vectors at each
(K = (3,)), probed by 4 probe-sets (W = (4,)). Then every array’s shape is:
Array |
Blocks |
Shape |
|---|---|---|
frame Tucker core |
|
|
frame TT core |
|
|
variation core |
|
|
probe vector |
|
|
forward probe |
|
|
Read it as: 2 base points, each with its own frame; 3 tangent vectors attached at each; probe every
one against 4 probe-sets → 4 × 3 × 2 probe results per mode. The frame (C) is shared
across the K tangents at it — never copied; frame-inner broadcasting (§3) handles that for free.
⚠️ The word “stack” itself means three different things in this codebase (
stack_shape; the tree↔object machinery instacking.py; the uniform supercore representation). When confused, first decide which. See §1.
Shape-notation legend (how to read the shapes in the code)#
The codebase annotates shapes in trailing comments and encodes them in names. The whole scheme:
A core’s shape is written
stack_shape + (tensor/rank axes), e.g.Bi.shape = stack_shape + (nᵢ, Nᵢ).Capital letters are grouped batch blocks, each standing for zero or more axes:
Cframe/core stack,Wprobe stack,Ktangent stack. An empty block contributes no axes (so the same code handles no-stack and stacked).lowercase letters are single axes — tensor modes / ranks (
n,N,r) or contraction legs (a,i,b,o,j).Order is frame-inner:
W + K + C + (axes)(probe outer, tangent middle, frame inner).Body locals suffix their layout:
mu_WCais an array with axesW + C + (a,).Grouped contractions are einsum strings:
contract('WCa,Caib,WCi->WCb', ...)reads as a per-operand block+leg signature (operand 1 =W+C+(a,); operand 2 = aC-only core with legsa,i,b; operand 3 =W+C+(i,); output frame-innerW+C+(b,)).A leading
din a subscripts term (dWCa) is the uniform layer’s supercore/derivative axis (seedocs/uniform_supercore_layout.md).
Glossary#
Term |
Meaning |
|---|---|
|
the leading batch axes shared by all of an object’s cores; is the frame/core stack |
frame / core stack |
a batch of T3s / base points, on every core ( |
probe stack |
a batch of probe-vector sets, on |
tangent stack |
a batch of tangent vectors at one frame, on the variation cores only; |
frame-inner |
the ordering rule: |
|
a |
heterogeneous stack |
one T3 whose cores have different-but-broadcastable stacks (frame |
“the split is recovered” |
|
|
transpose flag (§11): |
ragged / uniform / weighted |
the three representations. ragged (tuples of arrays) is the default; uniform (supercores + masks) is the jit/GPU mirror of the whole stack ( |
The block letters
W/K/Care deliberately disjoint from the core/variation symbols (U,P,Q,O,G,H,B) and fromJᵀ/tensor/T3, so a shape comment is never ambiguous.
0. TL;DR#
The word “stack” means three different things in this codebase. Keep them apart (§1).
There are three distinct batch blocks —
C(frame/core stack),W(probe stack),K(tangent stack). They batch different things and live on different subsets of the operands (§2).Library-wide convention is frame-inner: the core stack
Cis innermost (adjacent to the tensor indices); the extra stacksW,Kare outermost. Orders:W+C,K+C,W+K+C(§3).There are two machineries for batch axes: a leading
...in einsum (for one broadcastable prefix) and the grouped-einsum interpretercontractions.contract(for two independent blocks on different operand subsets) (§4).A T3 may have heterogeneous-but-broadcastable core stacks (e.g. frame cores
C, one variation coreK+C). This is first-class in the backend;t3_broadcast_to_common_stackmaterializes it when a uniform-stack object is required (§5).Backend functions accept raw tuples and tolerate heterogeneous stacks; frontend dataclasses (
validate) require a uniform stack. That asymmetry explains where broadcasting is lazy vs materialized (§5).The
K/Csplit is not stored — it is recovered from the(frame, variations)pairing (§6).jaxpytree registration makesT3Tangentvmap/jit-able; the frame and variations are both leaves (the same-frame guard is numerical, not identity), so the frame flows as traced data with no per-frame recompile (§7).
1. “Stacking” means three different things#
stack_shape— leading batch axes on ONE object’s cores. Every core of aTuckerTensorTrain/T3Frame/T3Variationsiscore.shape == stack_shape + (tensor/rank axes). A single leading'...'in an einsum rides these axes along for free. This is the common case and the meaning of “stacked” 95% of the time. Caveat: a plain'...'carries exactly one shared/broadcast prefix.backend/stacking.py— converting a Python tree of separate objects ↔ one stacked object.stack(tree, axes)/unstack(S, axes)/tree_zip/apply_func_to_leaf_subtrees. This is its own tree machinery — not jax pytrees, and not the same as meaning (1). It is whatT3Tangent.unstack_*/stack_*,T3Frame.stack, etc. are built on.The uniform supercore (
ut3_*,ufv_*,uniform_*) — a separate representation. One stacked supercore array + boolean rank masks, built forjax.lax.scanvectorization, GPU efficiency, and compile-oncejit— a faster mirror of the ragged layer, optimizers included. Seedocs/uniform_equivalence_contract.mdand the otheruniform_*notes.
When something is confusing, first ask which of these three you are dealing with.
2. The three batch blocks: C, W, K#
Within meaning (1), there are three semantically distinct things one might batch. They are not interchangeable, and a single object can carry more than one at once.
Block |
Name |
Batches… |
Lives on… |
Appears in |
|---|---|---|---|---|
|
frame / core stack |
a batch of T3 objects / base points |
the cores (every core) |
everywhere; |
|
probe stack |
a batch of probe vectors |
the probe vectors |
probing ( |
|
tangent stack |
a batch of tangent vectors sharing one frame |
the variations only (not the frame cores) |
|
Key facts:
Cis shared acrossWandK. A probe stackWof probe vectors is applied to aC-batch of T3s → output carries both. AK-batch of tangents shares oneC-batch of base points.WandKlive on different operands thanC.Wis only onww;Kis only on the variation cores;Cis only on the cores. This is exactly why a single'...'is not enough (§4):'...'broadcasts a shared prefix, butW/Kare present on a subset of operands.In the transpose of probing, the probe stack and the tangent stack coincide:
K == W(each probe residual becomes one tangent) — whensum_over_probes=False; setting itTruesumsWaway. See §11 for the full story on transposes andsum_over_probes.
Concrete shapes (frame-inner, see §3):
a T3 core:
C + (rL, n, rR)(tt core) orC + (n, N)(tucker core)a probe vector
ww[i]:W + (N_i,)a
T3Variationscore:K + C + (rL, n, rR)orK + C + (n, N)a forward probe output
zz[i]:W + C + (N_i,)(=t3_probeoutputW + C)a transpose-probe non-summed output: a
T3TangentwhoseK == W, i.e. variationsK + C
3. The frame-inner convention (and why)#
Rule (library-wide): order batch axes by how core-bound they are. The frame/core stack C is
INNERMOST (adjacent to the indices). Extra stacks — probe W, tangent K — are OUTERMOST.
So the canonical orderings are:
variation cores:
K + C + coreforward probe /
apply/entries:W + Cfull probe of a
K-stacked tangent:W + K + Ctranspose non-summed:
K + C(withK == W)
Why this exact order — the broadcasting mechanism#
This is the reason for the convention, and the thing most worth internalizing.
The bulk of the tangent/T3 operations combine a frame (carrying only C) with a variation
(carrying K + C, or W + C) using plain '...' einsums (to_dense, the gauge projections,
tv_project_t3_onto_tangent_space, corewise linalg). numpy/jax '...' broadcasting is right-aligned.
With frame-inner (
Cinnermost),Cis the trailing suffix ofK + C. So aC-stacked frame core broadcasts cleanly under aK + C-stacked variation core: the trailingCaxes align, and the leadingKaxes are replicated for free — exactly the semantics “the one base point is shared by allKtangent vectors at it.” Same forW + C.With frame-outer (
C + K), the trailing axes would beK,Cwould be the prefix, and the free right-aligned broadcast would mismatch. You would have to insert explicit transposes/reshapes at every boundary.
The custom grouped-block contractions (§4) are flops-neutral to block order (they reshape each block to one flat axis regardless), so they are made to follow the same frame-inner order purely for one consistent layout with no boundary-transpose copies. That is the whole justification — broadcasting determines the order; the contractions just conform.
Mnemonic: “a frame broadcasts over its batch only when the frame axes are on the inside.”
4. The two machineries for batch axes#
(a) One broadcastable prefix → a leading '...' in einsum#
If all operands share one batch prefix — or one operand’s prefix is a frame that broadcasts
under another’s via frame-inner (§3) — a leading '...' handles it for free. This covers to_dense,
the gauge projections, project_*, corewise_*, tv_to_dense/_t3, orthogonalization, etc.
This is why most of the library “just works” with stacking: you write the einsum with '...' and
negative axes, and C/K/W ride along.
(b) Two independent blocks on DIFFERENT operand subsets → contractions.contract#
When two independent batch blocks live on different subsets of the operands, a single '...'
cannot express it: right-aligned broadcasting would force the two blocks to align, but they are on
different operands and must stay independent. The canonical case is probing: the core/frame stack
C (on the cores) and the probe stack W (on the probe vectors only).
So probing is built on the grouped-einsum interpreter backend.contractions.contract:
The subscripts are a standard einsum string with one extension: an UPPERCASE letter is a GROUP of zero or more axes, lowercase letters are single axes — e.g.
contract('WCa,Caib,WCi->WCb', mu, P, xi). An empty group contributes no axes, so the same call handles no-stack / one-stack / both-stack.Each group’s axis count is solved from the operand ndims (a small exact linear system). Whether the ndims can pin a split is a property of the subscripts alone — so a call site either never needs help or always does, and in the latter case
contractraises, naming exactly thelen_<G>=keyword to pass (e.g.contract('WCo,WCa->Cao', z, eta, len_W=1)). Groups that always travel together ('WKCi,Cio->WKCo'— nothing separatesWfromKanywhere) need no split at all: only their combined total matters, and any supplied split is verified but changes nothing.The groups then expand into ordinary single-axis letters and one einsum runs on the operands exactly as given — no reshape, no data movement.
Output order is frame-inner:
W(andK) outer,Cinner. E.g.'...->WCb'returnsW_shape + C_shape + (b,).
A third independent block (forward-probing a K-stacked tangent — W probes, K tangents, C
frame) is just a third capital letter in the string: contract('WKCa,Caib,WCi->WKCb', ...).
Decision rule: if your two batches are on the same operands → '...'. If they are on different
operand subsets and must remain independent → contract. (Full usage guide, with examples and the
guarantees spelled out: grouped_contractions.)
Sharding for multi-GPU: any axis of any group shards freely. contract never reshapes, so every
group sub-axis is an honest einsum axis; sharding a batch/free axis costs zero collectives, and
sharding a summed axis costs exactly the all-reduce the mathematics requires. (This is checked by
compiling every subscripts string in the library under virtual devices —
tests/test_contractions_sharding.py; the history of why it is checked, not just argued, is
contributor material: contributor/batching_internals.md.)
5. Heterogeneous-but-broadcastable tuples, and the backend/frontend split#
A subtle but pervasive pattern: a single T3’s cores may have different (but broadcastable) stacks.
The archetype is a tangent term: the frame cores carry C, but one variation core carries K + C.
Per §3 this is a deliberate, valid layout — frame-inner makes C the suffix of K + C, so the
operation broadcasts the frame over K.
The
'...'-einsum ops handle this for free (gauge,project_*). They never materialize the broadcast — they keep the frame atCand let'...'replicate it. This is the efficient pattern (no|K|copies of the frame).The one reshape-based primitive,
to_dense, is broadcast-aware viat3_broadcast_to_common_stack(backend/t3_operations.py): computenp.broadcast_shapesof all core stacks,broadcast_toeach core up, then contract. No-op for a uniform-stack T3.
Backend tuples vs frontend dataclasses — the validate asymmetry#
This is the thing that decides where broadcasting is lazy vs materialized:
Backend functions take raw
(tucker_cores, tt_cores)tuples and tolerate heterogeneous stacks. Heterogeneous-but-broadcastable tuples are first-class there.Frontend dataclasses validate a UNIFORM stack.
TuckerTensorTrain.validate()(andT3Frame/T3Variations) hard-require every core to share onestack_shape. So any builder that produces a class instance must materialize the broadcast to uniform:tv_to_t3→ must broadcast frame→K+Cbefore concatenating the doubled-rank cores (its output is a validatedTuckerTensorTrain;[U_i ; V_i]is one array).frontend
bvf.fv_to_t3→ mustt3_broadcast_to_common_stackthe mixed-stack term before wrapping inTuckerTensorTrain.whereas
to_denseproduces a bare array, so it can broadcast lazily inside the contraction.
Rule of thumb: bare-array output → broadcast lazily; validated-class-instance output → materialize the broadcast to a uniform stack.
6. The K/C split is recovered, not stored#
T3Tangent bundles a T3Frame (stack C) with a T3Variations (stack K + C). The split is
derived, never stored as a field (minimal dataclasses):
frame_stack_shape(C)= frame.stack_shapestack_shape(K + C)= variations.stack_shapetangent_stack_shape(K)=the part ofvariations.stack_shapethat exceedsframe.stack_shape— i.e.Cis the trailing suffix ofK + C, andKis the leading remainder.
check_fv_pair enforces exactly this: frame.stack_shape must be the trailing (inner) suffix of
variations.stack_shape. (It does not require equality — that was the pre-K-stack invariant.)
T3FrameWeights is frame-like, and follows the same rule. A tangent metric
(weighting.md) is one metric per base point, so it carries C — not K + C — even
though it is absorbed into the variations. Where it batches and what it acts on are different questions;
conflating them is what put an over-strict K + C stack check on the weight predicate until it was fixed.
So pairing a weight with variations uses the trailing-suffix rule (fv_weights_consistent, blind to the
frame like the variations themselves), and check_fw_pair — the weight↔frame analog of check_fv_pair —
enforces the exact weights.stack_shape == frame.stack_shape wherever the frame is present.
Because the split is not recoverable from a bare tree of objects, the two-axis stack/unstack (§ below) must name which stack it peels.
Two-axis stack/unstack on T3Tangent#
A single monolithic stack/unstack cannot faithfully invert two stacks (the K/C split isn’t in
a bare tree). So T3Tangent has two explicit pairs, each peeling one named stack:
unstack_tangents/stack_tangents— peel the tangent stackK. Yields aK-shaped tree of tangents that share the frame (sameT3Frameobject → same tangent space → mutually linalg-compatible). “For each vector within the frame.”stack_tangentsguards that all leaves share the same frame (the same-frame check, as ininner/+).unstack_frame/stack_frame— peel the frame stackC. Yields aC-shaped tree of single-base-point tangents at distinct bases (distinct tangent spaces). “For each frame.”stack_frameplacesCinnermost (variation stack →K + C), which needs interior-axis stacking that the componentT3Variations.stackcan’t do — hence it is a real op, not user-assembled.
T3Frame/T3Variations keep their single plain stack/unstack (each is a single-stack object).
Backend functionals (tv_unstack_tangent_stack/tv_stack_tangent_stack/tv_unstack_frame_stack/
tv_stack_frame_stack in tv_operations.py) do the array/tree work; the T3Tangent methods are
thin wrappers doing the compatibility checks.
7. jax pytrees and vmap — how batching meets autodiff#
The frozen dataclasses are registered jax pytrees (if jax_available: at the end of each module), so they
can be jit/vmap/grad-ed:
TuckerTensorTrain,T3Frame,T3Variations: leaves = the cores (x.data), no aux.T3Tangent: the frame AND the variations are leaves (no aux). Both the fixed frame and the moving tangent vector flow as traced data. Consequences:vmapover aT3Tangentmaps over both the frame and variation leaves. For the batch-of-tangents-sharing-one-frame picture (vmap-over-K, frame fixed), close the frame over and map a function of the variations:vmap(lambda v: f(T3Tangent(frame, v))). TheK-stacked forward probe does not usevmapinternally — it uses the genuine 3-block (W,K,C) contractions (§4): consistent with thecontractinterpreter (the blessed mechanism for independent blocks), no PythonKloop on the numpy path, and low-level einsums fold into XLA at least as well as avmap(which can add layout/transpose churn over a long function).The same-frame guard is NUMERICAL (
safety.frames_equal), not object identity. Two tangents share a tangent space iff their frame cores are value-equal (with anis-identity fast path); the check is safe-mode + eager-only (skips undersafety.unsafe()/ any trace). So it survives a jit round-trip (a reconstructed, value-equal frame passes instead of false-failing) andinner/+jit cleanly.Payoff: NO per-frame recompile. Because the frame is a leaf (traced data, not a compile-time aux constant), a tangent or
GaussNewtonModelcrossing ajitboundary compiles once across all bases (traces=1) — you jit the frontend matvec directly. To grad w.r.t. the variations only, close the frame over; grad-w.r.t.-the-frame is also available (the frame is a leaf).T3Frame/T3Tangentare@dataclass(frozen=True, eq=False): value hash/eq on ndarray fields is ambiguous, so they stay identity-hashed — but the frame is a leaf-bearing pytree node, notaux_data.
vmap/jit invocation across the ops is checked cheaply in tests/test_dispatch.py (a jit-compile
of an op proves no hidden numpy — a stray np.* on a tracer raises).
8. Decoding the names (so you can read the code)#
The naming scheme encodes axis layout — once you know it, the einsums read themselves.
Body locals suffix the axis layout:
C_aib,mu_WCa,B0_b_j_c,xi_WCp. Capitals = grouped index blocks (C/W/K), lowercase = single axes, a leadingd= a stacked/derivative (uniform supercore) axis.apply/entriesuse the sameW(vec/index stack) andC(core stack) as everywhere else.Grouped contractions are
contract('WCa,Caib,WCi->WCb', ...)calls: read each term as a grouped-block einsum signature; output is frame-inner (Wouter,Cinner).Paper ↔ code (Appendix A of the T4S paper):
U=up_tucker,P=left_tt,Q=right_tt,O=down_tt (calleddown_tt_cores, not “outer”);δU=tucker_variations (V),δG=tt_variations (H). The block lettersW/K/Care deliberately disjoint from these core/variation symbols, soVhere means only the Tucker variation andGonly the TT core — no overload.
9. Gotchas (the ones that have actually bitten)#
corewise_dot/corewise_normcollapse EVERY axis (stacks included) to a scalar. To keep the stack (vectorized linalg), usecorewise.corewise_stack_dot(X, Y, n_stack).Validity checkers and residuals are per-stack-element.
is_orthogonal,is_gauged,is_consistent,is_left/right_orthogonal,allclose, and the*_residualproperties reduce over the non-stack axes and return an array of shapestack_shape— a scalar when unstacked, an array when stacked (stack elements can differ: e.g.frame.is_orthogonal()on a(2,)stack →[ True False]). Reduce with.all()for one verdict; the safe-mode preconditions do exactly this at the call site, so they require all stack elements to pass (seedocs/numerical_contracts.md). One asymmetry worth knowing: the structuralhas_minimal_ranksis a scalar in the ragged layer (one core shape ⇒ ranks are shared across the stack, so there is nothing per-element to report) but per-element in the uniform layer (ranks vary per stack element — the determinantal variety,docs/uniform_ranks_and_varieties.md); the numerical checkers are per-element in both layers.to_denseand the doubled-rankto_t3are not symmetric about broadcasting —to_densebroadcasts lazily (bare array out),to_t3materializes (validated class out). See §5.apply/entriesareW+C(vec/index stack OUTER, T3 stack INNER), like everything else.Canonical core-tuple orderings (frontend takes precedence):
TuckerTensorTrain.data = (tucker_cores, tt_cores);T3Frame.data = (up, down, left, right);T3Variations.data = (tucker_variations, tt_variations);(frame, variations)pairs are frame-first. Pass.datastraight through; do not reorder.
10. Where to look (file/function map)#
Concern |
Look at |
|---|---|
The |
|
The grouped-einsum interpreter ( |
|
Probing (2-block |
|
Tree ↔ stacked-object (meaning 2) |
|
Two-axis stack/unstack |
|
The |
|
jax pytree registration + |
bottom of |
The validate/uniform-stack requirement |
|
11. Transposes, adjoints, and sum_over_probes#
Every transpose in the library — probe_transpose, and the apply_transpose/entries_transpose
adjoints on both T3Tangent and TuckerTensorTrain — takes a sum_over_probes flag. This is the one
place where the probe stack W does something subtle, so here is the whole story.
The mental model#
The atomic operation is single-probe: one set of vectors ww, one residual. Everything batched is
that atom lifted over the stacks W/K/C (§2). The transpose lifts the same way:
sum_over_probes=False(default) is the primary transpose. It lifts the atomic adjoint overW, soWstays a passthrough stacking axis — one tangent (or tensor) per probe. This is the plain Jacobian-transpose; it is well-defined on its own, with no reference to summing. HereWbehaves exactly likeCandK: unstack → transpose each → restack.sum_over_probes=Trueis a derived convenience that additionally contractsW:sum_over_probes=True == (sum over the W axes of sum_over_probes=False)
It exists because, in optimization, the
Wprobes are the outputs of one operator on one shared input — the forward broadcasts that single input across allWprobes, and the transpose of a broadcast is a sum. Summing the per-probe contributions gives the one gradient / back-projectionJᵀr.
The trap (which has caught the authors): do not read True as “the real transpose” and False as
a special case. It is the reverse — False is the fundamental object, True is Σ_W applied on top.
Which mode do I want?#
Using
JandJᵀas standalone linear operators, or you want one output per probe →False(default).Assembling a gradient
g = Jᵀr, or a Gauss-Newton Hessian applyJᵀJ vin an optimizer (one residual vector over theWdata points → one gradient) →True.
Either way the two agree by the invariant above, so when in doubt use False and sum the W axes
yourself once you actually need the gradient.
Shape contract#
The residual lives in the forward’s output space; the transpose maps it back. False keeps W as
an output stack; True sums W away; K and C always pass through.
transpose |
residual in |
|
|
|---|---|---|---|
|
|
tangent stack |
tangent stack |
|
|
tangent stack |
tangent stack |
|
|
T3 |
T3 |
The apply/entries adjoints currently take a residual with no K block (K-stacked residuals are a
deliberately deferred probe_transpose-style extension). Their forward outputs
are W + K + C (tangent) and W + C (plain), so the residual-in column is just “the forward output,
adjoint-mapped back.”