Skip to main content

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}