spark_model/layers/
fp8_calibration.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Online FP8 KV cache scale calibration.
4//!
5//! Tracks running max |K| and max |V| values during the first N tokens of
6//! inference to compute per-tensor scales: `scale = max / 448.0` (mapping the
7//! observed dynamic range to FP8 E4M3 [-448, 448]).
8//!
9//! The scale is frozen on the FIRST observation (which runs immediately before
10//! the first KV write) and is then constant for the entire lifetime of every
11//! cache entry. This is required for correctness: FP8 KV round-trips (write
12//! `fp8=bf16/scale`, read `bf16=fp8*scale`) only if the SAME scale quantizes
13//! and dequantizes an entry. Freezing after N warmup tokens (the old design)
14//! wrote early entries at a placeholder scale, then froze to a different value,
15//! silently invalidating all already-written / cached KV — a paged multi-query
16//! read spanning the freeze boundary then read history through the wrong scale
17//! and generation degenerated. See the freeze block in `observe`.
18//!
19//! Thread safety: uses `parking_lot::Mutex` for interior mutability. The lock
20//! is uncontended (single inference thread) so lock overhead is negligible.
21
22use anyhow::Result;
23use parking_lot::Mutex;
24use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
25use spark_runtime::kv_cache::KvCacheDtype;
26
27/// FP8 E4M3 max representable magnitude.
28const FP8_E4M3_MAX: f32 = 448.0;
29
30/// Minimum scale to prevent division by zero or denormalized values.
31const MIN_SCALE: f32 = 1e-12;
32
33/// Whether this KV dtype's write path calls [`Fp8KvCalibration::observe`].
34///
35/// SSOT with `qwen3_attention/decode/write_kv_cache.rs`: only the plain
36/// `KvCacheDtype::Fp8` arm observes. BF16 boundary layers
37/// (`--kv-high-precision-layers auto` on FP8 KV) never observe; attaching a
38/// tracker there leaves `is_calibrating() == true` forever, and
39/// `Iterator::find_map` on that first attention layer pins CUDA graphs eager
40/// for the life of the process.
41pub fn dtype_runs_online_fp8_kv_calibration(kv_dtype: KvCacheDtype) -> bool {
42    matches!(kv_dtype, KvCacheDtype::Fp8)
43}
44
45/// Lift CUDA-graph suppression once every calibrating layer has frozen.
46///
47/// `None` = this layer does not calibrate (SSM, BF16 KV, static scales).
48/// `Some(false)` = still warming. Vacuously true when no layer calibrates.
49/// Must not use `find_map`: a BF16 boundary layer reporting `Some(false)`
50/// would shadow later FP8 layers that already froze.
51pub fn graphs_ready_after_fp8_kv_cal<I>(states: I) -> bool
52where
53    I: IntoIterator<Item = Option<bool>>,
54{
55    states.into_iter().all(|s| s.unwrap_or(true))
56}
57
58/// Mutable calibration state protected by Mutex for Send + Sync.
59struct CalibrationInner {
60    /// Running max of |K| values observed so far.
61    k_running_max: f32,
62    /// Running max of |V| values observed so far.
63    v_running_max: f32,
64    /// Total tokens processed during calibration.
65    tokens_seen: usize,
66    /// Whether calibration is complete (scales frozen).
67    frozen: bool,
68    /// Calibrated k_scale (set once after warmup).
69    k_scale: f32,
70    /// Calibrated v_scale (set once after warmup).
71    v_scale: f32,
72}
73
74/// Online FP8 KV cache scale calibration tracker for one attention layer.
75///
76/// Wraps calibration state in a Mutex so it can live inside a `Send + Sync`
77/// struct (required by `TransformerLayer` trait).
78pub struct Fp8KvCalibration {
79    inner: Mutex<CalibrationInner>,
80    /// Headroom multiplier on the first-observe absmax (CLI `--fp8-kv-headroom`).
81    headroom: f32,
82    /// GPU buffer for absmax reduction output: `[1]` f32 for K, `[1]` f32 for V.
83    /// Layout: `[k_absmax: f32, v_absmax: f32]` = 8 bytes.
84    absmax_buf: DevicePtr,
85    /// Kernel handle for bf16_absmax reduction.
86    absmax_kernel: KernelHandle,
87}
88
89// SAFETY: DevicePtr is a raw GPU pointer (u64). It is only accessed from the
90// inference thread that owns the CUDA context. The Mutex guards the mutable
91// calibration state. All kernel launches are serialized on the CUDA stream.
92unsafe impl Send for Fp8KvCalibration {}
93unsafe impl Sync for Fp8KvCalibration {}
94
95impl Fp8KvCalibration {
96    /// Create a new calibration tracker.
97    ///
98    /// `_warmup_tokens`: retained for API/CLI compat but no longer gates the
99    ///   freeze — the scale is now frozen on the FIRST observe (before any KV is
100    ///   persisted), so a warmup window would only reintroduce the write/read
101    ///   scale mismatch. Any value > 0 simply enables online calibration.
102    /// `headroom`: multiplier on the first-observe absmax when freezing
103    ///   (`--fp8-kv-headroom`, CLI-validated ≥ 1.0; clamped here as defense in
104    ///   depth because a sub-1.0 value guarantees clipping).
105    /// `gpu`: GPU backend for allocating the absmax reduction buffer.
106    pub fn new(_warmup_tokens: usize, headroom: f32, gpu: &dyn GpuBackend) -> Result<Self> {
107        let headroom = if headroom >= 1.0 {
108            headroom
109        } else {
110            tracing::warn!("fp8-kv headroom {headroom} < 1.0 guarantees clipping; clamped to 1.0");
111            1.0
112        };
113        let absmax_kernel = gpu.kernel("reshape_and_cache", "bf16_absmax")?;
114        // Allocate 8 bytes: [k_absmax: f32, v_absmax: f32]
115        let absmax_buf = gpu.alloc(8)?;
116        // Initialize to zero
117        let zeros = [0u8; 8];
118        gpu.copy_h2d(&zeros, absmax_buf)?;
119
120        Ok(Self {
121            inner: Mutex::new(CalibrationInner {
122                k_running_max: 0.0,
123                v_running_max: 0.0,
124                tokens_seen: 0,
125                frozen: false,
126                // Start with scale=2.0 (effective range ±896) during warmup.
127                // Models with large norm weights (Gemma-4 26B, Mistral) can produce
128                // K/V values up to ~600, which exceeds FP8 E4M3 range at scale=1.0 (±448).
129                // Scale=2.0 covers ±896 which is safe for all known models.
130                k_scale: 2.0,
131                v_scale: 2.0,
132            }),
133            headroom,
134            absmax_buf,
135            absmax_kernel,
136        })
137    }
138
139    /// Whether calibration is still in warmup phase (scales not yet frozen).
140    pub fn is_calibrating(&self) -> bool {
141        let inner = self.inner.lock();
142        !inner.frozen
143    }
144
145    /// Get current scales. Returns (k_scale, v_scale).
146    ///
147    /// Before the first observe: the conservative construction default (2.0,
148    /// covering ±896) — never used for a persisted write, since observe() runs
149    /// before every write and freezes on its first call. After the first
150    /// observe: the frozen, data-derived scale (constant thereafter).
151    pub fn scales(&self) -> (f32, f32) {
152        let inner = self.inner.lock();
153        (inner.k_scale, inner.v_scale)
154    }
155
156    /// Observe K/V projection outputs and update running max.
157    ///
158    /// Launches absmax reduction kernels on the K and V buffers, then reads
159    /// the results back to CPU after a sync. Call this AFTER K/V projections
160    /// and BEFORE writing to the KV cache.
161    ///
162    /// `k_data`: device BF16 buffer of K projection output
163    /// `v_data`: device BF16 buffer of V projection output
164    /// `num_tokens`: number of tokens in the batch
165    /// `num_kv_heads`: number of KV heads
166    /// `head_dim`: dimension per head
167    pub fn observe(
168        &self,
169        gpu: &dyn GpuBackend,
170        k_data: DevicePtr,
171        v_data: DevicePtr,
172        num_tokens: u32,
173        num_kv_heads: u32,
174        head_dim: u32,
175        stream: u64,
176    ) -> Result<()> {
177        // Observe during warmup (always) and periodically after (every 512 tokens)
178        // to catch distribution shifts in multi-turn conversations. Without periodic
179        // recalibration, FP8 KV scales become stale as context grows, causing recent
180        // K values to collapse into fewer E4M3 quantization buckets.
181        // Recalibrate every 128 tokens (was 512) to catch distribution shifts
182        // in multi-turn conversations where K/V statistics change frequently.
183        let should_observe = {
184            let inner = self.inner.lock();
185            !inner.frozen || (inner.tokens_seen % 128 < num_tokens as usize)
186        };
187        if !should_observe {
188            return Ok(());
189        }
190
191        let n_elems = num_tokens * num_kv_heads * head_dim;
192
193        // Reset absmax buffer to 0.0 before reduction (async to avoid sync/async conflict)
194        gpu.memset_async(self.absmax_buf, 0, 8, stream)?;
195
196        // Launch absmax for K
197        let k_out = self.absmax_buf;
198        super::ops::bf16_absmax(gpu, self.absmax_kernel, k_data, k_out, n_elems, stream)?;
199
200        // Launch absmax for V (write to offset 4 = second f32)
201        let v_out = self.absmax_buf.offset(4);
202        super::ops::bf16_absmax(gpu, self.absmax_kernel, v_data, v_out, n_elems, stream)?;
203
204        // Sync and read back
205        gpu.synchronize(stream)?;
206        let mut result_buf = [0u8; 8];
207        gpu.copy_d2h(self.absmax_buf, &mut result_buf)?;
208        let k_max =
209            f32::from_le_bytes([result_buf[0], result_buf[1], result_buf[2], result_buf[3]]);
210        let v_max =
211            f32::from_le_bytes([result_buf[4], result_buf[5], result_buf[6], result_buf[7]]);
212
213        // Update running max and check if warmup is complete
214        let mut inner = self.inner.lock();
215        inner.k_running_max = inner.k_running_max.max(k_max);
216        inner.v_running_max = inner.v_running_max.max(v_max);
217        inner.tokens_seen += num_tokens as usize;
218
219        if !inner.frozen {
220            // HARDENING (2026-07-25): freeze the scale on the FIRST observation,
221            // BEFORE any KV is persisted with it. The write path calls observe()
222            // immediately before quantizing+writing KV (write_kv_cache.rs:482-485),
223            // so the scale frozen HERE is exactly what THIS batch — and every
224            // later batch — writes with, and what every read dequantizes with.
225            //
226            // The previous design wrote the first `warmup_tokens` (256) at a
227            // placeholder scale (2.0), then froze to a data-derived value (~0.3)
228            // and never re-quantized the already-written entries. A paged /
229            // multi-query attention read (chunked prefill, or a prefix-cache
230            // resume where seq_len_start>0) covers the whole history in one pass,
231            // so once the sequence crossed the freeze boundary it dequantized the
232            // pre-freeze KV (and cached/shared prefixes) through the NEW scale —
233            // ~6x error → generation garbage (loops/empty). Freezing on the first
234            // observe guarantees ONE constant scale for every cache entry's whole
235            // lifetime — the exact invariant the EMA-recal guard below documents.
236            // `warmup_tokens` no longer gates the freeze (kept for API compat).
237            //
238            // Headroom: the first observe sees only the first prefill chunk, so
239            // size the scale to cover headroom× its observed max, covering later
240            // tokens whose magnitude grows (trades <1 bit of precision for no
241            // clipping). CLI `--fp8-kv-headroom`, threaded through ModelConfig —
242            // deliberately NOT an env var (no knobs outside the command line).
243            let headroom = self.headroom;
244            inner.k_scale = (inner.k_running_max * headroom / FP8_E4M3_MAX).max(MIN_SCALE);
245            inner.v_scale = (inner.v_running_max * headroom / FP8_E4M3_MAX).max(MIN_SCALE);
246            inner.frozen = true;
247            tracing::info!(
248                "FP8 KV scale frozen on first observe ({} tok, headroom={:.1}): k_scale={:.6} (max={:.2}), v_scale={:.6} (max={:.2}) — constant for all entries",
249                inner.tokens_seen,
250                headroom,
251                inner.k_scale,
252                inner.k_running_max,
253                inner.v_scale,
254                inner.v_running_max,
255            );
256        } else if inner.frozen
257            && inner.tokens_seen % 128 < num_tokens as usize
258            && std::env::var("ATLAS_FP8_KV_EMA_RECAL")
259                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
260                .unwrap_or(false)
261        {
262            // F5 (2026-05-26): Periodic EMA recalibration is now OPT-IN
263            // via `ATLAS_FP8_KV_EMA_RECAL=1`, default OFF. Rationale:
264            // changing `k_scale` / `v_scale` after `frozen=true` makes
265            // previously-written KV cache entries (quantized with the
266            // OLD scales) stale relative to the NEW scales — every
267            // attention read after a recalibration sees the historical
268            // cache through a shifted quantization basis. The comment
269            // "responds faster to multi-turn topic switches" was correct
270            // about the calibration signal but missed that retroactively
271            // rescaling the cache corrupts already-stored multi-turn
272            // context. The forensic study of the canonical opencode
273            // probe shows reasoning-channel collapse + drift-to-phantom-
274            // path patterns whose timing is consistent with deep-layer
275            // KV reading through a rescaled basis.
276            let new_k = (k_max / FP8_E4M3_MAX).max(MIN_SCALE);
277            let new_v = (v_max / FP8_E4M3_MAX).max(MIN_SCALE);
278            let k_shift = (new_k - inner.k_scale).abs() / inner.k_scale.max(MIN_SCALE);
279            let v_shift = (new_v - inner.v_scale).abs() / inner.v_scale.max(MIN_SCALE);
280            let alpha = if k_shift > 0.2 || v_shift > 0.2 {
281                0.3
282            } else {
283                0.1
284            };
285            inner.k_scale = (1.0 - alpha) * inner.k_scale + alpha * new_k;
286            inner.v_scale = (1.0 - alpha) * inner.v_scale + alpha * new_v;
287            // Reset running max for next observation window
288            inner.k_running_max = k_max;
289            inner.v_running_max = v_max;
290
291            tracing::info!(
292                "FP8 KV calibrated after {} tokens: k_scale={:.6} (max={:.2}), v_scale={:.6} (max={:.2})",
293                inner.tokens_seen,
294                inner.k_scale,
295                inner.k_running_max,
296                inner.v_scale,
297                inner.v_running_max,
298            );
299        }
300
301        Ok(())
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use spark_runtime::gpu::GpuBackend;
308    use spark_runtime::gpu::mock::MockGpuBackend;
309
310    use super::Fp8KvCalibration;
311
312    fn compact_source(src: &str) -> String {
313        src.chars().filter(|c| !c.is_whitespace()).collect()
314    }
315
316    /// The write/read-scale invariant, as a fails-without-the-fix test: the
317    /// scale a batch is WRITTEN with must be the scale every later read
318    /// dequantizes with, so after the first observe the scale may never move.
319    ///
320    /// On the pre-fix design this fails: the first 10-token observe leaves the
321    /// placeholder scale (2.0) live, and the observe that crosses the
322    /// `warmup_tokens = 256` boundary re-derives it (with the mock's zeroed
323    /// absmax buffer, to `MIN_SCALE` = 1e-12) — every entry written before the
324    /// boundary is then dequantized ~6× off in production, and here the
325    /// snapshot comparison trips.
326    #[test]
327    fn scale_freezes_on_first_observe_and_stays_fixed_by_default() {
328        let gpu = MockGpuBackend::new();
329        let cal = Fp8KvCalibration::new(256, 2.0, &gpu).expect("mock construct");
330        let k = gpu.alloc(4096).expect("k buf");
331        let v = gpu.alloc(4096).expect("v buf");
332        let stream = gpu.default_stream();
333
334        // First observe: a 10-token first prefill chunk. The fix freezes HERE,
335        // before any KV has been persisted.
336        cal.observe(&gpu, k, v, 10, 8, 128, stream)
337            .expect("observe");
338        assert!(
339            !cal.is_calibrating(),
340            "scale must freeze on the first observe"
341        );
342        let frozen = cal.scales();
343
344        // Cross the old warmup boundary (256 tokens) in later observes.
345        for _ in 0..30 {
346            cal.observe(&gpu, k, v, 10, 8, 128, stream)
347                .expect("observe");
348        }
349        assert_eq!(
350            cal.scales(),
351            frozen,
352            "the frozen scale moved after later observes — pre-freeze KV would \
353             now dequantize through a different scale than it was written with"
354        );
355    }
356
357    #[test]
358    fn only_plain_fp8_kv_runs_online_calibration() {
359        use spark_runtime::kv_cache::KvCacheDtype as D;
360        assert!(super::dtype_runs_online_fp8_kv_calibration(D::Fp8));
361        assert!(!super::dtype_runs_online_fp8_kv_calibration(D::Bf16));
362        assert!(!super::dtype_runs_online_fp8_kv_calibration(D::Nvfp4));
363        assert!(!super::dtype_runs_online_fp8_kv_calibration(D::Turbo8));
364        assert!(!super::dtype_runs_online_fp8_kv_calibration(D::Fp8KTurbo4V));
365    }
366
367    #[test]
368    fn graphs_ready_vacuous_when_no_calibrator() {
369        assert!(super::graphs_ready_after_fp8_kv_cal([None, None]));
370    }
371
372    #[test]
373    fn graphs_ready_blocked_while_any_calibrator_is_warm() {
374        assert!(!super::graphs_ready_after_fp8_kv_cal([
375            None,
376            Some(false),
377            Some(true)
378        ]));
379    }
380
381    #[test]
382    fn graphs_ready_when_every_calibrator_frozen() {
383        assert!(super::graphs_ready_after_fp8_kv_cal([
384            None,
385            Some(true),
386            Some(true)
387        ]));
388    }
389
390    #[test]
391    fn graphs_stay_blocked_when_a_warm_layer_follows_a_frozen_layer() {
392        assert!(!super::graphs_ready_after_fp8_kv_cal([
393            None,
394            Some(true),
395            Some(false),
396            None
397        ]));
398    }
399
400    #[test]
401    fn attention_init_gates_calibrator_on_tokens_and_plain_fp8() {
402        let src = compact_source(include_str!("qwen3_attention/init.rs"));
403        assert!(
404            src.contains(
405                "fp8_calibration:iffp8_calibration_tokens>0&&crate::layers::fp8_calibration::dtype_runs_online_fp8_kv_calibration(kv_dtype){Some(Fp8KvCalibration::new("
406            ),
407            "the attention initializer must require enabled tokens and an observing KV dtype"
408        );
409    }
410
411    #[test]
412    fn decode_uses_shared_calibration_readiness() {
413        let src = compact_source(include_str!("../model/trait_impl/decode_a.rs"));
414        assert!(
415            src.contains(
416                "fnfp8_calibration_frozen(&self)->bool{crate::layers::fp8_calibration::graphs_ready_after_fp8_kv_cal(self.layers.iter().map(|l|l.fp8_calibration_frozen()),)}"
417            ),
418            "decode readiness must aggregate every layer through the shared policy"
419        );
420    }
421
422    #[test]
423    fn fused_verify_unsuppress_matches_decode_frozen_flag() {
424        let src = compact_source(include_str!("../model/trait_impl/verify_fused.rs"));
425        assert!(
426            src.contains(
427                "&&self.fp8_calibration_frozen(){self.suppress_graphs.store(false,std::sync::atomic::Ordering::Relaxed);"
428            ),
429            "fused verify must unsuppress graphs from the frozen-state predicate"
430        );
431        assert!(
432            !src.contains("calibration_tokens+10"),
433            "old token-count gate kept fused verify eager for ~266 tokens"
434        );
435    }
436}