Repository navigation
train: the exact walk, one optimizer step per window on the gradient of its whole loss (opt-in) - #47
Conversation
…tes and no optimizer updates The training walk's exact gradient needs dL/d(cached prefix K/V): the sensitivity of a later chunk's loss to the K/V an earlier chunk produced, read back and carried to that chunk. A PARAM would get it, but also an optimizer step and moment buffers allocated once for a fixed set, so a per-chunk copy cannot be one. GGML_TENSOR_FLAG_GRAD (ggml_set_grad) marks a leaf that is a graph node for the backward (not a constant) and needs its gradient, and is never a parameter. test-grad-leaf: a GRAD leaf beside a PARAM gets its exact gradient, the PARAM keeps its own, and an unflagged constant gets none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…o gradients ggml_opt_prepare_alloc (the path llama_context's training takes: a graph per ubatch) keeps the gradient accumulators in ctx_static across graphs, and each backward adds into them in place. The only reset sat before the build, against gb_grad, which is null in this mode, so nothing ever zeroed them: step k applied the SUM of every gradient since the run began. Measured (test-opt-dynamic-accum, SGD on loss = w*x): the second step moved w by 2*lr*x, not lr*x. Upstream has the same code. The fix zeroes the parameter accumulators after the per-step build when a period begins. Static graphs are unchanged (test-opt: 4/4 backend x optimizer pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…node index ggml_opt_build created the gradient accumulators and AdamW momenta once, indexed by the FIRST graph's node order, and bound them to every later graph by that index. A graph built per step may differ in topology (the walk's chunks with and without a cached prefix; its reverse pass with GRAD leaves and a surrogate loss term), and then index i is another node: a parameter's accumulator lands on whatever took its slot. They are now keyed by the parameter tensor (and one for the loss); each build maps its own nodes to them, and a graph training a parameter the context was not built with is refused by assert. test-opt 4/4, test-opt-dynamic-accum, test-grad-leaf pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…xtra term, and GRAD-leaf gradients read back The training walk's exact gradient runs one graph per chunk in reverse and ONE optimizer step per window, with chunk counts that vary by window. ggml_opt_set_next_step(period_end, loss_scale, extra_loss) sets, for the next alloc + eval: whether this graph takes the step or only accumulates, its loss weight (replacing 1/opt_period), and an optional scalar node added to the loss the backward differentiates: the surrogate that carries later chunks' gradient on a chunk's K/V into it. The result keeps reporting the unweighted loss. A period starts from zero gradients after the step that ended the previous one. ggml_opt_leaf_grad returns the gradient computed for a GRAD leaf (marked output, so the allocator keeps it) until the next alloc. test-opt-dynamic-accum: three graphs at weights 0.5 / 0.25 (+ 3 w x) / 1 make one SGD step on exactly their weighted sum, and each graph's dL/dx reads back; a second period starts from zero. Mutation-checked: dropping the extra term fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…read the wrong elements) The slice is ggml_view_4d(grad, ..., grad->nb[1..3], offset), and a view's first stride is the element size. The training attention transposes V for its KQV product, so V's gradient reaches the concat (the walk's [cached prefix | this chunk's V]) through a transpose, with a first stride that is not the element size: the slice read the right NUMBER of elements from the wrong places. K is only permuted (0,2,1,3), keeping its first stride, so it came through intact. Measured on the 1.5B (test-walk-exact, SGD step = -lr*g read off the adapter): the walk's cross-chunk V term had the right norm (0.93) and was orthogonal to the true one (cosine -0.10); with the fix the exact walk in 4 chunks matches one graph at cosine 0.998, and the PLAIN walk's step moved from cosine 0.54 to 0.84 against the one-graph step: every walk-trained adapter had its attn_v gradient scrambled on every chunk with a cached prefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…of its whole loss (opt-in)
The plain walk steps per chunk and stops the gradient at every chunk boundary: a reply learns
only through its own chunk's K/V. walk_exact (llama_opt_params; "exact": true on /train) decodes
the window once under the adapter as it is, then trains it in REVERSE. Each chunk's graph
attends to the cache, its last walk_horizon prefix positions (0 = all) behind a zero GRAD leaf,
and adds the surrogate <K, G> + <V, G> on its own K/V, where G is what every later chunk put
there. Its backward carries that gradient through itself into the adapter and into its prefix's
G. Context chunks train through the surrogate alone (one zero-weighted output row). Each
chunk's loss is weighted by its labelled positions over the window's, so the chunk sum is the
window's loss (Fable). ONE optimizer step per window is also what keeps the reverse pass's
recomputed K/V equal to the cached K/V. Memory stays chunk x window, plus host accumulators of
layers x window x 2 x kv_dim floats; the cost is one decode plus one forward and backward per
chunk. v1 refuses a recurrent model by name (its state's gradient is next).
test-walk-exact (1.5B Q4_K_M, the adapter's SGD step = -lr * gradient):
every position labelled: exact walk in 4 chunks vs one graph: cosine 0.9983, norm 0.9954
(plain walk: cosine 0.81, norm 2.15)
context + reply: exact walk in 4 chunks vs one chunk per run: cosine 0.9982
(plain walk: cosine 0.63)
Mutation-checked: without the CONCAT backward fix it fails at cosine 0.43.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…nks (hybrid models) Fable's ask: a hybrid's linear layers carry the window in their state, so stopping the gradient there is where most of the bias would be. The state is treated like K/V. build_rs puts a zero GRAD leaf on the state ENTERING a chunk (the backward yields dL/d(entry state)), and the two delta-net helpers give the state a chunk LEAVES (the conv state's tail, the delta-net's new state) the surrogate <exit, G>, where G is the next chunk's dL/d(entry state) (build_walk_state_exit). Each exit state feeds exactly one chunk, so G is one vector per state tensor, overwritten as the reverse pass moves back. The forward pass decodes chunk by chunk and snapshots sequence 0's state rows on the host at every chunk start; the reverse pass restores each chunk's entry state before training it (the first chunk starts from zero). The delta-net backward already returns its initial state's gradient and folds in its final state's through the snapshot slots; the conv state goes through CONCAT (#46). test-walk-exact on Qwen3.5-0.8B (attention + gated delta-net), the SGD step read off the adapter: every position labelled: exact walk in 4 chunks vs one graph: cosine 0.9980, norm 0.9997 (plain walk: cosine 0.83, norm 2.13) context + reply: exact walk in 4 chunks vs one chunk per run: cosine 0.9985 Mutation-checked: without the state surrogate it fails (cosine 0.987, norm 0.969). The 1.5B results are unchanged. The test's contexts carry a scratch sequence over a unified cache, which the plain walk needs for a recurrent state; the exact walk keeps host snapshots instead and does not require it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
|
Hybrid support pushed (ed292df), Fable's ask 1. The recurrent state is carried like K/V: a zero GRAD leaf on the state entering each chunk (
Mutation-checked: without the state surrogate it fails (0.987 / 0.969). The 1.5B results are unchanged. Still to come before the 27B: the budget gate for the host accumulators and snapshots (ask 4), and the core sending |
…nd refused by name over its budget (Fable's ask 4) The K/V gradient (layers x positions x (k + v) floats, attention layers only) and a recurrent state's snapshots (chunks x one sequence's state rows) are known before anything is decoded: llama_opt_set_walk_host_budget caps them (/train: walk_host_budget_mib), llama_opt_walk_host_bytes reports them (/train status: walk_host_mib). A recurrent layer keeps no K/V accumulator. test-walk-exact: a 1-byte budget refuses by name and leaves the adapter untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…to fit its host budget A snapshot per chunk boundary is chunks x one sequence's state rows: on the 27B hybrid (48 delta-net layers, ~3.1 MiB each) at 63k in 512-token chunks that is ~18.7 GB beside 8.3 GB of K/V gradient, on a node whose serving engine needs its file cache. Under a host budget the state is checkpointed every `stride` chunks, the smallest stride that fits, and the reverse pass rebuilds a chunk's entry state by restoring its checkpoint and decoding forward to the chunk's start (that decode also rebuilds the attention K/V in between, under the same adapter: the same values). A longer stride trades host memory for decode, never for exactness. A budget the K/V gradient alone exceeds refuses by name, as before. test-walk-exact on the hybrid 0.8B: a budget of 3/4 of the per-chunk-snapshot size checkpoints every 2 chunks (69.8 -> 31.3 MiB) and takes the same step (cosine 1.0000, norm 0.9999). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…nd the run reports the one it used The chunk graph's largest growth with the window is its GRAD leaves (attention layers x horizon x (k + v) floats, twice with their gradient): on the 27B at a 63k horizon that is ~8 GB per pass. The graph preflight refuses a chunk that does not fit before allocating anything, so a memory refusal halves the horizon (whole chunks) and the same chunk is tried again; every later chunk keeps the smaller one. The forward still attends to the whole window and a recurrent state is carried exactly; only attention gradient past the horizon is dropped. A refusal at a horizon of one chunk, or for a node the device cannot run, stands. llama_opt_walk_horizon (and /train's status walk_horizon) reports the horizon the window trained at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
… by type (CI's backend-DL build) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
|
End to end through
The exact walk ends 24% lower on the 1.5B and 14% lower on the hybrid, with one optimizer step per window against the plain walk's one per chunk. Cost: 882 chunk passes against 504 (context chunks train too, plus the forward decode), and the longest window 267 ms against 208 ms. Status reports |
… their llama_opt_params gcc's -Werror=missing-field-initializers failed CI's ubuntu legs on #47; MSVC does not warn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
|
Review at 477e7cd (Fable). The design and the math are right, and the measurements (0.998 against one graph, hybrid included, mutation-checked) are the right proof. What I checked:
Before approval:
Non-blocking: Approve once 1 and 2 are in. |
…measured at the served cache types (Fable on #47) 1. The adaptive horizon ratcheted: a halving that fit one window persisted into every later window and epoch. Each window now starts from the requested horizon (opt_walk_horizon_req). 2. test-walk-exact measures the walk at f16 and q8_0 caches (flash-attention context, V untransposed), where the prefix gradient is taken at the cache's values and applied to the chunk's full-precision K/V (straight-through): cosine 0.9986/0.9981 (f16), 0.9978/0.9979 (q8_0) against one graph on the 1.5B / hybrid 0.8B, the same as F32's 0.998. Bar: 0.99. 3. The invariant that makes the reverse pass valid is stated in the code: no optimizer step between the forward decode and the last chunk's backward. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
|
Approve at 7b90bd1 (Fable). Both blockers resolved:
|
|
Both blockers in at 7b90bd1:
The invariant (no optimizer step between the forward decode and the last chunk's backward) is stated in the code. Non-blocking (3), an adaptive-horizon test, is next. bf16 accumulators per Joel's bit-depth note: I'll measure their cosine before switching, since a bf16 running sum over ~124 chunk additions loses mantissa that a single bf16 store doesn't. |
…o train/walk-exact-gradient # Conflicts: # ggml/src/ggml-opt.cpp # tests/CMakeLists.txt # tests/test-opt-dynamic-accum.cpp
Stacked on #40 (the walk). Opt-in:
"exact": trueon/train(llama_opt_params::walk_exact) pluswalk_horizon(0 = the whole window). With it off, nothing changes.What it does. The plain walk steps per chunk and stops the gradient at every chunk boundary, so a reply learns only through its own chunk's K/V. The exact walk decodes the window once under the adapter as it is, then trains it in reverse:
walk_horizonprefix positions behind a zero GRAD leaf.There is one optimizer step per window. That is also what keeps the reverse pass's recomputed K/V equal to the cached K/V. Memory stays chunk × window, plus host accumulators of layers × window × 2 × kv_dim floats.
Pieces (one commit each, each with its test):
GGML_TENSOR_FLAG_GRAD, a leaf whose gradient is computed and never optimized (test-grad-leaf).ggml_opt_set_next_step/ggml_opt_leaf_grad: a caller-driven period, a weighted loss with an extra term, and GRAD-leaf gradients read back.test-opt-dynamic-accum, mutation-checked.Acceptance (
test-walk-exact -m Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf, comparing the SGD step read off the adapter, i.e. −lr·g, gradients rather than losses per Fable):The remaining ~0.2% is the quantized matmuls' batch-size variance (a 128-row chunk and a 512-row graph take different kernels). Mutation-checked: without #46 the test fails at cosine 0.43.
Not yet (named, not hidden):
🤖 Generated with Claude Code
https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc