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}