Skip to main content

memra_kv/
lib.rs

1//! memra-kv — the dual KV/recurrent cache, extracted (Phase D, ARCHITECTURE-H100.md §5).
2//!
3//! Moved VERBATIM from memra-engine/src/cache.rs behind the `KvDev` seam: the cache only
4//! ever needed 7 device ops (alloc/copy/set), so the trait is that surface and nothing
5//! more. The append/dequant KERNELS stay in the engine fatbins — this crate owns the
6//! structure, sizing math, and the KV format policy (env-selected, shared by the engine's
7//! fatbin router and every cache consumer). memra-engine re-exports this as `cache` so
8//! call sites are unchanged.
9
10// ---------------- KV format policy (env-selected; moved from memra-engine) ----------------
11
12/// Env-selected KV cache formats (MEMRA_KV_K / MEMRA_KV_V). The engine's flash-fatbin router
13/// and the cache sizing below MUST agree — both read this one function.
14pub fn kv_cache_formats() -> (&'static str, &'static str) {
15    static F: std::sync::OnceLock<(&'static str, &'static str)> = std::sync::OnceLock::new();
16    *F.get_or_init(|| {
17        let k = match std::env::var("MEMRA_KV_K").as_deref() {
18            Ok("fp8") => "fp8",
19            Ok("q8_0") | Ok("") | Err(_) => "q8_0",
20            Ok(o) => panic!("MEMRA_KV_K={o} unsupported (q8_0 | fp8)"),
21        };
22        let v = match std::env::var("MEMRA_KV_V").as_deref() {
23            Ok("q4_0") => "q4_0",
24            Ok("fp8") => "fp8",
25            Ok("q5_1") | Ok("") | Err(_) => "q5_1",
26            Ok(o) => panic!("MEMRA_KV_V={o} unsupported (q5_1 | q4_0 | fp8)"),
27        };
28        if (k, v) != ("q8_0", "q5_1") {
29            eprintln!("[memra] KV cache format: K={k} V={v} (non-default — new numeric config)");
30        }
31        (k, v)
32    })
33}
34
35/// Per-32-element block bytes for the selected (K, V) formats.
36pub fn kv_blk_bytes() -> (usize, usize) {
37    let (k, v) = kv_cache_formats();
38    let kb = match k {
39        "fp8" => 32,
40        _ => 34,
41    };
42    let vb = match v {
43        "q4_0" => 18,
44        "fp8" => 32,
45        _ => 24,
46    };
47    (kb, vb)
48}
49
50/// FP8-GLOBALS switch (MEMRA_GEMMA_GKV, default ON): gemma global (hd512) layers keep
51/// their KV in e4m3 — the dequant-latency arc (HANDOVER). Windowed layers stay q8_0/q5_1.
52pub fn gkv_on() -> bool {
53    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
54    *ON.get_or_init(|| {
55        std::env::var("MEMRA_GEMMA_GKV")
56            .map(|v| v != "0")
57            .unwrap_or(true)
58    })
59}
60
61/// FP8-WINDOWED switch (MEMRA_GEMMA_WKV; serving-mode default): SPEC serving (MEMRA_DRAFT
62/// set) -> OFF, plain -> ON — the acceptance-vs-depth record lives on the engine-side
63/// history of `Engine::wkv_on` (git). Explicit env always wins.
64pub fn wkv_on() -> bool {
65    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
66    *ON.get_or_init(|| {
67        std::env::var("MEMRA_GEMMA_WKV")
68            .map(|v| v != "0")
69            .unwrap_or_else(|_| std::env::var("MEMRA_DRAFT").is_err())
70    })
71}
72
73/// Per-model FP8-KV door (-1 = unset → env/default off; 0 = off; 1 = on). Set at qwen
74/// model load: the 2026-07-12 arc closed per-model — 9B +0.7-4% scaling with depth,
75/// 27B flat (weight-bound), 35B −2% (fp8 format-gates its v3 dp4a lane off). Explicit
76/// MEMRA_KV_FP8 wins. 9B adoption attempt REVERTED by measurement 2026-07-29 (−1% at 12k
77/// on the then-current build) — loaders currently store 0.
78pub static KV_FP8_FORCE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
79
80/// QWEN FP8-KV switch (MEMRA_KV_FP8 explicit; else the per-model KV_FP8_FORCE door set
81/// at model load; else OFF). Non-gemma full-attn layers hold e4m3 K/V via the kf8vf8
82/// module.
83pub fn kv_fp8_on() -> bool {
84    static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
85    if let Some(v) = *ENV.get_or_init(|| std::env::var("MEMRA_KV_FP8").ok().map(|v| v == "1")) {
86        return v;
87    }
88    matches!(KV_FP8_FORCE.load(std::sync::atomic::Ordering::Relaxed), 1)
89}
90
91/// Step35 SWA-ring experiment (default OFF). The first cut is deliberately architecture-scoped:
92/// Gemma4's row-0-addressed window kernels cannot consume a rebased ring view.
93pub fn swa_ring_on() -> bool {
94    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
95    *ON.get_or_init(|| std::env::var("MEMRA_SWA_RING").as_deref() == Ok("1"))
96}
97
98/// With the SWA-ring door open, `prime_chunk_tokens` caps every legal chunk at this bound. The
99/// ring carries one whole maximum-size prime chunk in addition to the reader's window.
100pub const PRIME_CHUNK_MAX_TOKENS: usize = 4096;
101const SWA_VIEW_ALIGNMENT_ROWS: usize = 32;
102
103/// Physical rows required by the Step35 SWA reader contract. Prime starts at
104/// `(base_len - (window - 1)) & !31`, so at most 31 masked rows precede the live window.
105pub fn swa_ring_rows(window: usize, max_ctx: usize) -> usize {
106    max_ctx.min(window + PRIME_CHUNK_MAX_TOKENS + (SWA_VIEW_ALIGNMENT_ROWS - 1))
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct KvRing {
111    rows: usize,
112    window: usize,
113    base: usize,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum KvRingAppend {
118    Contiguous {
119        write_row: usize,
120    },
121    Rebase {
122        src_row: usize,
123        keep_rows: usize,
124        new_base: usize,
125        write_row: usize,
126    },
127}
128
129impl KvRing {
130    pub fn new(rows: usize, window: usize) -> Self {
131        assert!(window > 0 && rows > 0, "invalid SWA ring geometry");
132        Self {
133            rows,
134            window,
135            base: 0,
136        }
137    }
138
139    pub fn rows(&self) -> usize {
140        self.rows
141    }
142    pub fn base(&self) -> usize {
143        self.base
144    }
145    pub fn window(&self) -> usize {
146        self.window
147    }
148
149    /// Plan a contiguous physical append. When the tail would wrap, retain the caller's exact
150    /// aligned read prefix at row zero; the following read remains one contiguous CUDA view.
151    pub fn append_plan(
152        &self,
153        len: usize,
154        retain_from: usize,
155        append_rows: usize,
156    ) -> Result<KvRingAppend, String> {
157        if len < self.base || retain_from < self.base || retain_from > len {
158            return Err(format!(
159                "SWA ring lapped required rows (base {}, retain {retain_from}, len {len})",
160                self.base
161            ));
162        }
163        let used = len - self.base;
164        if used > self.rows {
165            return Err(format!(
166                "SWA ring state exceeds capacity ({used} > {})",
167                self.rows
168            ));
169        }
170        if used.saturating_add(append_rows) <= self.rows {
171            return Ok(KvRingAppend::Contiguous {
172                write_row: used % self.rows,
173            });
174        }
175
176        let keep_rows = len - retain_from;
177        if keep_rows.saturating_add(append_rows) > self.rows {
178            return Err(format!(
179                "SWA ring append does not fit (keep {keep_rows} + append {append_rows} > {})",
180                self.rows
181            ));
182        }
183        Ok(KvRingAppend::Rebase {
184            src_row: retain_from - self.base,
185            keep_rows,
186            new_base: retain_from,
187            write_row: keep_rows,
188        })
189    }
190
191    pub fn apply_rebase(&mut self, new_base: usize) {
192        debug_assert!(new_base >= self.base);
193        self.base = new_base;
194    }
195
196    pub fn physical_range(
197        &self,
198        start: usize,
199        end: usize,
200    ) -> Result<std::ops::Range<usize>, String> {
201        if start < self.base || end < start || end - self.base > self.rows {
202            return Err(format!(
203                "SWA ring view [{start},{end}) is outside resident [{},{})",
204                self.base,
205                self.base + self.rows
206            ));
207        }
208        let start_row = (start - self.base) % self.rows;
209        let len = end - start;
210        debug_assert!(
211            start_row + len <= self.rows,
212            "ring view must be contiguous after rebase"
213        );
214        Ok(start_row..start_row + len)
215    }
216
217    /// A rewind is usable only when the next aligned Step35 window view is still resident.
218    pub fn can_rewind_to(&self, len: usize) -> bool {
219        let raw = len.saturating_sub(self.window - 1);
220        let view_start = raw & !(SWA_VIEW_ALIGNMENT_ROWS - 1);
221        view_start >= self.base
222    }
223}
224
225// ---------------- the device seam ----------------
226
227/// The 7 device ops the cache needs — nothing more. Implemented by the engine (and by
228/// any future backend); all ops are stream-ordered on the implementor's worker stream.
229pub trait KvDev {
230    fn zeros(&self, n: usize) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>>;
231    fn uninit(&self, n: usize) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>>;
232    fn alloc_u8(&self, n: usize) -> Result<CudaSlice<u8>, Box<dyn std::error::Error>>;
233    fn htod_i32(&self, v: &[i32]) -> Result<CudaSlice<i32>, Box<dyn std::error::Error>>;
234    fn clone_dtod(
235        &self,
236        src: &CudaSlice<f32>,
237    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>>;
238    fn copy_into(
239        &self,
240        dst: &mut CudaSlice<f32>,
241        off: usize,
242        src: &CudaSlice<f32>,
243        len: usize,
244    ) -> Result<(), Box<dyn std::error::Error>>;
245    fn set_i32_one(&self, d: &mut CudaSlice<i32>, v: i32)
246    -> Result<(), Box<dyn std::error::Error>>;
247}
248
249use cudarc::driver::CudaSlice;
250use memra_gguf::config::{LayerKind, ModelConfig};
251
252/// Per-full-attn-layer growing KV cache, resident on GPU. QUANTIZED (KVQUANT-PLAN §B):
253/// K stored q8_0 (34 B/32 elem), V stored q5_1 (24 B/32 elem). Per-token byte layout keeps the
254/// [token, kv_head, dim] element order so a 32-block never straddles a head (assert head_dim%32==0).
255/// Element-within-token index = kv_head*head_dim + d; block = idx/32; lane = idx%32.
256pub struct KvLayer {
257    pub k: CudaSlice<u8>,   // q8_0 packed, capacity max_ctx*k_tok_bytes
258    pub v: CudaSlice<u8>,   // q5_1 packed, capacity max_ctx*v_tok_bytes
259    pub kv_dim_k: usize,    // head_dim_k * n_head_kv  (K elements per token)
260    pub kv_dim_v: usize,    // head_dim_v * n_head_kv  (V elements per token)
261    pub k_tok_bytes: usize, // (kv_dim_k/32)*34
262    pub v_tok_bytes: usize, // (kv_dim_v/32)*24
263    pub len: usize,
264    /// Step35 SWA physical-row state. `len` remains absolute; `None` keeps the original flat
265    /// `[0, max_ctx)` addressing contract.
266    pub ring: Option<KvRing>,
267    /// Device-resident mirror of `len` (CUDA-GRAPH-PLAN Phase 2). Holds the KV write SLOT for the
268    /// append-dc kernel (old len, before this step's append); after `inc_seqlen` it holds the new
269    /// len == t_kv for fa_decode_dc. Kept in lock-step with the host `len`. i32[1].
270    pub len_d: CudaSlice<i32>,
271}
272
273impl KvLayer {
274    pub fn physical_rows(
275        &self,
276        start: usize,
277        end: usize,
278    ) -> Result<std::ops::Range<usize>, String> {
279        match &self.ring {
280            Some(ring) => ring.physical_range(start, end),
281            None => Ok(start..end),
282        }
283    }
284}
285
286/// Per-linear-attn-layer fixed recurrent state.
287/// conv_state and ssm_state are BOTH kept RESIDENT on GPU — the conv ring assemble + roll runs
288/// on-device (conv_assemble_and_roll), so there is no per-step dtoh/htod for either.
289pub struct RecurLayer {
290    pub conv_state: CudaSlice<f32>, // GPU [conv_dim, d_conv-1] (channel c, tap j at c*pad + j)
291    pub ssm_state: CudaSlice<f32>,  // GPU [d_state, d_state, num_v] transposed M[col][i]
292    /// PERSISTENT second SSM-state buffer for the gdn-scan double buffer (DECODE DETERMINISM FIX).
293    /// gdn_scan needs DISTINCT in/out state buffers. The old eager path allocated a fresh
294    /// `state_scratch` via `e.uninit` every step and swapped its pointer into `ssm_state`; that
295    /// per-step alloc/free churned the stream-ordered async pool, and the freed prior `ssm_state`
296    /// block was recycled by the next step's scratch while a kernel referencing the swapped-in state
297    /// was still in flight — a use-after-reuse that produced RUN-TO-RUN nondeterministic decode
298    /// (two identical prompt primes diverged). We instead PING-PONG between two STABLE resident
299    /// buffers (no per-step alloc/free, no pool churn): step writes into the spare, then swaps the
300    /// two owned buffers in place. Stable pointers, identical math. Sized like `ssm_state`.
301    pub ssm_state_alt: CudaSlice<f32>,
302}
303
304pub struct Cache {
305    pub kv: Vec<Option<KvLayer>>,
306    pub recur: Vec<Option<RecurLayer>>,
307    pub pos: usize,
308    pub max_ctx: usize,
309    /// BATCHED-TICK increment 2 component 3 (lean logits, 2026-08-01): device-side park of
310    /// this session's LAST logits row. Device-sampled rows in the batched serving tick skip
311    /// the [n_vocab] logits D2H entirely; the tick instead dtod-copies the row here (device
312    /// bandwidth, ~µs) so the ONE consumer that truly needs the final row — the KV-reuse
313    /// pool's park-at-retire (an empty-suffix resume samples from parked last_logits) —
314    /// can D2H it once at retire. Lazily allocated on the first lean tick; None on every
315    /// non-lean path (zero cost). Travels with the Cache into the reuse pool.
316    pub last_logits_dev: Option<CudaSlice<f32>>,
317    /// DFlash tap sink (dflash lane, 2026-07-13): when armed, the gemma4 verify/prime
318    /// trunks copy the residual stream AFTER each tapped layer into `buf` rows
319    /// ([t, n_taps*hidden] row-major — the drafter fc input layout). None on every
320    /// non-dflash path (zero cost).
321    pub dflash_taps: Option<DflashTapSink>,
322}
323
324/// The context-linear K/V layout for one full-attention layer. This is the single sizing source
325/// used by both `Cache::new_inner` and `cache_bytes_per_token`: admission must never reimplement
326/// Gemma's per-layer geometry or the active KV-format doors independently from the allocator.
327fn full_attention_kv_layout(cfg: &ModelConfig, il: u32) -> (usize, usize, usize, usize) {
328    debug_assert_eq!(cfg.layer_kind(il), LayerKind::FullAttention);
329    let n_head_kv = cfg.n_head_kv as usize;
330    let (kv_dim_k, kv_dim_v) = match &cfg.gemma4 {
331        Some(g) => {
332            let hd = if g.swa_pattern[il as usize] {
333                g.key_length_swa
334            } else {
335                g.key_length_global
336            } as usize;
337            // E4B ships a SCALAR head_count_kv (per-layer vec empty; scalar = 2 in
338            // the gguf, landing in cfg.n_head_kv): kv_dim = hd * 2 for BOTH kinds —
339            // swa 2x256 = 512, global 2x512 = 1024. The old fallback used
340            // key_length_global (512) for both, which HALVED the global layers' K/V
341            // (the attn writes wk.out_features = 1024 rows): every E4B global layer
342            // stored/attended half its K/V and the batched append read row strides
343            // wrong — THE cross-mode maxdiff-30 root (2026-07-12 bisect, il=5 slot-1
344            // byte forensics). 26B/31B keep the per-layer vec.
345            let d = match g.head_count_kv.get(il as usize) {
346                Some(n) => hd * *n as usize,
347                None => hd * n_head_kv,
348            };
349            (d, d)
350        }
351        None => (
352            cfg.head_dim_k as usize * n_head_kv,
353            cfg.head_dim_v as usize * n_head_kv,
354        ),
355    };
356    assert!(
357        kv_dim_k % 32 == 0 && kv_dim_v % 32 == 0,
358        "KVQUANT requires per-layer kv_dim_k%32==0 && kv_dim_v%32==0 \
359         (layer {il}: k={kv_dim_k} v={kv_dim_v})"
360    );
361    let (kbb, vbb) = kv_blk_bytes();
362    let g4_global_fp8 = gkv_on()
363        && cfg
364            .gemma4
365            .as_ref()
366            .is_some_and(|g| !g.swa_pattern[il as usize]);
367    let g4_windowed_fp8 = wkv_on()
368        && cfg
369            .gemma4
370            .as_ref()
371            .is_some_and(|g| g.swa_pattern[il as usize]);
372    let qwen_fp8 = kv_fp8_on() && cfg.gemma4.is_none();
373    let (kbb_l, vbb_l) = if g4_global_fp8 || g4_windowed_fp8 || qwen_fp8 {
374        (32, 32)
375    } else {
376        (kbb, vbb)
377    };
378    (kv_dim_k, kv_dim_v, kbb_l, vbb_l)
379}
380
381fn kv_plane_allocation_bytes(rows: usize, token_bytes: usize) -> usize {
382    rows * token_bytes + 8
383}
384
385/// Context-linear bytes allocated by one trunk cache token.
386///
387/// Fixed allocations (the 8-byte plane tail pads, `len_d`, recurrent state, and optional lazy
388/// buffers) are deliberately excluded. Admission adds their measured high-water residual as a
389/// request-independent activation term; multiplying this coefficient by the request's own
390/// `ctx_cap` exactly mirrors the context-scaled allocations in `Cache::new_inner`.
391pub fn cache_bytes_per_token(cfg: &ModelConfig) -> usize {
392    cache_bytes_per_token_for_layers(cfg, 0, cfg.n_layer as usize)
393}
394
395/// Context-linear cache bytes per token owned by layers in `[lo, hi)`. PP admission uses the
396/// same layer ranges as `Cache::new_ppn`, so each device is charged for exactly the cache planes
397/// it allocates rather than for the aggregate model geometry.
398pub fn cache_bytes_per_token_for_layers(cfg: &ModelConfig, lo: usize, hi: usize) -> usize {
399    assert!(
400        lo <= hi && hi <= cfg.n_layer as usize,
401        "cache layer range out of bounds"
402    );
403    let shared = cfg.gemma4.as_ref().map(|g| g.shared_kv_layers).unwrap_or(0);
404    (lo as u32..hi as u32)
405        .filter(|&il| cfg.layer_kind(il) == LayerKind::FullAttention)
406        .filter(|&il| shared == 0 || il < cfg.n_layer - shared)
407        .map(|il| {
408            let (kv_dim_k, kv_dim_v, kbb, vbb) = full_attention_kv_layout(cfg, il);
409            (kv_dim_k / 32) * kbb + (kv_dim_v / 32) * vbb
410        })
411        .sum()
412}
413
414/// Portion of [`cache_bytes_per_token`] whose physical row count is capped by the Step35 SWA
415/// ring. Zero with the flag off and for every non-Step35 architecture.
416pub fn cache_ring_bytes_per_token(cfg: &ModelConfig) -> usize {
417    cache_ring_bytes_per_token_for_layers(cfg, 0, cfg.n_layer as usize)
418}
419
420/// Ring-capped portion of [`cache_bytes_per_token_for_layers`] for `[lo, hi)`.
421pub fn cache_ring_bytes_per_token_for_layers(cfg: &ModelConfig, lo: usize, hi: usize) -> usize {
422    assert!(
423        lo <= hi && hi <= cfg.n_layer as usize,
424        "cache layer range out of bounds"
425    );
426    if !swa_ring_on() || !cfg.arch.is_step35() {
427        return 0;
428    }
429    let shared = cfg.gemma4.as_ref().map(|g| g.shared_kv_layers).unwrap_or(0);
430    (lo as u32..hi as u32)
431        .filter(|&il| cfg.layer_kind(il) == LayerKind::FullAttention)
432        .filter(|&il| shared == 0 || il < cfg.n_layer - shared)
433        .filter(|&il| {
434            cfg.layer_geometry(il)
435                .is_some_and(|geometry| geometry.window.is_some())
436        })
437        .map(|il| {
438            let (kv_dim_k, kv_dim_v, kbb, vbb) = full_attention_kv_layout(cfg, il);
439            (kv_dim_k / 32) * kbb + (kv_dim_v / 32) * vbb
440        })
441        .sum()
442}
443
444/// Physical row cap shared by the Step35 SWA trunk and MTP scratch; zero when no ring is active.
445pub fn cache_ring_row_cap(cfg: &ModelConfig) -> usize {
446    if !swa_ring_on() || !cfg.arch.is_step35() {
447        return 0;
448    }
449    cfg.geometry
450        .as_ref()
451        .and_then(|table| table.classes().iter().find_map(|geometry| geometry.window))
452        .map(|window| swa_ring_rows(window as usize, usize::MAX))
453        .unwrap_or(0)
454}
455
456/// See [`Cache::dflash_taps`]. Armed per forward by the dflash round (t = that forward's
457/// row count); the trunk writes tap slot s of row r at buf[r*n_taps*hidden + s*hidden ..].
458pub struct DflashTapSink {
459    pub layer_ids: Vec<usize>,
460    pub buf: CudaSlice<f32>,
461    pub hidden: usize,
462    pub t: usize,
463}
464
465/// Snapshot of the dual cache taken BEFORE a spec-decode draft+verify round (MTP-PLAN §C/§D.4).
466/// - Full-attn KV: only the per-layer `len` is recorded; rollback truncates (append-only,
467///   position-addressed — no copy). C.1.
468/// - Linear-attn conv/ssm: real device-to-device COPIES of the recurrent state, because those
469///   buffers are mutated IN PLACE by the verify pass and have no position index to truncate. C.2.
470///   (CudaSlice::clone is an Arc refcount, NOT a buffer copy — so we alloc fresh + memcpy_dtod.)
471pub struct CacheSnapshot {
472    pub kv_len: Vec<Option<usize>>, // per layer (Some for full-attn layers)
473    pub conv: Vec<Option<CudaSlice<f32>>>, // per layer (Some for linear-attn layers, D2D copy)
474    pub ssm: Vec<Option<CudaSlice<f32>>>,
475    pub pos: usize,
476}
477
478impl Cache {
479    /// Allocate GPU-resident caches sized by arch + max context.
480    pub fn new(
481        e: &impl KvDev,
482        cfg: &ModelConfig,
483        max_ctx: usize,
484    ) -> Result<Self, Box<dyn std::error::Error>> {
485        Self::new_inner(&|_| e, cfg, max_ctx)
486    }
487
488    /// M1-PP2 increment 2 (stage-owned KV): layers [0, split) allocate through `dev0`,
489    /// layers [split, n) through `dev1` — each pipeline stage's cache lives on the
490    /// device that runs the stage. With dev0 == dev1 this is byte-for-byte `new`
491    /// (the single-device plumbing gate). Sizing math is IDENTICAL either way.
492    pub fn new_pp2(
493        dev0: &dyn KvDev,
494        dev1: &dyn KvDev,
495        split: usize,
496        cfg: &ModelConfig,
497        max_ctx: usize,
498    ) -> Result<Self, Box<dyn std::error::Error>> {
499        Self::new_inner(&|il| if il < split { dev0 } else { dev1 }, cfg, max_ctx)
500    }
501
502    /// M2 N-stage twin of `new_pp2`: `fence` is the stage map from `memra_engine::pp::
503    /// pp_cuts` ([0, c1, .., n_trunk]); layer il allocates through the engine of the
504    /// stage that runs it. Layers at/beyond the fence end (MTP/NextN blocks) allocate
505    /// through the LAST stage. Sizing math is IDENTICAL to `new` — only the allocating
506    /// device varies.
507    pub fn new_ppn<'a>(
508        devs: &[&'a dyn KvDev],
509        fence: &[usize],
510        cfg: &ModelConfig,
511        max_ctx: usize,
512    ) -> Result<Self, Box<dyn std::error::Error>> {
513        assert_eq!(
514            devs.len() + 1,
515            fence.len(),
516            "ppn cache: devs vs fence mismatch"
517        );
518        let pick = |il: usize| -> &dyn KvDev {
519            let s = match fence[1..fence.len() - 1].binary_search(&il) {
520                Ok(k) => k + 1,
521                Err(k) => k,
522            };
523            devs[s.min(devs.len() - 1)]
524        };
525        Self::new_inner(&pick, cfg, max_ctx)
526    }
527
528    /// Shared allocation walk: `pick(il)` supplies the device that OWNS layer il's
529    /// cache state (always the same device outside the pp2 door).
530    fn new_inner<'a>(
531        pick: &dyn Fn(usize) -> &'a dyn KvDev,
532        cfg: &ModelConfig,
533        max_ctx: usize,
534    ) -> Result<Self, Box<dyn std::error::Error>> {
535        let n = cfg.n_layer as usize;
536        let mut kv = Vec::with_capacity(n);
537        let mut recur = Vec::with_capacity(n);
538        let head_dim_k = cfg.head_dim_k as usize;
539        let head_dim_v = cfg.head_dim_v as usize;
540        assert!(
541            head_dim_k % 32 == 0 && head_dim_v % 32 == 0,
542            "KVQUANT requires head_dim_k%32==0 && head_dim_v%32==0 (got k={head_dim_k} v={head_dim_v})"
543        );
544        let (conv_dim, d_state, num_v, d_conv) = if let Some(s) = &cfg.ssm {
545            let num_k = s.group_count as usize;
546            let num_v = s.time_step_rank as usize;
547            let ds = s.state_size as usize;
548            (
549                ds * num_k * 2 + ds * num_v,
550                ds,
551                num_v,
552                s.conv_kernel as usize,
553            )
554        } else {
555            (0, 0, 0, 0)
556        };
557        for il in 0..cfg.n_layer {
558            // stage-owned allocation (pp2): the device that runs this layer allocates it.
559            let e = pick(il as usize);
560            // E4B KV-SHARING: the trailing shared_kv_layers have no k/v of their own — they
561            // attend an earlier layer's cache (hybrid_forward resolves the target). No KvLayer
562            // here: any accidental use is a loud unwrap at bring-up, and rewind/len loops
563            // (iter_mut().flatten()) skip None naturally.
564            let g4_shared = cfg.gemma4.as_ref().map(|g| g.shared_kv_layers).unwrap_or(0);
565            if g4_shared > 0 && il >= cfg.n_layer - g4_shared {
566                kv.push(None);
567                recur.push(None);
568                continue;
569            }
570            match cfg.layer_kind(il) {
571                LayerKind::FullAttention => {
572                    // Gemma per-layer geometry and every KV-format door are resolved by the same
573                    // helper admission uses for its analytic byte coefficient.
574                    let (kv_dim_k, kv_dim_v, kbb_l, vbb_l) = full_attention_kv_layout(cfg, il);
575                    let k_tok_bytes = (kv_dim_k / 32) * kbb_l;
576                    let v_tok_bytes = (kv_dim_v / 32) * vbb_l;
577                    let ring = if swa_ring_on() && cfg.arch.is_step35() {
578                        cfg.layer_geometry(il)
579                            .and_then(|geometry| geometry.window)
580                            .map(|window| {
581                                let window = window as usize;
582                                KvRing::new(swa_ring_rows(window, max_ctx), window)
583                            })
584                    } else {
585                        None
586                    };
587                    let alloc_rows = ring.as_ref().map(KvRing::rows).unwrap_or(max_ctx);
588                    kv.push(Some(KvLayer {
589                        // +8B tail pad: the v4 stage's aligned funnelshift window reads up to
590                        // 4B past the final block (PR #3's finding, adopted pad-style — the
591                        // expert-dot precedent; zero hot-loop branches, values discarded).
592                        k: e.alloc_u8(kv_plane_allocation_bytes(alloc_rows, k_tok_bytes))?,
593                        v: e.alloc_u8(kv_plane_allocation_bytes(alloc_rows, v_tok_bytes))?,
594                        kv_dim_k,
595                        kv_dim_v,
596                        k_tok_bytes,
597                        v_tok_bytes,
598                        len: 0,
599                        ring,
600                        len_d: e.htod_i32(&[0])?,
601                    }));
602                    recur.push(None);
603                }
604                LayerKind::LinearAttention => {
605                    kv.push(None);
606                    recur.push(Some(RecurLayer {
607                        conv_state: e.zeros(conv_dim * (d_conv - 1))?,
608                        ssm_state: e.zeros(d_state * d_state * num_v)?,
609                        ssm_state_alt: e.zeros(d_state * d_state * num_v)?,
610                    }));
611                }
612            }
613        }
614        Ok(Cache {
615            kv,
616            recur,
617            pos: 0,
618            max_ctx,
619            dflash_taps: None,
620            last_logits_dev: None,
621        })
622    }
623
624    pub fn has_swa_ring(&self) -> bool {
625        self.kv.iter().flatten().any(|layer| layer.ring.is_some())
626    }
627
628    pub fn can_rollback(&self, snap: &CacheSnapshot, accept_len: usize) -> bool {
629        self.kv
630            .iter()
631            .zip(&snap.kv_len)
632            .all(|(layer, saved)| match (layer, saved) {
633                (Some(layer), Some(saved)) => layer
634                    .ring
635                    .as_ref()
636                    .is_none_or(|ring| ring.can_rewind_to(saved + accept_len)),
637                _ => true,
638            })
639    }
640
641    /// Snapshot the dual cache before a spec-decode draft+verify round (MTP-PLAN §C/§D.4).
642    /// Records each full-attn `len` (cheap) and makes a REAL device copy of each linear-attn
643    /// conv_state/ssm_state (a fresh alloc + memcpy_dtod — NOT an Arc clone).
644    pub fn snapshot(&self, e: &impl KvDev) -> Result<CacheSnapshot, Box<dyn std::error::Error>> {
645        let n = self.kv.len();
646        let mut kv_len = Vec::with_capacity(n);
647        let mut conv = Vec::with_capacity(n);
648        let mut ssm = Vec::with_capacity(n);
649        for il in 0..n {
650            match &self.kv[il] {
651                Some(kvl) => kv_len.push(Some(kvl.len)),
652                None => kv_len.push(None),
653            }
654            match &self.recur[il] {
655                Some(rl) => {
656                    conv.push(Some(e.clone_dtod(&rl.conv_state)?));
657                    ssm.push(Some(e.clone_dtod(&rl.ssm_state)?));
658                }
659                None => {
660                    conv.push(None);
661                    ssm.push(None);
662                }
663            }
664        }
665        Ok(CacheSnapshot {
666            kv_len,
667            conv,
668            ssm,
669            pos: self.pos,
670        })
671    }
672
673    /// PERSISTENT-BUFFER snapshot (spec-decode hot loop): refresh `snap` IN PLACE — same values as
674    /// `snapshot()` but the conv/ssm device buffers are reused across rounds (D2D copy-into, ZERO
675    /// allocations vs 2 fresh clones per linear layer per round). `snap` must come from a prior
676    /// `snapshot()` of THIS cache (same layer shapes).
677    pub fn snapshot_into(
678        &self,
679        e: &impl KvDev,
680        snap: &mut CacheSnapshot,
681    ) -> Result<(), Box<dyn std::error::Error>> {
682        let n = self.kv.len();
683        for il in 0..n {
684            snap.kv_len[il] = self.kv[il].as_ref().map(|kvl| kvl.len);
685            if let Some(rl) = &self.recur[il] {
686                let dc = snap.conv[il]
687                    .as_mut()
688                    .expect("snapshot_into: shape mismatch (conv)");
689                let ds = snap.ssm[il]
690                    .as_mut()
691                    .expect("snapshot_into: shape mismatch (ssm)");
692                let (cn, sn) = (rl.conv_state.len(), rl.ssm_state.len());
693                e.copy_into(dc, 0, &rl.conv_state, cn)?;
694                e.copy_into(ds, 0, &rl.ssm_state, sn)?;
695            }
696        }
697        snap.pos = self.pos;
698        Ok(())
699    }
700
701    /// Roll the cache back to exactly `snap.pos + accept_len` committed tokens (MTP-PLAN §C).
702    /// - Full-attn KV (C.1): set len = snapshot_len + accept_len (truncate, no copy).
703    /// - Linear-attn (C.2): RESTORE the snapshot conv/ssm (real D2D copy back into the resident
704    ///   buffers). The caller must then REPLAY the `accept_len` committed tokens through the full
705    ///   T=1 decode path to rebuild the recurrent state for those positions. We restore (not
706    ///   replay here) because replay needs the model; this only resets state to the pre-round value.
707    /// `cache.pos` is set to `snap.pos` so the caller's replay advances it back to the commit point.
708    pub fn rollback(
709        &mut self,
710        e: &impl KvDev,
711        snap: &CacheSnapshot,
712        accept_len: usize,
713    ) -> Result<(), Box<dyn std::error::Error>> {
714        if !self.can_rollback(snap, accept_len) {
715            return Err(
716                "SWA ring rewind checkpoint has been lapped; full re-prime required".into(),
717            );
718        }
719        for il in 0..self.kv.len() {
720            if let (Some(kvl), Some(saved)) = (self.kv[il].as_mut(), snap.kv_len[il]) {
721                kvl.len = saved + accept_len;
722                // keep the device mirror in lock-step (CUDA-GRAPH-PLAN Phase 2). Set IN PLACE
723                // (stable pointer): a fresh htod_i32 would reallocate len_d, but its old pointer is
724                // baked into the captured decode graph's append/inc/fa_decode kernels — replacing it
725                // strands the graph on a freed buffer (stale-pointer hazard). memcpy_htod in place.
726                e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
727            }
728            if let Some(rl) = self.recur[il].as_mut() {
729                if let Some(c) = &snap.conv[il] {
730                    e.copy_into(&mut rl.conv_state, 0, c, c.len())?;
731                }
732                if let Some(s) = &snap.ssm[il] {
733                    e.copy_into(&mut rl.ssm_state, 0, s, s.len())?;
734                }
735            }
736        }
737        self.pos = snap.pos;
738        Ok(())
739    }
740}
741
742#[cfg(test)]
743mod swa_ring_tests {
744    use super::{KvRing, KvRingAppend, kv_plane_allocation_bytes, swa_ring_rows};
745
746    #[test]
747    fn allocation_rows_cover_window_max_prime_and_alignment_slack() {
748        assert_eq!(swa_ring_rows(512, 262_144), 512 + 4096 + 31);
749        assert_eq!(swa_ring_rows(512, 4096), 4096);
750        assert_eq!(
751            kv_plane_allocation_bytes(4639, 1088),
752            4639 * 1088 + 8,
753            "the Step35 session plane allocates ring rows plus the existing tail pad",
754        );
755    }
756
757    #[test]
758    fn ring_matches_flat_bytes_before_wrap() {
759        let ring = KvRing::new(swa_ring_rows(512, 262_144), 512);
760        let flat: Vec<u32> = (0..1024).collect();
761        let mut physical = vec![u32::MAX; ring.rows()];
762        let KvRingAppend::Contiguous { write_row } = ring.append_plan(0, 0, flat.len()).unwrap()
763        else {
764            panic!("first append unexpectedly wrapped")
765        };
766        physical[write_row..write_row + flat.len()].copy_from_slice(&flat);
767        let view = ring.physical_range(0, flat.len()).unwrap();
768        assert_eq!(&physical[view], flat.as_slice());
769    }
770
771    #[test]
772    fn wrap_rebases_the_exact_aligned_prime_view() {
773        let mut ring = KvRing::new(swa_ring_rows(512, 262_144), 512);
774        let flat: Vec<u32> = (0..8192).collect();
775        let mut physical = vec![u32::MAX; ring.rows()];
776        let KvRingAppend::Contiguous { write_row } = ring.append_plan(0, 0, 4096).unwrap() else {
777            panic!("first prime chunk unexpectedly wrapped")
778        };
779        physical[write_row..write_row + 4096].copy_from_slice(&flat[..4096]);
780
781        let off = (4096usize - (512 - 1)) & !31usize;
782        let KvRingAppend::Rebase {
783            src_row,
784            keep_rows,
785            new_base,
786            write_row,
787        } = ring.append_plan(4096, off, 4096).unwrap()
788        else {
789            panic!("second prime chunk did not wrap")
790        };
791        let retained = physical[src_row..src_row + keep_rows].to_vec();
792        physical[..keep_rows].copy_from_slice(&retained);
793        ring.apply_rebase(new_base);
794        physical[write_row..write_row + 4096].copy_from_slice(&flat[4096..8192]);
795
796        let view = ring.physical_range(off, 8192).unwrap();
797        assert_eq!(&physical[view], &flat[off..8192]);
798        assert_eq!(ring.base(), off);
799    }
800
801    #[test]
802    fn rewind_declines_once_the_required_window_was_lapped() {
803        let mut ring = KvRing::new(swa_ring_rows(512, 262_144), 512);
804        let KvRingAppend::Rebase { new_base, .. } = ring.append_plan(4096, 3584, 4096).unwrap()
805        else {
806            panic!("expected wrap")
807        };
808        ring.apply_rebase(new_base);
809        assert!(ring.can_rewind_to(4095));
810        assert!(!ring.can_rewind_to(4094));
811        assert!(!ring.can_rewind_to(0));
812    }
813}