Skip to main content

optirs_gpu/
quantization.rs

1//! # Quantization-Aware Training (QAT) primitives
2//!
3//! This module is a **CPU reference implementation** of the numerical core used
4//! for quantization-aware training. It contains no real GPU calls: every routine
5//! operates on host [`Array1`] / [`Array2`] data and exactly simulates the
6//! precision loss that the corresponding low-precision GPU kernels would
7//! introduce. The intent is that the *bit-for-bit* rounding behaviour modelled
8//! here matches what a fused int8 / fp8 kernel produces, so a network trained
9//! with these "fake-quant" operators behaves like the eventually-deployed
10//! quantized network.
11//!
12//! ## What "fake quantization" means
13//!
14//! A fake-quant operator maps a floating-point value through the
15//! quantize/dequantize round trip **while staying in floating point**:
16//!
17//! ```text
18//!   fake_quant(x) = dequant(quant(x))
19//! ```
20//!
21//! The result is the value the network *would* see if `x` were stored in the
22//! low-precision format, but it remains an `f64` so that the rest of the
23//! forward/backward pass runs in full precision. The gradient of this
24//! (piecewise-constant, hence a.e. zero-derivative) operator is supplied by the
25//! straight-through estimator (see [`fake_quant_backward`]).
26//!
27//! ## Integer quantization (`int8` / `int4`)
28//!
29//! For an affine integer grid with step `scale`, integer zero-point
30//! `zero_point` and clamp range `[qmin, qmax]`:
31//!
32//! ```text
33//!   quant(x)   = clamp(round(x / scale) + zero_point, qmin, qmax)
34//!   dequant(q) = (q - zero_point) * scale
35//! ```
36//!
37//! Two schemes are supported ([`QuantScheme`]):
38//!
39//! * **Symmetric** -- `zero_point = 0`. The grid is symmetric about zero and the
40//!   scale is derived from the absolute maximum. To keep the negative and
41//!   positive arms equal in length we use the **restricted** signed range, i.e.
42//!   `int8` uses `qmin = -127, qmax = 127` (the `-128` code is dropped) and
43//!   `int4` uses `qmin = -7, qmax = 7`. `scale = absmax / qmax`.
44//! * **Affine** (asymmetric) -- the scale comes from the real `[min, max]`
45//!   interval (nudged to include the real value `0`) and `zero_point` is the
46//!   integer code onto which the real value `0` maps. Affine uses the **full**
47//!   signed range, `int8 = [-128, 127]`, `int4 = [-8, 7]`.
48//!
49//! Note that because `dequant(quant(x)) = round(x / scale) * scale` the
50//! reconstructed grid is always a set of integer multiples of `scale` (the
51//! `zero_point` cancels in the round trip and only affects the asymmetric clamp
52//! window). Hence the per-element error is bounded by `scale / 2` for
53//! round-to-nearest, which the test-suite checks.
54//!
55//! ## fp8 quantization (`E4M3` / `E5M2`)
56//!
57//! Two 8-bit floating formats are modelled, decomposing each value into
58//! sign / exponent / mantissa and rounding the mantissa to the available bits,
59//! correctly handling **normals**, **subnormals** and **saturation**:
60//!
61//! | format | sign | exp | mantissa | bias | max-normal | min-normal | min-subnormal |
62//! |--------|------|-----|----------|------|-----------:|-----------:|--------------:|
63//! | E4M3   | 1    | 4   | 3        | 7    | `448`      | `2^-6`     | `2^-9`        |
64//! | E5M2   | 1    | 5   | 2        | 15   | `57344`    | `2^-14`    | `2^-16`       |
65//!
66//! * **E4M3** follows the OCP / deep-learning "E4M3" variant: there are **no
67//!   infinities**, the only NaN encoding is `S.1111.111`, and the largest finite
68//!   value is `S.1111.110 = 1.75 * 2^8 =` [`E4M3_MAX_NORMAL`] `= 448`. Its
69//!   dynamic range runs from the smallest subnormal `2^-9` up to `448`.
70//! * **E5M2** is IEEE-like (it *has* `Inf`/`NaN` at exponent field `11111`); the
71//!   largest finite value is `S.11110.11 = 1.75 * 2^15 =` [`E5M2_MAX_NORMAL`]
72//!   `= 57344`, with dynamic range from `2^-16` up to `57344`.
73//!
74//! The cast implemented here is **saturating**: magnitudes above the format max
75//! (and any infinities) clamp to the format max rather than overflowing to
76//! `Inf`. `NaN` inputs propagate to `NaN`.
77//!
78//! The rounding uses a single unified rule that is continuous across the
79//! normal/subnormal boundary. For a magnitude `a` with binade exponent
80//! `e = floor(log2(a))`, the unit-in-the-last-place is `2^(max(e, emin) - mbits)`
81//! where `emin = 1 - bias` is the smallest normal exponent and `mbits` is the
82//! mantissa width; `a` is rounded to the nearest multiple of that ULP. For
83//! `e >= emin` this reproduces the normal-number grid; for `e < emin` it freezes
84//! at the subnormal granularity `2^(emin - mbits)`.
85//!
86//! ## Rounding modes
87//!
88//! [`RoundingMode`] selects between:
89//!
90//! * **Nearest** -- round-to-nearest-**even** (ties to even), the deterministic
91//!   default.
92//! * **Stochastic** -- round up with probability equal to the fractional part
93//!   and down otherwise, drawing a uniform `[0, 1)` variate from the supplied
94//!   [`Rng`]. Stochastic rounding is **unbiased**: `E[round(r)] = r`, so the
95//!   expected reconstruction equals the true value; the test-suite verifies this
96//!   by Monte-Carlo averaging.
97//!
98//! ## Straight-through estimator (STE)
99//!
100//! Because `quant` is piecewise constant its true derivative is zero almost
101//! everywhere, which would block training. The STE replaces it with the identity
102//! on the representable interval: the incoming gradient passes through unchanged
103//! where the (pre-quant) value lies inside `[qmin_real, qmax_real]` and is zeroed
104//! outside (the clamp saturates, so no gradient flows). See
105//! [`fake_quant_backward`].
106//!
107//! ## QAT master-weight scheme
108//!
109//! [`QatOptimizer`] implements the standard QAT bookkeeping: a full-precision
110//! **master copy** of the weights is what the optimizer (here an Adam/AdamW
111//! update) actually integrates, while the **fake-quantized view** of those
112//! master weights is what the forward pass "uses". On every [`QatOptimizer::step`]
113//! the FP32 master is updated and the quantized view is re-derived (re-calibrated
114//! and re-rounded) from the new master. Keeping the master in full precision is
115//! essential: the tiny gradient steps would otherwise vanish under the
116//! quantization rounding and the network would never learn.
117
118use crate::GpuOptimError;
119use scirs2_core::ndarray::{Array1, Array2, Axis, Zip};
120use scirs2_core::random::{Rng, RngExt};
121
122/// Largest finite (max-normal) magnitude of the E4M3 format, `1.75 * 2^8`.
123pub const E4M3_MAX_NORMAL: f64 = 448.0;
124
125/// Largest finite (max-normal) magnitude of the E5M2 format, `1.75 * 2^15`.
126pub const E5M2_MAX_NORMAL: f64 = 57344.0;
127
128/// Quantization grid geometry: symmetric (zero-point pinned to `0`) or affine
129/// (asymmetric, zero-point derived from the real minimum).
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum QuantScheme {
132    /// Symmetric, zero-point `= 0`, restricted signed range.
133    Symmetric,
134    /// Affine / asymmetric, zero-point derived from the real `[min, max]`.
135    Affine,
136}
137
138/// Supported signed-integer quantization widths.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum IntDtype {
141    /// 8-bit signed integer quantization.
142    Int8,
143    /// 4-bit signed integer quantization.
144    Int4,
145}
146
147impl IntDtype {
148    /// Number of bits in the integer code.
149    pub fn bits(self) -> u32 {
150        match self {
151            IntDtype::Int8 => 8,
152            IntDtype::Int4 => 4,
153        }
154    }
155
156    /// Construct an [`IntDtype`] from a bit-width, rejecting unsupported widths.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`GpuOptimError::UnsupportedOperation`] for any width other than
161    /// `4` or `8`.
162    pub fn from_bits(bits: u32) -> Result<Self, GpuOptimError> {
163        match bits {
164            8 => Ok(IntDtype::Int8),
165            4 => Ok(IntDtype::Int4),
166            other => Err(GpuOptimError::UnsupportedOperation(format!(
167                "unsupported integer quantization width: {other} bits (expected 4 or 8)"
168            ))),
169        }
170    }
171
172    /// Integer clamp range `[qmin, qmax]` for this width under the given scheme.
173    ///
174    /// Symmetric uses the restricted range (drops the most-negative code) so the
175    /// grid is symmetric about zero; affine uses the full two's-complement range.
176    pub fn q_range(self, scheme: QuantScheme) -> (i32, i32) {
177        match (self, scheme) {
178            (IntDtype::Int8, QuantScheme::Symmetric) => (-127, 127),
179            (IntDtype::Int8, QuantScheme::Affine) => (-128, 127),
180            (IntDtype::Int4, QuantScheme::Symmetric) => (-7, 7),
181            (IntDtype::Int4, QuantScheme::Affine) => (-8, 7),
182        }
183    }
184}
185
186/// 8-bit floating-point formats modelled by [`fake_quant_fp8`].
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum Fp8Format {
189    /// 4 exponent bits, 3 mantissa bits, bias 7, max-normal `448` (no infinities).
190    E4M3,
191    /// 5 exponent bits, 2 mantissa bits, bias 15, max-normal `57344` (IEEE-like).
192    E5M2,
193}
194
195impl Fp8Format {
196    /// Number of explicit mantissa bits.
197    pub fn mantissa_bits(self) -> i32 {
198        match self {
199            Fp8Format::E4M3 => 3,
200            Fp8Format::E5M2 => 2,
201        }
202    }
203
204    /// Exponent bias.
205    pub fn exponent_bias(self) -> i32 {
206        match self {
207            Fp8Format::E4M3 => 7,
208            Fp8Format::E5M2 => 15,
209        }
210    }
211
212    /// Largest finite representable magnitude (the documented max-normal).
213    pub fn max_normal(self) -> f64 {
214        match self {
215            Fp8Format::E4M3 => E4M3_MAX_NORMAL,
216            Fp8Format::E5M2 => E5M2_MAX_NORMAL,
217        }
218    }
219
220    /// Smallest normal magnitude, `2^(1 - bias)`.
221    pub fn min_normal(self) -> f64 {
222        pow2(1 - self.exponent_bias())
223    }
224
225    /// Smallest (subnormal) positive magnitude, `2^(1 - bias - mantissa_bits)`.
226    pub fn min_subnormal(self) -> f64 {
227        pow2(1 - self.exponent_bias() - self.mantissa_bits())
228    }
229}
230
231/// Rounding rule applied when collapsing a real value onto the quantization grid.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum RoundingMode {
234    /// Round to nearest, ties to even (deterministic).
235    Nearest,
236    /// Stochastic rounding: round up with probability equal to the fractional
237    /// part. Unbiased in expectation.
238    Stochastic,
239}
240
241impl RoundingMode {
242    /// Round a real value `x` to an integer according to this mode.
243    ///
244    /// For [`RoundingMode::Stochastic`] a uniform `[0, 1)` variate is drawn from
245    /// `rng`; the result is `floor(x) + 1` with probability `x - floor(x)` and
246    /// `floor(x)` otherwise, which makes the expectation exactly `x`.
247    fn round_to_int(self, x: f64, rng: &mut impl Rng) -> f64 {
248        match self {
249            RoundingMode::Nearest => x.round_ties_even(),
250            RoundingMode::Stochastic => {
251                let lower = x.floor();
252                let frac = x - lower;
253                let draw: f64 = rng.random();
254                if draw < frac {
255                    lower + 1.0
256                } else {
257                    lower
258                }
259            }
260        }
261    }
262}
263
264/// Exact power of two `2^exp` for the small exponent range used by the fp8 grid.
265///
266/// `powi` multiplies/divides by two, each step exact in IEEE-754, so the result
267/// is the exact power of two.
268fn pow2(exp: i32) -> f64 {
269    2.0_f64.powi(exp)
270}
271
272/// Affine/symmetric integer quantization parameters.
273///
274/// Holds the grid step (`scale`), the integer `zero_point` (always `0` for the
275/// symmetric scheme) and the integer clamp range `[qmin, qmax]`.
276#[derive(Debug, Clone, Copy, PartialEq)]
277pub struct QuantParams {
278    /// Grid step: the real spacing between adjacent integer codes.
279    pub scale: f64,
280    /// Integer code onto which the real value `0` maps.
281    pub zero_point: i32,
282    /// Lowest representable integer code.
283    pub qmin: i32,
284    /// Highest representable integer code.
285    pub qmax: i32,
286}
287
288impl QuantParams {
289    /// Build parameters directly from a real `[min, max]` interval.
290    ///
291    /// For [`QuantScheme::Symmetric`] this reduces to [`Self::from_absmax`] over
292    /// `max(|min|, |max|)`. For [`QuantScheme::Affine`] the interval is nudged to
293    /// include the real `0`, `scale = (max - min) / (qmax - qmin)` and the
294    /// `zero_point` is chosen so the real `0` maps onto an exact integer code.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`GpuOptimError::InvalidState`] if the inputs are non-finite,
299    /// inverted (`max < min`), or describe a degenerate zero-width interval.
300    pub fn from_minmax(
301        min_v: f64,
302        max_v: f64,
303        dtype: IntDtype,
304        scheme: QuantScheme,
305    ) -> Result<Self, GpuOptimError> {
306        if !min_v.is_finite() || !max_v.is_finite() || max_v < min_v {
307            return Err(GpuOptimError::InvalidState(format!(
308                "invalid calibration interval: [{min_v}, {max_v}]"
309            )));
310        }
311        match scheme {
312            QuantScheme::Symmetric => Self::from_absmax(min_v.abs().max(max_v.abs()), dtype),
313            QuantScheme::Affine => {
314                let (qmin, qmax) = dtype.q_range(QuantScheme::Affine);
315                // Nudge the interval so the real value 0 is representable.
316                let rmin = min_v.min(0.0);
317                let rmax = max_v.max(0.0);
318                let range = rmax - rmin;
319                if range <= 0.0 {
320                    return Err(GpuOptimError::InvalidState(
321                        "degenerate (zero-range) calibration: real min == max == 0".to_string(),
322                    ));
323                }
324                let scale = range / f64::from(qmax - qmin);
325                if !scale.is_finite() || scale <= 0.0 {
326                    return Err(GpuOptimError::InvalidState(format!(
327                        "degenerate affine scale derived from interval [{min_v}, {max_v}]"
328                    )));
329                }
330                let zp = (f64::from(qmin) - rmin / scale)
331                    .round_ties_even()
332                    .clamp(f64::from(qmin), f64::from(qmax));
333                Ok(Self {
334                    scale,
335                    zero_point: zp as i32,
336                    qmin,
337                    qmax,
338                })
339            }
340        }
341    }
342
343    /// Build **symmetric** parameters from an absolute maximum.
344    ///
345    /// `scale = absmax / qmax`, `zero_point = 0`.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`GpuOptimError::InvalidState`] when `absmax` is non-finite or not
350    /// strictly positive (a degenerate, all-zero tensor cannot be calibrated).
351    pub fn from_absmax(absmax: f64, dtype: IntDtype) -> Result<Self, GpuOptimError> {
352        let (qmin, qmax) = dtype.q_range(QuantScheme::Symmetric);
353        if !absmax.is_finite() || absmax <= 0.0 {
354            return Err(GpuOptimError::InvalidState(
355                "degenerate (zero-range) calibration: absmax must be finite and > 0".to_string(),
356            ));
357        }
358        let scale = absmax / f64::from(qmax);
359        if !scale.is_finite() || scale <= 0.0 {
360            return Err(GpuOptimError::InvalidState(
361                "degenerate symmetric scale".to_string(),
362            ));
363        }
364        Ok(Self {
365            scale,
366            zero_point: 0,
367            qmin,
368            qmax,
369        })
370    }
371
372    /// Calibrate per-tensor parameters from the exact `[min, max]` of `x`.
373    ///
374    /// # Errors
375    ///
376    /// Returns [`GpuOptimError::InvalidState`] for an empty tensor or a
377    /// degenerate (zero-range) calibration.
378    pub fn per_tensor_minmax(
379        x: &Array1<f64>,
380        dtype: IntDtype,
381        scheme: QuantScheme,
382    ) -> Result<Self, GpuOptimError> {
383        let (min_v, max_v) = finite_min_max(x.iter().copied())?;
384        Self::from_minmax(min_v, max_v, dtype, scheme)
385    }
386
387    /// Calibrate symmetric parameters from `max(|x|)`.
388    ///
389    /// # Errors
390    ///
391    /// Returns [`GpuOptimError::InvalidState`] for an empty or all-zero tensor.
392    pub fn symmetric_from_absmax(x: &Array1<f64>, dtype: IntDtype) -> Result<Self, GpuOptimError> {
393        if x.is_empty() {
394            return Err(GpuOptimError::InvalidState(
395                "cannot calibrate an empty tensor".to_string(),
396            ));
397        }
398        let mut absmax = 0.0_f64;
399        for &v in x.iter() {
400            if !v.is_finite() {
401                return Err(GpuOptimError::InvalidState(
402                    "non-finite value in calibration tensor".to_string(),
403                ));
404            }
405            absmax = absmax.max(v.abs());
406        }
407        Self::from_absmax(absmax, dtype)
408    }
409
410    /// Calibrate per-tensor parameters after clipping the tails.
411    ///
412    /// The interval is taken between the `clip_fraction` and `1 - clip_fraction`
413    /// quantiles (nearest-rank) of `x`, which suppresses outliers before the
414    /// scale is derived.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`GpuOptimError::InvalidState`] for an empty tensor, a
419    /// `clip_fraction` outside `[0, 0.5)`, or a degenerate calibration.
420    pub fn per_tensor_percentile(
421        x: &Array1<f64>,
422        dtype: IntDtype,
423        scheme: QuantScheme,
424        clip_fraction: f64,
425    ) -> Result<Self, GpuOptimError> {
426        if !(0.0..0.5).contains(&clip_fraction) {
427            return Err(GpuOptimError::InvalidState(format!(
428                "clip_fraction must lie in [0, 0.5), got {clip_fraction}"
429            )));
430        }
431        if x.is_empty() {
432            return Err(GpuOptimError::InvalidState(
433                "cannot calibrate an empty tensor".to_string(),
434            ));
435        }
436        let mut sorted: Vec<f64> = Vec::with_capacity(x.len());
437        for &v in x.iter() {
438            if !v.is_finite() {
439                return Err(GpuOptimError::InvalidState(
440                    "non-finite value in calibration tensor".to_string(),
441                ));
442            }
443            sorted.push(v);
444        }
445        sorted.sort_by(|a, b| a.total_cmp(b));
446        let last = sorted.len() - 1;
447        let lo_idx = (clip_fraction * last as f64).floor() as usize;
448        let hi_idx = ((1.0 - clip_fraction) * last as f64).ceil() as usize;
449        let min_v = sorted[lo_idx.min(last)];
450        let max_v = sorted[hi_idx.min(last)];
451        Self::from_minmax(min_v, max_v, dtype, scheme)
452    }
453
454    /// Quantize a single real value to an integer code (with clamping).
455    pub fn quantize(&self, x: f64, mode: RoundingMode, rng: &mut impl Rng) -> i32 {
456        let scaled = x / self.scale + f64::from(self.zero_point);
457        let rounded = mode
458            .round_to_int(scaled, rng)
459            .clamp(f64::from(self.qmin), f64::from(self.qmax));
460        rounded as i32
461    }
462
463    /// Dequantize an integer code back to a real value.
464    pub fn dequantize(&self, q: i32) -> f64 {
465        f64::from(q - self.zero_point) * self.scale
466    }
467
468    /// Full fake-quant round trip for a single value, `dequant(quant(x))`.
469    pub fn fake_quant_scalar(&self, x: f64, mode: RoundingMode, rng: &mut impl Rng) -> f64 {
470        self.dequantize(self.quantize(x, mode, rng))
471    }
472
473    /// Lowest real value representable before the clamp saturates, `qmin*scale`
474    /// after removing the zero-point.
475    pub fn real_min(&self) -> f64 {
476        self.dequantize(self.qmin)
477    }
478
479    /// Highest real value representable before the clamp saturates.
480    pub fn real_max(&self) -> f64 {
481        self.dequantize(self.qmax)
482    }
483}
484
485/// Compute the finite `(min, max)` of an iterator of values.
486fn finite_min_max(values: impl Iterator<Item = f64>) -> Result<(f64, f64), GpuOptimError> {
487    let mut min_v = f64::INFINITY;
488    let mut max_v = f64::NEG_INFINITY;
489    let mut count = 0_usize;
490    for v in values {
491        if !v.is_finite() {
492            return Err(GpuOptimError::InvalidState(
493                "non-finite value in calibration tensor".to_string(),
494            ));
495        }
496        min_v = min_v.min(v);
497        max_v = max_v.max(v);
498        count += 1;
499    }
500    if count == 0 {
501        return Err(GpuOptimError::InvalidState(
502            "cannot calibrate an empty tensor".to_string(),
503        ));
504    }
505    Ok((min_v, max_v))
506}
507
508/// Fake-quantize a 1-D tensor through the integer grid (per-tensor).
509///
510/// Returns `dequant(quant(x))` element-wise; the output stays in `f64` but only
511/// takes values on the reconstructed grid.
512pub fn fake_quant_int(
513    x: &Array1<f64>,
514    params: &QuantParams,
515    mode: RoundingMode,
516    rng: &mut impl Rng,
517) -> Array1<f64> {
518    let mut out = Vec::with_capacity(x.len());
519    for &v in x.iter() {
520        out.push(params.fake_quant_scalar(v, mode, rng));
521    }
522    Array1::from_vec(out)
523}
524
525/// Calibrate one [`QuantParams`] per channel along `axis` of a 2-D tensor.
526///
527/// `axis == 0` treats each **row** as a channel, `axis == 1` each **column**.
528/// Each channel receives its own scale (and, for affine, its own zero-point)
529/// computed from that channel's slice only.
530///
531/// # Errors
532///
533/// Returns [`GpuOptimError::InvalidState`] for an invalid axis and propagates any
534/// per-channel calibration error (e.g. a degenerate all-zero channel).
535pub fn per_channel_params(
536    x: &Array2<f64>,
537    axis: usize,
538    dtype: IntDtype,
539    scheme: QuantScheme,
540) -> Result<Vec<QuantParams>, GpuOptimError> {
541    if axis > 1 {
542        return Err(GpuOptimError::InvalidState(format!(
543            "per-channel axis must be 0 or 1, got {axis}"
544        )));
545    }
546    let n_channels = x.shape()[axis];
547    let mut params = Vec::with_capacity(n_channels);
548    for channel in 0..n_channels {
549        let lane = x.index_axis(Axis(axis), channel);
550        let (min_v, max_v) = finite_min_max(lane.iter().copied())?;
551        params.push(QuantParams::from_minmax(min_v, max_v, dtype, scheme)?);
552    }
553    Ok(params)
554}
555
556/// Fake-quantize a 2-D tensor with one [`QuantParams`] per channel.
557///
558/// `params` must contain exactly one entry per channel along `axis` (as produced
559/// by [`per_channel_params`]).
560///
561/// # Errors
562///
563/// Returns [`GpuOptimError::InvalidState`] for an invalid axis and
564/// [`GpuOptimError::DimensionMismatch`] if `params.len()` does not match the
565/// number of channels.
566pub fn fake_quant_int_per_channel(
567    x: &Array2<f64>,
568    params: &[QuantParams],
569    axis: usize,
570    mode: RoundingMode,
571    rng: &mut impl Rng,
572) -> Result<Array2<f64>, GpuOptimError> {
573    if axis > 1 {
574        return Err(GpuOptimError::InvalidState(format!(
575            "per-channel axis must be 0 or 1, got {axis}"
576        )));
577    }
578    let n_channels = x.shape()[axis];
579    if params.len() != n_channels {
580        return Err(GpuOptimError::DimensionMismatch {
581            expected: vec![n_channels],
582            actual: vec![params.len()],
583        });
584    }
585    let (n_rows, n_cols) = (x.shape()[0], x.shape()[1]);
586    let mut out = Array2::<f64>::zeros((n_rows, n_cols));
587    for r in 0..n_rows {
588        for c in 0..n_cols {
589            let channel = if axis == 0 { r } else { c };
590            let p = &params[channel];
591            out[[r, c]] = p.fake_quant_scalar(x[[r, c]], mode, rng);
592        }
593    }
594    Ok(out)
595}
596
597/// Quantize a single magnitude onto an fp8 grid (sign handled by the caller).
598///
599/// Implements the unified normal/subnormal rounding described in the module
600/// documentation and saturates to `max_normal`.
601fn fp8_quantize_magnitude(
602    a: f64,
603    mantissa_bits: i32,
604    exponent_bias: i32,
605    max_normal: f64,
606    mode: RoundingMode,
607    rng: &mut impl Rng,
608) -> f64 {
609    if a == 0.0 {
610        return 0.0;
611    }
612    // Smallest normal exponent for the format.
613    let emin = 1 - exponent_bias;
614    // Binade exponent of `a` read directly from the IEEE-754 f64 bit pattern:
615    // for a normal f64 in [2^e, 2^(e+1)) the biased exponent field equals
616    // e + 1023, so this is an exact floor(log2(a)). Subnormal f64 inputs (field
617    // 0) are far below the fp8 range and collapse to 0 below.
618    let e = ((a.to_bits() >> 52) & 0x7ff) as i32 - 1023;
619    let step_exp = e.max(emin) - mantissa_bits;
620    let step = pow2(step_exp);
621    let ratio = a / step;
622    let rounded = mode.round_to_int(ratio, rng);
623    let magnitude = rounded * step;
624    if magnitude > max_normal {
625        max_normal
626    } else {
627        magnitude
628    }
629}
630
631/// Fake-quantize a 1-D tensor onto an fp8 (`E4M3` / `E5M2`) grid.
632///
633/// The cast is saturating: magnitudes above the format max (and infinities)
634/// clamp to `±max_normal`; `NaN` inputs propagate to `NaN`. The sign of the input
635/// (including signed zero) is preserved.
636pub fn fake_quant_fp8(
637    x: &Array1<f64>,
638    format: Fp8Format,
639    mode: RoundingMode,
640    rng: &mut impl Rng,
641) -> Array1<f64> {
642    let mantissa_bits = format.mantissa_bits();
643    let exponent_bias = format.exponent_bias();
644    let max_normal = format.max_normal();
645    let mut out = Vec::with_capacity(x.len());
646    for &v in x.iter() {
647        let q = if v.is_nan() {
648            f64::NAN
649        } else if v.is_infinite() {
650            max_normal.copysign(v)
651        } else {
652            let magnitude = fp8_quantize_magnitude(
653                v.abs(),
654                mantissa_bits,
655                exponent_bias,
656                max_normal,
657                mode,
658                rng,
659            );
660            magnitude.copysign(v)
661        };
662        out.push(q);
663    }
664    Array1::from_vec(out)
665}
666
667/// Straight-through estimator backward for a fake-quant op.
668///
669/// The gradient passes through unchanged where the (pre-quant) value `x` lies in
670/// the representable interval `[qmin_real, qmax_real]` and is zeroed where the
671/// clamp saturates (no gradient flows through a saturated value). The bounds may
672/// be supplied in either order.
673///
674/// # Errors
675///
676/// Returns [`GpuOptimError::DimensionMismatch`] if `grad` and `x` differ in length.
677pub fn fake_quant_backward(
678    grad: &Array1<f64>,
679    x: &Array1<f64>,
680    qmin_real: f64,
681    qmax_real: f64,
682) -> Result<Array1<f64>, GpuOptimError> {
683    if grad.len() != x.len() {
684        return Err(GpuOptimError::DimensionMismatch {
685            expected: vec![x.len()],
686            actual: vec![grad.len()],
687        });
688    }
689    let (lo, hi) = if qmin_real <= qmax_real {
690        (qmin_real, qmax_real)
691    } else {
692        (qmax_real, qmin_real)
693    };
694    let mut out = Vec::with_capacity(grad.len());
695    for (&g, &v) in grad.iter().zip(x.iter()) {
696        if v >= lo && v <= hi {
697            out.push(g);
698        } else {
699            out.push(0.0);
700        }
701    }
702    Ok(Array1::from_vec(out))
703}
704
705/// What a [`QatOptimizer`] quantizes its weights to.
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub enum QuantTarget {
708    /// Integer quantization with the given width.
709    Int(IntDtype),
710    /// fp8 quantization with the given format.
711    Fp8(Fp8Format),
712}
713
714/// Configuration for a [`QatOptimizer`]: the quantization target plus the
715/// (Adam/AdamW) master-update hyper-parameters.
716#[derive(Debug, Clone, Copy, PartialEq)]
717pub struct QatConfig {
718    /// Quantization target for the fake-quant view.
719    pub target: QuantTarget,
720    /// Integer scheme (ignored for fp8 targets).
721    pub scheme: QuantScheme,
722    /// Rounding mode applied when deriving the quantized view.
723    pub rounding: RoundingMode,
724    /// Learning rate.
725    pub lr: f64,
726    /// First-moment decay (`beta1`).
727    pub beta1: f64,
728    /// Second-moment decay (`beta2`).
729    pub beta2: f64,
730    /// Numerical-stability epsilon.
731    pub eps: f64,
732    /// Decoupled (AdamW) weight decay.
733    pub weight_decay: f64,
734}
735
736impl QatConfig {
737    /// Construct a config with standard Adam defaults
738    /// (`beta1 = 0.9`, `beta2 = 0.999`, `eps = 1e-8`, `weight_decay = 0`).
739    pub fn new(target: QuantTarget, scheme: QuantScheme, rounding: RoundingMode, lr: f64) -> Self {
740        Self {
741            target,
742            scheme,
743            rounding,
744            lr,
745            beta1: 0.9,
746            beta2: 0.999,
747            eps: 1e-8,
748            weight_decay: 0.0,
749        }
750    }
751}
752
753/// QAT optimizer wrapper maintaining full-precision master weights.
754///
755/// The optimizer owns the Adam moment state and a cached **fake-quantized view**
756/// of the master weights, but the FP32 master itself is owned by the caller and
757/// passed into [`Self::step`]. Each step updates the master in place and
758/// re-derives the quantized view from it.
759#[derive(Debug, Clone)]
760pub struct QatOptimizer {
761    config: QatConfig,
762    first_moment: Array1<f64>,
763    second_moment: Array1<f64>,
764    step_count: u64,
765    quantized: Array1<f64>,
766    int_params: Option<QuantParams>,
767}
768
769impl QatOptimizer {
770    /// Create an optimizer for the given FP32 `master` weights.
771    ///
772    /// The initial quantized view is derived immediately from `master`.
773    ///
774    /// # Errors
775    ///
776    /// Returns [`GpuOptimError::InvalidState`] for empty master weights and
777    /// propagates any calibration error from the initial quantization.
778    pub fn new(
779        master: &Array1<f64>,
780        config: QatConfig,
781        rng: &mut impl Rng,
782    ) -> Result<Self, GpuOptimError> {
783        if master.is_empty() {
784            return Err(GpuOptimError::InvalidState(
785                "cannot construct a QatOptimizer over empty master weights".to_string(),
786            ));
787        }
788        let n = master.len();
789        let mut optimizer = Self {
790            config,
791            first_moment: Array1::zeros(n),
792            second_moment: Array1::zeros(n),
793            step_count: 0,
794            quantized: Array1::zeros(n),
795            int_params: None,
796        };
797        optimizer.requantize(master, rng)?;
798        Ok(optimizer)
799    }
800
801    /// The current fake-quantized view of the master weights (what the forward
802    /// pass uses).
803    pub fn quantized_weights(&self) -> &Array1<f64> {
804        &self.quantized
805    }
806
807    /// The integer quantization parameters currently in effect, if the target is
808    /// integer (always `None` for fp8 targets).
809    pub fn quant_params(&self) -> Option<&QuantParams> {
810        self.int_params.as_ref()
811    }
812
813    /// Number of [`Self::step`] calls performed so far.
814    pub fn step_count(&self) -> u64 {
815        self.step_count
816    }
817
818    /// Perform one optimizer step: an Adam/AdamW update of the FP32 `master`
819    /// followed by re-derivation of the quantized view.
820    ///
821    /// The `master` weights stay in full precision; only the cached quantized
822    /// view (see [`Self::quantized_weights`]) is rounded onto the grid.
823    ///
824    /// # Errors
825    ///
826    /// Returns [`GpuOptimError::DimensionMismatch`] if `master` and `grad` (or the
827    /// optimizer's internal state) disagree on length, and propagates any
828    /// re-calibration error.
829    pub fn step(
830        &mut self,
831        master: &mut Array1<f64>,
832        grad: &Array1<f64>,
833        rng: &mut impl Rng,
834    ) -> Result<(), GpuOptimError> {
835        if master.len() != grad.len() {
836            return Err(GpuOptimError::DimensionMismatch {
837                expected: vec![master.len()],
838                actual: vec![grad.len()],
839            });
840        }
841        if master.len() != self.first_moment.len() {
842            return Err(GpuOptimError::DimensionMismatch {
843                expected: vec![self.first_moment.len()],
844                actual: vec![master.len()],
845            });
846        }
847
848        self.step_count += 1;
849        let t = self.step_count as i32;
850        let beta1 = self.config.beta1;
851        let beta2 = self.config.beta2;
852        let lr = self.config.lr;
853        let eps = self.config.eps;
854        let weight_decay = self.config.weight_decay;
855        let bias_correction1 = 1.0 - beta1.powi(t);
856        let bias_correction2 = 1.0 - beta2.powi(t);
857
858        Zip::from(&mut *master)
859            .and(grad)
860            .and(&mut self.first_moment)
861            .and(&mut self.second_moment)
862            .for_each(|weight, &g, m, v| {
863                *m = beta1 * *m + (1.0 - beta1) * g;
864                *v = beta2 * *v + (1.0 - beta2) * g * g;
865                let m_hat = *m / bias_correction1;
866                let v_hat = *v / bias_correction2;
867                // Decoupled (AdamW) weight decay applied to the FP32 master.
868                if weight_decay != 0.0 {
869                    *weight -= lr * weight_decay * *weight;
870                }
871                *weight -= lr * m_hat / (v_hat.sqrt() + eps);
872            });
873
874        self.requantize(master, rng)?;
875        Ok(())
876    }
877
878    /// Re-derive the cached quantized view from the current master weights.
879    fn requantize(
880        &mut self,
881        master: &Array1<f64>,
882        rng: &mut impl Rng,
883    ) -> Result<(), GpuOptimError> {
884        match self.config.target {
885            QuantTarget::Int(dtype) => {
886                let params = QuantParams::per_tensor_minmax(master, dtype, self.config.scheme)?;
887                self.quantized = fake_quant_int(master, &params, self.config.rounding, rng);
888                self.int_params = Some(params);
889            }
890            QuantTarget::Fp8(format) => {
891                self.quantized = fake_quant_fp8(master, format, self.config.rounding, rng);
892                self.int_params = None;
893            }
894        }
895        Ok(())
896    }
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use scirs2_core::random::Random;
903
904    const EPS: f64 = 1e-9;
905
906    fn seeded(seed: u64) -> Random<scirs2_core::random::rngs::StdRng> {
907        Random::seed(seed)
908    }
909
910    #[test]
911    fn int8_grid_values_quantize_to_themselves() {
912        // scale = 12.7 / 127 = 0.1, zero_point = 0.
913        let params = QuantParams::from_absmax(12.7, IntDtype::Int8).expect("calibrate");
914        assert!((params.scale - 0.1).abs() < EPS);
915        assert_eq!(params.zero_point, 0);
916        let mut rng = seeded(1);
917        // Values exactly on the grid (multiples of scale, inside the range).
918        for k in -120..=120 {
919            let on_grid = k as f64 * params.scale;
920            let round_trip = params.fake_quant_scalar(on_grid, RoundingMode::Nearest, &mut rng);
921            assert!(
922                (round_trip - on_grid).abs() < EPS,
923                "grid value {on_grid} did not map to itself (got {round_trip})"
924            );
925        }
926    }
927
928    #[test]
929    fn int8_affine_grid_values_quantize_to_themselves() {
930        let data = Array1::from_vec(vec![-0.3, 1.7, 0.0, 0.9, -0.1]);
931        let params = QuantParams::per_tensor_minmax(&data, IntDtype::Int8, QuantScheme::Affine)
932            .expect("cal");
933        let mut rng = seeded(7);
934        // (q - zero_point) * scale is on the reconstructed grid for any code q.
935        for q in params.qmin..=params.qmax {
936            let on_grid = params.dequantize(q);
937            let round_trip = params.fake_quant_scalar(on_grid, RoundingMode::Nearest, &mut rng);
938            assert!(
939                (round_trip - on_grid).abs() < 1e-9,
940                "affine grid value {on_grid} (q={q}) -> {round_trip}"
941            );
942        }
943    }
944
945    #[test]
946    fn int8_nearest_error_bounded_by_half_scale() {
947        let params = QuantParams::from_absmax(2.0, IntDtype::Int8).expect("calibrate");
948        let half = params.scale / 2.0;
949        let mut rng = seeded(2);
950        // Sweep values strictly inside the representable range.
951        let mut x = -1.9;
952        while x <= 1.9 {
953            let fq = params.fake_quant_scalar(x, RoundingMode::Nearest, &mut rng);
954            assert!(
955                (fq - x).abs() <= half + EPS,
956                "nearest error {} exceeded scale/2 = {half} at x = {x}",
957                (fq - x).abs()
958            );
959            x += 0.013;
960        }
961    }
962
963    #[test]
964    fn int4_round_trip_on_grid() {
965        let params = QuantParams::from_absmax(7.0, IntDtype::Int4).expect("calibrate");
966        // scale = 7 / 7 = 1.0.
967        assert!((params.scale - 1.0).abs() < EPS);
968        let mut rng = seeded(3);
969        for k in -7..=7 {
970            let on_grid = k as f64;
971            let fq = params.fake_quant_scalar(on_grid, RoundingMode::Nearest, &mut rng);
972            assert!((fq - on_grid).abs() < EPS, "int4 grid {on_grid} -> {fq}");
973        }
974    }
975
976    #[test]
977    fn stochastic_rounding_is_unbiased() {
978        let params = QuantParams::from_absmax(12.7, IntDtype::Int8).expect("calibrate");
979        let scale = params.scale; // 0.1
980                                  // A value sitting 70% of the way between two grid points.
981        let value = 3.0 + 0.7 * scale;
982        let n: usize = 400_000;
983        let mut rng = seeded(12345);
984        let mut sum = 0.0_f64;
985        let mut saw_lower = false;
986        let mut saw_upper = false;
987        let lower = 3.0;
988        let upper = 3.0 + scale;
989        for _ in 0..n {
990            let q = params.fake_quant_scalar(value, RoundingMode::Stochastic, &mut rng);
991            // Stochastic rounding only ever produces the two bracketing levels.
992            assert!(
993                (q - lower).abs() < 1e-9 || (q - upper).abs() < 1e-9,
994                "stochastic output {q} was not a bracketing grid level"
995            );
996            if (q - lower).abs() < 1e-9 {
997                saw_lower = true;
998            }
999            if (q - upper).abs() < 1e-9 {
1000                saw_upper = true;
1001            }
1002            sum += q;
1003        }
1004        let mean = sum / n as f64;
1005        // Standard error of the mean is <= scale / (2 sqrt(n)); allow ~8 sigma.
1006        let tolerance = 8.0 * scale / (n as f64).sqrt();
1007        assert!(
1008            (mean - value).abs() < tolerance,
1009            "stochastic mean {mean} deviated from {value} by more than {tolerance}"
1010        );
1011        assert!(saw_lower && saw_upper, "expected both rounding directions");
1012    }
1013
1014    #[test]
1015    fn stochastic_rounding_exact_grid_value_is_stable() {
1016        let params = QuantParams::from_absmax(12.7, IntDtype::Int8).expect("calibrate");
1017        let mut rng = seeded(99);
1018        let exact = 5.0 * params.scale; // exactly on the grid -> fractional part 0
1019        for _ in 0..1000 {
1020            let q = params.fake_quant_scalar(exact, RoundingMode::Stochastic, &mut rng);
1021            assert!((q - exact).abs() < EPS, "exact grid value drifted: {q}");
1022        }
1023    }
1024
1025    #[test]
1026    fn fp8_constants_match_documentation() {
1027        assert_eq!(E4M3_MAX_NORMAL, 448.0);
1028        assert_eq!(E5M2_MAX_NORMAL, 57344.0);
1029        assert_eq!(Fp8Format::E4M3.max_normal(), 448.0);
1030        assert_eq!(Fp8Format::E5M2.max_normal(), 57344.0);
1031        assert!((Fp8Format::E4M3.min_normal() - 2.0_f64.powi(-6)).abs() < EPS);
1032        assert!((Fp8Format::E4M3.min_subnormal() - 2.0_f64.powi(-9)).abs() < EPS);
1033        assert!((Fp8Format::E5M2.min_normal() - 2.0_f64.powi(-14)).abs() < EPS);
1034        assert!((Fp8Format::E5M2.min_subnormal() - 2.0_f64.powi(-16)).abs() < EPS);
1035    }
1036
1037    #[test]
1038    fn fp8_e4m3_representable_values_map_to_themselves() {
1039        let mut rng = seeded(4);
1040        let representable = [
1041            0.0,
1042            1.0,
1043            1.5,  // 1 + 4/8
1044            1.75, // 1 + 6/8
1045            2.0,
1046            -2.0,
1047            0.5,
1048            256.0,
1049            448.0, // max-normal
1050            -448.0,
1051            2.0_f64.powi(-6), // min-normal
1052            2.0_f64.powi(-9), // min-subnormal
1053        ];
1054        let input = Array1::from_vec(representable.to_vec());
1055        let out = fake_quant_fp8(&input, Fp8Format::E4M3, RoundingMode::Nearest, &mut rng);
1056        for (i, (&want, &got)) in representable.iter().zip(out.iter()).enumerate() {
1057            assert!(
1058                (want - got).abs() < EPS,
1059                "E4M3 representable[{i}] = {want} mapped to {got}"
1060            );
1061        }
1062    }
1063
1064    #[test]
1065    fn fp8_e4m3_saturates_to_max_normal() {
1066        let mut rng = seeded(5);
1067        let input = Array1::from_vec(vec![449.0, 1000.0, 1.0e6, -1000.0, f64::INFINITY]);
1068        let out = fake_quant_fp8(&input, Fp8Format::E4M3, RoundingMode::Nearest, &mut rng);
1069        assert_eq!(out[0], 448.0);
1070        assert_eq!(out[1], 448.0);
1071        assert_eq!(out[2], 448.0);
1072        assert_eq!(out[3], -448.0);
1073        assert_eq!(out[4], 448.0);
1074    }
1075
1076    #[test]
1077    fn fp8_e4m3_nan_propagates() {
1078        let mut rng = seeded(6);
1079        let input = Array1::from_vec(vec![f64::NAN]);
1080        let out = fake_quant_fp8(&input, Fp8Format::E4M3, RoundingMode::Nearest, &mut rng);
1081        assert!(out[0].is_nan());
1082    }
1083
1084    #[test]
1085    fn fp8_e4m3_rounds_to_nearest_grid_within_half_ulp() {
1086        let mut rng = seeded(8);
1087        // Around 1.0 the E4M3 step is 2^(0-3) = 0.125, grid: 1.0, 1.125, 1.25, ...
1088        let input = Array1::from_vec(vec![1.1]);
1089        let out = fake_quant_fp8(&input, Fp8Format::E4M3, RoundingMode::Nearest, &mut rng);
1090        assert!((out[0] - 1.125).abs() < EPS, "1.1 -> {}", out[0]);
1091        assert!((out[0] - 1.1).abs() <= 0.125 / 2.0 + EPS);
1092    }
1093
1094    #[test]
1095    fn fp8_e5m2_representable_values_map_to_themselves() {
1096        let mut rng = seeded(9);
1097        let representable = [
1098            0.0,
1099            1.0,
1100            1.5, // 1 + 2/4
1101            2.0,
1102            -4.0,
1103            57344.0, // max-normal
1104            -57344.0,
1105            2.0_f64.powi(-14), // min-normal
1106            2.0_f64.powi(-16), // min-subnormal
1107        ];
1108        let input = Array1::from_vec(representable.to_vec());
1109        let out = fake_quant_fp8(&input, Fp8Format::E5M2, RoundingMode::Nearest, &mut rng);
1110        for (i, (&want, &got)) in representable.iter().zip(out.iter()).enumerate() {
1111            assert!(
1112                (want - got).abs() < EPS,
1113                "E5M2 representable[{i}] = {want} mapped to {got}"
1114            );
1115        }
1116    }
1117
1118    #[test]
1119    fn fp8_e5m2_saturates_to_max_normal() {
1120        let mut rng = seeded(10);
1121        let input = Array1::from_vec(vec![60000.0, 1.0e8, -70000.0, f64::INFINITY]);
1122        let out = fake_quant_fp8(&input, Fp8Format::E5M2, RoundingMode::Nearest, &mut rng);
1123        assert_eq!(out[0], 57344.0);
1124        assert_eq!(out[1], 57344.0);
1125        assert_eq!(out[2], -57344.0);
1126        assert_eq!(out[3], 57344.0);
1127    }
1128
1129    #[test]
1130    fn per_channel_scales_differ_and_error_respects_each_scale() {
1131        // Row 0 is a small-magnitude channel, row 1 a large-magnitude channel.
1132        let x =
1133            Array2::from_shape_vec((2, 3), vec![0.0, 0.5, 1.0, 0.0, 50.0, 100.0]).expect("shape");
1134        let params =
1135            per_channel_params(&x, 0, IntDtype::Int8, QuantScheme::Symmetric).expect("cal");
1136        assert_eq!(params.len(), 2);
1137        // Channel scales must differ and track each channel's magnitude.
1138        assert!(params[1].scale > params[0].scale * 10.0);
1139        assert!((params[0].scale - 1.0 / 127.0).abs() < 1e-6);
1140        assert!((params[1].scale - 100.0 / 127.0).abs() < 1e-6);
1141
1142        let mut rng = seeded(11);
1143        let out = fake_quant_int_per_channel(&x, &params, 0, RoundingMode::Nearest, &mut rng)
1144            .expect("quantize");
1145        for r in 0..2 {
1146            let half = params[r].scale / 2.0;
1147            for c in 0..3 {
1148                let err = (out[[r, c]] - x[[r, c]]).abs();
1149                assert!(
1150                    err <= half + EPS,
1151                    "per-channel error {err} exceeded scale/2={half} at ({r},{c})"
1152                );
1153            }
1154        }
1155    }
1156
1157    #[test]
1158    fn per_channel_length_mismatch_errors() {
1159        let x = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).expect("shape");
1160        let params = per_channel_params(&x, 0, IntDtype::Int8, QuantScheme::Symmetric).expect("c");
1161        let mut rng = seeded(13);
1162        // Only one param for a two-channel tensor -> dimension mismatch.
1163        let result =
1164            fake_quant_int_per_channel(&x, &params[..1], 0, RoundingMode::Nearest, &mut rng);
1165        assert!(matches!(
1166            result,
1167            Err(GpuOptimError::DimensionMismatch { .. })
1168        ));
1169    }
1170
1171    #[test]
1172    fn ste_passes_gradient_in_range_and_zeros_out_of_range() {
1173        let x = Array1::from_vec(vec![-2.0, -0.5, 0.0, 0.5, 2.0]);
1174        let grad = Array1::from_vec(vec![1.0, 1.0, 1.0, 1.0, 1.0]);
1175        let out = fake_quant_backward(&grad, &x, -1.0, 1.0).expect("ste");
1176        let expected = [0.0, 1.0, 1.0, 1.0, 0.0];
1177        for (i, (&got, &want)) in out.iter().zip(expected.iter()).enumerate() {
1178            assert!((got - want).abs() < EPS, "STE[{i}] = {got}, want {want}");
1179        }
1180    }
1181
1182    #[test]
1183    fn ste_dimension_mismatch_errors() {
1184        let x = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1185        let grad = Array1::from_vec(vec![1.0, 1.0]);
1186        let result = fake_quant_backward(&grad, &x, -1.0, 1.0);
1187        assert!(matches!(
1188            result,
1189            Err(GpuOptimError::DimensionMismatch { .. })
1190        ));
1191    }
1192
1193    #[test]
1194    fn degenerate_calibration_errors() {
1195        let zeros = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1196        assert!(QuantParams::symmetric_from_absmax(&zeros, IntDtype::Int8).is_err());
1197        assert!(
1198            QuantParams::per_tensor_minmax(&zeros, IntDtype::Int8, QuantScheme::Affine).is_err()
1199        );
1200    }
1201
1202    #[test]
1203    fn invalid_bit_width_errors() {
1204        assert!(IntDtype::from_bits(8).is_ok());
1205        assert!(IntDtype::from_bits(4).is_ok());
1206        assert!(matches!(
1207            IntDtype::from_bits(3),
1208            Err(GpuOptimError::UnsupportedOperation(_))
1209        ));
1210        assert!(IntDtype::from_bits(16).is_err());
1211    }
1212
1213    #[test]
1214    fn qat_master_update_keeps_fp32_master_and_quantized_view_tracks_it() {
1215        let mut master = Array1::from_vec(vec![0.12, -0.37, 0.88, -0.05, 0.51]);
1216        let master_before = master.clone();
1217        let config = QatConfig::new(
1218            QuantTarget::Int(IntDtype::Int8),
1219            QuantScheme::Symmetric,
1220            RoundingMode::Nearest,
1221            0.1,
1222        );
1223        let mut rng = seeded(2024);
1224        let mut optimizer = QatOptimizer::new(&master, config, &mut rng).expect("new");
1225
1226        let grad = Array1::from_vec(vec![0.1, -0.2, 0.05, 0.3, -0.15]);
1227        optimizer.step(&mut master, &grad, &mut rng).expect("step");
1228
1229        // (1) The FP32 master actually moved.
1230        let mut moved = false;
1231        for (&a, &b) in master.iter().zip(master_before.iter()) {
1232            if (a - b).abs() > EPS {
1233                moved = true;
1234            }
1235        }
1236        assert!(moved, "Adam update did not change the master weights");
1237
1238        // (2) The quantized view equals an independent fake-quant of the master.
1239        let params = optimizer.quant_params().expect("int params").to_owned();
1240        let mut check_rng = seeded(2024);
1241        let reference = fake_quant_int(&master, &params, RoundingMode::Nearest, &mut check_rng);
1242        for (&q, &r) in optimizer.quantized_weights().iter().zip(reference.iter()) {
1243            assert!((q - r).abs() < EPS, "quantized view does not track master");
1244        }
1245
1246        // (3) The quantized view lies exactly on the grid (integer multiples of
1247        //     scale) while the FP32 master is genuinely full-precision (at least
1248        //     one master weight is off-grid).
1249        let scale = params.scale;
1250        for &q in optimizer.quantized_weights().iter() {
1251            let codes = q / scale;
1252            assert!(
1253                (codes - codes.round()).abs() < 1e-6,
1254                "quantized weight {q} is not on the grid"
1255            );
1256        }
1257        let mut some_off_grid = false;
1258        for &w in master.iter() {
1259            let codes = w / scale;
1260            if (codes - codes.round()).abs() > 1e-6 {
1261                some_off_grid = true;
1262            }
1263        }
1264        assert!(
1265            some_off_grid,
1266            "master weights appear to be quantized (not full precision)"
1267        );
1268
1269        assert_eq!(optimizer.step_count(), 1);
1270    }
1271
1272    #[test]
1273    fn qat_fp8_target_tracks_master() {
1274        let mut master = Array1::from_vec(vec![0.3, -1.2, 4.0, -0.01, 2.5]);
1275        let config = QatConfig::new(
1276            QuantTarget::Fp8(Fp8Format::E4M3),
1277            QuantScheme::Symmetric,
1278            RoundingMode::Nearest,
1279            0.05,
1280        );
1281        let mut rng = seeded(77);
1282        let mut optimizer = QatOptimizer::new(&master, config, &mut rng).expect("new");
1283        assert!(optimizer.quant_params().is_none());
1284
1285        let grad = Array1::from_vec(vec![0.2, 0.1, -0.3, 0.4, -0.05]);
1286        optimizer.step(&mut master, &grad, &mut rng).expect("step");
1287
1288        let mut check_rng = seeded(77);
1289        // Re-derive the fp8 view from the (already updated) master and compare.
1290        let reference = fake_quant_fp8(
1291            &master,
1292            Fp8Format::E4M3,
1293            RoundingMode::Nearest,
1294            &mut check_rng,
1295        );
1296        for (&q, &r) in optimizer.quantized_weights().iter().zip(reference.iter()) {
1297            assert!(
1298                (q - r).abs() < EPS,
1299                "fp8 quantized view does not track master"
1300            );
1301        }
1302    }
1303
1304    #[test]
1305    fn percentile_calibration_clips_outliers() {
1306        // One large outlier should be clipped away, yielding a smaller scale than
1307        // plain min/max calibration.
1308        let mut values = vec![0.0; 100];
1309        for (i, v) in values.iter_mut().enumerate() {
1310            *v = (i as f64) / 100.0; // 0.0 .. 0.99
1311        }
1312        values.push(1000.0); // outlier
1313        let data = Array1::from_vec(values);
1314        let plain = QuantParams::symmetric_from_absmax(&data, IntDtype::Int8).expect("plain");
1315        let clipped =
1316            QuantParams::per_tensor_percentile(&data, IntDtype::Int8, QuantScheme::Symmetric, 0.02)
1317                .expect("clipped");
1318        assert!(
1319            clipped.scale < plain.scale,
1320            "percentile clipping did not reduce the scale ({} vs {})",
1321            clipped.scale,
1322            plain.scale
1323        );
1324    }
1325}