fuel_tensor/decode_shape.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! The identity a held decode plan is baked against.
3//!
4//! A [`DecodeSession`](crate::inference_context::DecodeSession) holds a graph
5//! that has already been built **and optimized** for one specific model. Reusing
6//! it is what makes persistent decode fast — measured at 223× on CUDA against
7//! re-planning per token — and reusing it *wrongly* is silent: a baked graph
8//! computing the wrong architecture returns plausible logits at full speed, with
9//! nothing to report.
10//!
11//! Until this module existed the reuse predicate
12//! ([`DecodeSession::is_valid_for`](crate::inference_context::DecodeSession::is_valid_for))
13//! keyed on `(seq, max_seq_len, n_layers, cache_dtype)` — pure geometry. That was
14//! sufficient while one model shape drove the path and each caller happened to
15//! hold one session per model. It is not sufficient for either of the two things
16//! now in flight:
17//!
18//! - **Different architectures, identical geometry.** Qwen3 (per-head Q/K
19//! RmsNorm, sliding-window mask) and Llama at the same dims produce the same
20//! geometric key, so a plan built for one would be judged valid for the other.
21//! - **Same architecture, different weights.** This one needs no new model
22//! family to be wrong: two `Qwen3Model`s with identical config and different
23//! weights collide *today*, and the held graph carries the first one's baked
24//! weight `Const`s.
25//!
26//! ## What belongs in the key — and what must NOT
27//!
28//! The rule is **baked, not bound**. A held graph bakes its op structure and its
29//! weight `Const`s; per-token data (`token_ids`, `rope_cos`/`rope_sin`, `mask`,
30//! the KV-write offset) is `const_placeholder_like` and rebound every step from
31//! the *current* call.
32//!
33//! So RoPE frequencies are deliberately **absent** from the key, and that is not
34//! an oversight. LLaMA-3.1's scaled frequencies reach the graph through the
35//! per-token rebind, so a session is already correct across a frequency change.
36//! The instinct is "rope differs ⇒ must be in the key", and acting on it costs
37//! plan reuse for no correctness gain — a pure performance regression that hides
38//! behind a fully green correctness suite. Over-keying is the failure mode with
39//! no error message; that is why the tests here assert **both** halves — that a
40//! differing key invalidates, *and* that a matching one does not.
41//!
42//! ## Why a counter, and not weight pointers
43//!
44//! Model identity is a [`ModelInstanceId`] — a value from a process-global
45//! monotonic counter, minted once per constructed model. It is **never
46//! recycled**, so two distinct models cannot share one, ever, under any
47//! ownership arrangement.
48//!
49//! The obvious cheaper thing is to fold the weights' `Arc` addresses, and this
50//! module did that for exactly one commit. It is **unsound**, and the way it
51//! was unsound is the reason the counter is worth its cost:
52//!
53//! > Pointer identity is safe only while something pins the allocation. A
54//! > `DecodeSession` holds `StorageCache = HashMap<NodeId, Arc<RwLock<Storage>>>`
55//! > — it pins the *Storage* it baked, **not** the model's `Arc<[f32]>` weight
56//! > buffers, which are different allocations (and on GPU the host buffer is not
57//! > retained at all). So: hold a session for model A, drop A, construct B — the
58//! > allocator may hand back A's addresses, B's key equals A's, and the stale
59//! > plan baked with **A's** weight `Const`s is judged valid for B.
60//!
61//! That is reachable, not exotic: a driver serving many models with cached
62//! sessions is precisely where a session outlives its model.
63//!
64//! The lesson generalizes past this bug. The pointer scheme was justified by a
65//! *proof about lifetimes*, and the proof was true of the design it was written
66//! for (folding `base_cache`'s own Arcs) and silently stopped being true when
67//! the input moved to model-owned weights — a change made for unrelated and
68//! good reasons. **A counter that is never reused needs no proof**, so it cannot
69//! be invalidated by a refactor somewhere else. Prefer that to a correct
70//! argument whose premises live in another file.
71//!
72//! One consequence worth stating rather than discovering: a model reloaded from
73//! disk gets a fresh id and rebuilds its plan. Safe, mildly wasteful, and
74//! correct by default. Mutating a weight buffer in place under a live session
75//! does **not** invalidate it — decode does not do that; a training consumer
76//! must not reuse a decode session across an optimizer step.
77
78use std::sync::atomic::{AtomicU64, Ordering};
79
80/// A process-unique, never-recycled identity for one constructed model.
81///
82/// Mint one per model (lazily is fine) and mix it into that model's decode
83/// key. Because ids are monotonic and never reused, two distinct models cannot
84/// collide regardless of allocation, drop order, or who outlives whom — see the
85/// module docs for the pointer-identity scheme this replaced and why it failed.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub struct ModelInstanceId(u64);
88
89/// Starts at 1 so a `Default`/zero-initialized field can never be mistaken for
90/// a real id.
91static NEXT_MODEL_INSTANCE: AtomicU64 = AtomicU64::new(1);
92
93impl ModelInstanceId {
94 /// Mint the next id. Monotonic and never recycled; `u64` exhaustion would
95 /// need 2^64 model constructions in one process.
96 pub fn next() -> Self {
97 ModelInstanceId(NEXT_MODEL_INSTANCE.fetch_add(1, Ordering::Relaxed))
98 }
99
100 pub fn get(self) -> u64 {
101 self.0
102 }
103}
104
105/// A process-unique, never-recycled identity for one KV **allocation** — the
106/// set of storage buffers a held decode plan is welded to.
107///
108/// ## Why a held plan needs this at all
109///
110/// [`DecodeSession`](crate::inference_context::DecodeSession) keeps a
111/// `base_cache` holding the KV storage `Arc`s bound on the first decode token,
112/// and the per-token rebind overwrites *only* the data `Const`s — `token_ids`,
113/// RoPE tables, mask, offset. It never rebinds `kv_nodes`. So a held plan does
114/// not read the `&mut KvCache` it is handed; it reads **the buffers it was
115/// built against**, forever.
116///
117/// Geometry cannot see that. Two same-shaped caches key identically, so the
118/// slot-pooled serving happy path — retire request A, admit B on a fresh cache
119/// of the same shape, reuse the plan for speed — silently decodes B over A's
120/// KV. Full speed, plausible distribution, nothing to report. That is why this
121/// id exists and why it is in the validity key.
122///
123/// ## The id names the ALLOCATION, not the conversation
124///
125/// This distinction is the entire design, and getting it backwards breaks one
126/// of the two directions that matter:
127///
128/// - **Re-allocate ⇒ new id.** Constructing a cache, and any operation that
129/// replaces storage under it ([`KvCache::clear`] / [`KvCache::set_layer`]),
130/// mints a fresh id. A plan welded to the old buffers must be rebuilt.
131/// - **Rewind ⇒ SAME id.** [`KvCache::truncate_to`] — speculative decoding's
132/// reject path — moves `cached_len` and touches no storage. The plan is still
133/// welded to exactly the right buffers, so invalidating there would forfeit
134/// plan reuse (223× on CUDA) on every rejected draft batch, for no
135/// correctness gain.
136///
137/// [`KvCache::clear`]: crate::inference_context::KvCache::clear
138/// [`KvCache::set_layer`]: crate::inference_context::KvCache::set_layer
139/// [`KvCache::truncate_to`]: crate::inference_context::KvCache::truncate_to
140///
141/// Over-keying is the failure mode with no error message: an "always stale"
142/// predicate passes every correctness test while quietly disabling the
143/// optimization. The tests assert both halves for that reason.
144///
145/// Never recycled, for the same reason [`ModelInstanceId`] is: pointer identity
146/// would be sound only while something pins the allocation, and a session
147/// outliving its cache is exactly the case this guards. See the module docs.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
149pub struct KvAllocId(u64);
150
151/// Starts at 1 so a `Default`/zero-initialized field can never be mistaken for
152/// a real id.
153static NEXT_KV_ALLOC: AtomicU64 = AtomicU64::new(1);
154
155impl KvAllocId {
156 /// Mint the next id. Monotonic and never recycled.
157 pub fn next() -> Self {
158 KvAllocId(NEXT_KV_ALLOC.fetch_add(1, Ordering::Relaxed))
159 }
160
161 pub fn get(self) -> u64 {
162 self.0
163 }
164}
165
166/// FNV-1a 64 over a canonical encoding of a model's decode identity.
167///
168/// Same constants as `fuel_dispatch::fkc::revhash` and
169/// `fuel_memory::dlpack_view` — offset basis `0xcbf29ce484222325`, prime
170/// `0x100000001b3`. Pinned by known-answer vectors below rather than by
171/// cross-checking against those, so a consistent drift in all three cannot pass.
172#[derive(Debug, Clone)]
173pub struct ShapeKeyHasher(u64);
174
175impl Default for ShapeKeyHasher {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181impl ShapeKeyHasher {
182 pub fn new() -> Self {
183 ShapeKeyHasher(0xcbf2_9ce4_8422_2325)
184 }
185
186 fn mix_bytes(&mut self, bytes: &[u8]) {
187 for b in bytes {
188 self.0 ^= *b as u64;
189 self.0 = self.0.wrapping_mul(0x100_0000_01b3);
190 }
191 }
192
193 /// Mix a structural discriminant — the model family, a hook's presence, a
194 /// mask policy. Prefer a stable literal (`"qwen3"`, `"sliding_window"`) over
195 /// anything derived from a `Debug` impl, which is free to change.
196 pub fn mix_str(&mut self, s: &str) -> &mut Self {
197 self.mix_bytes(s.as_bytes());
198 // Length-delimit so `mix_str("ab") + mix_str("c")` cannot collide with
199 // `mix_str("a") + mix_str("bc")`.
200 self.mix_bytes(&(s.len() as u64).to_le_bytes());
201 self
202 }
203
204 /// Mix a numeric structural parameter (`n_layers`, `head_dim`, a window
205 /// size, an enum discriminant).
206 pub fn mix_u64(&mut self, v: u64) -> &mut Self {
207 self.mix_bytes(&v.to_le_bytes());
208 self
209 }
210
211 /// Mix a float structural parameter (`rms_norm_eps`). By bit pattern, so
212 /// `NaN` and `-0.0` need no comparison.
213 ///
214 /// Note this gives `+0.0` and `-0.0` different keys. That is deliberate and
215 /// conservative — the cost is a redundant plan rebuild in a case that does
216 /// not arise, and the alternative (normalizing) is a comparison that has to
217 /// stay correct forever. Do not "fix" it.
218 pub fn mix_f64(&mut self, v: f64) -> &mut Self {
219 self.mix_bytes(&v.to_bits().to_le_bytes());
220 self
221 }
222
223 /// Mix the model's identity — the component that separates two models of
224 /// the *same* architecture, whose baked weight `Const`s differ.
225 ///
226 /// There is deliberately **no** `mix_weight(&Arc<T>)` here. Folding weight
227 /// addresses is the obvious cheaper implementation and it is unsound: a
228 /// session does not pin the model's weight allocations, so a dropped model
229 /// frees addresses a later model can be handed. The module docs carry the
230 /// full sequence. A method that looks like identity and silently isn't is
231 /// worse than no method, so it is absent rather than deprecated.
232 pub fn mix_instance(&mut self, id: ModelInstanceId) -> &mut Self {
233 self.mix_str("instance").mix_u64(id.get())
234 }
235
236 /// Mix whether an optional hook is present, so a model with it disabled
237 /// cannot key the same as one with it enabled. The obvious implementation —
238 /// skipping the mix when absent — has exactly that bug.
239 pub fn mix_present(&mut self, present: bool) -> &mut Self {
240 self.mix_str(if present { "some" } else { "none" })
241 }
242
243 pub fn finish(&self) -> u64 {
244 self.0
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 /// Pin the hash to the FNV-1a **spec**, not to Fuel's other two
253 /// implementations of it. A cross-check between implementations proves only
254 /// that they agree with each other; a refactor changing all of them
255 /// consistently would keep them equal while silently invalidating every
256 /// key. Vectors are the published FNV-1a 64 test vectors.
257 #[test]
258 fn hasher_matches_the_fnv1a_spec() {
259 // The empty input must be the offset basis — and note this vector alone
260 // is NOT sufficient: FNV-1 and FNV-1a agree on empty input, so it
261 // cannot catch the classic xor/multiply transposition.
262 assert_eq!(ShapeKeyHasher::new().finish(), 0xcbf2_9ce4_8422_2325);
263
264 // Raw byte folds (no length delimiter) against the published vectors.
265 let fold = |s: &str| {
266 let mut h = ShapeKeyHasher::new();
267 h.mix_bytes(s.as_bytes());
268 h.finish()
269 };
270 assert_eq!(fold("a"), 0xaf63_dc4c_8601_ec8c);
271 assert_eq!(fold("foobar"), 0x8594_4171_f739_67e8);
272 }
273
274 #[test]
275 fn distinct_structure_gives_distinct_keys() {
276 let mut a = ShapeKeyHasher::new();
277 a.mix_str("llama").mix_u64(32);
278 let mut b = ShapeKeyHasher::new();
279 b.mix_str("qwen3").mix_u64(32);
280 assert_ne!(a.finish(), b.finish(), "model family must discriminate");
281 }
282
283 /// The length delimiter is load-bearing: without it, adjacent string mixes
284 /// concatenate and `("ab", "c")` collides with `("a", "bc")`.
285 #[test]
286 fn adjacent_string_mixes_cannot_be_confused() {
287 let mut a = ShapeKeyHasher::new();
288 a.mix_str("ab").mix_str("c");
289 let mut b = ShapeKeyHasher::new();
290 b.mix_str("a").mix_str("bc");
291 assert_ne!(a.finish(), b.finish());
292 }
293
294 /// [`KvAllocId`]'s whole contract, stated as a test on the real carrier:
295 /// **re-allocating re-mints, rewinding does not.** Both halves matter and
296 /// they fail differently — a missing re-mint is a silent wrong answer
297 /// (a held plan reused across a cache swap), while a spurious re-mint is a
298 /// silent performance loss (speculative decoding's reject path rebuilding
299 /// the plan on every rejected batch) that no correctness test would catch.
300 #[test]
301 fn alloc_id_tracks_the_allocation_not_the_conversation() {
302 use crate::inference_context::KvCache;
303
304 let dev = crate::Device::cpu();
305 let mut cache =
306 KvCache::with_capacity(2, 2, 4, 8, fuel_ir::DType::F32, &dev).expect("with_capacity");
307 let first = cache.alloc_id();
308
309 // A second cache of IDENTICAL geometry is a DIFFERENT allocation.
310 let other =
311 KvCache::with_capacity(2, 2, 4, 8, fuel_ir::DType::F32, &dev).expect("with_capacity");
312 assert_ne!(
313 other.alloc_id(),
314 first,
315 "same-geometry caches must not share an id — that collision IS the bug",
316 );
317
318 // Rewind the conversation: storage untouched ⇒ id preserved.
319 cache.cached_len = 5;
320 cache.truncate_to(2);
321 assert_eq!(
322 cache.alloc_id(),
323 first,
324 "truncate_to rewinds cached_len and touches no storage — re-minting \
325 here would forfeit plan reuse on every rejected draft batch",
326 );
327
328 // Release the storage: id must move.
329 cache.clear();
330 let after_clear = cache.alloc_id();
331 assert_ne!(
332 after_clear, first,
333 "clear() drops every layer, so a plan welded to them is stale",
334 );
335
336 // Replace one layer's storage: id must move again.
337 let replacement =
338 KvCache::with_capacity(2, 2, 4, 8, fuel_ir::DType::F32, &dev).expect("with_capacity");
339 let src = replacement.layer(0).expect("layer 0");
340 cache.set_layer(
341 0,
342 crate::inference_context::KvLayer {
343 k: std::sync::Arc::clone(&src.k),
344 v: std::sync::Arc::clone(&src.v),
345 k_layout: src.k_layout.clone(),
346 v_layout: src.v_layout.clone(),
347 k_version: 0,
348 v_version: 0,
349 k_authority: src.k_authority.clone(),
350 v_authority: src.v_authority.clone(),
351 },
352 );
353 assert_ne!(
354 cache.alloc_id(),
355 after_clear,
356 "set_layer swaps the Arc a held plan baked in — that is a new allocation",
357 );
358 }
359
360 /// Two models of the same architecture must key differently — this is the
361 /// half that needs no new model family to be wrong, since it fires on two
362 /// same-config models with different weights.
363 #[test]
364 fn distinct_model_instances_key_differently() {
365 let a = ModelInstanceId::next();
366 let b = ModelInstanceId::next();
367 assert_ne!(a, b, "ids must never repeat");
368 let key = |id| {
369 let mut h = ShapeKeyHasher::new();
370 h.mix_str("llama").mix_instance(id);
371 h.finish()
372 };
373 assert_ne!(key(a), key(b));
374
375 // …and the SAME instance must key identically. This is the half that
376 // guards plan reuse: "always stale" satisfies every correctness test
377 // ever written while silently forfeiting the 223x persistent-decode win.
378 assert_eq!(key(a), key(a));
379 }
380
381 /// The property the pointer scheme could not offer: ids are never recycled,
382 /// so a model constructed after another is dropped cannot inherit its key.
383 /// No lifetime reasoning, and nothing for a later refactor to invalidate.
384 #[test]
385 fn ids_are_not_recycled_across_drops() {
386 let first = {
387 let id = ModelInstanceId::next();
388 id.get()
389 };
390 let second = ModelInstanceId::next().get();
391 assert!(
392 second > first,
393 "id {second} did not advance past the dropped {first} — a recycled id would let a stale plan be judged valid for a different model",
394 );
395 }
396
397 #[test]
398 fn absent_and_present_hooks_differ() {
399 let mut with = ShapeKeyHasher::new();
400 with.mix_present(true);
401 let mut without = ShapeKeyHasher::new();
402 without.mix_present(false);
403 assert_ne!(
404 with.finish(),
405 without.finish(),
406 "a disabled hook must not key the same as an enabled one",
407 );
408 }
409
410 // `same_geometry_different_weights_do_not_share_a_plan` moved to
411 // `fuel-model-llama/tests/decode_shape_geometry.rs` (fuel-core dissolution):
412 // it constructs a real `LlamaModel`, which now lives in `fuel-model-llama`, a
413 // crate that depends on `fuel-core` -- keeping the test here as a
414 // [dev-dependencies] consumer hits Cargo's diamond-dependency limitation
415 // (two non-unifying copies of `fuel_core` in the same test binary's
416 // dependency graph, one being tested and one `fuel-model-llama` depends on).
417 // Moving the test to `fuel-model-llama`'s own integration tests avoids the
418 // diamond entirely: there `fuel_core` is an ordinary single dependency.
419}