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;