Gradient Population — freeze, fan out, observe
Status: active (opened 2026-09-24).
Diagnostic, not a training recipe. Numbers here are not canonical PPL
and do not go through bin/agpt_experiment; they are geometry
measurements of the per-partition gradients that the trainer normally
sums before stepping.
Idea
Big Issue #2 for AGPT is that more prefix sharing means fewer optimizer
updates per epoch. Thomas's reframing (2026-09-23): every training unit
is asked the same question, "how would you nudge this one frozen
model?" Because every unit answers against the same θ, aggregation still
buys full sharing — we just don't sum the answers. The un-summed set
{θ + δ_i} is a population, and its geometry is what tells us where
sharing is cheap (units agree) and where it is expensive (units cancel).
The quantity that directly addresses cadence is coherence:
rho(S) = || sum_{i in S} delta_i || / sum_{i in S} || delta_i ||
rho ≈ 1: the units in S agree, one aggregated step loses nothing. rho ≈ 0: the units cancel; the aggregated step is a small residual of large disagreeing pulls, and sequential steps would have taken a path the sum never sees.
Mechanism
bin/agpt_train_v2 gained an env-var probe (see GradDumpProbeV2 in
src/cudax/agpt_train_v2.cu):
AGPT_GRAD_DUMP_DIR=rnd/gradient-population/<name> \
bin/agpt_train_v2 --config rnd/gradient-population/configs/<cfg>.yml
In train-epoch mode every training unit's fired gradient (after
scale_gradients_for_fire, i.e. the event-mean gradient) is appended
as one float32 row to <dir>/grads.f32, with metadata in
<dir>/units.tsv and the parameter section map in <dir>/layout.json.
The optimizer step is skipped, so all rows are answers about the same
frozen weights. (AGPT_GRAD_DUMP_APPLY=1 keeps the step, to dump the
sequential trajectory instead.) TrainingUnit gained an anchor_id
field so pd>1 units can be tied back to their trie node.
The pd=1 fired gradient should equal the event-weighted mean of its pd=2
children (sum_i ev_i g_i / sum_i ev_i) restricted to targets at
depth ≥ 2 — the pd≥2 "descendant" plan makes the ancestors above the
anchor context-only, so their own next-char targets are never trained at
pd≥2. Events per root are exactly 16/15 between pd=1 and pd=2 for this
reason (events = Σ_depth mass, 16 depths vs 15). Measured with
experimental.loss_depth_min: 2 on the pd=1 side: forward losses agree
to 1e-5, but the gradients agree only to median 2.8% / max 15%
relative error, spread evenly over every parameter section. It is not
the ancestor-scatter path (anc_grad: false gives the same residual),
not chunking (single-chunk units show it too), and not the query weights
(events match exactly). Open item; see Next steps. It does not affect
the population statistics, each row being exactly what the trainer would
fire for that unit at that pd.
Analysis: src/tools/agpt_grad_population.py DUMP_DIR → prints the
tables below and writes DUMP_DIR/summary.json.
Setup
d=64 L=2 h=4 ff=256, depth-16 trie on the carved 95% Shakespeare train
slice (data/.tries/64e9fe109211b366), seq_len 16, anc_grad on, mass
weight linear. Frozen weights taken from the pd=1 Adam run
rnd/stochastic-agpt/20260611T160456-d64l2-depth16-pd1-100ep
(held-out byte PPL 8.04 @ ep10, 5.72 @ ep50, 5.34 @ ep100) plus the
seed. Partition depth 2 → 1401 units (65 root children × their depth-2
children), 108,481 floats per row, ~608 MB per dump. Dumps are
gitignored and regenerable in ~15 s each.
Results (pd=2, frozen fan-out)
Coherence of the population, by checkpoint:
| checkpoint | train loss | ρ global (event-wtd) | ρ global (unwtd) | ρ per-root, event-wtd avg | ρ per-root, unwtd avg | cos within root | cos across root | pairs with cos<0 |
|---|---|---|---|---|---|---|---|---|
| seed | 4.68 | 0.719 | 0.494 | 0.851 | 0.691 | 0.434 | 0.297 | 0.4% |
| ep10 | 2.04 | 0.590 | 0.212 | 0.693 | 0.404 | 0.144 | 0.112 | 18% |
| ep50 | 1.67 | 0.394 | 0.127 | 0.527 | 0.318 | 0.064 | 0.050 | 22% |
| ep100 | 1.60 | 0.130 | 0.049 | 0.351 | 0.279 | 0.008 | 0.000 | 51% |
"ρ global" is what pd=0 (one step per epoch) keeps; "ρ per-root" is what pd=1 keeps, averaged over root children weighted by events.
Cosine to the global event-weighted mean direction, and by unit size (event quartiles), ep100:
| events quartile | units | mean pairwise cos | ρ | mean ‖g‖ (event-mean) |
|---|---|---|---|---|
| 15 – 255 | 352 | 0.0007 | 0.068 | 10.09 |
| 255 – 1620 | 359 | 0.0008 | 0.066 | 3.24 |
| 1620 – 6960 | 352 | 0.0021 | 0.069 | 1.67 |
| 6960 – 393105 | 351 | 0.0081 | 0.102 | 1.16 |
Same table at ep10: cos 0.017 / 0.066 / 0.139 / 0.367, ρ 0.13 / 0.22 / 0.32 / 0.54. Big units agree with each other; small units are noise with large event-mean norms (an event-mean over 15 events is ~10× the norm of one over 100k events).
Per-root coherence at ep100 (unweighted ρ, roots with ≥5 units):
lowest ' 0.18, - 0.20, 0.22, \n 0.22, o 0.23, e 0.23;
highest , 0.81, j 0.73, X 0.71, v 0.64, W 0.54, J 0.49. The
space root (55 units, 2.4M events, 15% of all events) is among the least
coherent at every checkpoint after init.
Covariance spectrum of the centered population (1401 rows in 108k dims):
| checkpoint | participation ratio | top-1 var | top-10 | top-100 | rank for 90% | rank for 99% |
|---|---|---|---|---|---|---|
| seed | 145 | 4.7% | 18% | 58% | 429 | 1015 |
| ep10 | 158 | 2.8% | 17% | 63% | 334 | 799 |
| ep50 | 162 | 2.2% | 16% | 64% | 308 | 784 |
| ep100 | 152 | 2.1% | 16% | 67% | 269 | 707 |
Event-weighted, uncentered (Fisher-like second moment): participation ratio 3.6 → 11.8 → 54.0 → 148.5 (seed → ep100); top-1 share 52% → 3%.
Per-section coherence is flat across sections (ep100 event-weighted ρ
0.11–0.17 for every weight matrix, 0.23 for out_w); the embedding
table carries ~28% of gradient norm.
Reading
- Cancellation is the cost of sharing, and it grows with training. At init the population is nearly a single direction (ρ_w 0.72 for one global step; PR 3.6). By ep100 a pd=0 step keeps 13% of the gradient mass and a pd=1 step keeps 35%; half of all unit pairs point in opposite directions. This is Big Issue #2 measured directly: late in training, aggregation at the root throws away most of what the units asked for.
- Coherence is not uniform over the trie, so a single pd is the
wrong knob. Roots with few, similar continuations (
,jX) stay coherent and can be shared; the space root and vowels cancel and should be split. This is the same asymmetry the oldhotspotexperiment exploited by splitting the space subtree — and it explains why hotspot helped at pd=1 and hurt at pd=6. - Trie proximity buys only a little agreement. Siblings under one root child are more aligned than units under different roots, but by ~1.3–1.5× at ep10/ep50 and both are ~0 at ep100. The topology of the depth-1 split is a weak predictor of gradient direction; what units actually agree on is probably target-distribution similarity, not prefix similarity (untested, see next steps).
- The population is structured, not isotropic. 1401 random rows in 108k dims would give participation ratio ≈ 1401; we see ~150, with 90% of variance in ~270–430 directions. The disagreement lives in a low-hundreds-dimensional subspace. That is the input a quasi-Newton / natural-gradient step would want, and it is the precondition for any "predict the attractor" attempt — met, but not strongly (not rank-10).
- Unit-size normalization is doing damage at fine pd. The trainer
fires the event-mean gradient, so a 15-event unit steps as hard as a
400k-event unit in a direction that is mostly noise (mean pairwise cos
0.0007). That is the mechanism behind the pd=7 breakdown in
rnd/partition-depth/.
Trainer bug found on the way (fixed 2026-09-24)
The first dumps were not reproducible run-to-run even with frozen
weights: per-row gradients differed by 2–7% (median) and >100% (max), and
a unit's forward mean loss differed by up to 0.5%. Stage-by-stage
tensor diffs (AGPT_DIAG_TENSOR_DIR, extended to all layers plus the
per-query loss vector) localized it: first LayerNorm-1 (2 of 18,170 rows
differing), then, after fixing that, the per-query loss vector (48 rows,
some off by 20×). Root cause, confirmed by compute-sanitizer --tool racecheck: every two-phase shared-memory reduction did
float max_val = sdata[0]; // all threads read slot 0
sdata[tid] = local_sum; // thread 0 may overwrite slot 0 first
with no __syncthreads() between. Fixed in src/cuda/kernels.cu
(LayerNorm forward, softmax, fused attention softmax, fused softmax-CE,
batched varlen attention ×2 — these kernels are shared by the v1 and v2
trainers) and src/cudax/kernels_v2.cuh (agpt_loss_per_query_kernel_v2,
which runs 4 warps over the 65-token vocab and was the worst offender).
After the fix: forward loss bit-identical on 65/65 pd=1 units across
runs; gradient rows agree to ~1e-5, which is the backward atomicAdd floor
documented in todo/deterministic-backward.md.
All dumps and numbers in this README were regenerated with the fixed kernels. The pre-fix population statistics agreed with the post-fix ones to three decimals, so nothing above changed materially. Every CUDA AGPT training result before this fix was trained with ~0.3% of per-query losses randomly corrupted by up to 20×. Whether the canonical baselines deserve a re-run is Thomas's call.
Caveats
- pd≥2 dumps do not contain the depth < pd targets (see Mechanism).
- Rows are event-mean gradients. Multiply by
trained_eventsto get the raw summed contribution; the analysis reports both weightings. - The pd=1 ↔ Σ pd=2 identity holds only to ~3% (see Mechanism).
- Operational: do not run
compute-sanitizerwhile any other GPU job is running on this 15 GB / 8 GB-GPU machine; the combination thrashed swap and froze the box on 2026-09-24. Sanitizer logs from the diagnosis are kept in_sanitizer/.
Experiment 2 — coherence-driven cadence, static map (2026-09-24)
Question: does splitting only the incoherent root children deeper beat
uniform partition depth at equal epochs? Five arms, all v2, d64 L2
depth16 seq16, Adam lr 0.0015 warmup-cosine, 100 epochs from the seed,
fixed kernels, evaluated through bin/agpt_experiment on the carved
multi-chunk held-out split. Run dirs 2026…-cadence-*-100ep/; configs in
configs/cadence-*.yml; maps in maps/.
Map construction: roots with ≥5 pd=2 units ranked by unweighted ρ at
ep10, split into tertiles. coherence: top tertile pd1 (share), middle
pd2, bottom pd3 (split). reversed: the same tertiers flipped. Roots with
<5 units → pd1 in both. The mixed plan is experimental.partition_depth_map
(a <token_id> <depth> file) handled by build_mixed_partition_plan_v2.
| arm | units | events/unit median | units <1000 events | ep25 fixed | ep50 fixed | ep100 fixed | ep100 rolling byte | train wall |
|---|---|---|---|---|---|---|---|---|
| pd1 uniform | 65 | 83,488 | 5% | 6.114 | 5.149 | 4.810 | 5.348 | 570 s |
| pd2 uniform | 1,401 | 1,620 | 42% | 5.626 | 5.131 | 4.771 | 6.160 | 604 s |
| pd3 uniform | 11,481 | 154 | 78% | 5.167 | 4.860 | 4.591 | 7.043 | 1186 s |
| coherence map | 3,412 | 140 | 76% | 5.690 | 5.411 | 5.267 | 6.804 | 770 s |
| reversed map | 4,671 | 252 | 71% | 5.324 | 4.990 | 4.778 | 6.438 | 797 s |
"fixed" = agpt_fixed_token_perplexity (every scored token has the full
16-char window). "rolling byte" = canonical lm-eval rolling byte PPL
(each 16-token window scores its first tokens with 0…15 chars of
context).
Full-trie control added 2026-09-28
A post-fix pd=0 run completes the matched cadence comparison. Its resolved
config differs from the pd=1 run only in train.partition_depth and run
identifiers. Both use the same corpus, model seed, optimizer, 100-epoch budget,
and multi-chunk held-out evaluator.
| arm | rolling byte PPL at epoch 100 | fixed-token PPL | train wall |
|---|---|---|---|
| pd=0, one whole-trie update per epoch | 12.054 | 12.110 | 564 s |
| pd=1, one update per root child | 5.348 | 4.810 | 570 s |
The June pre-fix control showed the same difference to two decimal places. The matched post-fix runs now support the cadence result used on the project page; they remain an internal AGPT comparison, not a conventional baseline.
Reading
- The two metrics disagree, and the disagreement is a training-plan artifact. Any pd≥2 plan never trains the targets at depth < pd (the ancestors above the anchor are context-only), so those models have never seen a short-context prediction. Rolling byte PPL scores 1/16 of positions with zero context, 1/16 with one char, etc., and punishes exactly that. Under the full-window metric the ordering flips. For cadence comparisons the fixed-window metric is the fair one; for the canonical number, any split plan needs the shallow targets trained somewhere (e.g. one extra pd1-style pass over the depth<pd stubs).
- The static coherence map loses. At full window it is the worst arm (5.27 vs 4.81 for pd1), and it is the only arm whose curve stalls (5.69 → 5.41 → 5.27 while pd1 goes 6.11 → 5.15 → 4.81). The reversed control, which splits the coherent heavy roots, matches pd2 and pd1. pd3 uniform is best. Conclusion 2 of Experiment 1, as operationalised here, is refuted.
- What the ordering actually tracks is cadence on mass. Splitting the heavy roots (reversed, pd2, pd3) helps; splitting only the light roots (coherence) hurts. The "incoherent" tertile holds 2.5M events against the coherent tertile's 6.5M, and its low unweighted ρ is largely noise from tiny depth-2 children, not disagreement — the very thing Experiment 1's quartile table warned about. Unweighted ρ over children is a poor split criterion; it flags small subtrees.
- Likely mechanism for the stall: Adam with heterogeneous unit
sizes. The trainer fires event-mean gradients, so a 15-event unit
has ~10× the gradient norm of a 400k-event unit, and Adam's global
second-moment estimate is set by whichever units fire most often. In
the coherence arm 76% of units carry 2.8% of the events and fire
between ~30 giant steps per epoch; the heavy steps get normalised
against tiny-unit noise and shrink. In pd3 the same small units exist
but the mass is also delivered in many small steps, so the moments
match the steps. This is a hypothesis; the test is a fire
normalisation proportional to mass (summed rather than mean
gradient, or sqrt-mass), which the v2 trainer does not yet expose
(
train.fire_normaccepts onlymass).
What this does and does not settle
- Settled: a per-root split chosen by unweighted children-coherence, under Adam with event-mean firing, is worse than uniform splitting.
- Not settled: whether coherence properly measured (event-weighted, noise-corrected, at the depth actually being split) and properly applied (mass-proportional steps, or per-unit Adam state) beats uniform pd. Experiment 1's measurement stands; this experiment shows the naive translation into a plan does not.
Next
- Add
train.fire_norm: sum|sqrtto v2 and rerun coherence / reversed / pd3 — the cheapest test of the Adam-heterogeneity hypothesis. - Rebuild the maps from event-weighted ρ with a minimum-mass floor per unit (never split a subtree into units under ~1k events).
- Train the depth<pd stubs so split plans are comparable on the canonical rolling metric.
- Dynamic re-measurement every k epochs remains unbuilt.
Experiment 3 — does the pool converge? (2026-09-24)
Thomas's question, taken literally: start from one model, make a tiny pool, apply random node updates to each member over and over. Do the members converge to the same weights? To different weights? To weights that are the same under some projection?
Setup: θ₀ = the clean pd=1 Adam run's epoch-25 checkpoint. Four members
per learning rate, each a plain-SGD chain (no optimizer state, constant
lr) over the 1401 pd=2 units in its own seeded random order per epoch
(experimental.unit_order_seed), 40 epochs, checkpoints at 1, 2, 5, 10,
20, 30, 40. Two learning rates, 0.02 and 0.1 (0.5 was unstable in a
probe). Analysis: src/tools/agpt_pool_analysis.py; dirs pool/lr*-m*/;
summaries pool/summary-lr*.json. Held-out function-space numbers use
8192 fixed-window targets on the carved held-out slice.
Notation: disp = ‖mean(θ) − θ₀‖; spread = RMS distance of members to the pool mean; NLL in nats/char; pair KL = mean KL between members' next-char distributions on the same held-out targets.
lr = 0.02
| epoch | disp | spread | pair weight dist | member NLL | pool-mean model NLL | pair KL | KL mean→members |
|---|---|---|---|---|---|---|---|
| 1 | 1.35 | 0.65 | 1.01–1.12 | 1.817–1.858 | 1.808 | 0.080 | 0.032 |
| 5 | 3.92 | 0.95 | 1.52–1.58 | 1.802–1.820 | 1.776 | 0.087 | 0.034 |
| 10 | 6.40 | 1.30 | 2.08–2.19 | 1.765–1.795 | 1.745 | 0.095 | 0.038 |
| 20 | 10.23 | 2.12 | 3.38–3.56 | 1.723–1.785 | 1.707 | 0.115 | 0.046 |
| 40 | 15.60 | 4.01 | 6.46–6.72 | 1.675–1.690 | 1.661 | 0.109 | 0.047 |
spread ∝ epoch^0.49.
lr = 0.1
| epoch | disp | spread | pair weight dist | member NLL | pool-mean model NLL | pair KL | KL mean→members |
|---|---|---|---|---|---|---|---|
| 1 | 4.86 | 3.48 | 5.45–5.90 | 1.907–1.994 | 1.881 | 0.216 | 0.097 |
| 5 | 11.90 | 5.87 | 9.26–9.75 | 1.843–1.877 | 1.792 | 0.210 | 0.093 |
| 10 | 17.62 | 8.76 | 13.9–14.6 | 1.777–1.806 | 1.749 | 0.228 | 0.110 |
| 20 | 25.06 | 13.74 | 21.9–22.9 | 1.705–1.762 | 1.719 | 0.249 | 0.147 |
| 40 | 34.29 | 21.77 | 34.9–36.2 | 1.654–1.670 | 1.753 (worse than every member) | 0.246 | 0.232 |
spread ∝ epoch^0.51; spread at matched epoch is 5.4× the lr-0.02 value (lr ratio 5).
Answers
- They do not converge to a point. In weight space the members random-walk apart: spread ∝ √epoch at both learning rates, and ∝ lr, exactly the diffusion term η²·C_g per step summed over steps. No sign of saturation within 40 epochs. So the units do not share a minimizer — this is the conflict regime of the math above, where the pool converges in distribution (a cloud) rather than to a point.
- But the cloud is the same size in function space the whole time.
Pairwise KL between members is flat (0.08 → 0.11 nats at lr 0.02;
0.21 → 0.25 at lr 0.1) while pairwise weight distance grows 6×.
The growing weight-space separation is along directions the
function does not see — it concentrates in the FFN matrices
(
l0.l2_w,l1.l1_w,l0.l1_w, 15–17% each by epoch 40), where a ReLU MLP has its scaling and permutation-like flat directions. That is Thomas's "different, but the same under a projection": the projection is onto function space, and under it the members sit at a constant distance from each other set by the learning rate. - The center of the cloud is a better model than any member — until the cloud outgrows its basin. At lr 0.02 the weight-space mean of the four members beats every member at every checkpoint (1.661 vs 1.675–1.690 at epoch 40) and sits at less than half the pairwise KL from each. At lr 0.1 the same holds through epoch 10, then the mean model degrades and by epoch 40 it is worse than every member: the members have diffused out of a linearly connected region and averaging weights across it no longer gives a valid model. The crossover is at spread ≈ 10–14 in these units.
- The mean drifts ballistically. disp grows ~linearly in epochs (15.6 at 40 for lr 0.02), i.e. the pool's center follows a deterministic flow while the members diffuse around it — the moment-closure picture (mean follows the modified loss, covariance follows the linearised recursion) is the right description, not the N^k tree.
What this buys
- The explosion collapses to (mean, covariance), and the mean is a strictly better model than any chain — a free gain over a single sequential run, as long as the pool stays inside one basin (small lr or periodic re-centering).
- "Where is it headed" is a question about the mean's flow, which is low-dimensional and smooth; the members' diffusion is orthogonal to it in function space.
- Not yet tested: whether the pool mean's flow matches aggregated full-batch GD on the modified loss L̃ (i.e. whether the η² implicit regulariser is measurable here), and whether re-centering the pool every k epochs (Polyak-style averaging and restart) beats a single chain per gradient evaluation.
Experiment 4 — the pool with true per-node updates (2026-09-24)
Thomas's correction to Experiment 3: a pd=2 step is already an aggregated subtree, which hides within-subtree conflict and caps depth. Per-node updates are, by SGD-equivalence, plain SGD on (prefix, next-char) examples; a causal window of 16 chars is 16 nested nodes at depths 1–16 in one step. So this experiment uses no trie at all.
Setup: src/tools/agpt_pool_sgd.py (PyTorch, same architecture and the
same θ₀ = clean pd=1 Adam epoch-25 checkpoint, via agpt_ppl.AGPTModel).
M=4 members. Each step: draw a member uniformly, draw one random
16-char window from the training corpus, one plain SGD step on that
member with the mean causal cross-entropy over the window. 400,000 steps
per pool (≈100k per member ≈ 1.8 epochs of positions per member),
snapshots every 40k. Learning rates 0.002 and 0.01 (single-chain probes:
0.002 improves steadily from θ₀; ≥0.02 first worsens held-out NLL by the
SGD noise floor). Held-out numbers: 8192 fixed-window targets. θ₀ NLL =
1.8337.
lr = 0.002
| step | disp | spread | s/d | member NLL | pool-mean model NLL | pair KL | KL mean→members |
|---|---|---|---|---|---|---|---|
| 40k | 0.76 | 0.48 | 0.63 | 1.810–1.814 | 1.787 | 0.062 | 0.024 |
| 120k | 1.50 | 0.64 | 0.43 | 1.788–1.792 | 1.766 | 0.059 | 0.023 |
| 200k | 2.13 | 0.75 | 0.35 | 1.770–1.776 | 1.749 | 0.061 | 0.024 |
| 400k | 3.45 | 0.92 | 0.27 | 1.744–1.750 | 1.722 | 0.061 | 0.024 |
spread ∝ step^0.28.
lr = 0.01
| step | disp | spread | s/d | member NLL | pool-mean model NLL | pair KL | KL mean→members |
|---|---|---|---|---|---|---|---|
| 40k | 2.70 | 1.62 | 0.60 | 1.860–1.882 | 1.800 | 0.177 | 0.072 |
| 120k | 5.23 | 2.19 | 0.42 | 1.805–1.841 | 1.761 | 0.143 | 0.057 |
| 200k | 7.23 | 2.61 | 0.36 | 1.778–1.791 | 1.731 | 0.143 | 0.058 |
| 400k | 11.11 | 3.60 | 0.32 | 1.728–1.737 | 1.687 | 0.132 | 0.054 |
spread ∝ step^0.35.
Answers, per-node version
- Still no convergence to a point, but now sub-diffusive. Spread grows as step^0.28–0.35, slower than the √step random walk of the pd=2 chains. With true per-node steps the curvature's restoring pull is visible: the cloud is heading toward a saturated size rather than diffusing freely. It has not saturated within 100k steps per member. Spread at matched step scales ~lr^0.85 (3.60 vs 0.92 for 5× lr).
- Constant separation in function space, again. Pairwise KL is flat
from the first snapshot: 0.06 nats at lr 0.002, 0.13–0.18 at lr 0.01,
while weight distance keeps growing. The separation lives in the FFN
matrices (
layers.0.l2,layers.1.l1, 14–17% each) plus the embedding early on — the same flat directions as in Experiment 3. - The pool mean is a much better model than any member, at every snapshot, at both learning rates, and the gap is larger than with aggregated steps. At lr 0.01, step 400k: members 1.728–1.737, mean 1.687 — 0.04–0.05 nats better than the best member, and better than the lr-0.002 pool's mean (1.722) despite far noisier members. No member left the basin (spread 3.6, versus the ≈10–14 where averaging broke in Experiment 3). The noisier pool has the better centre: the members explore, the mean cancels their noise.
- Data efficiency. Each member here saw ≈1.8 epochs of positions and the pool mean reached 1.687. The pd=2 aggregated chains needed 40 epochs per member to reach a mean of 1.661. Per position seen, the per-node pool is ~20× more data-efficient, which is the cadence effect measured end-to-end: many small steps, then average.
Comparison with Experiment 3 (aggregated pd=2 steps)
| pd=2 aggregated chains | per-node SGD pool | |
|---|---|---|
| spread growth | step^0.5 (free diffusion) | step^0.3 (curvature-limited) |
| pair KL | flat, ~0.1 (lr .02) | flat, ~0.06 (lr .002) / ~0.14 (lr .01) |
| pool mean vs members | better until basin breaks (lr 0.1) | better throughout, larger margin |
| data per member for mean ≈ 1.66–1.69 | 40 epochs | ~1.8 epochs |
Aggregation inside the step was hiding both the restoring force and most of the cadence benefit, as Thomas suspected.
What this now supports
- Population as the optimizer, first working form: M plain-SGD members with random node updates, model = their weight mean. At lr 0.01 this beats every member by ~0.05 nats with no extra gradient evaluations beyond what the members do anyway (averaging is free).
- The moment-closure picture holds with per-node steps too: ballistic mean, sub-diffusive cloud, constant function-space radius set by lr.
- Next: (a) re-centre periodically (set all members to the mean every k steps) and compare against one chain at the same gradient budget; (b) larger M (the mean's advantage should grow like the variance reduction, ∝ 1/M, until the drift term dominates); (c) push lr until the basin breaks, to find the per-node version of the ≈10–14 spread threshold; (d) test whether the mean's trajectory matches full-batch GD on the modified loss L̃.
Experiment 4b — pool vs single chain at equal gradient budget (2026-09-25)
All runs: 400,000 single-window SGD steps in total from the same θ₀, held-out NLL of the final model. "mean" = weight average of members.
| run | lr | steps per member | member NLL | model used | NLL |
|---|---|---|---|---|---|
| single chain | 0.01 | 400k | 1.6045 | the chain | 1.6045 |
| single chain + tail iterate average (Polyak from step 200k) | 0.01 | 400k | 1.6045 | running mean of last 200k iterates | 1.5722 |
| 4-member pool | 0.01 | 100k | 1.728–1.737 | mean | 1.6870 |
| 4-member pool, re-centred every 40k | 0.01 | 100k | 1.724–1.741 | mean | 1.6820 |
| 16-member pool | 0.01 | 25k | 1.817–1.844 | mean | 1.7554 |
| 4-member pool | 0.03 | 100k | 1.713–1.725 | mean | 1.6777 |
| 4-member pool | 0.05 | 100k | 1.712–1.735 | mean | 1.7065 |
Reading:
- Averaging is variance reduction, not cadence. At equal budget the single chain that keeps all its sequential steps beats every pool mean by 0.07–0.15 nats. Splitting the budget across members costs sequential time that no amount of averaging buys back (16 members are worse than 4). Four members at 100k steps ≈ one chain at 160k steps.
- Running hotter does not rescue the pool. lr 0.03 gives the best pool mean (1.678) but still far behind the lr-0.01 chain; at lr 0.05 the mean barely beats its members (spread 16, near the basin limit).
- The cheapest variance reduction wins outright. A running average of one chain's iterates over its last 200k steps — zero extra gradient evaluations, no parallel members — gives 1.572, the best number of the whole strand, 0.03 below the raw chain and 0.10 below the best pool mean. Temporal averaging of one chain recovers what spatial averaging of M chains gives, without paying M× in steps.
- What a pool still offers is wall-clock parallelism (M devices, each 1/M of the steps, then average) — the Local-SGD trade — not a better optimizer per gradient evaluation.
Net for "population as the optimizer": the population's value is its mean's noise cancellation, and a single chain's own history is already a population for that purpose. The open question is therefore not the pool but the modified loss L̃ — whether one aggregated (trie-shared) step on L̃ tracks the sequential chain (Experiment 5, in progress).
Experiment 5 — does one aggregated step on the modified loss track the sequential chain? (2026-09-25)
Backward-error analysis says the mean of random-order SGD with step η follows gradient descent on L̃ = L + (η/4)·mean_i‖g_i‖². The extra term's gradient, (η/2)·mean_i H_i g_i, is a per-example Hessian-vector product that aggregates through the trie like a gradient. If full-batch descent on L̃ tracks the pool mean, aggregated AGPT plus one extra backward pass gets the effect of sequential cadence without threading.
Setup (src/tools/agpt_igr_test.py): from the same θ₀ as the lr-0.002
pool (Experiment 4), full-batch GD (65,536-window gradient per step,
lr 0.1 × 2000 steps = learning-rate mass 200, the same as one pool member's
100k × 0.002) in two variants: A on L, B on L̃ with the penalty
gradient estimated from 512 windows per step by finite-difference HVPs.
Compared against the four saved pool members and their mean (spread
0.924, mean displacement 3.452). Distances in parameter L2; KL on 8192
held-out fixed-window targets.
| disp | ‖·−pool mean‖ | ‖·−members‖ | KL(·→pool mean) | held-out NLL | |
|---|---|---|---|---|---|
| pool mean (reference) | 3.452 | 0 | 0.92 (=spread) | 0 | 1.7220 |
| pool members | — | 0.92 | — | ~0.024 | 1.744–1.750 |
| A: GD on L, lr-mass 200 | 3.670 | 1.707 | 1.94 | 0.0237 | 1.6988 |
| B: GD on L̃, lr-mass 200 | 3.392 | 0.949 | 1.31–1.34 | 0.0110 | 1.7126 |
Trajectory (distance to pool mean at lr-mass 50 / 100 / 150 / 200): A 2.47 / 1.84 / 1.57 / 1.71 — B 2.44 / 1.69 / 1.14 / 0.95. mean_i‖g_i‖² ≈ 55 throughout, so the penalty is (0.002/4)·55 ≈ 0.03 nats.
Reading
- The regularizer is real and does what the expansion says. Adding it halves the distance from the full-batch endpoint to the pool mean (1.71 → 0.95), halves the function-space KL (0.024 → 0.011), and brings the displacement magnitude onto the pool's (3.39 vs 3.45; plain GD overshoots to 3.67). The penalised endpoint sits inside the pool cloud: its distance to the mean equals one pool spread, and its distance to each member is √2·spread, exactly what a point at the cloud's centre would show. Plain GD sits two spreads outside. A term worth 0.03 nats accounts for half the discrepancy between one-shot aggregation and sequential SGD.
- It is computable by aggregation. The whole correction is mean_i H_i g_i, one HVP per example along its own gradient, summed — the same algebraic family as the gradient itself, so it factorises through shared prefixes. This is the "branch-point operation" Thomas asked about: not a merge rule on the proposals, but one more accumulated quantity per node.
- But fidelity is not value, at this learning rate. Held-out NLL: plain GD 1.699 < penalised GD 1.713 < pool mean 1.722 < members 1.745. At η = 0.002 the sequential process has no advantage to transfer: it is worse than one-shot aggregation on held-out, and the regulariser faithfully makes full-batch descent worse in the same direction. The regulariser's benefit (SGD's generalisation edge) has to be measured where SGD actually beats GD — at higher η or later in training — which is the run queued next (lr-0.01 pool, lr-mass 1000, ~2.5 h).
Status of the strand after Experiments 1–5
- Big Issue #2 (sharing vs cadence) is now a precise statement: aggregation at one θ loses (i) the SGD noise, which is harmful at small η and cancelled for free by iterate averaging anyway, and (ii) the second-order term (η/4)·mean‖g_i‖², which is recoverable inside aggregation by one extra backward pass. Whether (ii) is worth having is the remaining empirical question.
- The population/pool as an optimiser reduces to iterate averaging (Experiment 4b) plus this regulariser (Experiment 5).
Experiment 5b — value test at lr 0.01 (2026-09-25)
Same protocol against the lr-0.01 pool (members 100k × 0.01 = lr-mass 1000; spread 3.60, mean displacement 11.1, mean NLL 1.687, members 1.728–1.737). Full-batch GD lr 0.1 × 10,000 steps, 65,536-window gradient, penalty from 512 windows/step. ~2 h per variant.
| endpoint | disp | ‖·−pool mean‖ | KL(·→pool mean) | held-out NLL |
|---|---|---|---|---|
| pool mean | 11.11 | 0 | 0 | 1.6870 |
| pool members | — | 3.6 | — | 1.728–1.737 |
| A: GD on L | 10.43 | 10.73 | 0.124 | 1.5882 |
| B: GD on L̃ | 10.32 | 4.92 | 0.033 | 1.6655 |
Trajectory of ‖·−pool mean‖ at lr-mass 250/500/750/1000: A 9.6 / 9.4 / 9.9 / 10.7 (diverging from the chains); B 8.0 / 6.1 / 5.1 / 4.9 (converging onto them). mean‖g_i‖² falls 28 → 24 under the penalty.
Fidelity confirmed, strongly. With five times the noise, plain GD wanders off in a different direction from the sequential chains and keeps going (KL to the pool mean grows along the trajectory); the penalised run converges onto the chains' mean — KL 4× smaller, distance halved and still shrinking, displacement matched. The (η/4)·mean‖g_i‖² term is the fingerprint of sequential threading.
Value negative, again. Plain GD at lr-mass 1000 reaches 1.588 — better than the pool mean (1.687), better than every member, and better than the single 400k-step chain (1.6045, lr-mass 4000). Following the sequential trajectory costs 0.08 nats. At this model/corpus scale, one-shot aggregation is the better optimiser per unit of learning-rate mass, and cadence has no second-order magic to transfer.
The reframing this forces
The axes were being conflated. Two costs:
| sequential chain (batch 1) | full-batch GD | |
|---|---|---|
| steps to ≈1.59–1.60 | 400k | 10k |
| positions processed | 6.4M (0.4 epoch) | 10.5B (~650 epochs) |
| wall here (PyTorch) | 20 min | 4 h |
Per step, aggregation wins 40×. Per position processed, the chain wins 1600×. The chain is launch-overhead bound in PyTorch; the trie processes positions ~300× faster than that chain does, but even so one trie-aggregated GD step ≈ one epoch of trie work (~6 s at pd=1), so 10k of them is ~16 h against the chain's 20 min. Full-batch steps are capped by curvature (lr 0.2 oscillates, 0.5 diverges) — you cannot make each aggregated step cover more lr-mass with first-order updates.
So Big Issue #2, final form: the cadence advantage is a per-compute
advantage, not a per-step one, and no first-order correction to the
aggregated step changes that. The only way an exact, expensive
aggregated step beats many cheap noisy ones is if it extracts far more
than a first-order update per pass — i.e. curvature: Newton-CG,
L-BFGS, natural gradient. That is the optimizer-mismatch thread, and
Experiment 5 hands it a tool: the per-example HVPs that computed the
penalty are exactly the Hessian-vector products a trie-aggregated
Newton-CG step consumes, and they factorise through shared prefixes.
What survives from the population idea:
- iterate averaging (free, +0.03 nats on a chain; Experiment 4b);
- the modified-loss term as a measurable object (Experiments 5, 5b), not as a training improvement at this scale;
- HVP aggregation through the trie as the route to a curvature step.
Experiment 6 — curvature: L-BFGS passes-to-parity (2026-09-25)
The one lever left after Experiment 5b: can a curvature method make one
exact aggregated pass worth many cheap SGD steps? src/tools/agpt_lbfgs_test.py:
same model, same θ₀ (pd=1 Adam epoch-25 checkpoint, held-out 1.8337), a
FIXED random subset of 65,536 windows (≈1.05M positions, about one
corpus' worth) as a deterministic objective, torch L-BFGS with strong-
Wolfe line search, history 50. Every closure evaluation (line-search
trials included) counts as one gradient pass. 600-pass budget, 7.3 min.
| held-out NLL target | plain full-batch GD (Exp 5b A) | L-BFGS | ratio |
|---|---|---|---|
| 1.698 | 2,500 passes | 80 | 31× |
| 1.639 | 5,000 passes | 140 | 36× |
| 1.588 | 10,000 passes | 240 | 42× |
L-BFGS curve (passes → held-out): 100 → 1.664, 200 → 1.600, 300 → 1.566, 400 → 1.546, 500 → 1.532, 600 → 1.5228, still descending at budget. 1.5228 is the best held-out number of the entire strand: below the single chain + iterate averaging (1.572, 400k steps), below plain GD at 10k passes (1.588), and below the pd=1 Adam run's 100-epoch fixed-window result from the seed (1.571).
Reading
- Curvature buys ~40× in passes, and it is enough. The wall-clock break-even against the batch-1 chain needed ~50× fewer passes than GD at the trie's ~6 s/pass (240 passes ≈ 24 min vs the chain's 20 min). L-BFGS lands there, and its asymptote is better than anything the sequential methods reached. In PyTorch on the subset it is 0.73 s/pass: 240 passes = 3 min to the chain's 20-minute result.
- Exact gradients finally pay. This is the regime the optimizer-mismatch note predicted: a quasi-Newton method that is unusable on noisy gradients is very usable on the trie's exact ones. No noise, no averaging, no partition depth — one deterministic objective, ~600 passes.
- "One epoch and done" is closer than it looked. The objective here is one corpus' worth of positions, and L-BFGS solved it (to 1.52) in 600 passes over it. What it still is not: a single pass. The pass count is where the trie's sharing has to do its work.
Experiment 6b — from the seed, and the same-objective control (2026-09-25)
| run | 100 | 200 | 300 | 400 | 500 | 600 passes |
|---|---|---|---|---|---|---|
| L-BFGS from the random seed (held-out 4.639 at start) | 2.037 | 1.831 | 1.739 | 1.678 | 1.643 | 1.611 |
| plain GD lr 0.1 on the same fixed subset, from ep25 | 1.783 | — | 1.769 | — | — | 1.756 |
| L-BFGS from ep25 (Exp 6) | 1.664 | 1.600 | 1.566 | 1.546 | 1.532 | 1.523 |
- Curvature works from scratch, at ~0.035 nats per 100 passes late in the run and still descending at 600. It has not yet reached the pd=1 Adam run's 100-epoch mark (1.571); extrapolating, ~800 passes. On the trie at ~6 s/pass that is ~80 min against Adam pd=1's 9.5 min: from scratch, partitioned Adam still wins wall-clock at this scale by ~8×, with L-BFGS reaching a better asymptote.
- The same-objective control removes any doubt about the subset: GD on the identical fixed 65k windows gets 1.756 in 600 passes where L-BFGS gets 1.523.
- The hybrid is the interesting recipe: 25 epochs of partitioned Adam (2.4 min on the trie) followed by L-BFGS on the exact gradient reached 1.523, below anything else measured, including 100 epochs of Adam (1.571). Cheap noisy steps to get near the basin, exact curvature steps to finish. That is a concrete proposal for the CUDA trainer: pd=1 Adam warm start, then pd=0 L-BFGS.
Next
- Same test from the seed (random init) — passes to reach the Adam pd=1 100-epoch result (1.571); does curvature work from scratch?
- GD on the same fixed subset for 600 passes (same-objective control).
- Full-corpus objective (the trie's true gradient) rather than a subset.
- Then the real thing: L-BFGS driven by the CUDA trainer's aggregated gradient (pd=0, one exact pass per evaluation). The two-loop recursion is host-side vector algebra over 108k floats; the trainer only needs an "evaluate loss+gradient at θ" mode.
Experiment 7 — L-BFGS on the trie's own aggregated gradient (2026-09-25)
bin/agpt_train_v2 gained optimizer: lbfgs (train-epoch mode): every
epoch is one function evaluation — all pd=1 units run with their raw
gradient sums accumulated in a dedicated device buffer (the backward
zeroes d_grads at each unit's first chunk), scaled once by total event
mass. Two-loop recursion, ring-buffer history, Armijo backtracking, all
on device via cuBLAS; only scalars reach the host. Config fields
train.optimizer.history|c1|max_backtracks (also added to the
orchestrator schema). Run 20260925T220537-lbfgs-trie-pd1-ep25-600:
600 passes from the pd1 Adam epoch-25 checkpoint, history 20, 55 min.
| checkpoint | fixed-token PPL | rolling byte PPL |
|---|---|---|
| 100 | 5.270 | 5.787 |
| 200 | 5.088 | 5.605 |
| 300 | 4.989 | 5.512 |
| 400 | 4.975 | 5.499 |
| 600 | 4.888 (NLL 1.587) | 5.407 |
| ref: Adam pd1 100 ep from seed | 4.810 | 5.348 |
Worse than Adam, and much worse than the PyTorch L-BFGS curve on the same start and near-identical objective (first-pass train loss 1.8757 trie vs 1.8732 PyTorch; at pass 600 1.674 vs 1.555). After ~pass 150 the line search failed constantly: 264 of 600 evaluations were backtracks (steps down to 0.002), 332 accepted, 3 history resets.
Diagnosis: the trie gradient, not the line search
- Discriminator.
agpt_lbfgs_test.py --optimizer lbfgs-armijois a line-for-line mirror of the CUDA algorithm. On the exact PyTorch gradient it accepted 593 of 600 steps, 6 backtracks, 0 resets, and reached held-out 1.5287 (torch strong-Wolfe L-BFGS: 1.5228). Same algorithm, same start; only the gradient source differs. - Directional-derivative check (
src/tools/agpt_grad_check.py): trie loss at θ ± ε·ĝ versus ‖g‖.
| point | ‖g_anc‖ | FD / g_anc·d (ε .01 / .003) | FD / g_noanc·d | cos(g_anc, g_noanc) |
|---|---|---|---|---|
| ep25 start | 0.648 | 1.045 / 1.058 | 1.109 / 1.123 | 0.991 |
| L-BFGS pass 600 | 0.076 | 0.995 / 1.020 | 1.079 / 1.106 | 0.929 |
The gradient is right to a few percent along its own direction —
invisible to Adam — but it is not the exact gradient of the loss the
trainer reports, and L-BFGS amplifies exactly the low-curvature
directions where a few-percent error dominates. The ancestor-scatter
path matters (turning it off makes the mismatch 6–10% and rotates the
gradient 7%) but is truncated: the trainer's anc-grad (resolved
2026-05-20, todo/descendant-ancestor-scatter.md) scatters the
ancestor-slot K/V gradient into Wk/Wv only; it does not continue
back through the ancestor's LN1, the earlier layer's residual stream,
or the token embeddings, and ancestor K/V are read from a bf16
cache the backward treats as exact. That note also records that the
formal parity test against a numerical reference was never done —
this check is that test. Truncation is also the likely cause of the
3% pd=1 vs Σpd=2 non-additivity in Experiment 1. (Likely causes, not
yet isolated: truncation vs bf16 can be separated by rerunning the
check with an fp32 cache.)
Experiment 7b — bf16 cache ruled out (2026-09-26)
The ancestor K/V cache element type is now a compile-time switch
(agpt_kv_t in src/cudax/cuda_support.cuh; bf16 default, unchanged —
the default binary reproduces f(θ) = 1.8757458 bit-for-bit).
just build-agpt-train-v2-kvfp32 builds bin/agpt_train_v2_kvfp32;
agpt_grad_check.py --trainer selects the binary.
Finite-difference ratio FD / (g_anc·d), direction ĝ_anc:
| point | bf16 cache (ε .01 / .003) | fp32 cache (ε .01 / .003) | fp32, anc_grad off |
|---|---|---|---|
| ep25 start | 1.045 / 1.058 | 1.044 / 1.049 | 1.108 / 1.113 |
| L-BFGS 600 | 0.995 / 1.020 | 1.010 / 1.033 | 1.087 / 1.111 |
An fp32 cache changes the loss in the 6th decimal and leaves the mismatch where it was. The error is in the backward, not in bf16 rounding. Its sign fits truncation: the true slope is larger than the reported gradient (a dropped contribution), removing the ancestor path entirely roughly doubles the shortfall (4.4% → 10.8% at the start), so the Wk/Wv-only scatter recovers about half of the ancestor contribution and the rest — the path back through ancestor LN1, the earlier layer's residual stream and the embeddings — is what is missing. Step 2 (complete the ancestor backward) is the fix.
Consequence
AGPT's premise is an exact aggregated gradient. The CUDA trainer
delivers an approximate one, and Experiments 6/7 show that the
approximation is precisely what separates "curvature buys ~40×" from
"curvature loses to Adam". Completing the ancestor K/V backward (full
chain through ancestor positions, fp32 cache) is now the single
highest-value item: it is the prerequisite for any second-order
method on the trie, and the L-BFGS mode plus agpt_grad_check.py are
ready as its acceptance test (target: FD ratio 1.000 ± 0.005 and
L-BFGS backtrack rate ≈ 1%).
Experiment 8 — exact ancestor backward (2026-09-28)
Branch ancestor-backward. experimental.anc_grad_exact: true (requires
train.anc_grad: true; train-epoch mode, depth RoPE, no successor table,
sidecar, lightning or node dropout).
What was wrong. A node's attention reads its ancestors' K/V from the
cache. The descendant→ancestor K/V gradient was accumulated per unit and
finalised only as dWk += dKᵀ·h, dWv += dVᵀ·h: no K/V bias gradient,
nothing through the ancestor's LN1, its residual stream, the lower layer
(whose attention reads its ancestors) or the embeddings. It could not be
done in the normal pass because each chunk's backward runs right after its
forward in ascending depth order, before its descendants exist.
The fix. After a unit's normal chunks, a second pass revisits the
unit's internal nodes (edge_mass > 1 — leaves are never attended to;
~11% of query positions), grouped by endpoint depth, deepest group first.
Nodes with equal endpoint depth are never ancestor and descendant, so a
group is batched safely; each group is chunked against the runtime's fixed
node/query/kv capacities. Per chunk the forward is recomputed (same
weights, same cache) and run_backward_ancestor_path_v2 starts from zero
output gradient and injects the accumulated ancestor K/V gradient into the
query-row K/V gradient at every layer, before the K/V projection backward
(inject_anc_dkv_kernel_v2, post-RoPE space, same rows as the
accumulator). The ordinary layer backward then carries it through Wk/Wv
and biases, LN1, the residual stream, lower layers — scattering new
ancestor gradient further up, which the depth order guarantees is complete
before those ancestors are processed — and the embeddings. By linearity,
pass 1 + pass 2 is the exact gradient. The Wk/Wv-only finalize is skipped.
Cost. The second pass covers exactly the 983,047 internal-node positions in 984 chunks and takes ~1.0 s per epoch on top of ~5.6 s at pd=1 (d64 L2, depth-16 Shakespeare): about +18%.
Verification (src/tools/agpt_grad_check.py, extended with --dirs trunc diff: trunc is the old truncated gradient, diff the direction
exact − truncated, where the two disagree most). Ratio = finite-difference
slope / predicted slope; exact = 1.
| point | cache / TF32 | direction | ε 0.01 | ε 0.003 | ε 0.001 |
|---|---|---|---|---|---|
| ep25 start | bf16 / on | ĝ_exact | 1.0001 | 1.0140 | — |
| ep25 start | fp32 / on | ĝ_exact | 1.0007 | 1.0090 | — |
| L-BFGS 600 | fp32 / off | ĝ_exact | 0.9939 | 0.9997 | 1.0003 |
| L-BFGS 600 | fp32 / off | exact − trunc | 1.0187 | 1.0045 | 1.0000 |
Along the difference direction the finite difference is 0.060058; the exact gradient predicts 0.060060 and the truncated one 0.0046 (13× too small). At that checkpoint the truncated gradient was 35° off the true gradient (cos 0.82) and 21% too short (0.0761 vs 0.0968) — enough to explain the 44% backtrack rate in Experiment 7. At the ep25 start the gap is smaller (norm 0.648 → 0.687).
Acceptance: L-BFGS on the exact trie gradient. Run
20260928T151354-lbfgs-trie-exact-pd1-ep25-200, identical to Experiment 7
except anc_grad_exact: true; 200 passes, 1432 s (7.2 s/pass).
| L-BFGS on the trie | passes | accepted | backtracks | train loss | held-out fixed PPL | rolling byte PPL |
|---|---|---|---|---|---|---|
| truncated gradient (Exp 7) | 100 | 89 | 10 (10%) | 1.7440 | 5.270 | 5.787 |
| truncated gradient (Exp 7) | 200 | 149 | 50 (25%) | 1.7127 | 5.088 | 5.605 |
| truncated gradient (Exp 7) | 600 | 332 | 264 (44%) | 1.6740 | 4.888 | 5.407 |
| exact gradient | 100 | 96 | 3 (3%) | 1.7264 | 5.175 | 5.684 |
| exact gradient | 200 | 196 | 3 (2%) | 1.6738 | 4.889 | 5.406 |
All three backtracks are the initial step calibration. The exact run reaches in 200 passes what the truncated run needed 600 for, and is still descending steeply (fixed PPL 5.17 → 4.89 from pass 100 to 200). Reference: Adam pd=1, 100 epochs from the seed, fixed 4.810 / rolling 5.348. The PyTorch exact-gradient curve gained another ~0.08 nats between pass 200 and 600, so a 600-pass trie run should pass Adam.
The fix also improves ordinary Adam training. Run 20260928T153955-adam-pd1-exact-100ep, Adam
pd=1 100 epochs from the seed with anc_grad_exact: true, otherwise
identical to the post-fix baseline 20260924T205951-cadence-pd1-100ep:
| Adam pd=1 from the seed | ep 25 | ep 50 | ep 100 | train loss ep 100 | train wall |
|---|---|---|---|---|---|
| truncated gradient — rolling byte / fixed PPL | 6.637 / 6.114 | 5.704 / 5.149 | 5.348 / 4.810 | 1.6626 | 570 s |
| exact gradient — rolling byte / fixed PPL | 6.330 / 5.792 | 5.619 / 5.040 | 5.225 / 4.684 | 1.6374 | 654 s |
−2.3% rolling byte PPL and −2.6% fixed-window PPL at 100 epochs, better at every checkpoint, for +15% wall. It also wins at equal wall-clock: the truncated baseline curve is nearly flat by then (the June run moved 0.03 between epochs 80 and 100). One seed each; the gap is ~10× the run-to-run differences seen so far. Every AGPT-attention number trained before this fix optimised a slightly wrong gradient.
600 passes: L-BFGS on the exact trie gradient beats Adam. Run
20260928T155740-lbfgs-trie-exact-pd1-ep25-600 (3929 s, 6.5 s/pass), otherwise identical to Experiment 7:
| pass | rolling byte PPL | fixed PPL | backtracks (cumulative) | train loss |
|---|---|---|---|---|
| 100 | 5.668 | 5.159 | 3 | 1.7239 |
| 200 | 5.396 | 4.878 | 3 | 1.6710 |
| 300 | 5.239 | 4.710 | 3 | 1.6398 |
| 400 | 5.149 | 4.614 | 3 | 1.6182 |
| 600 | 5.019 | 4.481 | 3 | 1.5906 |
| Adam pd=1 100 ep, exact | 5.225 | 4.684 | 1.6374 | |
| Adam pd=1 100 ep, truncated | 5.348 | 4.810 | 1.6626 | |
| L-BFGS 600, truncated (Exp 7) | 5.407 | 4.888 | 264 | 1.6740 |
No backtracks after the three initial calibration steps, no history resets, still descending at 600.
Not a compute-matched comparison (correction, Thomas's catch). A pass and an epoch cost the same: one full traversal. This run is 25 Adam epochs
- 600 passes = 625 traversals against 100 for the Adam baseline, so the −4% endpoint gap is bought with 6× the compute. At equal traversals Adam leads clearly: after 100 traversals Adam is at fixed PPL 4.684, while L-BFGS at 125 traversals (warm start + 100 passes) is at 5.159. Whether L-BFGS beats Adam at equal compute needs Adam run for ~625 epochs; Adam was still improving at epoch 100 (5.040 → 4.684 from epoch 50), so that is open. The fair claim today: the exact gradient improves Adam at equal cost (above), and L-BFGS on it reaches a better endpoint than 100 Adam epochs. L-BFGS takes one update per full-trie gradient — the pd=0, full-sharing setting that was worst with plain steps (card 01's 12.05) — so the result also shows full aggregation stops being a handicap with curvature steps.
Compute-matched control: Adam wins. Run 20260928T172438-adam-pd1-exact-625ep: Adam pd=1 with the
exact gradient for 625 epochs from the seed (cosine over the whole run),
checkpoints aligned to the L-BFGS run's traversal counts (25 warm-start
epochs + passes). Train wall 4087 s vs 3929 s + ~160 s warm start —
equal compute, equal wall.
| traversals | Adam rolling / fixed | L-BFGS rolling / fixed | train loss Adam / L-BFGS |
|---|---|---|---|
| 125 | 5.201 / 4.584 | 5.668 / 5.159 | 1.619 / 1.724 |
| 225 | 4.943 / 4.344 | 5.396 / 4.878 | 1.564 / 1.671 |
| 325 | 4.853 / 4.262 | 5.239 / 4.710 | 1.536 / 1.640 |
| 425 | 4.790 / 4.217 | 5.149 / 4.614 | 1.515 / 1.618 |
| 625 | 4.764 / 4.208 | 5.019 / 4.481 | 1.499 / 1.591 |
At equal compute partitioned Adam (65 updates per traversal) beats full-trie L-BFGS (one update per traversal) by ~5–6% at every point, on training loss as well as held-out. Curvature recovers most of what full sharing loses to plain steps (pd=0 plain: 12.05; pd=0 L-BFGS: 5.02), but not all of it. The same flaw applies to the Experiment 6 claim "L-BFGS 1.523 vs Adam epoch-100 1.571": that compared 600 L-BFGS passes with 75 further Adam epochs. What stands: exact gradients help (Adam −2.3% at equal cost), L-BFGS needs them to work at all, and at this scale per- subtree Adam is still the better use of a traversal. Open: L-BFGS on partitioned units (one history per unit is what failed in May), larger models, or a curvature-preconditioned partitioned optimizer.
pd=0 with Adam is unchanged by the fix. Run 20260928T185255-adam-pd0-exact-100ep: Adam pd=0 (one
update per full trie) 100 epochs with the exact gradient, otherwise matched
to 20260928T004439-cadence-pd0-100ep: rolling byte 12.084 vs 12.054,
fixed 12.105 vs 12.110, train loss 2.492 vs 2.489 — identical within
noise. pd=0's poor Adam results were never the gradient bug: 100 updates
in 100 epochs is too few for Adam at this learning rate. The bug is what
kept L-BFGS from rescuing pd=0; with the exact gradient L-BFGS takes the
same one-update-per-traversal setting to 5.02.
Endpoint trends: over the last 200 traversals Adam-625 moved 4.790 → 4.764 (its cosine schedule reaches zero LR at 625, so it is designed to stop); L-BFGS moved 5.149 → 5.019 with no schedule and an unshrinking gradient norm (~0.096), so its limit is not yet measured.
Why L-BFGS cannot simply use pd=1: its curvature pairs (step s, gradient change y) need both gradients from the same loss; consecutive pd=1 steps use different subtrees' losses, so y mostly measures the change of subtree (the May failure). Routes that keep partitioned cadence: per-subtree curvature histories (65 at pd=1, ~1 GB — feasible; the May note ruled this out only at pd=6), or partitioned steps preconditioned by curvature learned from the full-trie gradient once per epoch (stochastic quasi-Newton; the full gradient is the free sum of the epoch's unit gradients).
The 625-epoch Adam run (rolling byte 4.764, fixed 4.208) is also the best AGPT d64 L2 result on record, from the gradient fix plus a longer run.
Default (2026-09-28). anc_grad_exact is now ON by default whenever
train.anc_grad is on. experimental.anc_grad_exact: false restores the
truncated gradient (needed only to reproduce older runs). In unsupported
configurations (non-train-epoch modes, non-depth RoPE, successor table,
target sidecar, lightning, node dropout) the default switches itself off
with a printed notice; an explicit true there is an error.
Measurement note: with TF32 matmuls (the cuBLAS default here) the loss
carries ~3e-5 of rounding noise, which makes small-ε checks wobble (the
curvature estimates disagree by up to 2×). NVIDIA_TF32_OVERRIDE=0 plus
the fp32 cache (bin/agpt_train_v2_kvfp32) removes it: curvature
estimates agree to 1% and the ratios converge as ε shrinks. The same noise
floor may matter for L-BFGS line searches late in training.
Next steps (Experiment 1)
- Coherence-driven cadence. Replace fixed
partition_depthwith a per-subtree split rule: share a subtree while ρ(S) is above a threshold, split it otherwise. Cheapest form: compute ρ per root child from a fan-out pass every k epochs and choose pd per root. Test at the pd=6 recipe on Shakespeare 1M where the pd=6/pd=7 cliff lives. - pd schedule. ρ falls monotonically with training, so early epochs tolerate coarse sharing and late epochs need fine splits — or the opposite reading via critical batch size (noise scale grows, so late training wants bigger aggregates per step). One run each way settles it.
- Cosine vs target-distribution similarity. Test whether units agree because their next-char count distributions are similar rather than because their prefixes are; the counts are in the trie already.
- pd=3 dump (11.5k units, ~5 GB) to see whether the within/across gap grows with depth. Needs a chunked Gram in the analysis script.
- Explain the ~3% pd=1 vs Σ pd=2 gradient non-additivity. Forward is identical, so it is in the backward's treatment of shared context between a unit's nodes. Matters for coherence-driven cadence only if it grows with depth; measure it at pd=3.
- The multi-stage population (members diverge, random node updates a random member) is deliberately not built yet: after stage 1 the prefix Jacobian is no longer shared, and the stage-1 spectrum (PR ~150) does not promise a cheap extrapolation.