Fix #148: walk annotation chain when final buffer is 2+ chunks ahead - #149
Fix #148: walk annotation chain when final buffer is 2+ chunks ahead#149mmjerge wants to merge 4 commits into
Conversation
… ahead _unpack_from_annotation assumed the final buffer for a (layer, kind) node is at most one chunk ahead of the annotation being unpacked. With long generated regions spanning 3+ backward chunks, autograd's backward for intermediate chunks can be served without unpacking their scatter/cat annotations, so the gap grows beyond one chunk and backward died with 'final chunk_idx = 3, must be in [1, 2]'. - _unpack_from_annotation now walks the annotation chain across multi-chunk gaps (all scatter/cat annotations are kept in _packed_arg_for_id by _flush_remaining_pack_arguments), applying intermediate annotations early and parking their reconstructed states in _id_to_unpacked so autograd can still fetch those IDs later. _find_prior_annotation is generalized to _find_chain_annotation. - grpo_step now passes may_match_twice=may_match_twice_flex_attention_sdpa (parity with the finetune path and test_gradient): with the new training replay cache, ext-* annotations legitimately match two pack arguments; leaving the second save unmatched is the likely trigger for the chain stalling in the first place. - New regression test builds a scatter-annotation chain with gaps of 1, 2, and 4 chunks; the >=2 cases fail with the exact issue awslabs#148 error before the fix.
… ahead Port of upstream PR awslabs#149 onto the RL branch: - _unpack_from_annotation walks the scatter/cat annotation chain across multi-chunk gaps, parking early-applied intermediate states - grpo_step passes may_match_twice=may_match_twice_flex_attention_sdpa (kept alongside the KV_DEBUG_ANNOTATIONS tracing hook) - regression test for chunk gaps 1/2/4 Unblocks long-generation runs (lm32k, lp*_html).
… ahead Port of upstream PR awslabs#149 onto the RL branch: - _unpack_from_annotation walks the scatter/cat annotation chain across multi-chunk gaps, parking early-applied intermediate states - grpo_step passes may_match_twice=may_match_twice_flex_attention_sdpa (kept alongside the KV_DEBUG_ANNOTATIONS tracing hook) - regression test for chunk gaps 1/2/4 Unblocks long-generation runs (lm32k, lp*_html).
Followup to the chain walk: 32k GPU runs then failed with 'final chunk_idx = 14, must be >= 15'. When an annotation is applied early (chain walk, or to serve an ext-* annotation for the same chunk) and the buffer is later walked further down, a subsequent unpack of that annotation's own ID found the state already passed. The old 'already done' skip only worked while the final buffer still sat exactly at that chunk. Park the reconstructed state for every early-applied annotation whose ID the autograd graph can request. IDs inserted by _flush_remaining_pack_arguments purely to keep the chain complete are tracked in _orphan_annotation_ids and not parked: nothing fetches them, and retaining cache-sized clones would cost GBs at 32k. Adds a regression test for the out-of-order request.
Device parking kept a full-size clone per walked chunk alive at the tightest point of the backward (~67MB per key/value buffer at cache 16384) and pushed large configurations into OOM. Park on CPU and restore to the original device on request.
|
@mseeger Fair questions — here is the full picture, including a concrete example. Short version: you are right that chunking is unaffected by generation, and right that there can be no holes in the reconstruction. Neither is violated. What breaks is the order in which autograd's backward requests the saved buffer states, and (in the GRPO path) which saved tensors get matched to annotations at all. What is different about our runs (it is not the data)Two things, both specific to the RL path: 1. The GRPO loop constructed the hooks without 2. The backward traversal order deviates more in the GRPO graph. The class docstring already documents that "the backward traversal ordering is not exactly the reverse of the forward traversal ordering", and the existing code special-cases exactly one deviation (an How generation "plays a role": it does not change chunking — chunk boundaries stay a function of cache length and chunk size only. But a gap of size g between the final buffer and a requested annotation needs g+1 saved buffer states between checkpoint columns whose unpack order deviates. With 32-token completions the loss-bearing region touches at most 2 chunks, so the reordering the GRPO graph induces has nothing to reorder across; with 2600-token completions it spans 3+ chunks. That is why hundreds of short-completion QA runs never hit it and long-generation runs hit it stochastically (the exact backward order also depends on which of the doubly-saved tensors matched, which is value-dependent). No holes — all deltas are applied, in orderThe fix does not skip any deltas. Every When annotation (l, c) is requested with final buffer at f > c+1, the walk applies the deltas for chunks f-1, f-2, ..., c+1, then c — strict descending order, every link applied exactly once. If a link were genuinely missing, the new code raises naming the missing chunk ( The one genuinely new piece of state: an intermediate annotation applied early has its reconstructed buffer parked (on CPU) under that annotation's ID. If autograd later asks for that ID — the "mirror" case above — it is served the exact state instead of hitting the walked-past buffer. The values are bit-exact: a scatter undo writes back the stored Small exampleThis is literally what the two new unit tests construct ( Take one layer, values only, cache length 32, three states linked by two scatters: Backward asks for chunk 1's state first (its request for chunk 2's ID comes later, or is satisfied by a raw unmatched copy and never comes):
Validation status (honest)
|
|
Traced run landed ( What the trace showsWithin each cell (4 chunks here), the backward's unpack requests arrive in strictly descending chunk order — no exotic reordering. Representative excerpt (layer 27, cell = chunks 21..24): Two facts matter:
So the invariant that breaks is narrower than "backward order deviates wildly": it is only the lifetime assumption behind the skip branch. The fix parks the state at the moment the scatter is applied early (one CPU copy per early-applied matched annotation, freed when served or at cell clear), so the late request is served bit-exactly. The original gap-2 errors ( Why the RL path and not finetune ("why would a loss head do this")I no longer believe the loss head is the mechanism. Inside a cell the graph structure is head-independent (the head enters only through the injected
That second point is a hypothesis I can test directly: run a finetune job under the same On the efficiency invariants you raisedThe change does not touch the matching side: annotations are still removed on first match (or marked once for Remaining issue (separate from this PR)The traced job — and the 32k reruns — now die in an eager-SDPA temp allocation ( |
|
Ran the two discriminating experiments discussed above. Traces in Experiment B: flex_attention path (your own
|
|
Update: we ran the discriminating experiment for the SDPA hypothesis, and the hypothesis is wrong. Correcting the record here. We added flex support to the RL example (two omissions had kept it off: Same ordering on both. On our RL workload, flex also applies every scatter early through the The corrected picture across all five traced setups:
Same machinery everywhere; the ordering is a property of the specific workload's graph, and we now have direct evidence it is not selected by the SDPA implementation. Which concrete property of the 32k graph causes autograd to consume the ext saves before the scatter saves, we have not isolated — but the measured claim the PR rests on is unchanged and now better supported: multiple orderings exist in practice, the current code assumes one, and the walk + parking make the reconstruction ordering-independent at zero cost to matching/packing (accounting in the earlier comment). Separately: both 32k runs OOM later in the backward (identical 2.31 GiB allocation on both paths, with the forward attention temp limit set). That is a capacity/tuning issue in our 32k recipe, independent of the annotation machinery — the 8k and longproc configs train to completion on the fixed code. |
Review question: can the parking cause memory problems? Make it measurable and bound it in a test rather than argue it. - Track bytes retained by parked states; report peak count and peak bytes in AnnotationUsageLog.report(), so every run prints the real number. - Release accounting when a parked state is fetched. - New test pins the two properties that bound the cost: flush-inserted (orphan) chain links are never parked, so walking a long chain of them costs zero; and a parked state is freed as soon as its request arrives, so the peak is set by outstanding requests, at most (chunks_per_cell - 1) buffers per (layer, kind), independent of model size and step count.
Summary
Fixes #148 (chunked backward fails when a generated region spans 3+ chunks).
_unpack_from_annotationassumed the final buffer for a(layer, kind)node is at most one chunk ahead of the annotation being unpacked (final_idx in [chunk_idx, chunk_idx + 1]). With long generated regions spanning 3+ backward chunks, autograd's backward for the intermediate chunks can be served without unpacking theirscatter-*/cat-*annotations, so the gap grows beyond one chunk and backward dies with e.g.ValueError: Annotation scatter-value (20,1): final chunk_idx = 3, must be in [1, 2].Changes
autograd_hooks.py: walk the annotation chain across multi-chunk gaps. Allscatter/catannotations are guaranteed to be present in_packed_arg_for_idafter_flush_remaining_pack_arguments(they are inserted under fresh IDs even when unmatched, precisely to keep the reconstruction chain complete)._unpack_from_annotationnow applies the intermediate annotations early, in descending chunk order, and parks each reconstructed intermediate state (as a copy, since the buffer is mutated in place) in_id_to_unpacked, so autograd can still fetch those IDs later._find_prior_annotationis generalized to_find_chain_annotation(annotation, chunk_idx); the legacy one-step ext-before-scatter behavior is preserved unchanged.rl/grpo/loop.py: passmay_match_twice=may_match_twice_flex_attention_sdpa. With the new training replay cache,ext-*annotations legitimately match two pack arguments. The finetune path (may_match_twice_factory) andtest_gradient.pyboth pass this predicate; the GRPO loop did not, so the second save of an ext buffer was left as an unmatched raw pack argument. Autograd's backward for that chunk can then be served from the raw copy without advancing the annotation chain — the likely trigger for the adjacency violation. This is the root-cause fix; change (1) makes the unpack robust even if the chain stalls for another reason.Why it only showed with long completions
With at most 2 buffer states per cell the gap can never exceed one chunk. QA-style runs (32-token completions) never hit it; long-generation runs (e.g. 2600-token completions,
chunk_size=1024) hit it stochastically, dependent on per-row eos raggedness — consistent with the reports in #148.Testing
test_unpack_walks_multi_chunk_annotation_chain(parametrized over chunk gaps 1, 2, 4): builds a ground-truth chain of buffer states linked byscatter-valueannotations, sets the final buffer several chunks ahead, unpacks the earliest annotation, and verifies (a) correct reconstruction, (b) parked intermediate states are served when their IDs are unpacked later. The gap ≥ 2 cases fail with the exact Chunked backward fails when a generated region spans 3+ chunks (autograd_hooks annotation adjacency) #148 error before the fix and pass after.test/kvcache/test_autograd_hooks.py: 5 passedtest/kvcache/test_gradient.py: 10 passedtest/kvcache/test_gradient_main.py+test/rl: pass (CUDA-only cases skipped; verified on CPU, macOS)Not verified: an end-to-end GPU reproduction of the original failing GRPO config (long-generation LongProc/longmath runs). We will requeue the previously crashed 32k jobs with this fix and report back on the issue.