Efficient fixed-length KV-cache for recurrent Transformer sampling - #525
Open
josephdviviano wants to merge 1 commit into
Open
Efficient fixed-length KV-cache for recurrent Transformer sampling#525josephdviviano wants to merge 1 commit into
josephdviviano wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #525 +/- ##
==========================================
+ Coverage 74.39% 74.49% +0.09%
==========================================
Files 59 59
Lines 10636 10692 +56
Branches 1546 1558 +12
==========================================
+ Hits 7913 7965 +52
- Misses 2219 2222 +3
- Partials 504 505 +1
🚀 New features to boost your workflow:
|
The recurrent Transformer sampling path was both incorrect and slow: the
estimator re-fed the whole committed prefix each step while the module threaded
a growing carry, so the KV-cache and absolute positions diverged (cache grew
0,1,2,4,7 instead of 0,1,2,3,4). It survived CI only because the sole
integration test asserts isfinite(loss).
This makes the recurrent Transformer carry correct and adds an opt-in in-place
KV-cache:
- TransformerDiscreteSequenceModel.init_carry(max_len=...) preallocates
[B, n_heads, max_len, head_dim] K/V buffers + a write cursor; the block writes
new K/V in place (narrow().copy_(), TorchScript-safe) instead of torch.cat.
max_len=None keeps the existing grow-by-cat carry.
- RecurrentDiscretePolicyEstimator gains use_kv_cache: False (default) is a
corrected grad-bearing full-prefix baseline; True feeds only the newest token
against the persistent in-place cache under no_grad (O(L^2) vs the O(L^3)
re-forward). A fixed-length guard rejects ragged active batches; variable-length
carry slicing is out of scope (see the Gap-2 TODO in prob_calculations.py).
Both paths build the canonical [BOS, w0, ..., w_{t-1}] stream and are numerically
identical to one full-sequence forward.
Tests (testing/test_kv_cache.py): dense-equivalence across shapes, in-place
preallocation, decode-position sensitivity, use_kv_cache OFF==ON==teacher-forced,
and the fixed-length guard. Benchmark (tutorials/examples/benchmark_kv_cache.py)
reports the sampling speedup, which widens with sequence length (~5x at L=64, CPU).
Stateless policies and the Sampler API are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
josephdviviano
force-pushed
the
feat/kv-cache
branch
from
July 11, 2026 04:04
e6027f2 to
d9cfd34
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
torchgfn's recurrent Transformer sampling path was both incorrect and slow. The estimator re-fed the whole committed prefix each step while the module threaded a growing carry, so the KV-cache and absolute positions diverged (cache grew
0,1,2,4,7,…instead of0,1,2,3,4; positions likewise). It survived CI only because the sole integration test assertsisfinite(loss).This PR makes the recurrent Transformer carry correct and adds an opt-in in-place KV-cache for fast autoregressive sampling.
Changes
TransformerDiscreteSequenceModel.init_carry(max_len=…)preallocates[B, n_heads, max_len, head_dim]K/V buffers plus a write cursor; the block writes new K/V in place (narrow().copy_(), TorchScript-safe) instead oftorch.cat. The two carry modes are selected by anOptional[int]cursor —Nonekeeps the existing grow-by-cat carry, so this is fully backward compatible.RecurrentDiscretePolicyEstimator(use_kv_cache=…):False(default): corrected, grad-bearing full-prefix baseline.True: feed only the newest committed token against the persistent in-place cache underno_grad— O(L²) rollout cost vs the O(L³) re-forward. Ideal for inference/rollout.cache_max_len(defaults to the module'smax_position_embeddings) is validated eagerly at construction, so an undersized cache fails fast rather than mid-rollout.Both paths build the canonical
[BOS, w0, …, w_{t-1}]stream and are numerically identical to a single full-sequence forward.Scope: fixed-length trajectories
The cached path assumes every active row shares a committed length (true for e.g.
BitSequence). Variable-length carry slicing is out of scope — see the Gap-2TODOinprob_calculations.py. Enforcement is layered so the hot path stays free of per-step host↔device syncs:debug=True— the stronger equal-length and cursor-sync checks.Why
no_grad?In-place cache mutation invalidates the autograd graph (a buffer slice saved for an earlier step's backward is overwritten by a later step). Sampling doesn't need gradients, so the cached path runs under
no_grad. The stacked follow-up (#526) adds an efficient teacher-forced loss recompute that turns this sampling win into an end-to-end training speedup.Tests
testing/test_kv_cache.py— 7 tests (12 with parametrization):test_inplace_cache_matches_full_forward— dense-equivalence vs one full forward, across shape configstest_inplace_cache_is_preallocated_and_mutated_in_place— horizon-length buffers, same tensor object across steps, no realloctest_decode_position_is_load_bearing— perturbing the decode position by ±1 breaks equivalencetest_estimator_cache_equivalence—use_kv_cacheOFF == ON == teacher-forcedtest_estimator_fixed_length_guard— ragged active batches rejected (underdebug)test_cache_max_len_validated_eagerly— undersized cache raises at constructiontest_sampling_runs_in_both_modes— end-to-end rollout in both modesBenchmark
tutorials/examples/benchmark_kv_cache.pyis an instructional example (it explains why KV-caching helps and how it is wired here) and gates every reported speedup behind a|Δ logits|equivalence check.Measured on this branch — single CPU run (Apple Silicon), batch 32, dim 64 / 4 heads / 2 layers,
word_size=1:Absolute figures are CPU- and machine-dependent and vary run to run; the robust claim is the trend — the gap widens with sequence length, as the O(L³) → O(L²) argument predicts.
Unaffected
Stateless policies (hypergrid, etc.),
DiscretePolicyEstimator, and theSamplerAPI are byte-for-byte unaffected. The fulltesting/suite passes on this branch: 1939 passed, 735 skipped.🤖 Generated with Claude Code