fuel_tensor/inference_context.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Persistent inference state for the pipelined executor (Phase 7.6
3//! step 9c, sub-phase E.3.0).
4//!
5//! ## What this module owns
6//!
7//! - **[`InferenceContext`]**: a host-side per-session struct holding
8//! long-lived `Arc<RwLock<Storage>>` references that survive across
9//! realize calls. Replaces the legacy executor's `const_pool`
10//! for the pipelined dispatch world. Today's backing is a simple
11//! in-memory `HashMap<NodeId, Arc<...>>`; future work may swap this
12//! for an mmap-backed coherent store per
13//! `project_unified_durable_tensor_store.md`.
14//!
15//! - **[`KvCache`]**, **[`KvLayer`]**, **[`KvLayerId`]**, **[`KvSlot`]**,
16//! **[`AuthorityState`]**: the backend-erased KV cache primitive that
17//! replaced `lazy::KVCache<B>` and `lazy::LlamaKVCache` (both
18//! retired — E.3.3.D and Unification Session 4 E.3.4). Single
19//! concrete type with no generic-over-B parameter; each layer holds
20//! `Arc<RwLock<Storage>>` for K and V plus side-table metadata
21//! (layout, monotonic version, coherence authority).
22//!
23//! ## What this module deliberately does NOT do (yet)
24//!
25//! - **Pre-allocated buffers** (added Phase E.3.3.A, 2026-05-20):
26//! [`KvCache::with_capacity`] eagerly allocates `[1, n_kv_heads,
27//! max_seq_len, head_dim]` zero-initialized K + V buffers on a
28//! target device. Subsequent forward steps write into these via
29//! `Op::WriteSlice` rather than growing-by-replacement. The legacy
30//! [`KvCache::with_dims`] constructor stays available for callers
31//! not yet on the pre-allocated path (grow-by-replace works without
32//! changes).
33//! - **Multi-device coherence protocol**: the `authority` and
34//! `version` fields exist as placeholders. No protocol consults
35//! them yet — every layer starts and stays `AuthorityState::Host`
36//! for the lifetime of the session in single-device usage.
37//! Phase J (multi-GPU) activates the protocol.
38//! - **Weight persistence**: weights stay in the graph's storage_map
39//! per the design discussion; the persistent map handles KV layers
40//! and transient cross-step state only.
41
42use std::collections::HashMap;
43use std::sync::atomic::{AtomicUsize, Ordering};
44use std::sync::{Arc, RwLock};
45
46use fuel_dispatch::{
47 optimize::OptimizedGraph,
48 pipelined::{PipelinedExecutor, StorageCache},
49};
50use fuel_graph::{Graph, Node, NodeId, Op};
51use fuel_ir::{DType, DeviceLocation, Error, Layout, Result, SType, Shape, SymEnv, SymId};
52use fuel_memory::{BackendStorage, Storage};
53
54use crate::Device;
55use crate::decode_shape::KvAllocId;
56
57// ===========================================================================
58// KV cache primitive types
59// ===========================================================================
60
61/// Identifies a single key-or-value tensor within a [`KvCache`].
62/// Stable across realize calls — the graph-level NodeId may churn
63/// per step (each `Op::WriteSlice` produces a fresh NodeId pointing
64/// at the same Storage Arc) but the logical KV slot identity stays.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub struct KvLayerId {
67 pub layer_idx: usize,
68 pub slot: KvSlot,
69}
70
71/// Which half of a KV pair a [`KvLayerId`] addresses.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum KvSlot {
74 K,
75 V,
76}
77
78/// Coherence authority for one KV slot's storage. Placeholder for the
79/// multi-device protocol; today every slot starts and stays
80/// [`AuthorityState::Host`] for the lifetime of the session unless
81/// future code explicitly transitions it.
82///
83/// The intended protocol (deferred until multi-GPU lands):
84/// - **Host**: the host has the most recent version. Devices may
85/// hold stale copies. The default at session start.
86/// - **Device(loc)**: a device owns the most recent version. The
87/// host's copy may be stale until an explicit flush.
88#[derive(Debug, Clone)]
89pub enum AuthorityState {
90 Host,
91 Device(DeviceLocation),
92}
93
94/// One layer's K + V slots in a backend-erased KV cache.
95///
96/// Storage Arcs are dispatch-erased: each Arc points at a
97/// [`fuel_memory::BackendStorage`] enum variant (`Cpu`, `Cuda`,
98/// `Vulkan`, `Metal`). The graph references these Arcs via the
99/// persistent map in [`InferenceContext`]; readers see the bytes
100/// through whichever Layout the graph carries for the consuming
101/// NodeId.
102pub struct KvLayer {
103 pub k: Arc<RwLock<Storage>>,
104 pub v: Arc<RwLock<Storage>>,
105 /// View layout into `k` describing the live extent. For the grow-
106 /// by-replacement pattern (today) the layout matches the storage's
107 /// full shape. For pre-allocated buffers (Phase E.3.2) the layout
108 /// describes the leading `[..., cached_len, ...]` slice.
109 pub k_layout: Layout,
110 pub v_layout: Layout,
111 /// Monotonic write version for K. Bumps on every successful
112 /// `Op::WriteSlice` targeting this slot. Used by the future
113 /// multi-device coherence protocol to detect staleness.
114 pub k_version: u64,
115 pub v_version: u64,
116 /// Coherence authority per slot. K and V can in principle diverge
117 /// (a future optimization that decouples their update cadence
118 /// could exploit this), though standard transformer decoding
119 /// updates both together every step.
120 pub k_authority: AuthorityState,
121 pub v_authority: AuthorityState,
122}
123
124/// Backend-erased KV cache. Replaces `lazy::KVCache<B>` and
125/// `lazy::LlamaKVCache`. Indexed by global layer index; `layers[i]`
126/// is `None` if layer `i` hasn't been populated yet.
127///
128/// Pipeline-parallel friendly: when a model is split across devices,
129/// each device's cache holds entries only for its layer range; other
130/// indices are `None`. The model code queries `cache.layer(global_i)`
131/// regardless of which device owns it; the storage Arc inside
132/// [`KvLayer`] carries the device identity via its
133/// [`fuel_memory::BackendStorage`] variant.
134pub struct KvCache {
135 pub layers: Vec<Option<KvLayer>>,
136 pub cached_len: usize,
137 pub n_kv_heads: usize,
138 pub head_dim: usize,
139 /// Identity of the *allocation* backing this cache — the thing a held
140 /// [`DecodeSession`] is welded to. Private with an [`Self::alloc_id`]
141 /// reader so it cannot be assigned by hand: every path that replaces
142 /// storage ([`Self::clear`], [`Self::set_layer`]) re-mints it, and
143 /// [`Self::truncate_to`] deliberately does not. See [`KvAllocId`].
144 alloc_id: KvAllocId,
145 /// Pre-allocated capacity along the sequence axis. `Some(n)` when
146 /// constructed via [`KvCache::with_capacity`] — every layer's K + V
147 /// storage holds `[1, n_kv_heads, n, head_dim]` of zeros and the
148 /// forward path writes into it via `Op::WriteSlice`. `None` for the
149 /// legacy [`KvCache::with_dims`] grow-by-replacement constructor.
150 pub max_seq_len: Option<usize>,
151 /// Dtype the pre-allocated buffers were allocated with. `Some(dt)`
152 /// in the [`KvCache::with_capacity`] path, `None` in the legacy
153 /// `with_dims` path (which leaves dtype to whatever the first
154 /// inserted layer specifies).
155 pub dtype: Option<DType>,
156 /// Where this cache's storage lives — `Some(loc)` only while every
157 /// buffer is one [`Self::with_capacity`] allocated on `loc`, `None`
158 /// the moment that stops being provable.
159 ///
160 /// Exists for [`DecodeSession::rebind_kv`], which splices these `Arc`s
161 /// into a graph whose placement stamps and residency `Op::Copy`s were
162 /// baked against the *previous* cache. Shape it can prove from the
163 /// storage itself; residency it cannot — `fuel_memory::Storage` exposes
164 /// a backend *variant*, and `VulkanStorage` records no device ordinal at
165 /// all, so a multi-GPU swap is invisible from the bytes. The cache is
166 /// therefore the honest carrier: it is handed a `&Device` at the one
167 /// moment residency is known for certain.
168 ///
169 /// `None` means "cannot prove", not "CPU" — and every consumer must
170 /// treat it as a refusal. [`Self::with_dims`] never had a device;
171 /// [`Self::set_layer`] installs caller-supplied storage from anywhere.
172 /// Both therefore forfeit re-bind and fall back to a plan rebuild,
173 /// which is what shipped in GAP-014 and is safe by construction.
174 device_location: Option<DeviceLocation>,
175}
176
177impl KvCache {
178 /// Legacy grow-by-replacement constructor. Layers start `None`; the
179 /// caller calls [`Self::set_layer`] each forward step with a freshly
180 /// allocated `KvLayer` whose storage is sized to the current cached
181 /// length. Subsequent steps replace the layer entirely with a
182 /// larger buffer.
183 ///
184 /// Replaced by [`Self::with_capacity`] when the caller knows the
185 /// generation's max sequence length up-front (the common case for
186 /// autoregressive decoding).
187 pub fn with_dims(n_layers: usize, n_kv_heads: usize, head_dim: usize) -> Self {
188 Self {
189 layers: (0..n_layers).map(|_| None).collect(),
190 cached_len: 0,
191 n_kv_heads,
192 head_dim,
193 max_seq_len: None,
194 dtype: None,
195 alloc_id: KvAllocId::next(),
196 // No device was named, and this constructor's whole shape is
197 // grow-by-replacement — the storage a plan baked is replaced
198 // every step anyway. Unprovable residency, and nothing lost.
199 device_location: None,
200 }
201 }
202
203 /// Pre-allocated KV cache. Every layer's K + V storage is allocated
204 /// up-front as a `[1, n_kv_heads, max_seq_len, head_dim]` zero
205 /// buffer on `device` with `dtype`. Subsequent forward steps write
206 /// fresh K/V slabs into these buffers via `Op::WriteSlice` rather
207 /// than growing-by-replacement.
208 ///
209 /// Memory footprint: `n_layers * 2 * n_kv_heads * max_seq_len *
210 /// head_dim * dtype_size` bytes. For Llama-7B at max_seq_len=4096,
211 /// bf16: 32 * 2 * 8 * 4096 * 128 * 2 ≈ 512 MiB.
212 ///
213 /// Returns `Err` if any per-layer allocation fails (e.g. CUDA OOM)
214 /// or the requested device hasn't been wired up in
215 /// [`crate::pipelined_bridge`] (Vulkan / Metal — D2H/H2D for those still
216 /// goes through the legacy executor).
217 pub fn with_capacity(
218 n_layers: usize,
219 n_kv_heads: usize,
220 head_dim: usize,
221 max_seq_len: usize,
222 dtype: DType,
223 device: &Device,
224 ) -> Result<Self> {
225 // Bridge-retirement Phase 3a (post-9c): allocate the 2N K/V
226 // buffers via `Op::Alloc` graph emission, not direct backend
227 // calls. The executor's `WorkItemKind::Alloc` arm dispatches
228 // per-backend; the per-`DeviceLocation` match that used to
229 // live in `alloc_zeroed_on` is now in the executor (the
230 // architectural dispatch layer).
231 let shape = Shape::from_dims(&[1, n_kv_heads, max_seq_len, head_dim]);
232 let layout = Layout::contiguous(shape.clone());
233 let target_loc = device.location();
234
235 // Build the transient graph. For non-CPU targets the first
236 // node is an `Op::Const` placeholder whose StorageCache entry
237 // carries a small "device anchor" storage — the executor's
238 // Alloc arm searches the cache for any storage on the target
239 // backend to derive the device handle, so this entry makes
240 // the first Op::Alloc's device lookup succeed. The anchor's
241 // NodeId must exist in the graph so `realize_many`'s
242 // `layout_cache` seeding (which calls `g.layout(id)` on every
243 // cache key) doesn't panic.
244 let graph = Arc::new(RwLock::new(Graph::new()));
245 let mut cache = StorageCache::new();
246 if let Some(seed) = crate::pipelined_bridge::device_seed_storage(device)? {
247 let anchor_id = {
248 let mut g = graph.write().map_err(|_| {
249 Error::Msg("graph lock poisoned during KvCache build".into()).bt()
250 })?;
251 g.push(Node {
252 op: Op::Const,
253 inputs: vec![],
254 shape: Shape::from_dims(&[4]),
255 dtype: DType::U8,
256 })
257 };
258 cache.insert(anchor_id, Arc::new(RwLock::new(seed)));
259 }
260 // Emit pairs of (Op::Alloc, Op::ZeroFill) per K/V slot. The
261 // ZeroFill consumes the Alloc destructively (in-place fill,
262 // output adopts the Alloc's Storage Arc). Realizing the
263 // ZeroFill IDs produces zero-initialized device storages.
264 //
265 // Phase 3a follow-up architecture: Op::Alloc gives uninit
266 // memory; Op::ZeroFill explicitly fills with zero. On Vulkan
267 // this replaces the host-staged-zeros path with device-side
268 // `vkCmdFillBuffer` (~2× the bandwidth saved). On CUDA the
269 // pair becomes uninit `cuMemAlloc` + async `cuMemsetD8Async`
270 // — same total cost as the old `alloc_zeros` but exposes the
271 // ZeroFill as a first-class graph node the optimizer can
272 // see (and skip if a downstream op covers the full buffer).
273 let zero_fill_ids: Vec<NodeId> = {
274 let mut g = graph
275 .write()
276 .map_err(|_| Error::Msg("graph lock poisoned during KvCache build".into()).bt())?;
277 (0..(2 * n_layers))
278 .map(|_| {
279 let alloc_id = g.push(Node {
280 op: Op::Alloc { target: target_loc },
281 inputs: vec![],
282 shape: shape.clone(),
283 dtype,
284 });
285 g.push(Node {
286 op: Op::ZeroFill,
287 inputs: vec![alloc_id],
288 shape: shape.clone(),
289 dtype,
290 })
291 })
292 .collect()
293 };
294
295 // Realize all 2*n_layers Op::ZeroFill targets in one pass —
296 // PipelinedExecutor::realize_many shares the compile/execute
297 // pipeline so device-handle reuse is automatic, and the
298 // executor's destructive_input cleanup evicts the Op::Alloc
299 // intermediate NodeIds while the ZeroFill targets adopt the
300 // same Arcs (post-fill bytes).
301 let realized = PipelinedExecutor::realize_many(Arc::clone(&graph), &zero_fill_ids, cache)?;
302 if realized.len() != 2 * n_layers {
303 return Err(Error::Msg(format!(
304 "KvCache::with_capacity: realize_many returned {} storages \
305 for {} Op::ZeroFill targets — internal bug",
306 realized.len(),
307 2 * n_layers,
308 ))
309 .bt());
310 }
311
312 // Never-panic: the length is checked above, but the production path
313 // must not `.expect()` — surface a typed `Err` if the iterator runs
314 // short. (Same pattern as `alloc_batched_kv` below.)
315 let mut realized_iter = realized.into_iter();
316 let short = |what: &str, l: usize| {
317 Error::Msg(format!(
318 "alloc_kv: realize_many yielded fewer storages than expected \
319 (ran out at layer {l} fetching {what})"
320 ))
321 .bt()
322 };
323 let mut layers: Vec<Option<KvLayer>> = Vec::with_capacity(n_layers);
324 for l in 0..n_layers {
325 let (k_arc, _) = realized_iter.next().ok_or_else(|| short("K", l))?;
326 let (v_arc, _) = realized_iter.next().ok_or_else(|| short("V", l))?;
327 layers.push(Some(KvLayer {
328 k: k_arc,
329 v: v_arc,
330 k_layout: layout.clone(),
331 v_layout: layout.clone(),
332 k_version: 0,
333 v_version: 0,
334 k_authority: AuthorityState::Host,
335 v_authority: AuthorityState::Host,
336 }));
337 }
338
339 // Silence the unused-bind lint for elem_count when this
340 // path no longer needs the legacy alloc helper.
341 let _ = (n_kv_heads, max_seq_len, head_dim);
342
343 Ok(Self {
344 layers,
345 cached_len: 0,
346 n_kv_heads,
347 head_dim,
348 max_seq_len: Some(max_seq_len),
349 dtype: Some(dtype),
350 alloc_id: KvAllocId::next(),
351 // Every buffer above was `Op::Alloc { target: target_loc }`, so
352 // this is measured at the allocation, not inferred later.
353 device_location: Some(target_loc),
354 })
355 }
356
357 /// Borrow the K-or-V storage Arc for a given layer + slot. Used by
358 /// the forward path to bind cache storage to per-step Const nodes
359 /// via [`InferenceContext::insert`]. Returns `None` if the layer
360 /// isn't populated (with_dims path before first
361 /// [`Self::set_layer`]) or `layer_idx` is out of range.
362 pub fn slot_storage(&self, layer_idx: usize, slot: KvSlot) -> Option<Arc<RwLock<Storage>>> {
363 let layer = self.layer(layer_idx)?;
364 Some(match slot {
365 KvSlot::K => Arc::clone(&layer.k),
366 KvSlot::V => Arc::clone(&layer.v),
367 })
368 }
369
370 /// Bump a slot's monotonic version counter. Called after every
371 /// successful `Op::WriteSlice` targeting the slot — the forward
372 /// path invokes this once per (layer, slot) per step.
373 ///
374 /// The version is consumed by the future multi-device coherence
375 /// protocol (placeholder today — see [`AuthorityState`]).
376 pub fn bump_version(&mut self, layer_idx: usize, slot: KvSlot) {
377 if let Some(layer) = self.layer_mut(layer_idx) {
378 match slot {
379 KvSlot::K => layer.k_version += 1,
380 KvSlot::V => layer.v_version += 1,
381 }
382 }
383 }
384
385 pub fn n_layers(&self) -> usize {
386 self.layers.len()
387 }
388
389 pub fn layer(&self, li: usize) -> Option<&KvLayer> {
390 self.layers.get(li).and_then(|o| o.as_ref())
391 }
392
393 pub fn layer_mut(&mut self, li: usize) -> Option<&mut KvLayer> {
394 self.layers.get_mut(li).and_then(|o| o.as_mut())
395 }
396
397 /// Identity of the storage this cache currently owns — the value a held
398 /// [`DecodeSession`]'s validity key carries so a cache **swap** cannot be
399 /// mistaken for a continuation. See [`KvAllocId`] for why re-allocation
400 /// re-mints it and rewinding does not.
401 pub fn alloc_id(&self) -> KvAllocId {
402 self.alloc_id
403 }
404
405 /// Where this cache's storage lives, or `None` when that is not provable.
406 /// See the field's doc — `None` is a refusal, never a CPU default.
407 pub fn device_location(&self) -> Option<DeviceLocation> {
408 self.device_location
409 }
410
411 /// Install a layer's storage, replacing whatever was there.
412 ///
413 /// This **re-mints [`Self::alloc_id`]**: the storage `Arc` a held plan
414 /// baked into its `base_cache` for this layer is no longer the storage
415 /// this cache reads, so any plan welded to the old one must be rebuilt.
416 /// It also clears [`Self::device_location`]: the storage arrives from the
417 /// caller, so where it lives is no longer something this cache measured.
418 /// That forfeits [`DecodeSession::rebind_kv`] for the rest of this cache's
419 /// life — deliberately, because an unprovable residency spliced into a
420 /// baked plan is the GAP-014 failure one layer down.
421 pub fn set_layer(&mut self, li: usize, layer: KvLayer) {
422 self.layers[li] = Some(layer);
423 self.alloc_id = KvAllocId::next();
424 self.device_location = None;
425 }
426
427 /// Drop every layer; reset `cached_len` to zero. Use between
428 /// independent generations (the cache's K/V shapes are tied to
429 /// a specific prompt prefix).
430 ///
431 /// **Re-mints [`Self::alloc_id`]** — every storage `Arc` is released, so
432 /// this is an allocation change, not a rewind. Contrast
433 /// [`Self::truncate_to`], which moves only `cached_len` and therefore must
434 /// preserve the id.
435 pub fn clear(&mut self) {
436 for layer in &mut self.layers {
437 *layer = None;
438 }
439 self.cached_len = 0;
440 self.alloc_id = KvAllocId::next();
441 // There is no storage left to have a residency. Repopulating goes
442 // through `set_layer`, which cannot prove one either.
443 self.device_location = None;
444 }
445
446 /// Shrink the cache to `new_len` cached positions. Speculative-
447 /// decoding's reject path calls this after a draft batch is
448 /// rejected by the target model.
449 ///
450 /// **Phase E.3.0 limitation**: this only updates `cached_len`;
451 /// the underlying storages and their layouts are *not* shrunk.
452 /// The grow-by-replacement pattern (today) means the next forward
453 /// pass would still see the old layout's full extent. Phase E.3.2
454 /// pairs this with pre-allocated buffers + `Op::WriteSlice` so
455 /// truncate becomes a pure metadata update — the trailing rows
456 /// of the pre-allocated buffer simply stop being read.
457 ///
458 /// **Deliberately does NOT touch [`Self::alloc_id`].** Rewinding the
459 /// conversation is not re-allocating the buffers: a held plan is still
460 /// welded to exactly the right storage, so invalidating here would forfeit
461 /// plan reuse on every rejected draft batch and buy no correctness. The
462 /// id names the allocation, never the conversation — see [`KvAllocId`].
463 pub fn truncate_to(&mut self, new_len: usize) {
464 if new_len < self.cached_len {
465 self.cached_len = new_len;
466 }
467 }
468
469 /// Write host-supplied K/V bytes into layer `layer_idx`'s `slot` buffer at
470 /// position range `[start, start+n)` — the `KvCache`-side sibling of
471 /// [`DeviceKvPool::write_block_bytes`](crate::kv_block_pool_device::DeviceKvPool::write_block_bytes).
472 ///
473 /// Builds a throwaway `Op::Const`(bytes) → `Op::WriteSlice` graph and
474 /// realizes it ONCE into the SAME buffer `Arc` this cache already holds —
475 /// it does **not** replace the Arc, so unlike [`Self::set_layer`] this
476 /// does **not** touch [`Self::alloc_id`] or [`Self::device_location`]: a
477 /// [`DecodeSession`] already welded to this cache's buffers (via
478 /// `base_cache`) sees the write transparently on its next token, with no
479 /// rebind and no rebuild — the write lands in the exact buffer the held
480 /// plan's `Op::WriteSlice` nodes already target.
481 ///
482 /// `bytes` is `n * n_kv_heads * head_dim * dtype.size_in_bytes()` raw
483 /// little-endian element bytes, row-major `[1, n_kv_heads, n, head_dim]`
484 /// — **HEAD-MAJOR** (position is axis 2). This is NOT the same layout as
485 /// [`DeviceKvPool`](crate::kv_block_pool_device::DeviceKvPool)'s
486 /// **position-major** `[block_size, Hkv, D]` block layout: a caller
487 /// moving bytes from the paged pool into a `KvCache` must transpose, a
488 /// plain memcpy silently produces wrong attention.
489 ///
490 /// `Err` (never a panic): this cache has no fixed dtype/capacity (built
491 /// via [`Self::with_dims`] rather than [`Self::with_capacity`] — this
492 /// primitive is `with_capacity`-only, since the legacy grow-by-replacement
493 /// path has no stable buffer to write into); `layer_idx` is out of range
494 /// or unpopulated; `start + n` exceeds the allocated `max_seq_len`; or
495 /// `bytes.len()` doesn't match the expected size for `n` positions.
496 pub fn write_bytes(
497 &self,
498 layer_idx: usize,
499 slot: KvSlot,
500 start: usize,
501 n: usize,
502 bytes: &[u8],
503 device: &Device,
504 ) -> Result<()> {
505 let dtype = self.dtype.ok_or_else(|| {
506 Error::Msg(
507 "KvCache::write_bytes: cache has no fixed dtype — not built via with_capacity"
508 .into(),
509 )
510 .bt()
511 })?;
512 let max_seq_len = self.max_seq_len.ok_or_else(|| {
513 Error::Msg(
514 "KvCache::write_bytes: cache has no fixed capacity — not built via with_capacity"
515 .into(),
516 )
517 .bt()
518 })?;
519 if start + n > max_seq_len {
520 return Err(Error::Msg(format!(
521 "KvCache::write_bytes: range [{start}, {}) exceeds max_seq_len {max_seq_len}",
522 start + n,
523 ))
524 .bt());
525 }
526 let elem_bytes = kv_cache_dtype_elem_bytes(dtype)?;
527 let want = n * self.n_kv_heads * self.head_dim * elem_bytes;
528 if bytes.len() != want {
529 return Err(Error::Msg(format!(
530 "KvCache::write_bytes: {} bytes != expected {want} \
531 ([1, n_kv_heads {}, n {n}, head_dim {}] for {dtype:?})",
532 bytes.len(),
533 self.n_kv_heads,
534 self.head_dim,
535 ))
536 .bt());
537 }
538 let layer = self.layer(layer_idx).ok_or_else(|| {
539 Error::Msg(format!(
540 "KvCache::write_bytes: layer {layer_idx} out of range or unpopulated"
541 ))
542 .bt()
543 })?;
544 let buf = match slot {
545 KvSlot::K => Arc::clone(&layer.k),
546 KvSlot::V => Arc::clone(&layer.v),
547 };
548 let full_shape = Shape::from_dims(&[1, self.n_kv_heads, max_seq_len, self.head_dim]);
549 let src = kv_cache_const_from_bytes(
550 dtype,
551 bytes,
552 Shape::from_dims(&[1, self.n_kv_heads, n, self.head_dim]),
553 device,
554 )?;
555 let dest = src.const_placeholder_like(full_shape, dtype);
556 let ranges = vec![
557 (0, 1),
558 (0, self.n_kv_heads),
559 (start, start + n),
560 (0, self.head_dim),
561 ];
562 let post = dest.write_slice(&src, ranges)?;
563 let mut cache = StorageCache::new();
564 cache.insert(dest.node_id(), buf);
565 let _ = crate::pipelined_bridge::realize_one_bytes_with_initial(
566 post.graph_handle(),
567 post.node_id(),
568 device,
569 cache,
570 )?;
571 Ok(())
572 }
573}
574
575/// Byte width of one `KvCache` element for byte-level movement — the
576/// `KvCache`-side sibling of
577/// [`DeviceKvPool::dtype_elem_bytes`](crate::kv_block_pool_device::DeviceKvPool).
578/// F32/BF16 only, matching [`KvCache::write_bytes`]'s supported dtypes.
579fn kv_cache_dtype_elem_bytes(dtype: DType) -> Result<usize> {
580 match dtype {
581 DType::F32 => Ok(4),
582 DType::BF16 => Ok(2),
583 other => Err(Error::Msg(format!(
584 "KvCache: byte movement unsupported for dtype {other:?} (F32/BF16)",
585 ))
586 .bt()),
587 }
588}
589
590/// Build a fresh-graph `Op::Const` of `dtype` from raw little-endian element
591/// bytes — the `KvCache`-side sibling of
592/// [`DeviceKvPool::const_from_bytes`](crate::kv_block_pool_device::DeviceKvPool).
593/// Dispatches `dtype` to the matching typed graph constructor via
594/// alignment-safe `try_cast_slice` (never panics).
595fn kv_cache_const_from_bytes(
596 dtype: DType,
597 bytes: &[u8],
598 shape: Shape,
599 device: &Device,
600) -> Result<crate::lazy::Tensor> {
601 match dtype {
602 DType::F32 => {
603 let v: &[f32] = bytemuck::try_cast_slice(bytes).map_err(|e| {
604 Error::Msg(format!("kv_cache_const_from_bytes: f32 cast: {e:?}")).bt()
605 })?;
606 crate::lazy::Tensor::from_f32(v.to_vec(), shape, device)
607 }
608 DType::BF16 => {
609 let v: &[half::bf16] = bytemuck::try_cast_slice(bytes).map_err(|e| {
610 Error::Msg(format!("kv_cache_const_from_bytes: bf16 cast: {e:?}")).bt()
611 })?;
612 let anchor =
613 crate::lazy::Tensor::from_f32(vec![0.0f32], Shape::from_dims(&[1]), device)?;
614 crate::lazy::Tensor::from_bf16_on(anchor.graph(), v.to_vec(), shape, device)
615 }
616 other => Err(Error::Msg(format!(
617 "kv_cache_const_from_bytes: unsupported dtype {other:?} (F32/BF16)",
618 ))
619 .bt()),
620 }
621}
622
623/// One layer's `(K, V)` cache storages. A KV cache is a `Vec` of these, one
624/// per layer, and the two halves are always allocated and dropped together.
625type KvStoragePair = (Arc<RwLock<Storage>>, Arc<RwLock<Storage>>);
626
627/// Allocate `n_layers` pairs of zero-initialized `[k, n_kv_heads,
628/// max_seq_len, head_dim]` K/V buffers on `device` with `dtype` — the shared
629/// batch-slot KV buffer for the multi-session batched decode arm (serving
630/// Increment 1, C3). Same `Op::Alloc`→`Op::ZeroFill` emission +
631/// `PipelinedExecutor::realize_many` pattern as [`KvCache::with_capacity`],
632/// with the leading dim `k` (the batch slot) instead of `1`. Returns the
633/// per-layer `(K_arc, V_arc)` pairs; the caller binds them into a batched
634/// decode graph.
635///
636/// Returns `Err` if any allocation fails (fail-on-OOM, spec #6).
637pub fn alloc_batched_kv(
638 k: usize,
639 n_layers: usize,
640 n_kv_heads: usize,
641 head_dim: usize,
642 max_seq_len: usize,
643 dtype: DType,
644 device: &Device,
645) -> Result<Vec<KvStoragePair>> {
646 let shape = Shape::from_dims(&[k, n_kv_heads, max_seq_len, head_dim]);
647 let target_loc = device.location();
648
649 let graph = Arc::new(RwLock::new(Graph::new()));
650 let mut cache = StorageCache::new();
651 if let Some(seed) = crate::pipelined_bridge::device_seed_storage(device)? {
652 let anchor_id = {
653 let mut g = graph.write().map_err(|_| {
654 Error::Msg("graph lock poisoned during alloc_batched_kv".into()).bt()
655 })?;
656 g.push(Node {
657 op: Op::Const,
658 inputs: vec![],
659 shape: Shape::from_dims(&[4]),
660 dtype: DType::U8,
661 })
662 };
663 cache.insert(anchor_id, Arc::new(RwLock::new(seed)));
664 }
665
666 let zero_fill_ids: Vec<NodeId> = {
667 let mut g = graph
668 .write()
669 .map_err(|_| Error::Msg("graph lock poisoned during alloc_batched_kv".into()).bt())?;
670 (0..(2 * n_layers))
671 .map(|_| {
672 let alloc_id = g.push(Node {
673 op: Op::Alloc { target: target_loc },
674 inputs: vec![],
675 shape: shape.clone(),
676 dtype,
677 });
678 g.push(Node {
679 op: Op::ZeroFill,
680 inputs: vec![alloc_id],
681 shape: shape.clone(),
682 dtype,
683 })
684 })
685 .collect()
686 };
687
688 let realized = PipelinedExecutor::realize_many(Arc::clone(&graph), &zero_fill_ids, cache)?;
689 if realized.len() != 2 * n_layers {
690 return Err(Error::Msg(format!(
691 "alloc_batched_kv: realize_many returned {} storages for {} Op::ZeroFill targets",
692 realized.len(),
693 2 * n_layers,
694 ))
695 .bt());
696 }
697
698 // Never-panic: the length is checked above, but the production path must
699 // not `.expect()` — surface a typed `Err` if the iterator is short.
700 let mut it = realized.into_iter();
701 let mut pairs = Vec::with_capacity(n_layers);
702 let short = || {
703 Error::Msg(
704 "alloc_batched_kv: realize_many yielded fewer storages than expected".to_string(),
705 )
706 .bt()
707 };
708 for _ in 0..n_layers {
709 let (k_arc, _) = it.next().ok_or_else(short)?;
710 let (v_arc, _) = it.next().ok_or_else(short)?;
711 pairs.push((k_arc, v_arc));
712 }
713 Ok(pairs)
714}
715
716// ===========================================================================
717// LatentKvCache — N-slot generalization of KvCache (MLA increment 4)
718// ===========================================================================
719
720/// One slot's persistent latent buffer within a [`LatentKvCache`] layer.
721/// The per-slot analogue of [`KvLayer`]'s `k`/`v` pair, generalized to an
722/// arbitrary slot count and independent per-slot trailing shape.
723pub struct LatentSlot {
724 pub storage: Arc<RwLock<Storage>>,
725 /// View layout into `storage`. For the pre-allocated (capacity)
726 /// buffers [`LatentKvCache::with_capacity`] allocates, the layout
727 /// matches the storage's full `[max_seq_len, ...trailing]` shape.
728 pub layout: Layout,
729 /// Monotonic write version. Bumps on every successful
730 /// `Op::WriteSlice` targeting this slot. Placeholder for the future
731 /// multi-device coherence protocol — mirrors [`KvLayer::k_version`] /
732 /// `v_version`.
733 pub version: u64,
734 /// Coherence authority. Placeholder — see [`AuthorityState`].
735 pub authority: AuthorityState,
736}
737
738/// Cross-graph, cross-forward-pass persistent **N-slot** decode cache —
739/// the [`KvCache`] generalization for latent-caching attention
740/// architectures.
741///
742/// [`KvCache`] hardwires a symmetric K/V pair (two buffers per layer,
743/// same shape). That's wrong for compression architectures:
744///
745/// - **Multi-head Latent Attention (DeepSeek-V2 MLA)**: per layer, a
746/// low-rank compressed latent `[kv_lora_rank]` (slot 0) **and** a
747/// single-head RoPE key `[qk_rope_head_dim]` (slot 1) — two slots of
748/// *different* trailing shape, neither a `[n_kv_heads, head_dim]` K/V.
749/// - **Two-projection attention / QKV pruning**: a single retained
750/// projection per layer — one slot.
751///
752/// `LatentKvCache` is this module's counterpart to
753/// [`crate::lazy_latent_cache::LatentCache`] — that type makes the
754/// exact same **per-layer, ordered list of latent buffers with
755/// independent trailing shapes** generalization for the **per-forward-
756/// pass** (single [`fuel_graph::Graph`]-anchored, functional
757/// append-and-thread) lifecycle. `LatentKvCache` is the **persistent**
758/// sibling: device-resident `Arc<RwLock<Storage>>` buffers that survive
759/// across graphs/forward calls and are mutated in place via
760/// `Op::WriteSlice`, exactly the relationship [`KvCache`] has to a plain
761/// per-pass K/V cache. Read both modules' docs together for the two
762/// lifecycles side by side.
763///
764/// # Shape contract
765///
766/// Slot `s` of every layer is a buffer `[max_seq_len, ...slot_trailing[s]]`
767/// — the capacity axis is dim **0**. This differs from [`KvCache`]'s `[1,
768/// n_kv_heads, max_seq_len, head_dim]` convention (capacity axis 2):
769/// a latent is a per-token *vector*, not a per-head *plane*, so there is
770/// no head axis to lead with. Matches [`crate::lazy_latent_cache::
771/// LatentCache`]'s dim-0 convention exactly — this type's per-slot
772/// buffer is byte-for-byte what that type's `slot_buffer_full` realizes,
773/// modulo device residency.
774pub struct LatentKvCache {
775 /// `layers[l]` is `None` until populated; `Some(slots)` holds one
776 /// [`LatentSlot`] per slot index (pipeline-parallel friendly — mirrors
777 /// [`KvCache::layers`]).
778 pub layers: Vec<Option<Vec<LatentSlot>>>,
779 pub cached_len: usize,
780 /// Per-slot trailing shape (past the leading capacity axis); its
781 /// length is the per-layer slot count.
782 pub slot_trailing: Vec<Vec<usize>>,
783 pub max_seq_len: usize,
784 pub dtype: DType,
785 /// Identity of the allocation backing this cache. Same role and same
786 /// re-mint rules as [`KvCache::alloc_id`] — MLA reuses [`DecodeSession`]
787 /// verbatim (its 2 latent slots ride `kv_nodes` as `(latent, kpe)` pairs),
788 /// so it inherits the same welding and needs the same guard.
789 alloc_id: KvAllocId,
790 /// Where the slot buffers live. Unconditional here, unlike
791 /// [`KvCache::device_location`]: this type has exactly one constructor
792 /// and it is handed a `&Device`, so residency is never unprovable.
793 device_location: DeviceLocation,
794}
795
796impl LatentKvCache {
797 /// Pre-allocated N-slot latent cache. Every layer's slot buffers are
798 /// allocated up-front as zero buffers on `device` with `dtype`,
799 /// shaped `[max_seq_len, ...slot_trailing[s]]`. Mirrors [`KvCache::
800 /// with_capacity`]'s `Op::Alloc` → `Op::ZeroFill` graph-emission
801 /// pattern exactly, generalized from a fixed 2-buffer (K, V) layer to
802 /// `slot_trailing.len()` buffers of independent shape.
803 ///
804 /// Returns `Err` for degenerate geometry (`n_layers == 0`,
805 /// `max_seq_len == 0`, or no slots) or if any per-layer allocation
806 /// fails (e.g. CUDA OOM / an unwired device).
807 pub fn with_capacity(
808 n_layers: usize,
809 max_seq_len: usize,
810 slot_trailing: Vec<Vec<usize>>,
811 dtype: DType,
812 device: &Device,
813 ) -> Result<Self> {
814 if n_layers == 0 {
815 return Err(
816 Error::Msg("LatentKvCache::with_capacity: n_layers must be >= 1".into()).bt(),
817 );
818 }
819 if max_seq_len == 0 {
820 return Err(Error::Msg(
821 "LatentKvCache::with_capacity: max_seq_len must be >= 1".into(),
822 )
823 .bt());
824 }
825 if slot_trailing.is_empty() {
826 return Err(Error::Msg(
827 "LatentKvCache::with_capacity: need at least one slot (slot_trailing empty)".into(),
828 )
829 .bt());
830 }
831 let n_slots = slot_trailing.len();
832 let shapes: Vec<Shape> = slot_trailing
833 .iter()
834 .map(|trailing| {
835 let mut dims = Vec::with_capacity(1 + trailing.len());
836 dims.push(max_seq_len);
837 dims.extend_from_slice(trailing);
838 Shape::from_dims(&dims)
839 })
840 .collect();
841 let layouts: Vec<Layout> = shapes
842 .iter()
843 .map(|s| Layout::contiguous(s.clone()))
844 .collect();
845 let target_loc = device.location();
846
847 // Transient graph, same device-anchor trick + Alloc/ZeroFill
848 // emission pattern as KvCache::with_capacity — see that
849 // constructor's doc for the full rationale.
850 let graph = Arc::new(RwLock::new(Graph::new()));
851 let mut cache = StorageCache::new();
852 if let Some(seed) = crate::pipelined_bridge::device_seed_storage(device)? {
853 let anchor_id = {
854 let mut g = graph.write().map_err(|_| {
855 Error::Msg("graph lock poisoned during LatentKvCache build".into()).bt()
856 })?;
857 g.push(Node {
858 op: Op::Const,
859 inputs: vec![],
860 shape: Shape::from_dims(&[4]),
861 dtype: DType::U8,
862 })
863 };
864 cache.insert(anchor_id, Arc::new(RwLock::new(seed)));
865 }
866
867 // Emit (Op::Alloc, Op::ZeroFill) pairs for every (layer, slot),
868 // each shaped per its own slot's shape.
869 let mut zero_fill_ids: Vec<NodeId> = Vec::with_capacity(n_layers * n_slots);
870 {
871 let mut g = graph.write().map_err(|_| {
872 Error::Msg("graph lock poisoned during LatentKvCache build".into()).bt()
873 })?;
874 for _ in 0..n_layers {
875 for shape in &shapes {
876 let alloc_id = g.push(Node {
877 op: Op::Alloc { target: target_loc },
878 inputs: vec![],
879 shape: shape.clone(),
880 dtype,
881 });
882 let zf_id = g.push(Node {
883 op: Op::ZeroFill,
884 inputs: vec![alloc_id],
885 shape: shape.clone(),
886 dtype,
887 });
888 zero_fill_ids.push(zf_id);
889 }
890 }
891 }
892
893 // Realize all n_layers * n_slots Op::ZeroFill targets in one pass
894 // — see KvCache::with_capacity's doc for why a single realize_many
895 // call is used instead of one realize per buffer.
896 let realized = PipelinedExecutor::realize_many(Arc::clone(&graph), &zero_fill_ids, cache)?;
897 if realized.len() != n_layers * n_slots {
898 return Err(Error::Msg(format!(
899 "LatentKvCache::with_capacity: realize_many returned {} storages \
900 for {} Op::ZeroFill targets — internal bug",
901 realized.len(),
902 n_layers * n_slots,
903 ))
904 .bt());
905 }
906
907 // Never-panic: length checked above; still surface a typed `Err`
908 // rather than `.expect()` on a production path.
909 let mut realized_iter = realized.into_iter();
910 let short = |l: usize| {
911 Error::Msg(format!(
912 "alloc_latent: realize_many yielded fewer storages than \
913 expected (ran out at layer {l})"
914 ))
915 .bt()
916 };
917 let mut layers: Vec<Option<Vec<LatentSlot>>> = Vec::with_capacity(n_layers);
918 for l in 0..n_layers {
919 let mut slots = Vec::with_capacity(n_slots);
920 for layout in &layouts {
921 let (arc, _) = realized_iter.next().ok_or_else(|| short(l))?;
922 slots.push(LatentSlot {
923 storage: arc,
924 layout: layout.clone(),
925 version: 0,
926 authority: AuthorityState::Host,
927 });
928 }
929 layers.push(Some(slots));
930 }
931
932 Ok(Self {
933 layers,
934 cached_len: 0,
935 slot_trailing,
936 max_seq_len,
937 dtype,
938 alloc_id: KvAllocId::next(),
939 device_location: target_loc,
940 })
941 }
942
943 /// Borrow the slot `s` storage Arc for layer `layer_idx`. `None` if
944 /// the layer is unpopulated or either index is out of range. Used by
945 /// the forward path to bind cache storage to per-step Const nodes via
946 /// [`InferenceContext::insert`]. Mirrors [`KvCache::slot_storage`].
947 pub fn slot_storage(&self, layer_idx: usize, slot: usize) -> Option<Arc<RwLock<Storage>>> {
948 let layer = self.layers.get(layer_idx)?.as_ref()?;
949 layer.get(slot).map(|s| Arc::clone(&s.storage))
950 }
951
952 /// Bump slot `s`'s monotonic version counter for `layer_idx`. No-op
953 /// (never panics) if either index is out of range. Mirrors
954 /// [`KvCache::bump_version`].
955 pub fn bump_version(&mut self, layer_idx: usize, slot: usize) {
956 if let Some(Some(layer)) = self.layers.get_mut(layer_idx)
957 && let Some(s) = layer.get_mut(slot)
958 {
959 s.version += 1;
960 }
961 }
962
963 pub fn n_layers(&self) -> usize {
964 self.layers.len()
965 }
966
967 /// Number of slots per layer.
968 pub fn n_slots(&self) -> usize {
969 self.slot_trailing.len()
970 }
971
972 /// Trailing shape of slot `s` (past the leading capacity axis).
973 pub fn slot_trailing(&self, s: usize) -> &[usize] {
974 &self.slot_trailing[s]
975 }
976
977 /// Identity of the storage this cache currently owns. Mirrors
978 /// [`KvCache::alloc_id`]; see [`KvAllocId`].
979 pub fn alloc_id(&self) -> KvAllocId {
980 self.alloc_id
981 }
982
983 /// Drop every layer; reset `cached_len` to zero. Mirrors [`KvCache::clear`],
984 /// including the [`Self::alloc_id`] re-mint — every storage `Arc` is
985 /// released, so a plan welded to them must be rebuilt.
986 pub fn clear(&mut self) {
987 for layer in &mut self.layers {
988 *layer = None;
989 }
990 self.cached_len = 0;
991 self.alloc_id = KvAllocId::next();
992 }
993
994 /// Shrink the cache to `new_len` cached positions — metadata-only,
995 /// same Phase E.3.0 limitation and rationale as [`KvCache::
996 /// truncate_to`]: the underlying storages are not shrunk; the
997 /// pre-allocated buffers' trailing rows simply stop being read until
998 /// overwritten by a later `Op::WriteSlice` at the same positions.
999 ///
1000 /// Deliberately preserves [`Self::alloc_id`] — a rewind is not a
1001 /// re-allocation. Same rule as [`KvCache::truncate_to`].
1002 pub fn truncate_to(&mut self, new_len: usize) {
1003 if new_len < self.cached_len {
1004 self.cached_len = new_len;
1005 }
1006 }
1007}
1008
1009// ===========================================================================
1010// Guarded KV re-bind (GAP-028)
1011// ===========================================================================
1012
1013/// A KV allocation a held [`DecodeSession`] can be re-bound onto — the one
1014/// abstraction over [`KvCache`] (K/V pairs) and [`LatentKvCache`] (MLA's
1015/// `(latent, kpe)` pairs), which already share `DecodeSession` verbatim and
1016/// therefore share its welding.
1017///
1018/// Deliberately narrow: identity, residency, and the storage behind a baked
1019/// `kv_nodes` slot. Geometry is **not** here, because the guard does not ask
1020/// this trait for it — it compares the incoming storage against the storage
1021/// already baked into the plan, which is ground truth and cannot drift from
1022/// whatever a cache believes about itself.
1023pub trait KvRebindSource {
1024 /// Identity of this allocation. See [`KvAllocId`].
1025 fn alloc_id(&self) -> KvAllocId;
1026 /// Where the storage lives, or `None` when that is not provable — which
1027 /// [`DecodeSession::rebind_kv`] treats as a refusal, never as a default.
1028 fn rebind_location(&self) -> Option<DeviceLocation>;
1029 /// Storage for slot `which` (0 or 1) of `layer` — the two halves of the
1030 /// plan's `kv_nodes[layer]` pair, in the order the builder bound them.
1031 fn rebind_slot(&self, layer: usize, which: usize) -> Option<Arc<RwLock<Storage>>>;
1032}
1033
1034impl KvRebindSource for KvCache {
1035 fn alloc_id(&self) -> KvAllocId {
1036 KvCache::alloc_id(self)
1037 }
1038 fn rebind_location(&self) -> Option<DeviceLocation> {
1039 self.device_location
1040 }
1041 fn rebind_slot(&self, layer: usize, which: usize) -> Option<Arc<RwLock<Storage>>> {
1042 // The builder pushes `(k_id, v_id)` — this order is the contract.
1043 let slot = match which {
1044 0 => KvSlot::K,
1045 1 => KvSlot::V,
1046 _ => return None,
1047 };
1048 self.slot_storage(layer, slot)
1049 }
1050}
1051
1052impl KvRebindSource for LatentKvCache {
1053 fn alloc_id(&self) -> KvAllocId {
1054 LatentKvCache::alloc_id(self)
1055 }
1056 fn rebind_location(&self) -> Option<DeviceLocation> {
1057 Some(self.device_location)
1058 }
1059 fn rebind_slot(&self, layer: usize, which: usize) -> Option<Arc<RwLock<Storage>>> {
1060 // MLA binds `(latent, kpe)` as slots 0 and 1 — same positional
1061 // contract as K/V above, which is why one `DecodeSession` serves both.
1062 self.slot_storage(layer, which)
1063 }
1064}
1065
1066/// Why a held plan could not be re-bound onto a replacement KV allocation.
1067///
1068/// A refusal is a normal outcome, not an error: the caller falls back to the
1069/// GAP-014 rebuild, which is correct by construction. The variants exist so
1070/// the guard's reasons are *nameable in a test* — the point of GAP-028 is that
1071/// this guard is load-bearing for correctness, so "it refused" is not enough
1072/// to know it refused for the right reason.
1073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1074pub enum RebindRefusal {
1075 /// The replacement cannot say where its storage lives, so residency
1076 /// cannot be proven equal to the plan's. See [`KvCache::device_location`].
1077 ResidencyUnknown,
1078 /// The plan's placement stamps and residency copies were baked for a
1079 /// different device.
1080 ResidencyDiffers {
1081 baked: DeviceLocation,
1082 incoming: DeviceLocation,
1083 },
1084 /// The replacement has no storage for a slot the plan writes.
1085 MissingSlot { layer: usize, which: usize },
1086 /// A baked KV `Const` has no `base_cache` entry — an internal
1087 /// inconsistency in the held plan, not a property of the replacement.
1088 UnbakedSlot { layer: usize, which: usize },
1089 /// The replacement storage is not byte-interchangeable with the baked
1090 /// storage: different dtype, encoding, backend variant, or extent.
1091 StorageDiffers { layer: usize, which: usize },
1092 /// A storage lock was poisoned while comparing. Refuse rather than guess.
1093 LockPoisoned,
1094}
1095
1096/// Whether a held plan may serve a step, and if not, why not — **one** set of
1097/// comparisons, so the "is it valid" and "is only the allocation different"
1098/// questions cannot drift apart. [`DecodeSession::is_valid_for`] is a thin
1099/// view over this.
1100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1101pub enum PlanValidity {
1102 /// Reuse the plan as-is.
1103 Valid,
1104 /// Everything the plan bakes still matches **except which KV allocation
1105 /// it is welded to.** A guarded [`DecodeSession::rebind_kv`] may rescue
1106 /// it; without one, the plan MUST NOT be reused — that is GAP-014.
1107 AllocationChanged,
1108 /// Something structural differs (shape, layer count, dtype, or the model
1109 /// itself). Rebuild.
1110 Stale,
1111}
1112
1113/// Everything about a storage that a baked graph depends on and that is
1114/// readable from the storage itself. Deliberately excludes residency, which
1115/// is NOT readable — see [`DecodeSession::rebind_kv`].
1116#[derive(PartialEq, Eq)]
1117struct StorageFingerprint {
1118 dtype: DType,
1119 stype: SType,
1120 bundled: bool,
1121 backend: std::mem::Discriminant<BackendStorage>,
1122 len_bytes: usize,
1123}
1124
1125fn fingerprint(s: &Arc<RwLock<Storage>>) -> Option<StorageFingerprint> {
1126 let g = s.read().ok()?;
1127 Some(StorageFingerprint {
1128 dtype: g.dtype,
1129 stype: g.stype.clone(),
1130 bundled: g.bundle.is_some(),
1131 backend: std::mem::discriminant(&g.inner),
1132 len_bytes: g.inner.len_bytes(),
1133 })
1134}
1135
1136/// May `incoming` stand in for `baked` inside an already-optimized graph?
1137/// `None` means a lock was poisoned and the question could not be answered —
1138/// which callers must treat as "no", not as "probably fine".
1139///
1140/// The two locks are taken one at a time and released before the next, so this
1141/// cannot deadlock even if some future caller passes overlapping storage.
1142fn storage_is_interchangeable(
1143 baked: &Arc<RwLock<Storage>>,
1144 incoming: &Arc<RwLock<Storage>>,
1145) -> Option<bool> {
1146 if Arc::ptr_eq(baked, incoming) {
1147 return Some(true);
1148 }
1149 let a = fingerprint(baked)?;
1150 let b = fingerprint(incoming)?;
1151 Some(a == b)
1152}
1153
1154// ===========================================================================
1155// DecodeSession (Phase D · D2b — plan-once persistent decode)
1156// ===========================================================================
1157
1158/// Per-token re-bound data-Const Arcs for a persistent decode step
1159/// (Phase D · D2b). Each is a device-resident `fuel_memory::Storage`
1160/// built from freshly recomputed host bytes (token-ids = the new token,
1161/// RoPE cos+sin at `position = cached_len`, mask with the shifted `-inf`
1162/// boundary). [`DecodeSession::realize_token`] overwrites the held
1163/// `base_cache`'s stable data-Const entries with these for the token.
1164pub struct DecodeTokenData {
1165 pub token_ids: Arc<RwLock<Storage>>,
1166 pub rope_cos: Arc<RwLock<Storage>>,
1167 pub rope_sin: Arc<RwLock<Storage>>,
1168 pub mask: Arc<RwLock<Storage>>,
1169 /// The device-resident KV-write offset (`cached_len` as a rank-0
1170 /// `I64`), present ONLY on the device-offset decode path (CUDA/CPU
1171 /// targets, where `Op::WriteSliceDoff` reads the start from this
1172 /// buffer at kernel launch — capture-ready). `None` on the SymEnv
1173 /// path (Vulkan), where the KV write is `Op::WriteSlice` with a
1174 /// `DynScalar::Sym(cached_len)` offset resolved host-side per token.
1175 pub offset: Option<Arc<RwLock<Storage>>>,
1176}
1177
1178/// Plan-once persistent decode state for one `LlamaModel` + one
1179/// [`KvCache`] capacity/dtype. Built on the first `seq == 1` decode token;
1180/// reused (graph + optimize view HELD, only the per-token data Consts +
1181/// `SymEnv` re-bound) for every subsequent token.
1182///
1183/// The ~1.8×/token win lives here: the decode-step graph is built +
1184/// `prepare`d (D2H `Op::Copy` spliced at the root) + `optimize_graph`'d
1185/// (placement stamps + residency `Op::Copy` + layout `Op::Contiguize`
1186/// baked in place) ONCE, then re-realized via the D2a prebuilt seam
1187/// ([`InferenceContext::realize_prebuilt_as_with_env`]) which SKIPS both
1188/// `prepare` and `optimize_graph`.
1189///
1190/// Held by the generate-loop owner (the caller of
1191/// `forward_with_kv_context_persistent`), NOT by the immutable
1192/// `LlamaModel` (the model is read-only weights; a session is per-
1193/// generation state). Constructed lazily as `Option<DecodeSession>` and
1194/// dropped-then-rebuilt on any validity-key mismatch / `TopologyChanged`
1195/// / a non-`seq==1` step (see `forward_with_kv_context_persistent`).
1196///
1197/// ## Why the graph is HELD here
1198///
1199/// D1 rebuilds a fresh `Tensor` graph every token and drops it after
1200/// realize. D2 keeps the `Arc<RwLock<Graph>>` alive on the session so the
1201/// already-optimized structure (the stamps + inserted copies) survives.
1202/// The cached [`OptimizedGraph`] holds only `{roots, generation}` — it
1203/// bakes NO Const data / storage / `SymEnv`, so reusing it is sound
1204/// **iff the graph structure + topology generation are unchanged** (D1's
1205/// input-independent-graph guarantee).
1206pub struct DecodeSession {
1207 /// The held decode-step graph, ALREADY optimized in place (stamps +
1208 /// residency/layout copies + D2H root splice baked in). Structure is
1209 /// stable across tokens (D1 guarantee).
1210 graph: Arc<RwLock<Graph>>,
1211 /// The cached optimize view from the first realize. Holds only
1212 /// `{roots, generation}`; valid while `graph` structure + topology
1213 /// generation are unchanged.
1214 optimized: OptimizedGraph,
1215 /// The realize root the executor was asked for — the D2H `Op::Copy`
1216 /// NodeId `prepare` spliced (NOT the logits node itself). Stable
1217 /// across tokens.
1218 effective_target: NodeId,
1219 /// The logits node (pre-D2H-splice) — `effective_target`'s input.
1220 /// Retained for D3 attribution / debugging; the executor is asked
1221 /// for `effective_target`. Also the CapturedRun replay TARGET
1222 /// (`CapturedDecodeSession::capture` must be given the pre-splice
1223 /// node — see [`Self::logits_node`]'s doc for why).
1224 logits_node: NodeId,
1225 /// Stable re-bindable token-ids Const (`[seq]` U32).
1226 token_ids_node: NodeId,
1227 /// Stable re-bindable RoPE cos table Const (`[seq, head_dim]` F32).
1228 rope_cos_node: NodeId,
1229 /// Stable re-bindable RoPE sin table Const (`[seq, head_dim]` F32).
1230 rope_sin_node: NodeId,
1231 /// Stable re-bindable causal mask Const (`[1, 1, seq, max_seq_len]`
1232 /// F32) — hoisted to ONE shared Const (was per-layer in D1).
1233 mask_node: NodeId,
1234 /// Per-layer `(k_const, v_const)` stable KV placeholder NodeIds. The
1235 /// KV Arcs are re-bound once at build time and mutated in place by
1236 /// `Op::WriteSlice`/`Op::WriteSliceDoff` each token (never re-inserted
1237 /// per token).
1238 kv_nodes: Vec<(NodeId, NodeId)>,
1239 /// The stable rank-0 `I64` KV-write offset Const, present ONLY on the
1240 /// **device-offset** decode path (CUDA/CPU — `Op::WriteSliceDoff`
1241 /// reads `cached_len` from this buffer device-side at launch, making
1242 /// the decode step CUDA-graph-capturable). `None` on the SymEnv path
1243 /// (Vulkan — the offset is host-resolved via `cached_len_sym`). When
1244 /// `Some`, `realize_token` re-binds the per-token offset Arc into the
1245 /// base-cache clone alongside token-ids/RoPE/mask.
1246 offset_node: Option<NodeId>,
1247 /// The symbol the per-pass `SymEnv` binds to `cached_len` each token.
1248 cached_len_sym: SymId,
1249 /// The symbol the per-pass `SymEnv` binds to the live **attended
1250 /// prefix length** (`cached_len + seq`) each token. This is the
1251 /// `k_len` the optimizer-emitted CUDA flash-decode arm resolves
1252 /// against (`decode_flash::DecodeFlashSpec::k_len`); distinct from
1253 /// `cached_len_sym` because the flash kernel attends `[0, k_len)`
1254 /// (the whole prefix including this token), while the KV-write lands
1255 /// at `[cached_len, cached_len + seq)`. Unreferenced on today's f32
1256 /// decode graph (no flash arm offered) — a harmless extra binding.
1257 attended_len_sym: SymId,
1258 /// The full realized [`StorageCache`] from the first realize — every
1259 /// reachable `Op::Const` (weights + the KV Arcs + the initial data
1260 /// Consts) that `build_const_cache` uploaded. Held because the
1261 /// prebuilt-realize path SKIPS the const-cache walk: each token we
1262 /// clone this and overwrite ONLY the per-token data-Const entries
1263 /// (token-ids / RoPE / mask). The weight Arcs + KV Arcs are stable
1264 /// (KV mutates in place via `Op::WriteSlice`).
1265 base_cache: StorageCache,
1266 /// The `seq` the held graph is shape-keyed to (always 1 for D2 decode).
1267 seq: usize,
1268 // ---- validity keys — rebuild if any change vs. the live cache/model ----
1269 max_seq_len: usize,
1270 n_layers: usize,
1271 cache_dtype: DType,
1272 /// The [`crate::decode_shape`] key this plan was baked against — model
1273 /// structure + weight identity. See [`Self::is_valid_for`].
1274 shape_key: u64,
1275 /// The KV **allocation** whose storage `Arc`s are baked into `base_cache`.
1276 /// `rebind_and_realize_prebuilt` never re-binds `kv_nodes`, so this plan
1277 /// reads and writes THESE buffers regardless of which cache the caller
1278 /// hands in — unless [`Self::rebind_kv`] re-points them — making the id,
1279 /// not the geometry, the thing that decides reuse. See [`KvAllocId`] and
1280 /// [`Self::is_valid_for`].
1281 alloc_id: KvAllocId,
1282 /// Where the KV `Arc`s baked into `base_cache` lived when this graph was
1283 /// optimized. The placement stamps and residency `Op::Copy`s in `graph`
1284 /// were chosen against THIS location, so [`Self::rebind_kv`] must refuse a
1285 /// replacement that lives anywhere else. `None` when the source could not
1286 /// prove it, which is likewise a refusal.
1287 kv_location: Option<DeviceLocation>,
1288 /// `order_for` fix track (`docs/design/incremental-consumer-index.md`
1289 /// §5): the memoized dispatch order for this session's
1290 /// `(graph, effective_target, optimized)` triple, computed once on the
1291 /// first `realize_token` call and reused on every later one — measured
1292 /// as the single largest named per-token sub-cost prior to this cache
1293 /// (`extract_runs_multi` + `non_chosen_arm_nodes`, ~56ms/step on an RTX
1294 /// 4070). Empty at construction; `realize_token` never resets it, since
1295 /// a `DecodeSession` holds one shape-keyed graph for its whole lifetime
1296 /// (see `is_valid_for` — any mismatch rebuilds a fresh session with a
1297 /// fresh, empty cache, never reuses this one against a different graph).
1298 order_cache: std::sync::OnceLock<Vec<NodeId>>,
1299}
1300
1301impl DecodeSession {
1302 /// Assemble a held session from the artifacts a first-realize
1303 /// prebuild produced. `graph` is the (already prepared + optimized)
1304 /// held decode graph; `effective_target` / `optimized` come from
1305 /// [`InferenceContext::prebuild_optimized_as_with_env`]; the node ids
1306 /// are the STABLE data-Const NodeIds the builder minted.
1307 pub fn new(
1308 graph: Arc<RwLock<Graph>>,
1309 optimized: OptimizedGraph,
1310 effective_target: NodeId,
1311 logits_node: NodeId,
1312 token_ids_node: NodeId,
1313 rope_cos_node: NodeId,
1314 rope_sin_node: NodeId,
1315 mask_node: NodeId,
1316 kv_nodes: Vec<(NodeId, NodeId)>,
1317 offset_node: Option<NodeId>,
1318 cached_len_sym: SymId,
1319 attended_len_sym: SymId,
1320 base_cache: StorageCache,
1321 seq: usize,
1322 max_seq_len: usize,
1323 n_layers: usize,
1324 cache_dtype: DType,
1325 shape_key: u64,
1326 kv: &dyn KvRebindSource,
1327 ) -> Self {
1328 Self {
1329 shape_key,
1330 // Identity and residency are read from the SAME source in the same
1331 // breath, so they cannot describe different allocations — which a
1332 // pair of separate `alloc_id` / `location` parameters could.
1333 alloc_id: kv.alloc_id(),
1334 kv_location: kv.rebind_location(),
1335 graph,
1336 optimized,
1337 effective_target,
1338 logits_node,
1339 token_ids_node,
1340 rope_cos_node,
1341 rope_sin_node,
1342 mask_node,
1343 kv_nodes,
1344 offset_node,
1345 cached_len_sym,
1346 attended_len_sym,
1347 base_cache,
1348 seq,
1349 max_seq_len,
1350 n_layers,
1351 cache_dtype,
1352 order_cache: std::sync::OnceLock::new(),
1353 }
1354 }
1355
1356 /// Realize the held graph for one decode token via the D2a prebuilt
1357 /// seam (SKIP `prepare` + `optimize_graph`). `data` supplies the
1358 /// per-token re-bound data-Const Arcs (token-ids / RoPE cos+sin /
1359 /// mask); they overwrite the held `base_cache`'s entries for this
1360 /// token (the weight + KV Arcs stay from `base_cache`). `sym_env`
1361 /// binds `cached_len`. `TopologyChanged` surfaces typed (caller
1362 /// invalidates the session).
1363 pub fn realize_token(
1364 &self,
1365 device: &Device,
1366 data: DecodeTokenData,
1367 sym_env: &SymEnv,
1368 ) -> Result<Vec<f32>> {
1369 let _t = fuel_dispatch::compiled::op_profile::PhaseTimer::start("decode.realize_token");
1370 let mut cache = self.base_cache.clone();
1371 cache.insert(self.token_ids_node, data.token_ids);
1372 cache.insert(self.rope_cos_node, data.rope_cos);
1373 cache.insert(self.rope_sin_node, data.rope_sin);
1374 cache.insert(self.mask_node, data.mask);
1375 // Device-offset path (CUDA/CPU): re-bind the per-token KV-write
1376 // offset (`cached_len` as I64) into the base-cache clone so the
1377 // held `Op::WriteSliceDoff` nodes read the live position from a
1378 // fixed device buffer. On the SymEnv path both are `None` (the
1379 // offset rides `cached_len_sym` in `sym_env` instead).
1380 if let (Some(offset_node), Some(offset)) = (self.offset_node, data.offset) {
1381 cache.insert(offset_node, offset);
1382 }
1383 crate::pipelined_bridge::realize_one_prebuilt_cached_env::<f32>(
1384 &self.graph,
1385 self.effective_target,
1386 &self.optimized,
1387 &self.order_cache,
1388 device,
1389 cache,
1390 sym_env,
1391 )
1392 }
1393
1394 /// Whether this held session is valid for the given decode step.
1395 /// Rebuild (drop + build fresh) on any mismatch. `seq` must be 1
1396 /// (the held graph is the seq==1 decode graph); a change in
1397 /// `max_seq_len` / `n_layers` / `cache_dtype` means a different
1398 /// model/cache → the held graph's shapes are stale.
1399 /// May this held plan be reused for a step with these parameters?
1400 ///
1401 /// `shape_key` is the caller's [`crate::decode_shape`] key. Geometry alone
1402 /// is NOT sufficient and was the whole reason that module exists: a held
1403 /// graph bakes its op structure and its weight `Const`s, so two models at
1404 /// identical dims — a different architecture, or the same architecture with
1405 /// different weights — would otherwise be judged interchangeable and one
1406 /// would silently execute the other's baked plan.
1407 ///
1408 /// `alloc_id` is the live cache's [`KvCache::alloc_id`]. It is a SEPARATE
1409 /// question from `shape_key` and neither subsumes the other: `shape_key`
1410 /// names the model whose weights are baked in, `alloc_id` names the KV
1411 /// buffers the plan writes into. One model serving many concurrent
1412 /// requests has one `shape_key` and one allocation per request, and it is
1413 /// exactly that case — the slot-pooled server — where geometry-only keying
1414 /// hands request B a plan welded to request A's KV. Nothing errors: B just
1415 /// decodes over A's context at full speed.
1416 ///
1417 /// Both directions matter. Returning `true` too readily is a wrong answer
1418 /// at full speed; returning `false` too readily forfeits plan reuse
1419 /// (measured 223× on CUDA) while every correctness test stays green. That
1420 /// second direction is why `alloc_id` tracks the ALLOCATION and not the
1421 /// conversation — see [`KvCache::truncate_to`], which must not invalidate.
1422 pub fn is_valid_for(
1423 &self,
1424 seq: usize,
1425 max_seq_len: usize,
1426 n_layers: usize,
1427 cache_dtype: DType,
1428 shape_key: u64,
1429 alloc_id: KvAllocId,
1430 ) -> bool {
1431 matches!(
1432 self.validity_for(seq, max_seq_len, n_layers, cache_dtype, shape_key, alloc_id),
1433 PlanValidity::Valid,
1434 )
1435 }
1436
1437 /// The same comparisons as [`Self::is_valid_for`], reporting *which kind*
1438 /// of mismatch occurred rather than collapsing to a bool.
1439 ///
1440 /// The split exists so GAP-028 does not introduce a second predicate. A
1441 /// caller that wants to try [`Self::rebind_kv`] needs to know "everything
1442 /// matches except the allocation", and answering that with an
1443 /// `is_valid_ignoring_alloc` twin would leave two comparison lists to keep
1444 /// in sync — the exact drift that made
1445 /// `lazy::invalidate_decode_pair_if_stale` a shared free function instead
1446 /// of a per-model copy. There is one list, here.
1447 ///
1448 /// Note the asymmetry between the two non-`Valid` answers.
1449 /// `AllocationChanged` is a *conditional* verdict: it says a re-bind is
1450 /// worth attempting, NOT that one is safe. Everything it reports on is a
1451 /// property of the held plan; whether the incoming storage is actually
1452 /// interchangeable is [`Self::rebind_kv`]'s question, and it is answered
1453 /// against the storage itself.
1454 pub fn validity_for(
1455 &self,
1456 seq: usize,
1457 max_seq_len: usize,
1458 n_layers: usize,
1459 cache_dtype: DType,
1460 shape_key: u64,
1461 alloc_id: KvAllocId,
1462 ) -> PlanValidity {
1463 let structural = self.seq == seq
1464 && self.max_seq_len == max_seq_len
1465 && self.n_layers == n_layers
1466 && self.cache_dtype == cache_dtype
1467 && self.shape_key == shape_key;
1468 if !structural {
1469 return PlanValidity::Stale;
1470 }
1471 if self.alloc_id == alloc_id {
1472 PlanValidity::Valid
1473 } else {
1474 PlanValidity::AllocationChanged
1475 }
1476 }
1477
1478 /// Re-point the held plan's baked KV `Const`s at a replacement allocation,
1479 /// or refuse — the GAP-028 alternative to throwing the plan away.
1480 ///
1481 /// ## What this trades
1482 ///
1483 /// GAP-014 shipped invalidate-and-rebuild, which is safe **by
1484 /// construction**: a plan welded to storage it no longer serves is simply
1485 /// destroyed, and no guard can be wrong because there is no guard. This
1486 /// method replaces that with a guarantee that holds **by proof**. If the
1487 /// checks below ever accept an incompatible allocation, the failure mode is
1488 /// GAP-014 verbatim — a plan executing one request's tokens over another's
1489 /// KV, at full speed, with a plausible distribution and nothing to report.
1490 /// Every check here is therefore load-bearing for correctness, not for
1491 /// performance, and the cost of refusing wrongly is only a rebuild.
1492 ///
1493 /// ## What is checked, and against what
1494 ///
1495 /// Not against the cache's own description of itself — against the storage
1496 /// **already baked into `base_cache`**, which is ground truth and cannot
1497 /// disagree with the graph the way a `KvCache` field can. Per slot: dtype,
1498 /// encoding, backend variant, bundling, and byte extent. Byte extent is the
1499 /// load-bearing one: it collapses `batch × n_kv_heads × max_seq_len ×
1500 /// head_dim` into a single comparison that needs no reconstruction of the
1501 /// baked shape.
1502 ///
1503 /// Residency is the exception and is why [`KvRebindSource::rebind_location`]
1504 /// exists. `fuel_memory::Storage` exposes a backend *variant* but no device
1505 /// ordinal — `VulkanStorage` records none at all — so a same-geometry cache
1506 /// on a *different GPU of the same backend* is invisible from the bytes. It
1507 /// would pass every check above and be spliced into a graph whose placement
1508 /// stamps and residency `Op::Copy`s were baked for the old device. An
1509 /// unprovable residency is therefore a refusal, never a default.
1510 ///
1511 /// ## What the caller still owes
1512 ///
1513 /// **A re-bind invalidates any capture.** A `CapturedDecodeSession` records
1514 /// a CUDA graph over FIXED device addresses drawn from `base_cache`; after
1515 /// this returns `Ok`, some of those addresses name the old allocation.
1516 /// Callers must clear the capture alongside, on the same
1517 /// reader-before-owner rule that governs
1518 /// `lazy::invalidate_decode_pair_if_stale`.
1519 ///
1520 /// Structural validity is NOT rechecked here — call this only when
1521 /// [`Self::validity_for`] answered [`PlanValidity::AllocationChanged`].
1522 pub fn rebind_kv(
1523 &mut self,
1524 kv: &dyn KvRebindSource,
1525 ) -> core::result::Result<(), RebindRefusal> {
1526 let incoming_loc = kv
1527 .rebind_location()
1528 .ok_or(RebindRefusal::ResidencyUnknown)?;
1529 let baked_loc = self.kv_location.ok_or(RebindRefusal::ResidencyUnknown)?;
1530 if baked_loc != incoming_loc {
1531 return Err(RebindRefusal::ResidencyDiffers {
1532 baked: baked_loc,
1533 incoming: incoming_loc,
1534 });
1535 }
1536
1537 // Collect first, mutate second. A partial re-bind — some slots on the
1538 // new allocation, the rest still on the old — is the one state strictly
1539 // worse than either endpoint, and the only way to avoid it is to refuse
1540 // before touching `base_cache`.
1541 let mut staged: Vec<(NodeId, Arc<RwLock<Storage>>)> =
1542 Vec::with_capacity(self.kv_nodes.len() * 2);
1543 for (layer, &(k_node, v_node)) in self.kv_nodes.iter().enumerate() {
1544 for (which, node) in [(0usize, k_node), (1usize, v_node)] {
1545 let incoming = kv
1546 .rebind_slot(layer, which)
1547 .ok_or(RebindRefusal::MissingSlot { layer, which })?;
1548 let baked = self
1549 .base_cache
1550 .get(&node)
1551 .ok_or(RebindRefusal::UnbakedSlot { layer, which })?;
1552 if !storage_is_interchangeable(baked, &incoming)
1553 .ok_or(RebindRefusal::LockPoisoned)?
1554 {
1555 return Err(RebindRefusal::StorageDiffers { layer, which });
1556 }
1557 staged.push((node, incoming));
1558 }
1559 }
1560
1561 for (node, arc) in staged {
1562 self.base_cache.insert(node, arc);
1563 }
1564 self.alloc_id = kv.alloc_id();
1565 Ok(())
1566 }
1567
1568 /// The held graph handle (the caller re-binds data Consts + realizes
1569 /// through it).
1570 pub fn graph(&self) -> &Arc<RwLock<Graph>> {
1571 &self.graph
1572 }
1573
1574 /// The cached optimize view (fed to the prebuilt-realize seam).
1575 pub fn optimized(&self) -> &OptimizedGraph {
1576 &self.optimized
1577 }
1578
1579 /// The D2H `Op::Copy` root the executor is asked for.
1580 pub fn effective_target(&self) -> NodeId {
1581 self.effective_target
1582 }
1583
1584 /// The logits node (pre-D2H-splice) — `capture_decode` hard-rejects
1585 /// any `Op::Copy`/`Move` in the captured graph (cross-device, not
1586 /// single-device-CUDA-capturable), so [`fuel_dispatch::pipelined::
1587 /// CapturedDecodeSession::capture`] must target THIS node, not
1588 /// [`Self::effective_target`] (the spliced D2H `Op::Copy` root).
1589 /// The D2H happens OUTSIDE the capture, after `replay_token` returns
1590 /// the device-resident output Arc.
1591 pub fn logits_node(&self) -> NodeId {
1592 self.logits_node
1593 }
1594 pub fn token_ids_node(&self) -> NodeId {
1595 self.token_ids_node
1596 }
1597 pub fn rope_cos_node(&self) -> NodeId {
1598 self.rope_cos_node
1599 }
1600 pub fn rope_sin_node(&self) -> NodeId {
1601 self.rope_sin_node
1602 }
1603 pub fn mask_node(&self) -> NodeId {
1604 self.mask_node
1605 }
1606 pub fn kv_nodes(&self) -> &[(NodeId, NodeId)] {
1607 &self.kv_nodes
1608 }
1609 /// The device-resident KV-write offset Const NodeId, `Some` only on
1610 /// the device-offset decode path (CUDA/CPU); `None` on Vulkan's
1611 /// SymEnv path. Used by the CapturedRun replay wiring (Phase 3) and
1612 /// the per-token offset re-bind.
1613 pub fn offset_node(&self) -> Option<NodeId> {
1614 self.offset_node
1615 }
1616 pub fn cached_len_sym(&self) -> SymId {
1617 self.cached_len_sym
1618 }
1619 pub fn attended_len_sym(&self) -> SymId {
1620 self.attended_len_sym
1621 }
1622 pub fn max_seq_len(&self) -> usize {
1623 self.max_seq_len
1624 }
1625
1626 /// Build the per-token [`SymEnv`] for one decode step: bind
1627 /// `cached_len_sym = cached_len` (the KV-write offset) AND
1628 /// `attended_len_sym = cached_len + seq` (the flash-arm `k_len` — the
1629 /// live attended prefix including this token). Both are bound each
1630 /// token; the attended-length binding is unreferenced on today's f32
1631 /// decode graph (no flash arm) and becomes load-bearing the moment a
1632 /// bf16/f16 CUDA decode offers the arm. Write-once per pass (a
1633 /// conflicting rebind surfaces a typed error, never a panic).
1634 /// # DO NOT DELETE THE `cached_len_sym` BINDING BECAUSE IT LOOKS DEAD
1635 ///
1636 /// **`LlamaModel` and `PhiModel` take DIFFERENT decode paths on CPU/F32,
1637 /// measured 2026-08-12 (GAP-029 2b):**
1638 ///
1639 /// ```text
1640 /// llama: offset_node.is_some() == true → device-offset path; SymEnv INERT
1641 /// phi: offset_node.is_some() == false → SymEnv path; SymEnv LIVE
1642 /// ```
1643 ///
1644 /// On the device-offset path the KV write offset rides a device-resident
1645 /// buffer (`Op::WriteSliceDoff` reads the start from
1646 /// [`DecodeTokenData::offset`] at launch), so **no symbol here drives
1647 /// Llama's write** and both bindings are inert for it. On the SymEnv path
1648 /// the offset rides `cached_len_sym`, and it is **load-bearing for Phi** —
1649 /// proved by negative control: binding it wrong moves Phi's logits
1650 /// (`phi_attended_len_sym_is_unreferenced_negative_control`).
1651 ///
1652 /// The trap this comment exists to prevent: someone profiling or tracing
1653 /// **the Llama path** sees a binding nothing consumes and removes it as dead
1654 /// code. **Their evidence would be about Llama; the requirement comes from
1655 /// Phi**, and the breakage would be silent — wrong KV write offsets, not a
1656 /// compile error. Whichever model you were looking at, you were looking at
1657 /// half the callers.
1658 ///
1659 /// `attended_len_sym` is separately inert on **both** paths today (F32, CPU,
1660 /// no flash arm) and is bound anyway: it becomes load-bearing the moment a
1661 /// bf16/f16 CUDA decode offers the flash arm, and binding it unconditionally
1662 /// is what makes that switch a no-op rather than a hunt. That claim is
1663 /// scoped — re-run the controls above before relying on it under any other
1664 /// dtype/graph.
1665 pub fn per_token_sym_env(&self, cached_len: usize) -> Result<SymEnv> {
1666 let mut env = SymEnv::new();
1667 env.bind(self.cached_len_sym, cached_len)?;
1668 env.bind(self.attended_len_sym, cached_len + self.seq)?;
1669 Ok(env)
1670 }
1671
1672 /// The held graph's node count. The D2b born-red test asserts this
1673 /// is stable from token 2 onward (no per-token node growth — the
1674 /// guard against an accidental re-splice / re-insert / a builder
1675 /// sneaking a `cached_len`-dependent shape back in).
1676 pub fn graph_node_count(&self) -> usize {
1677 self.graph.read().map(|g| g.len()).unwrap_or(0)
1678 }
1679
1680 /// The full realized [`StorageCache`] from the first realize (every
1681 /// weight Const + the KV Arcs + the initial per-token data Consts).
1682 /// The CapturedRun capture-building step clones this cheaply
1683 /// (Arc-clones only) and overwrites the per-token entries with fresh
1684 /// FIXED-address Arcs before handing the merged cache to
1685 /// [`fuel_dispatch::pipelined::CapturedDecodeSession::capture`] as
1686 /// `inputs`.
1687 pub fn base_cache(&self) -> &StorageCache {
1688 &self.base_cache
1689 }
1690}
1691
1692/// Runtime policy for the paged persistent decode path
1693/// (`LlamaModel::forward_paged_step_persistent`):
1694/// whether to build the plan
1695/// ONCE and reuse it across tokens, or re-plan every token (the pre-plan-once
1696/// behavior). The paged driver holds this per config and passes it each step;
1697/// **the driver default is [`PlanOnce`](Self::PlanOnce)** (set by
1698/// `PagedSessionScheduler::new`), with [`Replan`](Self::Replan) available as an
1699/// explicit opt-out.
1700///
1701/// The A/B rollout this flag was built for is DONE, and the default moved with
1702/// the result. Measured (nsys, Lightbulb, 2026-08-01; 16 tokens, same binary and
1703/// model, only the plan mode differing): `Replan` → `PlanOnce` is **29.7×** on
1704/// ms/token (8,192.0 → 275.8) and cuts host-ward DtoH 61% (101,691 → 40,088 MB)
1705/// at IDENTICAL kernel-launch count (17,802 in both arms — same compute, less
1706/// data movement). Shipping `Replan` as the default meant every consumer who did
1707/// not know to opt in paid that 29.7×.
1708///
1709/// It remains a real runtime flag — not a test hook — because toggling it in ONE
1710/// process is what lets the correctness gate run both arms off a single setup,
1711/// and because `Replan` is the parity reference the plan-once path is checked
1712/// against. On [`Replan`](Self::Replan) the persistent forward drops any held
1713/// session (so nothing stale lingers) and delegates to the re-planning
1714/// `LlamaModel::forward_paged_step`;
1715/// on [`PlanOnce`](Self::PlanOnce) it
1716/// builds-once / rebinds.
1717#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1718pub enum PagedDecodePlan {
1719 /// Re-plan (build + optimize the graph) every token — the pre-plan-once
1720 /// path. Drops any held [`PagedDecodeSession`] and routes to
1721 /// `LlamaModel::forward_paged_step`.
1722 Replan,
1723 /// Build + optimize the graph ONCE (into a [`PagedDecodeSession`]) and reuse
1724 /// it (rebinding only the per-token data) for every subsequent token.
1725 PlanOnce,
1726}
1727
1728/// Per-token re-bound data Consts for one PAGED decode step — the paged twin
1729/// of [`DecodeTokenData`]. The shapes are stable across tokens (that is the
1730/// whole point of the plan-once path); only the bytes change, so each is an
1731/// `Arc<RwLock<Storage>>` overwriting the held [`PagedDecodeSession`]'s
1732/// base-cache entry for its stable NodeId.
1733pub struct PagedDecodeTokenData {
1734 /// The single decode token id (`[1]` U32).
1735 pub token_ids: Arc<RwLock<Storage>>,
1736 /// RoPE cos table at THIS token's absolute position (`[1, head_dim]` F32).
1737 pub rope_cos: Arc<RwLock<Storage>>,
1738 /// RoPE sin table at THIS token's absolute position (`[1, head_dim]` F32).
1739 pub rope_sin: Arc<RwLock<Storage>>,
1740 /// Capacity-padded block table (`[1, max_blocks_cap]` U32) — the shape is
1741 /// FIXED by `max_blocks_cap`; the content grows a physical block whenever
1742 /// this token crosses a block boundary (padded tail stays 0, masked out).
1743 pub block_table: Arc<RwLock<Storage>>,
1744 /// Attended prefix length for this row (`[1]` U32) — the `Op::PagedAttn`
1745 /// mask cutoff (`Ge(pos, context_len) → −inf`) that neutralizes both the
1746 /// future positions AND the padded block_table tail.
1747 pub context_lens: Arc<RwLock<Storage>>,
1748 /// The flattened KV-write offset (`linear = phys·block_size + slot` as a
1749 /// rank-0 I64), present ONLY on the device-offset path (CPU/CUDA, where
1750 /// `Op::WriteSliceDoff` reads the start device-side). `None` on the SymEnv
1751 /// path (Vulkan), where the offset rides `write_sym` in the per-pass env.
1752 pub offset: Option<Arc<RwLock<Storage>>>,
1753}
1754
1755/// Plan-once persistent decode state for one PAGED (block-pool) decode
1756/// stream — the paged twin of [`DecodeSession`]. Built on the first
1757/// `seq == 1` paged decode token; reused (graph + optimize view HELD, only
1758/// the per-token data Consts + `SymEnv` re-bound) for every subsequent token.
1759///
1760/// Paged decode's structural variabilities that a re-plan would otherwise pay
1761/// EVERY token are removed here: (1) the fresh `Tensor` graph root becomes
1762/// a held graph with stable re-bindable Const placeholders; (2) the L-varying
1763/// `block_table` shape is pinned to `[1, max_blocks_cap]` (Task 1 padded
1764/// materialize); (3) the per-step KV-write range becomes ONE flattened
1765/// dynamic offset (Task 2 `build_decode_attn_off`). What remains per token is
1766/// shape-stable data re-bound into a clone of `base_cache` + one bound scalar.
1767///
1768/// The pool K/V buffer Arcs are bound ONCE into `base_cache` and mutate IN
1769/// PLACE via `Op::WriteSliceDoff`/`Op::WriteSliceDyn` each token (never
1770/// re-inserted) — exactly the contiguous KV relationship, so the write this
1771/// step lands in the pool and is visible next step.
1772///
1773/// Held by the paged generate-loop owner alongside the `DeviceKvPool` +
1774/// `SessionHandle`; the caller must NOT swap the pool/model out from under a
1775/// live session (the held `base_cache` references THIS pool's buffers). A
1776/// validity-key mismatch (`is_valid_for`) or `TopologyChanged` drops + rebuilds
1777/// it (see `LlamaModel::forward_paged_step_persistent`). Single-session (B = 1)
1778/// today; the ragged batched persistent path is a follow-on.
1779pub struct PagedDecodeSession {
1780 /// The held paged decode-step graph, ALREADY optimized in place. Structure
1781 /// is stable across tokens (variabilities #1–#3 removed).
1782 graph: Arc<RwLock<Graph>>,
1783 /// The cached optimize view from the first realize. Holds only
1784 /// `{roots, generation}`; valid while `graph` structure + topology
1785 /// generation are unchanged.
1786 optimized: OptimizedGraph,
1787 /// The realize root the executor is asked for (the D2H `Op::Copy` splice).
1788 effective_target: NodeId,
1789 /// The logits node (pre-D2H-splice) — retained for debugging/attribution.
1790 logits_node: NodeId,
1791 /// Stable re-bindable single-token-id Const (`[1]` U32).
1792 token_ids_node: NodeId,
1793 /// Stable re-bindable RoPE cos table Const (`[1, head_dim]` F32).
1794 rope_cos_node: NodeId,
1795 /// Stable re-bindable RoPE sin table Const (`[1, head_dim]` F32).
1796 rope_sin_node: NodeId,
1797 /// Stable re-bindable padded block_table Const (`[1, max_blocks_cap]` U32).
1798 block_table_node: NodeId,
1799 /// Stable re-bindable context_lens Const (`[1]` U32).
1800 context_lens_node: NodeId,
1801 /// Per-layer `(k_pool_ph, v_pool_ph)` stable KV placeholder NodeIds, bound
1802 /// ONCE to the pool buffers (viewed at `pool_shape_flat`) and mutated in
1803 /// place by the flattened-offset write each token.
1804 kv_nodes: Vec<(NodeId, NodeId)>,
1805 /// The stable rank-0 `I64` KV-write offset Const, present ONLY on the
1806 /// device-offset path (CPU/CUDA — `Op::WriteSliceDoff`). `None` on the
1807 /// SymEnv path (Vulkan — the offset rides `write_sym`).
1808 offset_node: Option<NodeId>,
1809 /// The symbol the per-pass `SymEnv` binds to the flattened write offset
1810 /// (`linear`) each token. Load-bearing on the SymEnv path; a harmless
1811 /// (unreferenced) extra binding on the device-offset path.
1812 write_sym: SymId,
1813 /// The full realized [`StorageCache`] from the first realize (weights +
1814 /// the pool KV Arcs + the initial per-token data Consts). Each token we
1815 /// clone this and overwrite ONLY the per-token data-Const entries; the
1816 /// weight + pool Arcs are stable (the pool mutates in place).
1817 base_cache: StorageCache,
1818 // ---- validity keys — rebuild if any change vs. the live pool ----
1819 max_blocks_cap: usize,
1820 n_layers: usize,
1821 block_size: usize,
1822 cache_dtype: DType,
1823 /// The model's `decode_shape_key()` at build time — family + structure-
1824 /// affecting config + WEIGHT IDENTITY (a never-recycled `ModelInstanceId`).
1825 ///
1826 /// Geometry alone cannot separate two models: `PagedDecodeSession` bakes the
1827 /// weight `Const`s, so two same-shaped models produce identical
1828 /// `(max_blocks_cap, n_layers, block_size, cache_dtype)` and a plan built for
1829 /// one would be judged valid for the other — silently computing the wrong
1830 /// architecture, or the right architecture with the wrong weights, at full
1831 /// speed. The contiguous twin closed this first; this is the paged half of
1832 /// the same hole.
1833 shape_key: u64,
1834 /// The [`DeviceKvPool`](crate::kv_block_pool_device::DeviceKvPool) whose
1835 /// per-layer block buffers are baked into `base_cache`. The paged half of
1836 /// the same welding: `rebind_and_realize_paged_prebuilt` re-binds the
1837 /// per-token data (token id, RoPE, block_table, context_lens, offset) and
1838 /// never the pool, so this plan reads THESE buffers whichever pool the
1839 /// caller passes. Two same-geometry pools are otherwise indistinguishable
1840 /// to the key. See [`KvAllocId`].
1841 alloc_id: KvAllocId,
1842 /// Count of REBIND realizes this session has served (one per
1843 /// [`Self::realize_token`]). The build itself realizes through the prebuild
1844 /// seam, NOT `realize_token`, so it stays 0 right after construction and
1845 /// increments only on each plan-reuse token. A DEVICE-INDEPENDENT plan-HIT
1846 /// signal (the `optimize_calls_thread_local` counter is CPU-scoped — GPU
1847 /// per-token uploads bump it), so the correctness gate can assert "this
1848 /// token reused the plan" on any backend. Interior-mutable (`realize_token`
1849 /// takes `&self`); `Relaxed` — it is telemetry, ordered by the caller.
1850 realize_count: AtomicUsize,
1851 /// `order_for` fix track (`docs/design/incremental-consumer-index.md`
1852 /// §5) — see [`DecodeSession::order_cache`]'s doc for the full
1853 /// argument; identical role here for the paged path.
1854 order_cache: std::sync::OnceLock<Vec<NodeId>>,
1855}
1856
1857impl PagedDecodeSession {
1858 /// Assemble a held paged session from the artifacts a first-realize
1859 /// prebuild produced. The node ids are the STABLE data-Const NodeIds the
1860 /// paged builder minted.
1861 pub fn new(
1862 graph: Arc<RwLock<Graph>>,
1863 optimized: OptimizedGraph,
1864 effective_target: NodeId,
1865 logits_node: NodeId,
1866 token_ids_node: NodeId,
1867 rope_cos_node: NodeId,
1868 rope_sin_node: NodeId,
1869 block_table_node: NodeId,
1870 context_lens_node: NodeId,
1871 kv_nodes: Vec<(NodeId, NodeId)>,
1872 offset_node: Option<NodeId>,
1873 write_sym: SymId,
1874 base_cache: StorageCache,
1875 max_blocks_cap: usize,
1876 n_layers: usize,
1877 block_size: usize,
1878 cache_dtype: DType,
1879 shape_key: u64,
1880 alloc_id: KvAllocId,
1881 ) -> Self {
1882 Self {
1883 alloc_id,
1884 graph,
1885 optimized,
1886 effective_target,
1887 logits_node,
1888 token_ids_node,
1889 rope_cos_node,
1890 rope_sin_node,
1891 block_table_node,
1892 context_lens_node,
1893 kv_nodes,
1894 offset_node,
1895 write_sym,
1896 base_cache,
1897 max_blocks_cap,
1898 n_layers,
1899 block_size,
1900 cache_dtype,
1901 shape_key,
1902 realize_count: AtomicUsize::new(0),
1903 order_cache: std::sync::OnceLock::new(),
1904 }
1905 }
1906
1907 /// Realize the held paged graph for one decode token via the prebuilt seam
1908 /// (SKIP `prepare` + `optimize_graph`). `data` supplies the per-token
1909 /// re-bound data-Const Arcs; they overwrite the held `base_cache`'s entries
1910 /// for this token (the weight + pool KV Arcs stay from `base_cache`).
1911 /// `sym_env` binds `write_sym` (load-bearing on the SymEnv path, harmless
1912 /// on the device-offset path). `TopologyChanged` surfaces typed (caller
1913 /// invalidates the session).
1914 pub fn realize_token(
1915 &self,
1916 device: &Device,
1917 data: PagedDecodeTokenData,
1918 sym_env: &SymEnv,
1919 ) -> Result<Vec<f32>> {
1920 let mut cache = self.base_cache.clone();
1921 cache.insert(self.token_ids_node, data.token_ids);
1922 cache.insert(self.rope_cos_node, data.rope_cos);
1923 cache.insert(self.rope_sin_node, data.rope_sin);
1924 cache.insert(self.block_table_node, data.block_table);
1925 cache.insert(self.context_lens_node, data.context_lens);
1926 if let (Some(offset_node), Some(offset)) = (self.offset_node, data.offset) {
1927 cache.insert(offset_node, offset);
1928 }
1929 // Device-independent plan-HIT tick: this token reuses the held plan
1930 // (the prebuild seam below SKIPs optimize). Bumped BEFORE the realize
1931 // so a realize error still records the attempted rebind.
1932 self.realize_count.fetch_add(1, Ordering::Relaxed);
1933 crate::pipelined_bridge::realize_one_prebuilt_cached_env::<f32>(
1934 &self.graph,
1935 self.effective_target,
1936 &self.optimized,
1937 &self.order_cache,
1938 device,
1939 cache,
1940 sym_env,
1941 )
1942 }
1943
1944 /// Whether this held session is valid for the given paged decode step.
1945 /// Rebuild (drop + build fresh) on any mismatch: a change in
1946 /// `max_blocks_cap` (the block_table shape key), `n_layers`, `block_size`,
1947 /// or `cache_dtype` means the held graph's shapes / offsets are stale.
1948 ///
1949 /// `alloc_id` is the live pool's
1950 /// [`DeviceKvPool::alloc_id`](crate::kv_block_pool_device::DeviceKvPool::alloc_id)
1951 /// — which POOL's buffers, as distinct from `shape_key`'s which MODEL's
1952 /// weights. Note it is deliberately not sensitive to block allocation:
1953 /// which physical blocks a session owns rides the per-token `block_table`
1954 /// rebind, so a generation that grows into new blocks keeps its plan.
1955 pub fn is_valid_for(
1956 &self,
1957 max_blocks_cap: usize,
1958 n_layers: usize,
1959 block_size: usize,
1960 cache_dtype: DType,
1961 shape_key: u64,
1962 alloc_id: KvAllocId,
1963 ) -> bool {
1964 self.max_blocks_cap == max_blocks_cap
1965 && self.n_layers == n_layers
1966 && self.block_size == block_size
1967 && self.cache_dtype == cache_dtype
1968 && self.shape_key == shape_key
1969 && self.alloc_id == alloc_id
1970 }
1971
1972 /// Build the per-token [`SymEnv`] for one paged decode step: bind
1973 /// `write_sym = linear` (the flattened KV-write offset). Load-bearing on
1974 /// the SymEnv path; a harmless unreferenced binding on the device-offset
1975 /// path (a conflicting rebind surfaces a typed error, never a panic).
1976 pub fn per_token_sym_env(&self, linear: usize) -> Result<SymEnv> {
1977 let mut env = SymEnv::new();
1978 env.bind(self.write_sym, linear)?;
1979 Ok(env)
1980 }
1981
1982 /// The held graph handle.
1983 pub fn graph(&self) -> &Arc<RwLock<Graph>> {
1984 &self.graph
1985 }
1986 /// The cached optimize view (fed to the prebuilt-realize seam).
1987 pub fn optimized(&self) -> &OptimizedGraph {
1988 &self.optimized
1989 }
1990 pub fn effective_target(&self) -> NodeId {
1991 self.effective_target
1992 }
1993 pub fn logits_node(&self) -> NodeId {
1994 self.logits_node
1995 }
1996 pub fn token_ids_node(&self) -> NodeId {
1997 self.token_ids_node
1998 }
1999 pub fn rope_cos_node(&self) -> NodeId {
2000 self.rope_cos_node
2001 }
2002 pub fn rope_sin_node(&self) -> NodeId {
2003 self.rope_sin_node
2004 }
2005 pub fn block_table_node(&self) -> NodeId {
2006 self.block_table_node
2007 }
2008 pub fn context_lens_node(&self) -> NodeId {
2009 self.context_lens_node
2010 }
2011 pub fn kv_nodes(&self) -> &[(NodeId, NodeId)] {
2012 &self.kv_nodes
2013 }
2014 pub fn offset_node(&self) -> Option<NodeId> {
2015 self.offset_node
2016 }
2017 pub fn write_sym(&self) -> SymId {
2018 self.write_sym
2019 }
2020 pub fn max_blocks_cap(&self) -> usize {
2021 self.max_blocks_cap
2022 }
2023
2024 /// How many REBIND (plan-reuse) tokens this session has served — a
2025 /// device-independent plan-HIT signal (see [`Self::realize_count`]). Zero
2026 /// right after the build; `n` after `n` reuse tokens. The correctness gate
2027 /// asserts this advances by exactly one per plan-once decode step.
2028 pub fn realize_count(&self) -> usize {
2029 self.realize_count.load(Ordering::Relaxed)
2030 }
2031
2032 /// The held graph's node count. The Task 3 born-red test asserts this is
2033 /// stable from token 2 onward (no per-token node growth — the guard
2034 /// against an accidental re-splice / re-insert).
2035 pub fn graph_node_count(&self) -> usize {
2036 self.graph.read().map(|g| g.len()).unwrap_or(0)
2037 }
2038
2039 /// The full realized [`StorageCache`] from the first realize.
2040 pub fn base_cache(&self) -> &StorageCache {
2041 &self.base_cache
2042 }
2043}
2044
2045// `alloc_zeroed_on` retired 2026-05-22 (bridge-retirement Phase 3a).
2046// Zero-init device allocation now flows through `Op::Alloc` graph
2047// emission + the executor's `WorkItemKind::Alloc` arm. The per-
2048// `DeviceLocation` match this function used to carry lives in
2049// `fuel-storage::pipelined::execute_work_item`'s Alloc arm; the
2050// residual "0-byte device anchor" helper lives in
2051// `crate::pipelined_bridge::device_seed_storage`.
2052
2053// ===========================================================================
2054// InferenceContext
2055// ===========================================================================
2056
2057/// Per-session host-side context holding long-lived storage Arcs
2058/// that survive across realize calls.
2059///
2060/// The persistent map is the seam the unified-storage / mmap-coherence
2061/// backplane work later replaces (see
2062/// `project_unified_durable_tensor_store.md`); today it's a simple
2063/// in-memory `HashMap`. Each realize call clones the Arcs into the
2064/// executor's input cache; persistent entries reuse those Arcs
2065/// across calls instead of re-uploading.
2066///
2067/// ## Lifecycle
2068///
2069/// 1. Construct with a target device: `InferenceContext::new(device)`.
2070/// 2. Insert long-lived storages (KV layer storages, anything else
2071/// that should survive across realize calls):
2072/// `ctx.insert(node_id, arc)`.
2073/// 3. Realize: `ctx.realize_one_as::<f32>(&graph, target_node)`. The
2074/// persistent map is seeded into the executor's input cache; new
2075/// `Op::Const` nodes the graph references that aren't in the
2076/// persistent map get uploaded fresh from the graph's storage map.
2077/// 4. Subsequent realize calls reuse the persistent Arcs — no
2078/// re-upload, no D2H/H2D round-trip.
2079pub struct InferenceContext {
2080 device: Device,
2081 persistent: HashMap<NodeId, Arc<RwLock<Storage>>>,
2082 /// Held plan-once decode plan for `LlamaModel::forward_decode_step`
2083 /// — the carrier that lets a caller-driven decode loop get plan reuse
2084 /// WITHOUT threading an `Option<DecodeSession>` of its own.
2085 ///
2086 /// This exists because the ergonomic call shape and the fast call shape had
2087 /// drifted apart. `forward_with_kv_context(tokens, cache, ctx)` is the raw
2088 /// rebuild primitive; its persistent sibling takes a fourth argument, so a
2089 /// consumer hand-rolling a decode loop reaches the slow one and never learns
2090 /// the fast one exists. Not hypothetical: that gap cost a measured consumer
2091 /// 5,901 → 26.47 ms/token before anyone noticed (nsys, 2026-08-01).
2092 ///
2093 /// **One context per model.** Already this codebase's invariant — speculative
2094 /// decoding builds a separate `InferenceContext` for the draft and the target
2095 /// — and the held plan self-heals regardless: `DecodeSession::is_valid_for`
2096 /// drops it on any cache-geometry or dtype change.
2097 decode_session: Option<DecodeSession>,
2098}
2099
2100impl InferenceContext {
2101 pub fn new(device: Device) -> Self {
2102 Self {
2103 device,
2104 persistent: HashMap::new(),
2105 decode_session: None,
2106 }
2107 }
2108
2109 pub fn device(&self) -> &Device {
2110 &self.device
2111 }
2112
2113 /// Take the held decode plan out of the context, leaving `None`.
2114 ///
2115 /// Paired with [`Self::put_decode_session`]. The take/put dance exists for
2116 /// the borrow checker: the persistent forward needs `&mut InferenceContext`
2117 /// AND `&mut Option<DecodeSession>` at once, which it cannot have if the
2118 /// session lives inside the context. Callers outside
2119 /// `forward_decode_step` should not need either method.
2120 pub fn take_decode_session(&mut self) -> Option<DecodeSession> {
2121 self.decode_session.take()
2122 }
2123
2124 /// Put a decode plan back into the context. See [`Self::take_decode_session`].
2125 pub fn put_decode_session(&mut self, session: Option<DecodeSession>) {
2126 self.decode_session = session;
2127 }
2128
2129 /// Whether a decode plan is currently held. Observability hook — a caller
2130 /// that has decoded at least one token through
2131 /// `LlamaModel::forward_decode_step` holds one.
2132 pub fn has_decode_session(&self) -> bool {
2133 self.decode_session.is_some()
2134 }
2135
2136 /// Insert a long-lived storage at `node_id`. The Arc survives
2137 /// across subsequent realize calls and is treated as pre-realized
2138 /// for any graph that references `node_id` as a Const-shaped leaf.
2139 ///
2140 /// Replaces any prior entry at `node_id`.
2141 pub fn insert(&mut self, node_id: NodeId, storage: Arc<RwLock<Storage>>) {
2142 self.persistent.insert(node_id, storage);
2143 }
2144
2145 /// Remove the persistent entry at `node_id`. Returns the Arc if
2146 /// one was present. After this, the next realize that touches
2147 /// `node_id` will re-fetch from the graph's storage_map (and fail
2148 /// if the slot isn't populated there either).
2149 pub fn remove(&mut self, node_id: NodeId) -> Option<Arc<RwLock<Storage>>> {
2150 self.persistent.remove(&node_id)
2151 }
2152
2153 /// Whether the persistent map has an entry at `node_id`.
2154 pub fn contains(&self, node_id: NodeId) -> bool {
2155 self.persistent.contains_key(&node_id)
2156 }
2157
2158 /// Borrow the persistent entry at `node_id` if present.
2159 pub fn get(&self, node_id: NodeId) -> Option<&Arc<RwLock<Storage>>> {
2160 self.persistent.get(&node_id)
2161 }
2162
2163 /// Number of persistent slots.
2164 pub fn len(&self) -> usize {
2165 self.persistent.len()
2166 }
2167
2168 pub fn is_empty(&self) -> bool {
2169 self.persistent.is_empty()
2170 }
2171
2172 /// Drop every persistent entry. The Arcs are released; storages
2173 /// whose only remaining reference was the context's are freed
2174 /// (host memory for CPU, deferred-on-stream for async backends).
2175 pub fn clear_persistent(&mut self) {
2176 self.persistent.clear();
2177 }
2178
2179 /// Realize a single target. The persistent map is seeded into
2180 /// the executor's input cache via `Arc::clone` (the persistent
2181 /// entries survive the call). Op::Const NodeIds in the graph
2182 /// that aren't already in the persistent map get uploaded fresh
2183 /// from `graph.storage_for(id)` per the existing pipelined-bridge
2184 /// pattern.
2185 pub fn realize_one_as<T: bytemuck::Pod + fuel_ir::WithDType>(
2186 &self,
2187 graph: &Arc<RwLock<Graph>>,
2188 target: NodeId,
2189 ) -> Result<Vec<T>> {
2190 self.realize_one_as_with_env::<T>(graph, target, &SymEnv::default())
2191 }
2192
2193 /// Env-carrying counterpart of [`Self::realize_one_as`]: supplies a
2194 /// per-pass [`SymEnv`] binding the runtime values of any `DynScalar`
2195 /// op params (Phase D symbolic extents — e.g. the decode KV-cache
2196 /// write offset `cached_len`). The env is **per-pass** (re-supplied
2197 /// every forward step) while the persistent map is **per-session**;
2198 /// an empty env is byte-identical to [`Self::realize_one_as`].
2199 pub fn realize_one_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2200 &self,
2201 graph: &Arc<RwLock<Graph>>,
2202 target: NodeId,
2203 env: &SymEnv,
2204 ) -> Result<Vec<T>> {
2205 crate::pipelined_bridge::realize_one_as_with_initial_env::<T>(
2206 graph,
2207 target,
2208 &self.device,
2209 self.cloned_persistent(),
2210 env,
2211 )
2212 }
2213
2214 /// Phase D · D2a — first-realize that ALSO captures the reusable
2215 /// optimize artifacts for a later prebuilt (plan-once) re-realize.
2216 ///
2217 /// Runs the full `prepare` + `optimize_graph` + dispatch path ONCE (like
2218 /// [`Self::realize_one_as_with_env`]) and returns
2219 /// `(effective_target, OptimizedGraph, result)`. The caller (D2b's
2220 /// `DecodeSession`) holds the `effective_target` (the spliced D2H
2221 /// `Op::Copy` root) + the cached `OptimizedGraph` and feeds them to
2222 /// [`Self::realize_prebuilt_as_with_env`] on later tokens to SKIP the
2223 /// re-plan. See [`crate::pipelined_bridge::prebuild_optimized_env`].
2224 pub fn prebuild_optimized_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2225 &self,
2226 graph: &Arc<RwLock<Graph>>,
2227 target: NodeId,
2228 env: &SymEnv,
2229 ) -> Result<(NodeId, fuel_dispatch::optimize::OptimizedGraph, Vec<T>)> {
2230 crate::pipelined_bridge::prebuild_optimized_env::<T>(
2231 graph,
2232 target,
2233 &self.device,
2234 self.cloned_persistent(),
2235 env,
2236 )
2237 }
2238
2239 /// Phase D · D2b — first-realize that captures the reusable optimize
2240 /// artifacts AND the full realized [`StorageCache`] (all weight
2241 /// Consts uploaded by `build_const_cache`, merged over this context's
2242 /// persistent Arcs). The [`DecodeSession`] holds the cache so later
2243 /// prebuilt realizes (which SKIP the const-cache walk) still resolve
2244 /// every weight Const. Returns
2245 /// `(effective_target, OptimizedGraph, full_cache, result)`.
2246 pub fn prebuild_optimized_capturing_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2247 &self,
2248 graph: &Arc<RwLock<Graph>>,
2249 target: NodeId,
2250 env: &SymEnv,
2251 ) -> Result<(
2252 NodeId,
2253 fuel_dispatch::optimize::OptimizedGraph,
2254 StorageCache,
2255 Vec<T>,
2256 )> {
2257 crate::pipelined_bridge::prebuild_optimized_env_capturing_cache::<T>(
2258 graph,
2259 target,
2260 &self.device,
2261 self.cloned_persistent(),
2262 env,
2263 )
2264 }
2265
2266 /// Phase D · D2a — plan-once re-realize over a graph already prepared +
2267 /// optimized by a prior [`Self::prebuild_optimized_as_with_env`]. Skips
2268 /// BOTH `prepare` (no D2H re-splice / const-cache walk) AND
2269 /// `optimize_graph` (no re-plan / double-insert). Re-binds only the
2270 /// per-call `StorageCache` (this context's persistent Arcs, incl. the
2271 /// re-bound per-token data Consts) + `env`, then dispatches straight to
2272 /// the executor. `effective_target` + `optimized` come from the prebuild.
2273 ///
2274 /// A `TopologyChanged` error surfaces to the caller (typed, not
2275 /// retried) — the cached view is stale; invalidate + rebuild the session.
2276 pub fn realize_prebuilt_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2277 &self,
2278 graph: &Arc<RwLock<Graph>>,
2279 effective_target: NodeId,
2280 optimized: &fuel_dispatch::optimize::OptimizedGraph,
2281 env: &SymEnv,
2282 ) -> Result<Vec<T>> {
2283 crate::pipelined_bridge::realize_one_prebuilt_env::<T>(
2284 graph,
2285 effective_target,
2286 optimized,
2287 &self.device,
2288 self.cloned_persistent(),
2289 env,
2290 )
2291 }
2292
2293 /// Multi-target counterpart of [`Self::realize_one_as`].
2294 pub fn realize_many_as<T: bytemuck::Pod + fuel_ir::WithDType>(
2295 &self,
2296 graph: &Arc<RwLock<Graph>>,
2297 targets: &[NodeId],
2298 ) -> Result<Vec<Vec<T>>> {
2299 self.realize_many_as_with_env::<T>(graph, targets, &SymEnv::default())
2300 }
2301
2302 /// Env-carrying counterpart of [`Self::realize_many_as`] (Phase D
2303 /// symbolic extents). An empty env is byte-identical to
2304 /// [`Self::realize_many_as`].
2305 pub fn realize_many_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2306 &self,
2307 graph: &Arc<RwLock<Graph>>,
2308 targets: &[NodeId],
2309 env: &SymEnv,
2310 ) -> Result<Vec<Vec<T>>> {
2311 crate::pipelined_bridge::realize_many_as_with_initial_env::<T>(
2312 graph,
2313 targets,
2314 &self.device,
2315 self.cloned_persistent(),
2316 env,
2317 )
2318 }
2319
2320 /// Realize-split counterpart of [`Self::realize_many_as`]: the
2321 /// first `n_host` targets are downloaded to host `Vec<T>`s, the
2322 /// rest come back as device-resident `(storage, layout)` pairs —
2323 /// no D2H for results that feed the next step's graph. See
2324 /// [`crate::pipelined_bridge::realize_split_as_with_initial`].
2325 pub fn realize_split_as<T: bytemuck::Pod + fuel_ir::WithDType>(
2326 &self,
2327 graph: &Arc<RwLock<Graph>>,
2328 targets: &[NodeId],
2329 n_host: usize,
2330 ) -> Result<crate::pipelined_bridge::SplitRealizeOutput<T>> {
2331 crate::pipelined_bridge::realize_split_as_with_initial::<T>(
2332 graph,
2333 targets,
2334 n_host,
2335 &self.device,
2336 self.cloned_persistent(),
2337 )
2338 }
2339
2340 /// Build a [`StorageCache`] containing Arc-clones of every
2341 /// persistent entry. The clone is cheap (Arc refcount bumps); the
2342 /// returned `StorageCache` is consumed by the realize call but
2343 /// the original Arcs in `self.persistent` survive.
2344 fn cloned_persistent(&self) -> StorageCache {
2345 let mut out = StorageCache::with_capacity(self.persistent.len());
2346 for (id, arc) in &self.persistent {
2347 out.insert(*id, Arc::clone(arc));
2348 }
2349 out
2350 }
2351}
2352
2353// ===========================================================================
2354// Tests
2355// ===========================================================================
2356
2357#[cfg(test)]
2358mod tests {
2359 use super::*;
2360 use fuel_graph::{Node, Op};
2361 use fuel_ir::{DType, Shape};
2362
2363 /// `InferenceContext::insert` + immediate retrieval round-trips.
2364 #[test]
2365 fn context_insert_retrieve() {
2366 let mut ctx = InferenceContext::new(Device::cpu());
2367 let storage = fuel_memory::from_slice_cpu(&[1.0_f32, 2.0, 3.0]);
2368 let arc = Arc::new(RwLock::new(storage));
2369 let id = NodeId(42);
2370
2371 assert!(!ctx.contains(id));
2372 assert_eq!(ctx.len(), 0);
2373
2374 ctx.insert(id, Arc::clone(&arc));
2375
2376 assert!(ctx.contains(id));
2377 assert_eq!(ctx.len(), 1);
2378 // The returned Arc is the same as what we inserted.
2379 let retrieved = ctx.get(id).expect("just inserted");
2380 assert!(Arc::ptr_eq(retrieved, &arc));
2381 }
2382
2383 /// Inserted Arcs survive across realize calls. Build a minimal
2384 /// 3-node graph (Const + Const + Add). Both Const slots come
2385 /// from the context's persistent map (no graph.storage_map
2386 /// involvement). Realize twice; verify the persistent Arcs are
2387 /// still held by the context after each call.
2388 #[test]
2389 fn context_persistent_arc_survives_realize() {
2390 let lhs_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[
2391 1.0_f32, 2.0, 3.0,
2392 ])));
2393 let rhs_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[
2394 10.0_f32, 20.0, 30.0,
2395 ])));
2396
2397 let graph = Arc::new(RwLock::new(Graph::new()));
2398 let (lhs_id, rhs_id, add_id) = {
2399 let mut g = graph.write().unwrap();
2400 let lhs = g.push(Node {
2401 op: Op::Const,
2402 inputs: vec![],
2403 shape: Shape::from_dims(&[3]),
2404 dtype: DType::F32,
2405 });
2406 let rhs = g.push(Node {
2407 op: Op::Const,
2408 inputs: vec![],
2409 shape: Shape::from_dims(&[3]),
2410 dtype: DType::F32,
2411 });
2412 let add = g.push(Node {
2413 op: Op::Add,
2414 inputs: vec![lhs, rhs],
2415 shape: Shape::from_dims(&[3]),
2416 dtype: DType::F32,
2417 });
2418 (lhs, rhs, add)
2419 };
2420
2421 let mut ctx = InferenceContext::new(Device::cpu());
2422 ctx.insert(lhs_id, Arc::clone(&lhs_arc));
2423 ctx.insert(rhs_id, Arc::clone(&rhs_arc));
2424
2425 // Before realize: each Arc held by the test (1) + context (1) = 2.
2426 assert_eq!(Arc::strong_count(&lhs_arc), 2);
2427 assert_eq!(Arc::strong_count(&rhs_arc), 2);
2428
2429 let out1 = ctx
2430 .realize_one_as::<f32>(&graph, add_id)
2431 .expect("first realize");
2432 assert_eq!(out1, vec![11.0, 22.0, 33.0]);
2433
2434 // After first realize: same count. The realize call's
2435 // cloned_persistent() bumped the counts to 3 during the call
2436 // and dropped them back to 2 when the call returned.
2437 assert_eq!(Arc::strong_count(&lhs_arc), 2);
2438 assert_eq!(Arc::strong_count(&rhs_arc), 2);
2439
2440 // Realize again — same Arcs get reused without re-upload.
2441 // This is the autoregressive-decoding pattern: weights stay
2442 // resident across forward passes.
2443 let out2 = ctx
2444 .realize_one_as::<f32>(&graph, add_id)
2445 .expect("second realize");
2446 assert_eq!(out2, vec![11.0, 22.0, 33.0]);
2447 assert_eq!(Arc::strong_count(&lhs_arc), 2);
2448 assert_eq!(Arc::strong_count(&rhs_arc), 2);
2449 }
2450
2451 /// Read back layer `layer_idx`'s `slot` buffer in full, as a flat
2452 /// row-major `[n_kv_heads * max_seq_len * head_dim]` f32 vec — test-only
2453 /// readback counterpart to [`KvCache::write_bytes`], deliberately NOT
2454 /// sliced, so a wrong write RANGE shows up as corruption anywhere in the
2455 /// buffer rather than being hidden by only reading the written window.
2456 fn read_kv_layer_f32(
2457 cache: &KvCache,
2458 layer_idx: usize,
2459 slot: KvSlot,
2460 device: &Device,
2461 ) -> Vec<f32> {
2462 let max_seq_len = cache.max_seq_len.expect("with_capacity cache");
2463 let full_shape = Shape::from_dims(&[1, cache.n_kv_heads, max_seq_len, cache.head_dim]);
2464 let buf = match slot {
2465 KvSlot::K => Arc::clone(&cache.layer(layer_idx).unwrap().k),
2466 KvSlot::V => Arc::clone(&cache.layer(layer_idx).unwrap().v),
2467 };
2468 let anchor =
2469 crate::lazy::Tensor::from_f32(vec![0.0f32], Shape::from_dims(&[1]), device).unwrap();
2470 let dest = anchor.const_placeholder_like(full_shape, DType::F32);
2471 let mut sc = StorageCache::new();
2472 sc.insert(dest.node_id(), buf);
2473 let bytes = crate::pipelined_bridge::realize_one_bytes_with_initial(
2474 dest.graph_handle(),
2475 dest.node_id(),
2476 device,
2477 sc,
2478 )
2479 .expect("readback realize");
2480 bytemuck::try_cast_slice::<u8, f32>(&bytes)
2481 .expect("f32 cast")
2482 .to_vec()
2483 }
2484
2485 /// Board item 86 (OverMind self-fork / Lightbulb KV-cache handoff design):
2486 /// [`KvCache::write_bytes`] must write EXACTLY the `[start, start+n)`
2487 /// position range on the right axis (2, head-major) and leave every other
2488 /// position untouched — a wrong range (e.g. accidentally position-major,
2489 /// axis 0) would either corrupt unrelated positions or silently land on
2490 /// the wrong head/position entirely, and reading back the FULL buffer
2491 /// (not just the written window) is what catches either failure.
2492 #[test]
2493 fn write_bytes_lands_at_the_correct_position_and_nowhere_else() {
2494 let device = Device::cpu();
2495 let (n_layers, n_kv_heads, head_dim, max_seq_len) = (1, 2, 3, 5);
2496 let cache = KvCache::with_capacity(
2497 n_layers,
2498 n_kv_heads,
2499 head_dim,
2500 max_seq_len,
2501 DType::F32,
2502 &device,
2503 )
2504 .expect("with_capacity");
2505
2506 let alloc_before = cache.alloc_id();
2507 let loc_before = cache.device_location();
2508
2509 // Write positions [1, 3) (n=2) with a distinctive, non-zero pattern —
2510 // zero would be indistinguishable from the buffer's own zero-init.
2511 let (start, n) = (1usize, 2usize);
2512 let payload: Vec<f32> = (0..(n * n_kv_heads * head_dim))
2513 .map(|i| 1000.0 + i as f32)
2514 .collect();
2515 cache
2516 .write_bytes(
2517 0,
2518 KvSlot::K,
2519 start,
2520 n,
2521 bytemuck::cast_slice(&payload),
2522 &device,
2523 )
2524 .expect("write_bytes");
2525
2526 // THE CORE CLAIM: no Arc replacement happened, so neither identity
2527 // marker moved — this is what makes a held DecodeSession safe to
2528 // keep using without a rebind or rebuild.
2529 assert_eq!(
2530 cache.alloc_id(),
2531 alloc_before,
2532 "write_bytes must not re-mint alloc_id (that would mean the Arc was replaced)",
2533 );
2534 assert_eq!(
2535 cache.device_location(),
2536 loc_before,
2537 "write_bytes must not clear device_location (that would mean residency became unprovable)",
2538 );
2539
2540 let full = read_kv_layer_f32(&cache, 0, KvSlot::K, &device);
2541 for h in 0..n_kv_heads {
2542 for p in 0..max_seq_len {
2543 for d in 0..head_dim {
2544 let got = full[h * max_seq_len * head_dim + p * head_dim + d];
2545 if (start..start + n).contains(&p) {
2546 let local_p = p - start;
2547 let want = 1000.0 + (h * n * head_dim + local_p * head_dim + d) as f32;
2548 assert_eq!(
2549 got, want,
2550 "head {h} position {p} dim {d}: written value must land exactly here",
2551 );
2552 } else {
2553 assert_eq!(
2554 got,
2555 0.0,
2556 "head {h} position {p} dim {d}: outside [{start},{}) must stay \
2557 untouched (zero-init) — a wrong write range would corrupt this",
2558 start + n,
2559 );
2560 }
2561 }
2562 }
2563 }
2564 }
2565
2566 /// `write_bytes` is `with_capacity`-only (needs a fixed dtype + a stable
2567 /// buffer to write into) — a `with_dims` (grow-by-replacement) cache has
2568 /// neither, and must refuse with a typed error, never panic.
2569 #[test]
2570 fn write_bytes_refuses_a_with_dims_cache() {
2571 let device = Device::cpu();
2572 let cache = KvCache::with_dims(1, 2, 3);
2573 let err = cache
2574 .write_bytes(0, KvSlot::K, 0, 1, &[0u8; 24], &device)
2575 .expect_err("with_dims cache has no fixed dtype/capacity");
2576 assert!(
2577 err.to_string().contains("with_capacity"),
2578 "refusal must name the real constraint, got: {err}",
2579 );
2580 }
2581
2582 /// A range exceeding `max_seq_len` must refuse before touching anything —
2583 /// never silently clamp (which would write at the wrong position) and
2584 /// never panic.
2585 #[test]
2586 fn write_bytes_refuses_an_out_of_range_position() {
2587 let device = Device::cpu();
2588 let cache = KvCache::with_capacity(1, 2, 3, 5, DType::F32, &device).unwrap();
2589 let payload = vec![0.0f32; 2 * 2 * 3]; // n=2 positions, valid byte length
2590 let err = cache
2591 .write_bytes(
2592 0,
2593 KvSlot::K,
2594 4, // start=4, n=2 -> [4,6) exceeds max_seq_len=5
2595 2,
2596 bytemuck::cast_slice(&payload),
2597 &device,
2598 )
2599 .expect_err("range must be refused, not clamped");
2600 assert!(
2601 err.to_string().contains("max_seq_len"),
2602 "refusal must name the real constraint, got: {err}",
2603 );
2604 }
2605
2606 /// A `bytes` length that doesn't match `n * n_kv_heads * head_dim *
2607 /// dtype_size` must refuse — a mismatched length is the shape of exactly
2608 /// the bug this check exists to catch (a caller passing position-major
2609 /// pool bytes straight through without transposing to head-major).
2610 #[test]
2611 fn write_bytes_refuses_a_mismatched_byte_length() {
2612 let device = Device::cpu();
2613 let cache = KvCache::with_capacity(1, 2, 3, 5, DType::F32, &device).unwrap();
2614 let wrong_payload = vec![0.0f32; 5]; // not n_kv_heads*n*head_dim for any valid n
2615 let err = cache
2616 .write_bytes(
2617 0,
2618 KvSlot::K,
2619 0,
2620 1,
2621 bytemuck::cast_slice(&wrong_payload),
2622 &device,
2623 )
2624 .expect_err("mismatched byte length must be refused");
2625 assert!(
2626 err.to_string().contains("bytes"),
2627 "refusal must name the byte-length mismatch, got: {err}",
2628 );
2629 }
2630
2631 /// Phase D symbolic extents — the decode-shaped path: a persistent
2632 /// fixed-capacity buffer + an `Op::WriteSlice` whose start offset is
2633 /// a per-pass `SymEnv` binding (the KV-cache append at `cached_len`).
2634 /// `realize_one_as_with_env` threads the env from the session through
2635 /// the bridge to the executor; the width-2 slab lands at the bound
2636 /// offset (3), not the static placeholder (0). Re-realizing the SAME
2637 /// graph with a different binding lands the slab at the new offset —
2638 /// the input-independent-graph property persistent decode relies on.
2639 #[test]
2640 fn realize_one_as_with_env_resolves_write_slice_offset() {
2641 use fuel_ir::{DynScalar, SymId};
2642 let dest_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 6])));
2643 let src_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[7.0_f32, 8.0])));
2644 let graph = Arc::new(RwLock::new(Graph::new()));
2645 let sym = SymId(0);
2646 let (dest_id, src_id, ws_id) = {
2647 let mut g = graph.write().unwrap();
2648 let dest = g.push(Node {
2649 op: Op::Const,
2650 inputs: vec![],
2651 shape: Shape::from_dims(&[6]),
2652 dtype: DType::F32,
2653 });
2654 let src = g.push(Node {
2655 op: Op::Const,
2656 inputs: vec![],
2657 shape: Shape::from_dims(&[2]),
2658 dtype: DType::F32,
2659 });
2660 let ws = g.push(Node {
2661 op: Op::WriteSlice {
2662 ranges: vec![(0, 2)],
2663 dyn_offset: Some((0, DynScalar::Sym(sym))),
2664 },
2665 inputs: vec![dest, src],
2666 shape: Shape::from_dims(&[6]),
2667 dtype: DType::F32,
2668 });
2669 (dest, src, ws)
2670 };
2671 let mut ctx = InferenceContext::new(Device::cpu());
2672 ctx.insert(dest_id, Arc::clone(&dest_arc));
2673 ctx.insert(src_id, Arc::clone(&src_arc));
2674
2675 // Bind cached_len = 3: the slab must append at indices [3, 4].
2676 let mut env = SymEnv::new();
2677 env.bind(sym, 3).unwrap();
2678 let out = ctx
2679 .realize_one_as_with_env::<f32>(&graph, ws_id, &env)
2680 .expect("realize_one_as_with_env");
2681 assert_eq!(
2682 out,
2683 vec![0.0, 0.0, 0.0, 7.0, 8.0, 0.0],
2684 "KV slab must land at the SymEnv-bound cached_len=3, not the placeholder 0",
2685 );
2686
2687 // An empty env on a graph with an unbound dyn_offset surfaces a
2688 // typed error (never a panic) — the write-once contract's
2689 // "presence ⇒ produced" read at realize.
2690 let err = ctx.realize_one_as_with_env::<f32>(&graph, ws_id, &SymEnv::new());
2691 assert!(
2692 err.is_err(),
2693 "unbound cached_len must surface a typed error"
2694 );
2695 }
2696
2697 /// `KvCache::with_dims` produces a fresh cache of the right
2698 /// shape with all layers `None` and `cached_len = 0`.
2699 #[test]
2700 fn kv_cache_with_dims_constructs_fresh() {
2701 let cache = KvCache::with_dims(4, 8, 64);
2702 assert_eq!(cache.n_layers(), 4);
2703 assert_eq!(cache.cached_len, 0);
2704 assert_eq!(cache.n_kv_heads, 8);
2705 assert_eq!(cache.head_dim, 64);
2706 for li in 0..4 {
2707 assert!(cache.layer(li).is_none());
2708 }
2709 }
2710
2711 /// `KvCache::set_layer` + `layer` round-trip.
2712 #[test]
2713 fn kv_cache_set_and_get_layer() {
2714 let mut cache = KvCache::with_dims(2, 4, 8);
2715 let k_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2716 let v_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2717 let layout = Layout::contiguous(Shape::from_dims(&[1, 4, 1, 8]));
2718 cache.set_layer(
2719 0,
2720 KvLayer {
2721 k: Arc::clone(&k_arc),
2722 v: Arc::clone(&v_arc),
2723 k_layout: layout.clone(),
2724 v_layout: layout.clone(),
2725 k_version: 0,
2726 v_version: 0,
2727 k_authority: AuthorityState::Host,
2728 v_authority: AuthorityState::Host,
2729 },
2730 );
2731 let layer = cache.layer(0).expect("just set");
2732 assert!(Arc::ptr_eq(&layer.k, &k_arc));
2733 assert!(Arc::ptr_eq(&layer.v, &v_arc));
2734 assert_eq!(layer.k_version, 0);
2735 assert!(matches!(layer.k_authority, AuthorityState::Host));
2736 assert!(cache.layer(1).is_none());
2737 }
2738
2739 /// `KvCache::clear` drops all layers and resets `cached_len`.
2740 #[test]
2741 fn kv_cache_clear_drops_layers() {
2742 let mut cache = KvCache::with_dims(2, 4, 8);
2743 let k = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2744 let v = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2745 let layout = Layout::contiguous(Shape::from_dims(&[1, 4, 1, 8]));
2746 cache.set_layer(
2747 0,
2748 KvLayer {
2749 k: Arc::clone(&k),
2750 v: Arc::clone(&v),
2751 k_layout: layout.clone(),
2752 v_layout: layout,
2753 k_version: 1,
2754 v_version: 1,
2755 k_authority: AuthorityState::Host,
2756 v_authority: AuthorityState::Host,
2757 },
2758 );
2759 cache.cached_len = 7;
2760 // Before clear: cache holds an Arc to k → strong_count 2.
2761 assert_eq!(Arc::strong_count(&k), 2);
2762
2763 cache.clear();
2764
2765 assert!(cache.layer(0).is_none());
2766 assert_eq!(cache.cached_len, 0);
2767 // After clear: cache's Arc dropped → strong_count 1.
2768 assert_eq!(Arc::strong_count(&k), 1);
2769 }
2770
2771 /// `KvCache::truncate_to` updates `cached_len` only (Phase E.3.0
2772 /// limitation; pre-allocated buffer + WriteSlice handles the
2773 /// in-place truncate in Phase E.3.2).
2774 #[test]
2775 fn kv_cache_truncate_updates_cached_len() {
2776 let mut cache = KvCache::with_dims(2, 4, 8);
2777 cache.cached_len = 16;
2778 cache.truncate_to(10);
2779 assert_eq!(cache.cached_len, 10);
2780 // Truncating to a value >= current is a no-op.
2781 cache.truncate_to(15);
2782 assert_eq!(cache.cached_len, 10);
2783 // Truncating to zero clears the live extent.
2784 cache.truncate_to(0);
2785 assert_eq!(cache.cached_len, 0);
2786 }
2787
2788 /// Authority + version fields are placeholders today; just
2789 /// confirm they default sensibly via `KvLayer` construction.
2790 #[test]
2791 fn kv_layer_default_authority_is_host() {
2792 let arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 4])));
2793 let layout = Layout::contiguous(Shape::from_dims(&[4]));
2794 let layer = KvLayer {
2795 k: Arc::clone(&arc),
2796 v: Arc::clone(&arc),
2797 k_layout: layout.clone(),
2798 v_layout: layout,
2799 k_version: 0,
2800 v_version: 0,
2801 k_authority: AuthorityState::Host,
2802 v_authority: AuthorityState::Host,
2803 };
2804 assert!(matches!(layer.k_authority, AuthorityState::Host));
2805 assert!(matches!(layer.v_authority, AuthorityState::Host));
2806 assert_eq!(layer.k_version, 0);
2807 assert_eq!(layer.v_version, 0);
2808 }
2809
2810 // ---- KvCache::with_capacity + accessors (Phase E.3.3.A) -----------------
2811
2812 /// `with_capacity` allocates n_layers fresh K + V buffers on the
2813 /// CPU device. Each buffer is `n_kv_heads * max_seq_len * head_dim`
2814 /// elements of the requested dtype, zero-initialized, with the
2815 /// `[1, n_kv_heads, max_seq_len, head_dim]` layout pre-populated.
2816 #[test]
2817 // `BackendStorage::{Cuda,Vulkan,Metal}` are cfg-gated, so at default
2818 // features `Cpu` is the only variant and this `if let` is irrefutable;
2819 // under --features cuda/vulkan/metal the pattern is refutable, so a plain
2820 // `let` would be an E0005 there. Allow the default-features lint.
2821 #[allow(irrefutable_let_patterns)]
2822 fn kv_cache_with_capacity_allocates_all_layers_on_cpu() {
2823 let device = Device::cpu();
2824 let cache = KvCache::with_capacity(
2825 /* n_layers */ 3,
2826 /* n_kv_heads */ 4,
2827 /* head_dim */ 16,
2828 /* max_seq_len */ 32,
2829 DType::F32,
2830 &device,
2831 )
2832 .expect("with_capacity");
2833
2834 assert_eq!(cache.n_layers(), 3);
2835 assert_eq!(cache.cached_len, 0);
2836 assert_eq!(cache.n_kv_heads, 4);
2837 assert_eq!(cache.head_dim, 16);
2838 assert_eq!(cache.max_seq_len, Some(32));
2839 assert_eq!(cache.dtype, Some(DType::F32));
2840
2841 // Every layer is populated and has the expected layout.
2842 for li in 0..3 {
2843 let layer = cache.layer(li).expect("layer populated");
2844 assert_eq!(layer.k_layout.shape().dims(), &[1, 4, 32, 16]);
2845 assert_eq!(layer.v_layout.shape().dims(), &[1, 4, 32, 16]);
2846 // Bytes are zero-initialized: 4 (n_kv) * 32 (seq) * 16 (head)
2847 // * 4 (f32) = 8192 bytes per slot.
2848 let k_guard = layer.k.read().unwrap();
2849 assert_eq!(k_guard.inner.len_bytes(), 8192);
2850 assert_eq!(k_guard.dtype, DType::F32);
2851 // Spot-check zero init.
2852 if let BackendStorage::Cpu(c) = &k_guard.inner {
2853 let typed: &[f32] = c.as_slice().unwrap();
2854 assert!(typed.iter().all(|&x| x == 0.0));
2855 } else {
2856 panic!("expected CPU storage");
2857 }
2858 }
2859 }
2860
2861 /// `slot_storage(li, K)` returns the same Arc as `layer(li).k`.
2862 /// Used by the forward path to bind cache storage to per-step
2863 /// Const nodes.
2864 #[test]
2865 fn kv_cache_slot_storage_returns_layer_arc() {
2866 let device = Device::cpu();
2867 let cache =
2868 KvCache::with_capacity(2, 4, 8, 16, DType::F32, &device).expect("with_capacity");
2869
2870 let k_via_layer = Arc::clone(&cache.layer(0).unwrap().k);
2871 let k_via_slot = cache.slot_storage(0, KvSlot::K).expect("layer 0 K");
2872 assert!(
2873 Arc::ptr_eq(&k_via_layer, &k_via_slot),
2874 "slot_storage should return the same Arc as layer().k",
2875 );
2876
2877 let v_via_layer = Arc::clone(&cache.layer(1).unwrap().v);
2878 let v_via_slot = cache.slot_storage(1, KvSlot::V).expect("layer 1 V");
2879 assert!(Arc::ptr_eq(&v_via_layer, &v_via_slot));
2880 }
2881
2882 /// `slot_storage` returns `None` for an unpopulated layer (the
2883 /// with_dims path before the first set_layer) and for an out-of-
2884 /// range layer index.
2885 #[test]
2886 fn kv_cache_slot_storage_returns_none_for_unpopulated() {
2887 let cache = KvCache::with_dims(2, 4, 8);
2888 assert!(cache.slot_storage(0, KvSlot::K).is_none());
2889 assert!(cache.slot_storage(1, KvSlot::V).is_none());
2890 // Out of range.
2891 assert!(cache.slot_storage(5, KvSlot::K).is_none());
2892 }
2893
2894 /// `bump_version` advances the per-slot version counter
2895 /// independently for K and V.
2896 #[test]
2897 fn kv_cache_bump_version_advances_per_slot() {
2898 let device = Device::cpu();
2899 let mut cache =
2900 KvCache::with_capacity(1, 4, 8, 16, DType::F32, &device).expect("with_capacity");
2901 assert_eq!(cache.layer(0).unwrap().k_version, 0);
2902 assert_eq!(cache.layer(0).unwrap().v_version, 0);
2903
2904 cache.bump_version(0, KvSlot::K);
2905 cache.bump_version(0, KvSlot::K);
2906 cache.bump_version(0, KvSlot::V);
2907
2908 assert_eq!(cache.layer(0).unwrap().k_version, 2);
2909 assert_eq!(cache.layer(0).unwrap().v_version, 1);
2910 }
2911
2912 /// Bumping a version on an unpopulated layer is a no-op (not a
2913 /// panic). The `with_dims` path leaves layers `None`; the
2914 /// forward code shouldn't blow up if it calls bump_version
2915 /// before the layer is wired.
2916 #[test]
2917 fn kv_cache_bump_version_unpopulated_is_noop() {
2918 let mut cache = KvCache::with_dims(2, 4, 8);
2919 cache.bump_version(0, KvSlot::K);
2920 cache.bump_version(99, KvSlot::V); // out of range, also no-op
2921 assert!(cache.layer(0).is_none());
2922 }
2923
2924 /// Sanity: bf16 capacity allocation produces 2-byte elements.
2925 #[test]
2926 fn kv_cache_with_capacity_bf16_byte_count() {
2927 let device = Device::cpu();
2928 let cache =
2929 KvCache::with_capacity(1, 2, 4, 8, DType::BF16, &device).expect("with_capacity bf16");
2930 let layer = cache.layer(0).unwrap();
2931 // 2 (n_kv) * 8 (seq) * 4 (head) * 2 (bf16) = 128 bytes per slot.
2932 assert_eq!(layer.k.read().unwrap().inner.len_bytes(), 128);
2933 assert_eq!(cache.dtype, Some(DType::BF16));
2934 }
2935
2936 // ---- LatentKvCache (MLA increment 4 — persistent N-slot decode cache) --
2937
2938 /// `LatentKvCache::with_capacity` allocates `n_layers * n_slots` fresh
2939 /// zero-initialized buffers on the CPU device, one per (layer, slot),
2940 /// shaped `[max_seq_len, ...slot_trailing[s]]`.
2941 #[test]
2942 // Irrefutable only at default features: `BackendStorage::{Cuda,Vulkan,
2943 // Metal}` are cfg-gated, so under --features cuda/vulkan/metal this pattern
2944 // is refutable and a plain `let` would be an E0005. Allow the default lint.
2945 #[allow(irrefutable_let_patterns)]
2946 fn latent_kv_cache_with_capacity_allocates_all_layers_on_cpu() {
2947 let device = Device::cpu();
2948 let cache = LatentKvCache::with_capacity(
2949 /* n_layers */ 3,
2950 /* max_seq_len */ 8,
2951 // MLA-shaped: slot 0 latent trailing [5], slot 1 k_pe trailing [2].
2952 vec![vec![5], vec![2]],
2953 DType::F32,
2954 &device,
2955 )
2956 .expect("with_capacity");
2957
2958 assert_eq!(cache.n_layers(), 3);
2959 assert_eq!(cache.n_slots(), 2);
2960 assert_eq!(cache.cached_len, 0);
2961 assert_eq!(cache.max_seq_len, 8);
2962 assert_eq!(cache.dtype, DType::F32);
2963 assert_eq!(cache.slot_trailing(0), &[5]);
2964 assert_eq!(cache.slot_trailing(1), &[2]);
2965
2966 for li in 0..3 {
2967 let latent = cache.slot_storage(li, 0).expect("latent slot populated");
2968 let kpe = cache.slot_storage(li, 1).expect("k_pe slot populated");
2969 // 8 (max_seq) * 5 (trailing) * 4 (f32) = 160 bytes.
2970 assert_eq!(latent.read().unwrap().inner.len_bytes(), 160);
2971 // 8 (max_seq) * 2 (trailing) * 4 (f32) = 64 bytes.
2972 assert_eq!(kpe.read().unwrap().inner.len_bytes(), 64);
2973 let guard = latent.read().unwrap();
2974 if let BackendStorage::Cpu(c) = &guard.inner {
2975 let typed: &[f32] = c.as_slice().unwrap();
2976 assert!(typed.iter().all(|&x| x == 0.0));
2977 } else {
2978 panic!("expected CPU storage");
2979 }
2980 }
2981 }
2982
2983 /// `slot_storage` returns `None` for an out-of-range layer or slot
2984 /// index (never panics).
2985 #[test]
2986 fn latent_kv_cache_slot_storage_returns_none_for_oob() {
2987 let device = Device::cpu();
2988 let cache = LatentKvCache::with_capacity(2, 4, vec![vec![3]], DType::F32, &device)
2989 .expect("with_capacity");
2990 assert!(cache.slot_storage(5, 0).is_none()); // layer OOB
2991 assert!(cache.slot_storage(0, 5).is_none()); // slot OOB
2992 }
2993
2994 /// `bump_version` advances the per-slot version counter independently
2995 /// per slot; out-of-range (layer, slot) is a no-op, never a panic.
2996 #[test]
2997 fn latent_kv_cache_bump_version_advances_per_slot_and_oob_is_noop() {
2998 let device = Device::cpu();
2999 let mut cache =
3000 LatentKvCache::with_capacity(1, 4, vec![vec![2], vec![1]], DType::F32, &device)
3001 .expect("with_capacity");
3002
3003 cache.bump_version(0, 0);
3004 cache.bump_version(0, 0);
3005 cache.bump_version(0, 1);
3006 cache.bump_version(99, 0); // layer OOB, no-op
3007 cache.bump_version(0, 99); // slot OOB, no-op
3008
3009 let layer0 = cache.layers[0].as_ref().expect("layer 0 populated");
3010 assert_eq!(layer0[0].version, 2);
3011 assert_eq!(layer0[1].version, 1);
3012 }
3013
3014 /// Degenerate geometry (`n_layers == 0`, `max_seq_len == 0`, or no
3015 /// slots) surfaces as a typed `Err`, never a panic.
3016 #[test]
3017 fn latent_kv_cache_with_capacity_rejects_bad_geometry() {
3018 let device = Device::cpu();
3019 assert!(LatentKvCache::with_capacity(0, 4, vec![vec![2]], DType::F32, &device).is_err());
3020 assert!(LatentKvCache::with_capacity(1, 0, vec![vec![2]], DType::F32, &device).is_err());
3021 assert!(LatentKvCache::with_capacity(1, 4, vec![], DType::F32, &device).is_err());
3022 }
3023}