metrale_model_layers/layers/fp8_calibration.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Online per-tensor FP8 KV scale calibration for one attention layer.
4//!
5//! The running max of |K| and |V| accumulates across requests over the first
6//! `window_tokens` observed tokens (`--fp8-kv-calibration-tokens`, clamped to
7//! `MAX_STAGED_TOKENS`). The observe that reaches the window freezes
8//! `scale = amax * headroom / 448`, over every observed token including its
9//! own batch (`state.rs`).
10//!
11//! Attention dequantizes a sequence's whole history with one
12//! `k_scale`/`v_scale`, so an entry is read correctly only at the scale that
13//! wrote it. Entries written inside the window use `PROVISIONAL_SCALE`; their
14//! BF16 K/V and slot mappings are staged and, at the freeze, rewritten through
15//! `ops::reshape_and_cache_fp8` at the frozen scale (`staging.rs`).
16//!
17//! Owner: model-layers (FP8 KV cache).
18//! Invariants:
19//! - The scales change once, at the freeze, and afterwards only on the opt-in
20//! `METRALE_FP8_KV_EMA_RECAL` path.
21//! - The scale set at the freeze is at least `amax / 448`, where `amax` covers
22//! every token observed up to the freeze (`headroom` is clamped to `>= 1.0`
23//! in `Fp8KvCalibration::new`).
24
25use anyhow::Result;
26use metrale_cache::kv_cache::KvCacheDtype;
27use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
28use parking_lot::Mutex;
29
30mod staging;
31mod state;
32
33pub use staging::Fp8KvWriteTarget;
34use staging::{KvStaging, MAX_STAGED_TOKENS};
35use state::{CalibrationState, CalibrationStep, POST_FREEZE_OBSERVE_PERIOD};
36
37/// 2026-09-25: Scale of the window's own writes, before the freeze: `2.0 * 448`
38/// covers |x| <= 896. Staged writes are rewritten at the frozen scale when the
39/// window closes, so this only has to avoid clipping.
40const PROVISIONAL_SCALE: f32 = 2.0;
41
42/// 2026-09-25: Whether this KV dtype's write path calls [`Fp8KvCalibration::observe`].
43///
44/// Only the `KvCacheDtype::Fp8` arm of `qwen3_attention/decode/write_kv_cache.rs`
45/// does (`write_kv_cache_fp8`). A calibrator on a layer that never observes
46/// would never freeze, and [`graphs_ready_after_fp8_kv_cal`] would then keep
47/// CUDA graphs suppressed for the life of the process.
48pub fn dtype_runs_online_fp8_kv_calibration(kv_dtype: KvCacheDtype) -> bool {
49 matches!(kv_dtype, KvCacheDtype::Fp8)
50}
51
52/// 2026-09-25: True when every calibrating layer has frozen, which is when the
53/// model lifts its CUDA-graph suppression (`trait_impl/decode_a.rs`).
54///
55/// Per layer: `None` = no calibrator, `Some(false)` = still inside the window,
56/// `Some(true)` = frozen. True when no layer calibrates. Graphs therefore stay
57/// eager for the whole window.
58pub fn graphs_ready_after_fp8_kv_cal<I>(states: I) -> bool
59where
60 I: IntoIterator<Item = Option<bool>>,
61{
62 states.into_iter().all(|s| s.unwrap_or(true))
63}
64
65struct CalibrationInner {
66 state: CalibrationState,
67 staging: KvStaging,
68 /// 2026-09-25: Tokens of batches `KvStaging::stage` declined, for the freeze log.
69 unstaged_tokens: usize,
70}
71
72/// 2026-09-25: The calibrator of one attention layer. `observe` takes `&self`;
73/// the state sits behind a `Mutex`.
74pub struct Fp8KvCalibration {
75 inner: Mutex<CalibrationInner>,
76 attn_layer_idx: usize,
77 /// 2026-09-25: Tokens the window was asked for, before the `MAX_STAGED_TOKENS` clamp.
78 requested_tokens: usize,
79 /// 2026-09-25: 8 device bytes: `[k_absmax: f32, v_absmax: f32]`.
80 absmax_buf: DevicePtr,
81 absmax_kernel: KernelHandle,
82}
83
84// 2026-09-25: SAFETY: every field is a `Mutex` or a plain integer handle
85// (`DevicePtr(u64)`, `KernelHandle(u64)`, `usize`), so no field is shared
86// through a raw pointer; the state that changes is behind the `Mutex`.
87unsafe impl Send for Fp8KvCalibration {}
88unsafe impl Sync for Fp8KvCalibration {}
89
90impl Fp8KvCalibration {
91 /// 2026-09-25: Build the calibrator for attention layer `attn_layer_idx`.
92 ///
93 /// `window_tokens` is `--fp8-kv-calibration-tokens`, clamped to
94 /// `MAX_STAGED_TOKENS` with a warning. 1 freezes on the first observe and
95 /// stages nothing. The attention initializer builds no calibrator for 0
96 /// (`qwen3_attention/init.rs`).
97 ///
98 /// `headroom` is `--fp8-kv-headroom`; a value below 1.0 is clamped to 1.0
99 /// with a warning, because it would clip the window's own amax.
100 ///
101 /// Errors if the `bf16_absmax` kernel is missing or the 8-byte absmax
102 /// buffer cannot be allocated or zeroed.
103 pub fn new(
104 attn_layer_idx: usize,
105 window_tokens: usize,
106 headroom: f32,
107 gpu: &dyn GpuBackend,
108 ) -> Result<Self> {
109 let headroom = if headroom >= 1.0 {
110 headroom
111 } else {
112 tracing::warn!("fp8-kv headroom {headroom} < 1.0 guarantees clipping; clamped to 1.0");
113 1.0
114 };
115 let effective = window_tokens.min(MAX_STAGED_TOKENS);
116 if effective != window_tokens && attn_layer_idx == 0 {
117 tracing::warn!(
118 "--fp8-kv-calibration-tokens {window_tokens} exceeds the {MAX_STAGED_TOKENS}-token \
119 staging cap (the window's KV is held in BF16 so it can be requantized at the \
120 freeze); calibrating on {effective} tokens instead."
121 );
122 }
123 let absmax_kernel = gpu.kernel("reshape_and_cache", "bf16_absmax")?;
124 let absmax_buf = gpu.alloc(8)?;
125 let zeros = [0u8; 8];
126 gpu.copy_h2d(&zeros, absmax_buf)?;
127
128 Ok(Self {
129 inner: Mutex::new(CalibrationInner {
130 state: CalibrationState::new(effective, headroom, PROVISIONAL_SCALE),
131 staging: KvStaging::default(),
132 unstaged_tokens: 0,
133 }),
134 attn_layer_idx,
135 requested_tokens: window_tokens,
136 absmax_buf,
137 absmax_kernel,
138 })
139 }
140
141 /// 2026-09-25: True until the scales freeze.
142 pub fn is_calibrating(&self) -> bool {
143 !self.inner.lock().state.frozen
144 }
145
146 /// 2026-09-25: `(k_scale, v_scale)`: `PROVISIONAL_SCALE` inside the window,
147 /// the frozen scales after it.
148 pub fn scales(&self) -> (f32, f32) {
149 let inner = self.inner.lock();
150 (inner.state.k_scale, inner.state.v_scale)
151 }
152
153 /// 2026-09-25: Fold this batch's BF16 K/V (`[num_tokens, num_kv_heads, head_dim]`)
154 /// into the window.
155 ///
156 /// Inside the window it reduces the absmax on the device, synchronizes the
157 /// stream to read it back, and stages the batch. The observe that reaches
158 /// the window freezes the scales and replays the staged batches into
159 /// `target`'s pools at them. Call it after the K/V projections and before
160 /// the cache write, which then uses [`Self::scales`]. After the freeze it
161 /// reduces only when `CalibrationState::should_observe` asks, for the
162 /// opt-in EMA path.
163 #[allow(clippy::too_many_arguments)]
164 pub fn observe(
165 &self,
166 gpu: &dyn GpuBackend,
167 k_data: DevicePtr,
168 v_data: DevicePtr,
169 num_tokens: u32,
170 num_kv_heads: u32,
171 head_dim: u32,
172 stream: u64,
173 target: &Fp8KvWriteTarget,
174 ) -> Result<()> {
175 {
176 let inner = self.inner.lock();
177 if !inner.state.should_observe(num_tokens as usize) {
178 return Ok(());
179 }
180 }
181
182 let (k_max, v_max) = self.absmax(
183 gpu,
184 k_data,
185 v_data,
186 num_tokens,
187 num_kv_heads,
188 head_dim,
189 stream,
190 )?;
191
192 let mut inner = self.inner.lock();
193 match inner.state.record(k_max, v_max, num_tokens as usize) {
194 CalibrationStep::Stage => {
195 let capacity = inner.state.window_tokens;
196 let elems = (num_kv_heads * head_dim) as usize;
197 let staged = inner.staging.stage(
198 gpu, k_data, v_data, num_tokens, elems, target, capacity, stream,
199 )?;
200 if !staged {
201 inner.unstaged_tokens += num_tokens as usize;
202 }
203 }
204 CalibrationStep::Freeze {
205 k_scale,
206 v_scale,
207 tokens_seen,
208 } => {
209 let staged_tokens = inner.staging.used_tokens();
210 let inner = &mut *inner;
211 inner.staging.replay_and_release(
212 gpu,
213 target,
214 num_kv_heads,
215 head_dim,
216 k_scale,
217 v_scale,
218 stream,
219 )?;
220 tracing::info!(
221 "FP8 KV scales frozen after {} tokens (requested {}) on attn layer {}: \
222 k_scale={:.6} (amax={:.3}), v_scale={:.6} (amax={:.3}), headroom={:.2}; \
223 requantized {} staged tokens in {} batches, {} unstaged",
224 tokens_seen,
225 self.requested_tokens,
226 self.attn_layer_idx,
227 k_scale,
228 inner.state.k_running_max,
229 v_scale,
230 inner.state.v_running_max,
231 inner.state.headroom,
232 staged_tokens,
233 inner.state.staged_batches,
234 inner.unstaged_tokens,
235 );
236 }
237 CalibrationStep::Frozen => {
238 if inner.state.tokens_seen % POST_FREEZE_OBSERVE_PERIOD < num_tokens as usize
239 && ema_recal_enabled()
240 {
241 // 2026-09-25: Opt-in only (`METRALE_FP8_KV_EMA_RECAL`). Moving
242 // a frozen scale changes the basis every already-written entry
243 // is read through, and nothing rewrites entries after the window.
244 inner.state.ema_recalibrate(k_max, v_max);
245 tracing::info!(
246 "FP8 KV EMA-recalibrated after {} tokens on attn layer {}: \
247 k_scale={:.6} (amax={:.2}), v_scale={:.6} (amax={:.2})",
248 inner.state.tokens_seen,
249 self.attn_layer_idx,
250 inner.state.k_scale,
251 inner.state.k_running_max,
252 inner.state.v_scale,
253 inner.state.v_running_max,
254 );
255 }
256 }
257 }
258 Ok(())
259 }
260
261 /// 2026-09-25: Absmax of the K and V buffers, read back after a stream synchronize.
262 #[allow(clippy::too_many_arguments)]
263 fn absmax(
264 &self,
265 gpu: &dyn GpuBackend,
266 k_data: DevicePtr,
267 v_data: DevicePtr,
268 num_tokens: u32,
269 num_kv_heads: u32,
270 head_dim: u32,
271 stream: u64,
272 ) -> Result<(f32, f32)> {
273 let n_elems = num_tokens * num_kv_heads * head_dim;
274 // 2026-09-25: `bf16_absmax` max-accumulates into its output, so zero it first.
275 gpu.memset_async(self.absmax_buf, 0, 8, stream)?;
276 let k_out = self.absmax_buf;
277 super::ops::bf16_absmax(gpu, self.absmax_kernel, k_data, k_out, n_elems, stream)?;
278 let v_out = self.absmax_buf.offset(4);
279 super::ops::bf16_absmax(gpu, self.absmax_kernel, v_data, v_out, n_elems, stream)?;
280
281 gpu.synchronize(stream)?;
282 let mut buf = [0u8; 8];
283 gpu.copy_d2h(self.absmax_buf, &mut buf)?;
284 Ok((
285 f32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
286 f32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
287 ))
288 }
289}
290
291fn ema_recal_enabled() -> bool {
292 std::env::var("METRALE_FP8_KV_EMA_RECAL")
293 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
294 .unwrap_or(false)
295}
296
297#[cfg(test)]
298mod tests;