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| during the first
6//! `--fp8-kv-calibration-tokens` tokens of inference to compute per-tensor
7//! scales: `scale = amax * headroom / 448.0` (mapping the observed dynamic
8//! range onto FP8 E4M3 `[-448, 448]`).
9//!
10//! # The invariant
11//!
12//! FP8 KV round-trips (write `fp8 = bf16/scale`, read `bf16 = fp8*scale`) only
13//! if the SAME scale quantizes and dequantizes an entry. Paged / multi-query
14//! attention reads a sequence's whole history in one pass with ONE
15//! `k_scale`/`v_scale`, so a scale that changes under live cache entries
16//! dequantizes them through the wrong basis (~6x error → generation garbage:
17//! loops, empty completions). That is why the 2026-07-25 hardening froze the
18//! scale on the FIRST observe.
19//!
20//! # Atlas #919
21//!
22//! Freezing on the first observe made `--fp8-kv-calibration-tokens 256` a lie.
23//! Every H100 serve log showed
24//! `FP8 KV cache with online calibration (checkpoint ships no k/v scales):
25//!  freezing per-tensor scales on the first observed tokens.`
26//! and the thing being observed was the readiness probe — so a 24k-context
27//! serve ran on scales derived from ~13 tokens.
28//!
29//! The window is now real: the amax accumulates ACROSS requests until
30//! `window_tokens` have been observed, and the batch that reaches the window
31//! freezes on the amax of everything seen, itself included. The invariant is
32//! preserved by REWRITING the entries written inside the window — the window's
33//! BF16 K/V and slot mappings are staged aside and replayed through the
34//! existing `reshape_and_cache_fp8` kernel at the frozen scale (see the
35//! private `staging` submodule of this module for the full tradeoff). A
36//! readiness probe therefore counts toward the window and can never end it on
37//! its own.
38//!
39//! Thread safety: uses `parking_lot::Mutex` for interior mutability. The lock
40//! is uncontended (single inference thread) so lock overhead is negligible.
41
42use anyhow::Result;
43use parking_lot::Mutex;
44use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
45use spark_runtime::kv_cache::KvCacheDtype;
46
47mod staging;
48mod state;
49
50pub use staging::Fp8KvWriteTarget;
51use staging::{KvStaging, MAX_STAGED_TOKENS};
52use state::{CalibrationState, CalibrationStep, POST_FREEZE_OBSERVE_PERIOD};
53
54/// Scale used for the calibration window's own writes, before the freeze.
55///
56/// Covers ±896: models with large norm weights (Gemma-4 26B, Mistral) reach
57/// |K| ~600, which clips at scale 1.0. Those writes are requantized at the
58/// frozen scale when the window closes, so this only has to be SAFE, not tight.
59const PROVISIONAL_SCALE: f32 = 2.0;
60
61/// Whether this KV dtype's write path calls [`Fp8KvCalibration::observe`].
62///
63/// SSOT with `qwen3_attention/decode/write_kv_cache.rs`: only the plain
64/// `KvCacheDtype::Fp8` arm observes. BF16 boundary layers
65/// (`--kv-high-precision-layers auto` on FP8 KV) never observe; attaching a
66/// tracker there leaves `is_calibrating() == true` forever, and
67/// `Iterator::find_map` on that first attention layer pins CUDA graphs eager
68/// for the life of the process.
69pub fn dtype_runs_online_fp8_kv_calibration(kv_dtype: KvCacheDtype) -> bool {
70    matches!(kv_dtype, KvCacheDtype::Fp8)
71}
72
73/// Lift CUDA-graph suppression once every calibrating layer has frozen.
74///
75/// `None` = this layer does not calibrate (SSM, BF16 KV, static scales).
76/// `Some(false)` = still warming. Vacuously true when no layer calibrates.
77/// Must not use `find_map`: a BF16 boundary layer reporting `Some(false)`
78/// would shadow later FP8 layers that already froze.
79///
80/// #919 note: graphs now stay eager for the whole calibration window instead
81/// of one batch. That is the cost of calibrating on the requested token count;
82/// the window is a few hundred tokens, once per process.
83pub fn graphs_ready_after_fp8_kv_cal<I>(states: I) -> bool
84where
85    I: IntoIterator<Item = Option<bool>>,
86{
87    states.into_iter().all(|s| s.unwrap_or(true))
88}
89
90/// Mutable calibration state protected by Mutex for Send + Sync.
91struct CalibrationInner {
92    state: CalibrationState,
93    staging: KvStaging,
94    /// Set when a batch could not be staged, so the freeze log can say so.
95    unstaged_tokens: usize,
96}
97
98/// Online FP8 KV cache scale calibration tracker for one attention layer.
99///
100/// Wraps calibration state in a Mutex so it can live inside a `Send + Sync`
101/// struct (required by `TransformerLayer` trait).
102pub struct Fp8KvCalibration {
103    inner: Mutex<CalibrationInner>,
104    /// Attention-layer index, for the freeze log line.
105    attn_layer_idx: usize,
106    /// Tokens the window was asked for, before the `MAX_STAGED_TOKENS` clamp.
107    requested_tokens: usize,
108    /// GPU buffer for absmax reduction output: `[1]` f32 for K, `[1]` f32 for V.
109    /// Layout: `[k_absmax: f32, v_absmax: f32]` = 8 bytes.
110    absmax_buf: DevicePtr,
111    /// Kernel handle for bf16_absmax reduction.
112    absmax_kernel: KernelHandle,
113}
114
115// SAFETY: DevicePtr is a raw GPU pointer (u64). It is only accessed from the
116// inference thread that owns the CUDA context. The Mutex guards the mutable
117// calibration state. All kernel launches are serialized on the CUDA stream.
118unsafe impl Send for Fp8KvCalibration {}
119unsafe impl Sync for Fp8KvCalibration {}
120
121impl Fp8KvCalibration {
122    /// Create a new calibration tracker.
123    ///
124    /// `attn_layer_idx`: this layer's index, for the freeze log line.
125    /// `window_tokens`: `--fp8-kv-calibration-tokens`. The amax accumulates
126    ///   over this many observed tokens, across requests, before the scale
127    ///   freezes (#919). Clamped to `MAX_STAGED_TOKENS` so the BF16 staging
128    ///   cannot reserve gigabytes per layer; 1 reproduces the pre-#919
129    ///   freeze-on-first-observe behaviour, and 0 never gets here (the
130    ///   attention initializer does not build a calibrator at all).
131    /// `headroom`: multiplier on the accumulated amax when freezing
132    ///   (`--fp8-kv-headroom`, CLI-validated ≥ 1.0; clamped here as defense in
133    ///   depth because a sub-1.0 value guarantees clipping).
134    /// `gpu`: GPU backend for allocating the absmax reduction buffer.
135    pub fn new(
136        attn_layer_idx: usize,
137        window_tokens: usize,
138        headroom: f32,
139        gpu: &dyn GpuBackend,
140    ) -> Result<Self> {
141        let headroom = if headroom >= 1.0 {
142            headroom
143        } else {
144            tracing::warn!("fp8-kv headroom {headroom} < 1.0 guarantees clipping; clamped to 1.0");
145            1.0
146        };
147        let effective = window_tokens.min(MAX_STAGED_TOKENS);
148        if effective != window_tokens && attn_layer_idx == 0 {
149            tracing::warn!(
150                "--fp8-kv-calibration-tokens {window_tokens} exceeds the {MAX_STAGED_TOKENS}-token \
151                 staging cap (the window's KV is held in BF16 so it can be requantized at the \
152                 freeze); calibrating on {effective} tokens instead."
153            );
154        }
155        let absmax_kernel = gpu.kernel("reshape_and_cache", "bf16_absmax")?;
156        // Allocate 8 bytes: [k_absmax: f32, v_absmax: f32]
157        let absmax_buf = gpu.alloc(8)?;
158        // Initialize to zero
159        let zeros = [0u8; 8];
160        gpu.copy_h2d(&zeros, absmax_buf)?;
161
162        Ok(Self {
163            inner: Mutex::new(CalibrationInner {
164                state: CalibrationState::new(effective, headroom, PROVISIONAL_SCALE),
165                staging: KvStaging::default(),
166                unstaged_tokens: 0,
167            }),
168            attn_layer_idx,
169            requested_tokens: window_tokens,
170            absmax_buf,
171            absmax_kernel,
172        })
173    }
174
175    /// Whether calibration is still in warmup phase (scales not yet frozen).
176    pub fn is_calibrating(&self) -> bool {
177        !self.inner.lock().state.frozen
178    }
179
180    /// Get current scales. Returns (k_scale, v_scale).
181    ///
182    /// Inside the window: `PROVISIONAL_SCALE` (private to this module), which
183    /// every read during the window also uses — consistent, just coarse. After
184    /// the freeze: the data-derived scale the whole window was requantized to
185    /// (constant thereafter).
186    pub fn scales(&self) -> (f32, f32) {
187        let inner = self.inner.lock();
188        (inner.state.k_scale, inner.state.v_scale)
189    }
190
191    /// Observe K/V projection outputs and update the running max.
192    ///
193    /// Launches absmax reductions on the K and V buffers, reads them back after
194    /// a sync, then either stages this batch (still inside the window) or
195    /// freezes and requantizes the staged window. Call this AFTER the K/V
196    /// projections and BEFORE writing to the KV cache — the caller's write then
197    /// uses [`Self::scales`], which is exactly what this call just decided.
198    ///
199    /// `k_data`/`v_data`: device BF16 K/V projection outputs.
200    /// `num_tokens`/`num_kv_heads`/`head_dim`: this batch's shape.
201    /// `target`: where the caller is about to write, so the freeze can replay
202    ///   the window into the same pools.
203    #[allow(clippy::too_many_arguments)]
204    pub fn observe(
205        &self,
206        gpu: &dyn GpuBackend,
207        k_data: DevicePtr,
208        v_data: DevicePtr,
209        num_tokens: u32,
210        num_kv_heads: u32,
211        head_dim: u32,
212        stream: u64,
213        target: &Fp8KvWriteTarget,
214    ) -> Result<()> {
215        {
216            let inner = self.inner.lock();
217            if !inner.state.should_observe(num_tokens as usize) {
218                return Ok(());
219            }
220        }
221
222        let (k_max, v_max) = self.absmax(
223            gpu,
224            k_data,
225            v_data,
226            num_tokens,
227            num_kv_heads,
228            head_dim,
229            stream,
230        )?;
231
232        let mut inner = self.inner.lock();
233        match inner.state.record(k_max, v_max, num_tokens as usize) {
234            CalibrationStep::Stage => {
235                let capacity = inner.state.window_tokens;
236                let elems = (num_kv_heads * head_dim) as usize;
237                let staged = inner.staging.stage(
238                    gpu, k_data, v_data, num_tokens, elems, target, capacity, stream,
239                )?;
240                if !staged {
241                    inner.unstaged_tokens += num_tokens as usize;
242                }
243            }
244            CalibrationStep::Freeze {
245                k_scale,
246                v_scale,
247                tokens_seen,
248            } => {
249                let staged_tokens = inner.staging.used_tokens();
250                let inner = &mut *inner;
251                inner.staging.replay_and_release(
252                    gpu,
253                    target,
254                    num_kv_heads,
255                    head_dim,
256                    k_scale,
257                    v_scale,
258                    stream,
259                )?;
260                tracing::info!(
261                    "FP8 KV scales frozen after {} tokens (requested {}) on attn layer {}: \
262                     k_scale={:.6} (amax={:.3}), v_scale={:.6} (amax={:.3}), headroom={:.2}; \
263                     requantized {} staged tokens in {} batches, {} unstaged",
264                    tokens_seen,
265                    self.requested_tokens,
266                    self.attn_layer_idx,
267                    k_scale,
268                    inner.state.k_running_max,
269                    v_scale,
270                    inner.state.v_running_max,
271                    inner.state.headroom,
272                    staged_tokens,
273                    inner.state.staged_batches,
274                    inner.unstaged_tokens,
275                );
276            }
277            CalibrationStep::Frozen => {
278                if inner.state.tokens_seen % POST_FREEZE_OBSERVE_PERIOD < num_tokens as usize
279                    && ema_recal_enabled()
280                {
281                    // F5 (2026-05-26): post-freeze EMA recalibration is OPT-IN via
282                    // `ATLAS_FP8_KV_EMA_RECAL=1`, default OFF. Moving `k_scale` /
283                    // `v_scale` after the freeze makes every already-written cache
284                    // entry stale relative to the new scales — attention then reads
285                    // the whole history through a shifted quantization basis. The
286                    // forensic study of the canonical opencode probe shows
287                    // reasoning-channel collapse and drift-to-phantom-path patterns
288                    // whose timing matches deep-layer KV read through a rescaled
289                    // basis. #919's staging only covers the calibration window, so
290                    // this path is still unsafe by construction — it stays off.
291                    inner.state.ema_recalibrate(k_max, v_max);
292                    tracing::info!(
293                        "FP8 KV EMA-recalibrated after {} tokens on attn layer {}: \
294                         k_scale={:.6} (amax={:.2}), v_scale={:.6} (amax={:.2})",
295                        inner.state.tokens_seen,
296                        self.attn_layer_idx,
297                        inner.state.k_scale,
298                        inner.state.k_running_max,
299                        inner.state.v_scale,
300                        inner.state.v_running_max,
301                    );
302                }
303            }
304        }
305        Ok(())
306    }
307
308    /// Absmax of the K and V projection outputs, read back to the host.
309    #[allow(clippy::too_many_arguments)]
310    fn absmax(
311        &self,
312        gpu: &dyn GpuBackend,
313        k_data: DevicePtr,
314        v_data: DevicePtr,
315        num_tokens: u32,
316        num_kv_heads: u32,
317        head_dim: u32,
318        stream: u64,
319    ) -> Result<(f32, f32)> {
320        let n_elems = num_tokens * num_kv_heads * head_dim;
321        // Reset the absmax buffer to 0.0 before the reduction (async, to avoid
322        // a sync/async conflict on the stream).
323        gpu.memset_async(self.absmax_buf, 0, 8, stream)?;
324        let k_out = self.absmax_buf;
325        super::ops::bf16_absmax(gpu, self.absmax_kernel, k_data, k_out, n_elems, stream)?;
326        // V writes to offset 4 = the second f32.
327        let v_out = self.absmax_buf.offset(4);
328        super::ops::bf16_absmax(gpu, self.absmax_kernel, v_data, v_out, n_elems, stream)?;
329
330        gpu.synchronize(stream)?;
331        let mut buf = [0u8; 8];
332        gpu.copy_d2h(self.absmax_buf, &mut buf)?;
333        Ok((
334            f32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
335            f32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
336        ))
337    }
338}
339
340fn ema_recal_enabled() -> bool {
341    std::env::var("ATLAS_FP8_KV_EMA_RECAL")
342        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
343        .unwrap_or(false)
344}
345
346#[cfg(test)]
347mod tests;