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;