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
11// ---------------- KV format policy (env-selected; moved from memra-engine) ----------------
12
13/// Env-selected KV cache formats (MEMRA_KV_K / MEMRA_KV_V). The engine's flash-fatbin router
14/// and the cache sizing below MUST agree — both read this one function.
15pub fn kv_cache_formats() -> (&'static str, &'static str) {
16 static F: std::sync::OnceLock<(&'static str, &'static str)> = std::sync::OnceLock::new();
17 *F.get_or_init(|| {
18 let k = match std::env::var("MEMRA_KV_K").as_deref() {
19 Ok("fp8") => "fp8",
20 Ok("q8_0") | Ok("") | Err(_) => "q8_0",
21 Ok(o) => panic!("MEMRA_KV_K={o} unsupported (q8_0 | fp8)"),
22 };
23 let v = match std::env::var("MEMRA_KV_V").as_deref() {
24 Ok("q4_0") => "q4_0",
25 Ok("fp8") => "fp8",
26 Ok("q5_1") | Ok("") | Err(_) => "q5_1",
27 Ok(o) => panic!("MEMRA_KV_V={o} unsupported (q5_1 | q4_0 | fp8)"),
28 };
29 if (k, v) != ("q8_0", "q5_1") {
30 eprintln!("[memra] KV cache format: K={k} V={v} (non-default — new numeric config)");
31 }
32 (k, v)
33 })
34}
35
36/// Per-32-element block bytes for the selected (K, V) formats.
37pub fn kv_blk_bytes() -> (usize, usize) {
38 let (k, v) = kv_cache_formats();
39 let kb = match k { "fp8" => 32, _ => 34 };
40 let vb = match v { "q4_0" => 18, "fp8" => 32, _ => 24 };
41 (kb, vb)
42}
43
44/// FP8-GLOBALS switch (MEMRA_GEMMA_GKV, default ON): gemma global (hd512) layers keep
45/// their KV in e4m3 — the dequant-latency arc (HANDOVER). Windowed layers stay q8_0/q5_1.
46pub fn gkv_on() -> bool {
47 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
48 *ON.get_or_init(|| std::env::var("MEMRA_GEMMA_GKV").map(|v| v != "0").unwrap_or(true))
49}
50
51/// FP8-WINDOWED switch (MEMRA_GEMMA_WKV; serving-mode default): SPEC serving (MEMRA_DRAFT
52/// set) -> OFF, plain -> ON — the acceptance-vs-depth record lives on the engine-side
53/// history of `Engine::wkv_on` (git). Explicit env always wins.
54pub fn wkv_on() -> bool {
55 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
56 *ON.get_or_init(|| std::env::var("MEMRA_GEMMA_WKV").map(|v| v != "0")
57 .unwrap_or_else(|_| std::env::var("MEMRA_DRAFT").is_err()))
58}
59
60/// Per-model FP8-KV door (-1 = unset → env/default off; 0 = off; 1 = on). Set at qwen
61/// model load: the 2026-07-12 arc closed per-model — 9B +0.7-4% scaling with depth,
62/// 27B flat (weight-bound), 35B −2% (fp8 format-gates its v3 dp4a lane off). Explicit
63/// MEMRA_KV_FP8 wins. 9B adoption attempt REVERTED by measurement 2026-07-29 (−1% at 12k
64/// on the then-current build) — loaders currently store 0.
65pub static KV_FP8_FORCE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
66
67/// QWEN FP8-KV switch (MEMRA_KV_FP8 explicit; else the per-model KV_FP8_FORCE door set
68/// at model load; else OFF). Non-gemma full-attn layers hold e4m3 K/V via the kf8vf8
69/// module.
70pub fn kv_fp8_on() -> bool {
71 static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
72 if let Some(v) = *ENV.get_or_init(|| std::env::var("MEMRA_KV_FP8").ok()
73 .map(|v| v == "1")) { return v; }
74 matches!(KV_FP8_FORCE.load(std::sync::atomic::Ordering::Relaxed), 1)
75}
76
77// ---------------- the device seam ----------------
78
79/// The 7 device ops the cache needs — nothing more. Implemented by the engine (and by
80/// any future backend); all ops are stream-ordered on the implementor's worker stream.
81pub trait KvDev {
82 fn zeros(&self, n: usize) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>>;
83 fn uninit(&self, n: usize) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>>;
84 fn alloc_u8(&self, n: usize) -> Result<CudaSlice<u8>, Box<dyn std::error::Error>>;
85 fn htod_i32(&self, v: &[i32]) -> Result<CudaSlice<i32>, Box<dyn std::error::Error>>;
86 fn clone_dtod(&self, src: &CudaSlice<f32>) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>>;
87 fn copy_into(&self, dst: &mut CudaSlice<f32>, off: usize, src: &CudaSlice<f32>, len: usize)
88 -> Result<(), Box<dyn std::error::Error>>;
89 fn set_i32_one(&self, d: &mut CudaSlice<i32>, v: i32) -> Result<(), Box<dyn std::error::Error>>;
90}
91
92use memra_gguf::config::{LayerKind, ModelConfig};
93use cudarc::driver::CudaSlice;
94
95/// Per-full-attn-layer growing KV cache, resident on GPU. QUANTIZED (KVQUANT-PLAN §B):
96/// K stored q8_0 (34 B/32 elem), V stored q5_1 (24 B/32 elem). Per-token byte layout keeps the
97/// [token, kv_head, dim] element order so a 32-block never straddles a head (assert head_dim%32==0).
98/// Element-within-token index = kv_head*head_dim + d; block = idx/32; lane = idx%32.
99pub struct KvLayer {
100 pub k: CudaSlice<u8>, // q8_0 packed, capacity max_ctx*k_tok_bytes
101 pub v: CudaSlice<u8>, // q5_1 packed, capacity max_ctx*v_tok_bytes
102 pub kv_dim_k: usize, // head_dim_k * n_head_kv (K elements per token)
103 pub kv_dim_v: usize, // head_dim_v * n_head_kv (V elements per token)
104 pub k_tok_bytes: usize, // (kv_dim_k/32)*34
105 pub v_tok_bytes: usize, // (kv_dim_v/32)*24
106 pub len: usize,
107 /// Device-resident mirror of `len` (CUDA-GRAPH-PLAN Phase 2). Holds the KV write SLOT for the
108 /// append-dc kernel (old len, before this step's append); after `inc_seqlen` it holds the new
109 /// len == t_kv for fa_decode_dc. Kept in lock-step with the host `len`. i32[1].
110 pub len_d: CudaSlice<i32>,
111}
112
113/// Per-linear-attn-layer fixed recurrent state.
114/// conv_state and ssm_state are BOTH kept RESIDENT on GPU — the conv ring assemble + roll runs
115/// on-device (conv_assemble_and_roll), so there is no per-step dtoh/htod for either.
116pub struct RecurLayer {
117 pub conv_state: CudaSlice<f32>, // GPU [conv_dim, d_conv-1] (channel c, tap j at c*pad + j)
118 pub ssm_state: CudaSlice<f32>, // GPU [d_state, d_state, num_v] transposed M[col][i]
119 /// PERSISTENT second SSM-state buffer for the gdn-scan double buffer (DECODE DETERMINISM FIX).
120 /// gdn_scan needs DISTINCT in/out state buffers. The old eager path allocated a fresh
121 /// `state_scratch` via `e.uninit` every step and swapped its pointer into `ssm_state`; that
122 /// per-step alloc/free churned the stream-ordered async pool, and the freed prior `ssm_state`
123 /// block was recycled by the next step's scratch while a kernel referencing the swapped-in state
124 /// was still in flight — a use-after-reuse that produced RUN-TO-RUN nondeterministic decode
125 /// (two identical prompt primes diverged). We instead PING-PONG between two STABLE resident
126 /// buffers (no per-step alloc/free, no pool churn): step writes into the spare, then swaps the
127 /// two owned buffers in place. Stable pointers, identical math. Sized like `ssm_state`.
128 pub ssm_state_alt: CudaSlice<f32>,
129}
130
131pub struct Cache {
132 pub kv: Vec<Option<KvLayer>>,
133 pub recur: Vec<Option<RecurLayer>>,
134 pub pos: usize,
135 pub max_ctx: usize,
136 /// BATCHED-TICK increment 2 component 3 (lean logits, 2026-08-01): device-side park of
137 /// this session's LAST logits row. Device-sampled rows in the batched serving tick skip
138 /// the [n_vocab] logits D2H entirely; the tick instead dtod-copies the row here (device
139 /// bandwidth, ~µs) so the ONE consumer that truly needs the final row — the KV-reuse
140 /// pool's park-at-retire (an empty-suffix resume samples from parked last_logits) —
141 /// can D2H it once at retire. Lazily allocated on the first lean tick; None on every
142 /// non-lean path (zero cost). Travels with the Cache into the reuse pool.
143 pub last_logits_dev: Option<CudaSlice<f32>>,
144 /// DFlash tap sink (dflash lane, 2026-07-13): when armed, the gemma4 verify/prime
145 /// trunks copy the residual stream AFTER each tapped layer into `buf` rows
146 /// ([t, n_taps*hidden] row-major — the drafter fc input layout). None on every
147 /// non-dflash path (zero cost).
148 pub dflash_taps: Option<DflashTapSink>,
149}
150
151/// The context-linear K/V layout for one full-attention layer. This is the single sizing source
152/// used by both `Cache::new_inner` and `cache_bytes_per_token`: admission must never reimplement
153/// Gemma's per-layer geometry or the active KV-format doors independently from the allocator.
154fn full_attention_kv_layout(cfg: &ModelConfig, il: u32) -> (usize, usize, usize, usize) {
155 debug_assert_eq!(cfg.layer_kind(il), LayerKind::FullAttention);
156 let n_head_kv = cfg.n_head_kv as usize;
157 let (kv_dim_k, kv_dim_v) = match &cfg.gemma4 {
158 Some(g) => {
159 let hd = if g.swa_pattern[il as usize] {
160 g.key_length_swa
161 } else {
162 g.key_length_global
163 } as usize;
164 // E4B ships a SCALAR head_count_kv (per-layer vec empty; scalar = 2 in
165 // the gguf, landing in cfg.n_head_kv): kv_dim = hd * 2 for BOTH kinds —
166 // swa 2x256 = 512, global 2x512 = 1024. The old fallback used
167 // key_length_global (512) for both, which HALVED the global layers' K/V
168 // (the attn writes wk.out_features = 1024 rows): every E4B global layer
169 // stored/attended half its K/V and the batched append read row strides
170 // wrong — THE cross-mode maxdiff-30 root (2026-07-12 bisect, il=5 slot-1
171 // byte forensics). 26B/31B keep the per-layer vec.
172 let d = match g.head_count_kv.get(il as usize) {
173 Some(n) => hd * *n as usize,
174 None => hd * n_head_kv,
175 };
176 (d, d)
177 }
178 None => (
179 cfg.head_dim_k as usize * n_head_kv,
180 cfg.head_dim_v as usize * n_head_kv,
181 ),
182 };
183 assert!(
184 kv_dim_k % 32 == 0 && kv_dim_v % 32 == 0,
185 "KVQUANT requires per-layer kv_dim_k%32==0 && kv_dim_v%32==0 \
186 (layer {il}: k={kv_dim_k} v={kv_dim_v})"
187 );
188 let (kbb, vbb) = kv_blk_bytes();
189 let g4_global_fp8 = gkv_on()
190 && cfg
191 .gemma4
192 .as_ref()
193 .is_some_and(|g| !g.swa_pattern[il as usize]);
194 let g4_windowed_fp8 = wkv_on()
195 && cfg
196 .gemma4
197 .as_ref()
198 .is_some_and(|g| g.swa_pattern[il as usize]);
199 let qwen_fp8 = kv_fp8_on() && cfg.gemma4.is_none();
200 let (kbb_l, vbb_l) = if g4_global_fp8 || g4_windowed_fp8 || qwen_fp8 {
201 (32, 32)
202 } else {
203 (kbb, vbb)
204 };
205 (kv_dim_k, kv_dim_v, kbb_l, vbb_l)
206}
207
208/// Context-linear bytes allocated by one trunk cache token.
209///
210/// Fixed allocations (the 8-byte plane tail pads, `len_d`, recurrent state, and optional lazy
211/// buffers) are deliberately excluded. Admission adds their measured high-water residual as a
212/// request-independent activation term; multiplying this coefficient by the request's own
213/// `ctx_cap` exactly mirrors the context-scaled allocations in `Cache::new_inner`.
214pub fn cache_bytes_per_token(cfg: &ModelConfig) -> usize {
215 let shared = cfg.gemma4.as_ref().map(|g| g.shared_kv_layers).unwrap_or(0);
216 (0..cfg.n_layer)
217 .filter(|&il| cfg.layer_kind(il) == LayerKind::FullAttention)
218 .filter(|&il| shared == 0 || il < cfg.n_layer - shared)
219 .map(|il| {
220 let (kv_dim_k, kv_dim_v, kbb, vbb) = full_attention_kv_layout(cfg, il);
221 (kv_dim_k / 32) * kbb + (kv_dim_v / 32) * vbb
222 })
223 .sum()
224}
225
226/// See [`Cache::dflash_taps`]. Armed per forward by the dflash round (t = that forward's
227/// row count); the trunk writes tap slot s of row r at buf[r*n_taps*hidden + s*hidden ..].
228pub struct DflashTapSink {
229 pub layer_ids: Vec<usize>,
230 pub buf: CudaSlice<f32>,
231 pub hidden: usize,
232 pub t: usize,
233}
234
235/// Snapshot of the dual cache taken BEFORE a spec-decode draft+verify round (MTP-PLAN §C/§D.4).
236/// - Full-attn KV: only the per-layer `len` is recorded; rollback truncates (append-only,
237/// position-addressed — no copy). C.1.
238/// - Linear-attn conv/ssm: real device-to-device COPIES of the recurrent state, because those
239/// buffers are mutated IN PLACE by the verify pass and have no position index to truncate. C.2.
240/// (CudaSlice::clone is an Arc refcount, NOT a buffer copy — so we alloc fresh + memcpy_dtod.)
241pub struct CacheSnapshot {
242 pub kv_len: Vec<Option<usize>>, // per layer (Some for full-attn layers)
243 pub conv: Vec<Option<CudaSlice<f32>>>, // per layer (Some for linear-attn layers, D2D copy)
244 pub ssm: Vec<Option<CudaSlice<f32>>>,
245 pub pos: usize,
246}
247
248impl Cache {
249 /// Allocate GPU-resident caches sized by arch + max context.
250 pub fn new(
251 e: &impl KvDev,
252 cfg: &ModelConfig,
253 max_ctx: usize,
254 ) -> Result<Self, Box<dyn std::error::Error>> {
255 Self::new_inner(&|_| e, cfg, max_ctx)
256 }
257
258 /// M1-PP2 increment 2 (stage-owned KV): layers [0, split) allocate through `dev0`,
259 /// layers [split, n) through `dev1` — each pipeline stage's cache lives on the
260 /// device that runs the stage. With dev0 == dev1 this is byte-for-byte `new`
261 /// (the single-device plumbing gate). Sizing math is IDENTICAL either way.
262 pub fn new_pp2(
263 dev0: &dyn KvDev,
264 dev1: &dyn KvDev,
265 split: usize,
266 cfg: &ModelConfig,
267 max_ctx: usize,
268 ) -> Result<Self, Box<dyn std::error::Error>> {
269 Self::new_inner(&|il| if il < split { dev0 } else { dev1 }, cfg, max_ctx)
270 }
271
272 /// M2 N-stage twin of `new_pp2`: `fence` is the stage map from `memra_engine::pp::
273 /// pp_cuts` ([0, c1, .., n_trunk]); layer il allocates through the engine of the
274 /// stage that runs it. Layers at/beyond the fence end (MTP/NextN blocks) allocate
275 /// through the LAST stage. Sizing math is IDENTICAL to `new` — only the allocating
276 /// device varies.
277 pub fn new_ppn<'a>(
278 devs: &[&'a dyn KvDev],
279 fence: &[usize],
280 cfg: &ModelConfig,
281 max_ctx: usize,
282 ) -> Result<Self, Box<dyn std::error::Error>> {
283 assert_eq!(devs.len() + 1, fence.len(), "ppn cache: devs vs fence mismatch");
284 let pick = |il: usize| -> &dyn KvDev {
285 let s = match fence[1..fence.len() - 1].binary_search(&il) {
286 Ok(k) => k + 1,
287 Err(k) => k,
288 };
289 devs[s.min(devs.len() - 1)]
290 };
291 Self::new_inner(&pick, cfg, max_ctx)
292 }
293
294 /// Shared allocation walk: `pick(il)` supplies the device that OWNS layer il's
295 /// cache state (always the same device outside the pp2 door).
296 fn new_inner<'a>(
297 pick: &dyn Fn(usize) -> &'a dyn KvDev,
298 cfg: &ModelConfig,
299 max_ctx: usize,
300 ) -> Result<Self, Box<dyn std::error::Error>> {
301 let n = cfg.n_layer as usize;
302 let mut kv = Vec::with_capacity(n);
303 let mut recur = Vec::with_capacity(n);
304 let head_dim_k = cfg.head_dim_k as usize;
305 let head_dim_v = cfg.head_dim_v as usize;
306 assert!(head_dim_k % 32 == 0 && head_dim_v % 32 == 0,
307 "KVQUANT requires head_dim_k%32==0 && head_dim_v%32==0 (got k={head_dim_k} v={head_dim_v})");
308 let (conv_dim, d_state, num_v, d_conv) = if let Some(s) = &cfg.ssm {
309 let num_k = s.group_count as usize;
310 let num_v = s.time_step_rank as usize;
311 let ds = s.state_size as usize;
312 (
313 ds * num_k * 2 + ds * num_v,
314 ds,
315 num_v,
316 s.conv_kernel as usize,
317 )
318 } else {
319 (0, 0, 0, 0)
320 };
321 for il in 0..cfg.n_layer {
322 // stage-owned allocation (pp2): the device that runs this layer allocates it.
323 let e = pick(il as usize);
324 // E4B KV-SHARING: the trailing shared_kv_layers have no k/v of their own — they
325 // attend an earlier layer's cache (hybrid_forward resolves the target). No KvLayer
326 // here: any accidental use is a loud unwrap at bring-up, and rewind/len loops
327 // (iter_mut().flatten()) skip None naturally.
328 let g4_shared = cfg.gemma4.as_ref().map(|g| g.shared_kv_layers).unwrap_or(0);
329 if g4_shared > 0 && il >= cfg.n_layer - g4_shared {
330 kv.push(None);
331 recur.push(None);
332 continue;
333 }
334 match cfg.layer_kind(il) {
335 LayerKind::FullAttention => {
336 // Gemma per-layer geometry and every KV-format door are resolved by the same
337 // helper admission uses for its analytic byte coefficient.
338 let (kv_dim_k, kv_dim_v, kbb_l, vbb_l) =
339 full_attention_kv_layout(cfg, il);
340 let k_tok_bytes = (kv_dim_k / 32) * kbb_l;
341 let v_tok_bytes = (kv_dim_v / 32) * vbb_l;
342 kv.push(Some(KvLayer {
343 // +8B tail pad: the v4 stage's aligned funnelshift window reads up to
344 // 4B past the final block (PR #3's finding, adopted pad-style — the
345 // expert-dot precedent; zero hot-loop branches, values discarded).
346 k: e.alloc_u8(max_ctx * k_tok_bytes + 8)?,
347 v: e.alloc_u8(max_ctx * v_tok_bytes + 8)?,
348 kv_dim_k,
349 kv_dim_v,
350 k_tok_bytes,
351 v_tok_bytes,
352 len: 0,
353 len_d: e.htod_i32(&[0])?,
354 }));
355 recur.push(None);
356 }
357 LayerKind::LinearAttention => {
358 kv.push(None);
359 recur.push(Some(RecurLayer {
360 conv_state: e.zeros(conv_dim * (d_conv - 1))?,
361 ssm_state: e.zeros(d_state * d_state * num_v)?,
362 ssm_state_alt: e.zeros(d_state * d_state * num_v)?,
363 }));
364 }
365 }
366 }
367 Ok(Cache { kv, recur, pos: 0, max_ctx, dflash_taps: None, last_logits_dev: None })
368 }
369
370 /// Snapshot the dual cache before a spec-decode draft+verify round (MTP-PLAN §C/§D.4).
371 /// Records each full-attn `len` (cheap) and makes a REAL device copy of each linear-attn
372 /// conv_state/ssm_state (a fresh alloc + memcpy_dtod — NOT an Arc clone).
373 pub fn snapshot(&self, e: &impl KvDev) -> Result<CacheSnapshot, Box<dyn std::error::Error>> {
374 let n = self.kv.len();
375 let mut kv_len = Vec::with_capacity(n);
376 let mut conv = Vec::with_capacity(n);
377 let mut ssm = Vec::with_capacity(n);
378 for il in 0..n {
379 match &self.kv[il] {
380 Some(kvl) => kv_len.push(Some(kvl.len)),
381 None => kv_len.push(None),
382 }
383 match &self.recur[il] {
384 Some(rl) => {
385 conv.push(Some(e.clone_dtod(&rl.conv_state)?));
386 ssm.push(Some(e.clone_dtod(&rl.ssm_state)?));
387 }
388 None => {
389 conv.push(None);
390 ssm.push(None);
391 }
392 }
393 }
394 Ok(CacheSnapshot {
395 kv_len,
396 conv,
397 ssm,
398 pos: self.pos,
399 })
400 }
401
402 /// PERSISTENT-BUFFER snapshot (spec-decode hot loop): refresh `snap` IN PLACE — same values as
403 /// `snapshot()` but the conv/ssm device buffers are reused across rounds (D2D copy-into, ZERO
404 /// allocations vs 2 fresh clones per linear layer per round). `snap` must come from a prior
405 /// `snapshot()` of THIS cache (same layer shapes).
406 pub fn snapshot_into(
407 &self,
408 e: &impl KvDev,
409 snap: &mut CacheSnapshot,
410 ) -> Result<(), Box<dyn std::error::Error>> {
411 let n = self.kv.len();
412 for il in 0..n {
413 snap.kv_len[il] = self.kv[il].as_ref().map(|kvl| kvl.len);
414 if let Some(rl) = &self.recur[il] {
415 let dc = snap.conv[il]
416 .as_mut()
417 .expect("snapshot_into: shape mismatch (conv)");
418 let ds = snap.ssm[il]
419 .as_mut()
420 .expect("snapshot_into: shape mismatch (ssm)");
421 let (cn, sn) = (rl.conv_state.len(), rl.ssm_state.len());
422 e.copy_into(dc, 0, &rl.conv_state, cn)?;
423 e.copy_into(ds, 0, &rl.ssm_state, sn)?;
424 }
425 }
426 snap.pos = self.pos;
427 Ok(())
428 }
429
430 /// Roll the cache back to exactly `snap.pos + accept_len` committed tokens (MTP-PLAN §C).
431 /// - Full-attn KV (C.1): set len = snapshot_len + accept_len (truncate, no copy).
432 /// - Linear-attn (C.2): RESTORE the snapshot conv/ssm (real D2D copy back into the resident
433 /// buffers). The caller must then REPLAY the `accept_len` committed tokens through the full
434 /// T=1 decode path to rebuild the recurrent state for those positions. We restore (not
435 /// replay here) because replay needs the model; this only resets state to the pre-round value.
436 /// `cache.pos` is set to `snap.pos` so the caller's replay advances it back to the commit point.
437 pub fn rollback(
438 &mut self,
439 e: &impl KvDev,
440 snap: &CacheSnapshot,
441 accept_len: usize,
442 ) -> Result<(), Box<dyn std::error::Error>> {
443 for il in 0..self.kv.len() {
444 if let (Some(kvl), Some(saved)) = (self.kv[il].as_mut(), snap.kv_len[il]) {
445 kvl.len = saved + accept_len;
446 // keep the device mirror in lock-step (CUDA-GRAPH-PLAN Phase 2). Set IN PLACE
447 // (stable pointer): a fresh htod_i32 would reallocate len_d, but its old pointer is
448 // baked into the captured decode graph's append/inc/fa_decode kernels — replacing it
449 // strands the graph on a freed buffer (stale-pointer hazard). memcpy_htod in place.
450 e.set_i32_one(&mut kvl.len_d, kvl.len as i32)?;
451 }
452 if let Some(rl) = self.recur[il].as_mut() {
453 if let Some(c) = &snap.conv[il] {
454 e.copy_into(&mut rl.conv_state, 0, c, c.len())?;
455 }
456 if let Some(s) = &snap.ssm[il] {
457 e.copy_into(&mut rl.ssm_state, 0, s, s.len())?;
458 }
459 }
460 }
461 self.pos = snap.pos;
462 Ok(())
463 }
464}