Skip to main content

optirs_gpu/
mixed_precision.rs

1//! Mixed-precision (AMP) building blocks: IEEE-754 binary16 conversion and
2//! dynamic loss scaling.
3//!
4//! This module is pure Rust and has no device dependency, so it is usable from
5//! CPU code paths and from the GPU optimizer path alike. The conversions are
6//! full IEEE-754 `binary16` implementations — subnormals, infinities, NaN
7//! payload preservation and round-half-to-even are all handled — not the
8//! truncating placeholder they replace.
9
10/// Configuration for dynamic loss scaling.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct MixedPrecisionConfig {
13    /// Initial loss scale factor.
14    pub init_scale: f32,
15    /// Multiplier applied when growing the scale.
16    pub growth_factor: f32,
17    /// Multiplier applied when an overflow is observed.
18    pub backoff_factor: f32,
19    /// Number of consecutive overflow-free steps before growing.
20    pub growth_interval: u32,
21    /// Lower clamp for the scale.
22    pub min_scale: f32,
23    /// Upper clamp for the scale.
24    pub max_scale: f32,
25    /// Use `bfloat16` rather than `float16` for the reduced-precision copy.
26    pub use_bfloat16: bool,
27}
28
29impl Default for MixedPrecisionConfig {
30    fn default() -> Self {
31        Self {
32            init_scale: 65536.0,
33            growth_factor: 2.0,
34            backoff_factor: 0.5,
35            growth_interval: 2000,
36            min_scale: 1.0,
37            max_scale: 65536.0 * 128.0,
38            use_bfloat16: false,
39        }
40    }
41}
42
43/// Aggregate overflow statistics over the scaler's rolling window.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct OverflowStats {
46    /// Steps recorded in the rolling window.
47    pub total_steps: usize,
48    /// Overflowing steps in the rolling window.
49    pub overflow_count: usize,
50    /// `overflow_count / total_steps`, or `0.0` for an empty window.
51    pub overflow_rate: f32,
52    /// Scale currently in effect.
53    pub current_scale: f32,
54}
55
56/// Dynamic loss scaler with the standard grow/back-off schedule.
57#[derive(Debug, Clone)]
58pub struct DynamicLossScaler {
59    scale: f32,
60    config: MixedPrecisionConfig,
61    growth_tracker: u32,
62    window: Vec<bool>,
63}
64
65impl DynamicLossScaler {
66    /// Rolling window length used for [`Self::overflow_stats`].
67    pub const WINDOW: usize = 100;
68
69    /// Create a scaler from a configuration.
70    pub fn new(config: MixedPrecisionConfig) -> Self {
71        Self {
72            scale: config.init_scale.clamp(config.min_scale, config.max_scale),
73            config,
74            growth_tracker: 0,
75            window: Vec::with_capacity(Self::WINDOW),
76        }
77    }
78
79    /// Scale currently in effect.
80    pub fn scale(&self) -> f32 {
81        self.scale
82    }
83
84    /// Reciprocal of the current scale, for unscaling gradients.
85    pub fn inv_scale(&self) -> f32 {
86        1.0 / self.scale
87    }
88
89    /// Record the outcome of one step and update the scale.
90    pub fn update(&mut self, has_overflow: bool) {
91        if self.window.len() == Self::WINDOW {
92            self.window.remove(0);
93        }
94        self.window.push(has_overflow);
95
96        if has_overflow {
97            self.scale = (self.scale * self.config.backoff_factor).max(self.config.min_scale);
98            self.growth_tracker = 0;
99        } else {
100            self.growth_tracker = self.growth_tracker.saturating_add(1);
101            if self.growth_tracker >= self.config.growth_interval {
102                self.scale = (self.scale * self.config.growth_factor).min(self.config.max_scale);
103                self.growth_tracker = 0;
104            }
105        }
106    }
107
108    /// Statistics over the rolling window.
109    pub fn overflow_stats(&self) -> OverflowStats {
110        let total = self.window.len();
111        let overflows = self.window.iter().filter(|&&x| x).count();
112        OverflowStats {
113            total_steps: total,
114            overflow_count: overflows,
115            overflow_rate: if total > 0 {
116                overflows as f32 / total as f32
117            } else {
118                0.0
119            },
120            current_scale: self.scale,
121        }
122    }
123
124    /// Divide `values` by the current scale, reporting whether the *scaled*
125    /// input contained a non-finite entry.
126    ///
127    /// This is the standard AMP overflow test: an `inf`/`NaN` produced by the
128    /// scaled backward pass means the step must be skipped and the scale cut.
129    pub fn unscale_and_check(&mut self, values: &mut [f32]) -> bool {
130        let inv = self.inv_scale();
131        let mut overflow = false;
132        for v in values.iter_mut() {
133            if !v.is_finite() {
134                overflow = true;
135            }
136            *v *= inv;
137        }
138        self.update(overflow);
139        overflow
140    }
141}
142
143/// Convert an `f32` to IEEE-754 `binary16` bits with round-half-to-even.
144///
145/// Overflow saturates to the signed infinity of `binary16` (the same behaviour
146/// as hardware `f32 → f16` conversion); subnormal results are produced
147/// correctly rather than flushed to zero; NaN stays NaN with a non-zero
148/// mantissa.
149pub fn f32_to_f16_bits(value: f32) -> u16 {
150    let bits = value.to_bits();
151    let sign = ((bits >> 16) & 0x8000) as u16;
152    let exponent = ((bits >> 23) & 0xff) as i32;
153    let mantissa = bits & 0x007f_ffff;
154
155    if exponent == 0xff {
156        // Inf or NaN.
157        return if mantissa == 0 {
158            sign | 0x7c00
159        } else {
160            // Preserve NaN-ness; keep the top mantissa bits and force non-zero.
161            sign | 0x7c00 | ((mantissa >> 13) as u16) | 0x0200
162        };
163    }
164
165    // Unbiased exponent, then rebias for binary16.
166    let unbiased = exponent - 127;
167    let half_exp = unbiased + 15;
168
169    if half_exp >= 0x1f {
170        // Overflow → infinity.
171        return sign | 0x7c00;
172    }
173
174    if half_exp <= 0 {
175        // Subnormal (or underflow to zero). Reintroduce the implicit bit and
176        // shift the significand into the subnormal range.
177        if half_exp < -10 {
178            return sign;
179        }
180        let significand = mantissa | 0x0080_0000;
181        let shift = (14 - half_exp) as u32; // 14 = 23 - 10 + 1
182        let result = significand >> shift;
183        // Round half to even using the bits shifted out.
184        let round_bit = 1u32 << (shift - 1);
185        let remainder = significand & (round_bit.saturating_mul(2) - 1);
186        let mut half = result as u16;
187        if remainder > round_bit || (remainder == round_bit && (result & 1) == 1) {
188            half = half.wrapping_add(1);
189        }
190        return sign | half;
191    }
192
193    // Normal range.
194    let mut half = ((half_exp as u16) << 10) | ((mantissa >> 13) as u16);
195    let remainder = mantissa & 0x1fff;
196    if remainder > 0x1000 || (remainder == 0x1000 && (half & 1) == 1) {
197        // Carrying into the exponent is handled naturally by the addition:
198        // a mantissa of all ones rolls over into the exponent field, and an
199        // exponent of 0x1e rolling over yields exactly the infinity pattern.
200        half = half.wrapping_add(1);
201    }
202    sign | half
203}
204
205/// Convert IEEE-754 `binary16` bits to `f32`. Exact for every input.
206pub fn f16_bits_to_f32(bits: u16) -> f32 {
207    let sign = ((bits as u32) & 0x8000) << 16;
208    let exponent = ((bits >> 10) & 0x1f) as u32;
209    let mantissa = ((bits & 0x03ff) as u32) << 13;
210
211    if exponent == 0 {
212        if mantissa == 0 {
213            return f32::from_bits(sign);
214        }
215        // Subnormal: shift the significand left until the implicit bit lands
216        // on bit 23. A binary16 subnormal is `m * 2^-24` with `m` in
217        // `[1, 1023]`; after `k` normalising shifts the value is
218        // `1.f * 2^(-14 - k)`, i.e. a biased f32 exponent of `113 - k`.
219        let mut mant = mantissa;
220        let mut shifts: i32 = 0;
221        while mant & 0x0080_0000 == 0 {
222            mant <<= 1;
223            shifts += 1;
224        }
225        mant &= 0x007f_ffff;
226        let f32_exp = ((113 - shifts) as u32) << 23;
227        return f32::from_bits(sign | f32_exp | mant);
228    }
229
230    if exponent == 0x1f {
231        // Inf / NaN.
232        return f32::from_bits(sign | 0x7f80_0000 | mantissa);
233    }
234
235    let f32_exp = (exponent + (127 - 15)) << 23;
236    f32::from_bits(sign | f32_exp | mantissa)
237}
238
239/// Convert a slice of `f32` to `binary16` bit patterns.
240pub fn f32_slice_to_f16_bits(values: &[f32]) -> Vec<u16> {
241    values.iter().copied().map(f32_to_f16_bits).collect()
242}
243
244/// Convert a slice of `binary16` bit patterns back to `f32`.
245pub fn f16_bits_slice_to_f32(bits: &[u16]) -> Vec<f32> {
246    bits.iter().copied().map(f16_bits_to_f32).collect()
247}
248
249/// Largest finite magnitude representable in `binary16`.
250pub const F16_MAX: f32 = 65504.0;
251
252/// Clamp to the `binary16` finite range before conversion.
253pub fn saturate_to_f16_range(value: f32) -> f32 {
254    if value.is_nan() {
255        value
256    } else {
257        value.clamp(-F16_MAX, F16_MAX)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn round_trips_exact_values() {
267        for &v in &[
268            0.0f32, -0.0, 1.0, -1.0, 0.5, 2.0, 65504.0, -65504.0, 0.125, 1024.0,
269        ] {
270            let back = f16_bits_to_f32(f32_to_f16_bits(v));
271            assert_eq!(back.to_bits(), v.to_bits(), "value {v} did not round-trip");
272        }
273    }
274
275    #[test]
276    fn round_trips_every_finite_f16() {
277        // Property: f16 -> f32 -> f16 is the identity on all 65536 patterns.
278        for bits in 0u16..=u16::MAX {
279            let exponent = (bits >> 10) & 0x1f;
280            let value = f16_bits_to_f32(bits);
281            if exponent == 0x1f {
282                // Inf/NaN class: only require the class to be preserved.
283                let back = f32_to_f16_bits(value);
284                assert_eq!(
285                    (back >> 10) & 0x1f,
286                    0x1f,
287                    "bits {bits:#06x} lost its inf/NaN class"
288                );
289                assert_eq!(back & 0x8000, bits & 0x8000, "bits {bits:#06x} lost sign");
290                continue;
291            }
292            let back = f32_to_f16_bits(value);
293            assert_eq!(
294                back, bits,
295                "bits {bits:#06x} did not round-trip (f32 {value})"
296            );
297        }
298    }
299
300    #[test]
301    fn subnormals_are_not_flushed_to_zero() {
302        // Smallest positive f16 subnormal is 2^-24.
303        let smallest = f16_bits_to_f32(1);
304        assert!(smallest > 0.0);
305        assert!((smallest - 2f32.powi(-24)).abs() < f32::EPSILON * smallest);
306        assert_eq!(f32_to_f16_bits(smallest), 1);
307    }
308
309    #[test]
310    fn rounds_half_to_even() {
311        // 1.0 + 2^-11 lies exactly halfway between 1.0 (even mantissa) and the
312        // next f16; round-half-to-even must pick 1.0.
313        let halfway = 1.0f32 + 2f32.powi(-11);
314        assert_eq!(f32_to_f16_bits(halfway), f32_to_f16_bits(1.0));
315        // 1.0 + 3 * 2^-11 lies halfway between the first and second f16 above
316        // 1.0; the even neighbour is the second one.
317        let halfway_up = 1.0f32 + 3.0 * 2f32.powi(-11);
318        assert_eq!(f32_to_f16_bits(halfway_up), f32_to_f16_bits(1.0) + 2);
319    }
320
321    #[test]
322    fn overflow_saturates_to_infinity() {
323        assert_eq!(f32_to_f16_bits(1.0e30), 0x7c00);
324        assert_eq!(f32_to_f16_bits(-1.0e30), 0xfc00);
325        assert!(f16_bits_to_f32(0x7c00).is_infinite());
326    }
327
328    #[test]
329    fn nan_stays_nan() {
330        assert!(f16_bits_to_f32(f32_to_f16_bits(f32::NAN)).is_nan());
331    }
332
333    #[test]
334    fn loss_scaler_backs_off_and_grows() {
335        let config = MixedPrecisionConfig {
336            growth_interval: 4,
337            ..MixedPrecisionConfig::default()
338        };
339        let mut scaler = DynamicLossScaler::new(config);
340        assert_eq!(scaler.scale(), 65536.0);
341
342        scaler.update(true);
343        assert_eq!(scaler.scale(), 32768.0);
344
345        for _ in 0..4 {
346            scaler.update(false);
347        }
348        assert_eq!(scaler.scale(), 65536.0);
349    }
350
351    #[test]
352    fn loss_scaler_detects_overflow_while_unscaling() {
353        let mut scaler = DynamicLossScaler::new(MixedPrecisionConfig {
354            init_scale: 4.0,
355            ..MixedPrecisionConfig::default()
356        });
357        let mut grads = [8.0f32, -4.0, 2.0];
358        assert!(!scaler.unscale_and_check(&mut grads));
359        assert_eq!(grads, [2.0, -1.0, 0.5]);
360        assert_eq!(scaler.scale(), 4.0);
361
362        let mut bad = [f32::INFINITY, 1.0];
363        assert!(scaler.unscale_and_check(&mut bad));
364        assert_eq!(scaler.scale(), 2.0);
365
366        let stats = scaler.overflow_stats();
367        assert_eq!(stats.total_steps, 2);
368        assert_eq!(stats.overflow_count, 1);
369    }
370
371    #[test]
372    fn saturation_keeps_values_finite() {
373        assert_eq!(saturate_to_f16_range(1.0e30), F16_MAX);
374        assert_eq!(saturate_to_f16_range(-1.0e30), -F16_MAX);
375        assert!(saturate_to_f16_range(f32::NAN).is_nan());
376    }
377}