docs: M2a — KV-cache decode engine results (token-identical + length-dependent speedup)
Implementation log (docs/18) + Phase-3 row (evolution.md): the two decode primitives and their gates, the engine design (host-cache baseline), the token-identical centerpiece gate, and the measured throughput baseline showing the cache win is sequence-length-dependent (~1.0x@32, ~1.9x@128, naive OOM@256). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -360,3 +360,53 @@ gap is exactly what the verifiable reward in M3 (DPO) / M4 (GRPO) is built to cl
|
||||
held-out correct > 0 confirms the checker + eval harness score real matches (not just format).
|
||||
M1 delivers the format floor + the reusable task spec / checker / eval harness — not arithmetic
|
||||
skill, which is downstream by design.
|
||||
|
||||
### M2a — KV-cache incremental-decode engine (single sequence, landed)
|
||||
|
||||
The decode engine (D3, built up front) that replaces the naive sampler — which re-runs the
|
||||
full forward over the growing prefix every step (O(t²), a fresh autograd graph per token). Two
|
||||
forward-only primitives + a raw-Tensor per-token block forward, each gated in isolation.
|
||||
|
||||
**Primitives (`xtrain-tensor`, both forward-only):**
|
||||
- `Tensor::rope_at(theta, pos0)` — RoPE at a token's *absolute* position (`pos = pos0 + row`,
|
||||
no modulo), vs the training `rope` (`pos = row % period`) which is left untouched (new CUDA
|
||||
kernel `rope_at_k` → no training-path risk). Cached K is stored post-RoPE, so it must match
|
||||
what the full forward produced at that position. **Gate:** bit-identical to the full-sequence
|
||||
rope's row `t` (`integration::rope_at_matches_full_rope_row`).
|
||||
- `Tensor::decode_attention(k, v, scale)` — single-query × cached-K/V SDPA (`[bh,1,hd]` vs
|
||||
`[bh,t,hd]`, no causal mask: the one query sees all cached keys). Composed from the existing
|
||||
strided batched GEMM + plain softmax — **no new kernel**. **Gate:** equals the full causal
|
||||
attention's last query row, max |Δ| 6e-8 (`integration::decode_attention_matches_…`).
|
||||
|
||||
**Engine (`xtrain-model/src/decode.rs`, `generate_greedy_cached`):** per-layer K/V cache +
|
||||
single-token incremental forward. Prefill = the first `prompt.len()` decode steps (one code
|
||||
path). Mirrors `model::block_forward` at the raw-Tensor level (no autograd tape — inference
|
||||
needs no grads), pulling weights via the public `params()` stable order (no model-internal
|
||||
visibility changes). The cache is host-accumulated token-major f32, rebuilt per step — the
|
||||
honest M2a baseline; M2b moves it device-side + adds batched ragged decode.
|
||||
|
||||
**Gate (the M2 centerpiece — token-identical):** KV-cache greedy decode is byte-for-byte the
|
||||
same token sequence as the naive full-recompute greedy. Verified two ways:
|
||||
- `xtrain-train/tests/decode_kv.rs` — small GQA model (8 query / 2 kv heads), F32, 24 generated
|
||||
tokens, exact token-equality. (Unit gate runs F32: a random model's near-uniform logits make
|
||||
argmax fragile to ~1e-6, so the tightest path is used; the trained model below has peaked
|
||||
logits → robust.)
|
||||
- v12 1.05B SFT checkpoint: `eval_arith --cached` produces the **identical** eval outcome to the
|
||||
naive run (format 100/100, correct 8/100) and byte-identical completions.
|
||||
|
||||
**Throughput baseline (v12 1.05B, batch 1, F32, profile-first — measured, not assumed):** the
|
||||
cache win is **sequence-length-dependent**, which is the honest systems finding here:
|
||||
|
||||
| max_new | naive | kv-cache | note |
|
||||
|---------|-------|----------|------|
|
||||
| 32 | 108 tok/s | 111 tok/s | ~1.0× — both **launch/overhead-bound** at short seq |
|
||||
| 128 | 69 tok/s | **133 tok/s** | **~1.9×** — naive's O(t²) recompute starts to bite |
|
||||
| 256 | **OOM** | 129 tok/s | naive rebuilds the O(seq²) graph every step → OOM |
|
||||
|
||||
Cached throughput stays ~constant (O(1)/token compute + constant memory); naive **decays**
|
||||
(108→69 tok/s, O(t)/token) and eventually **OOMs** (the full autograd graph per step). So at the
|
||||
short arithmetic-eval lengths the cache is overhead-bound and gives ~nothing — it matters for
|
||||
**long rollouts** (DPO pair-generation, GRPO completions), exactly where M3/M4 use it. (M2a's
|
||||
per-layer host round-trip is part of why short-seq is overhead-bound; M2b's device-side cache
|
||||
targets it.) This is the same measure-first lesson as T17 (process-per-GPU throughput-neutral):
|
||||
the win is real but only in the regime that actually stresses the bottleneck.
|
||||
|
||||
Reference in New Issue
Block a user