Skip to main content

oxicuda_quantum/statevec/
fp16.rs

1//! FP16 / BF16 amplitude storage for memory-bound large-qubit simulation.
2//!
3//! A dense state vector dominates memory: `n` qubits need `2^n` complex
4//! amplitudes, and at `f32` that is `8 · 2^n` bytes. Halving the amplitude width
5//! to 16 bits (per real/imag component) halves the footprint, letting one extra
6//! qubit fit in the same memory budget — the standard memory-bound trade in
7//! large-scale state-vector simulation, accepting reduced precision.
8//!
9//! This module provides pure-Rust, dependency-free conversions for the two
10//! 16-bit formats used on modern accelerators:
11//!
12//! * **IEEE-754 binary16** ([`f32_to_f16`] / [`f16_to_f32`]) — 1 sign, 5
13//!   exponent, 10 mantissa bits; correctly handles subnormals, overflow to ±∞,
14//!   and round-to-nearest-even.
15//! * **bfloat16** ([`f32_to_bf16`] / [`bf16_to_f32`]) — 1 sign, 8 exponent, 7
16//!   mantissa bits; the truncated top 16 bits of an `f32` with round-to-nearest.
17//!
18//! [`HalfStateVector`] stores complex amplitudes as packed `(re16, im16)` pairs
19//! in either format and converts to/from a full-precision [`StateVector`].
20
21use num_complex::Complex;
22
23use crate::error::{QuantumError, QuantumResult};
24use crate::statevec::state::StateVector;
25
26type Complex32 = Complex<f32>;
27
28// ─── IEEE-754 binary16 ────────────────────────────────────────────────────────
29
30/// Convert an `f32` to IEEE-754 binary16 bits (round-to-nearest-even).
31#[must_use]
32pub fn f32_to_f16(value: f32) -> u16 {
33    let bits = value.to_bits();
34    let sign = ((bits >> 16) & 0x8000) as u16;
35    // Unbiased exponent and mantissa of the f32.
36    let exp = ((bits >> 23) & 0xff) as i32;
37    let mant = bits & 0x007f_ffff;
38
39    if exp == 0xff {
40        // Inf / NaN.
41        return if mant != 0 {
42            sign | 0x7e00 // quiet NaN
43        } else {
44            sign | 0x7c00 // ±inf
45        };
46    }
47
48    // Rebias exponent from 127 (f32) to 15 (f16).
49    let new_exp = exp - 127 + 15;
50
51    if new_exp >= 0x1f {
52        // Overflow → ±inf.
53        return sign | 0x7c00;
54    }
55    if new_exp <= 0 {
56        // Subnormal or zero in f16.
57        if new_exp < -10 {
58            // Too small even for the smallest subnormal → signed zero.
59            return sign;
60        }
61        // Add the implicit leading 1, then shift into subnormal position.
62        let mant_with_implicit = mant | 0x0080_0000;
63        let shift = (14 - new_exp) as u32; // 1 - new_exp + 13
64        let mut half_mant = mant_with_implicit >> shift;
65        // Round-to-nearest-even using the bits shifted out.
66        let remainder = mant_with_implicit & ((1 << shift) - 1);
67        let halfway = 1u32 << (shift - 1);
68        if remainder > halfway || (remainder == halfway && (half_mant & 1) == 1) {
69            half_mant += 1;
70        }
71        return sign | (half_mant as u16);
72    }
73
74    // Normal f16.
75    let mut half_mant = (mant >> 13) as u16;
76    let remainder = mant & 0x0000_1fff;
77    let halfway = 0x0000_1000u32;
78    let mut he = new_exp as u16;
79    if remainder > halfway || (remainder == halfway && (half_mant & 1) == 1) {
80        half_mant += 1;
81        if half_mant == 0x400 {
82            // Mantissa overflow → bump exponent, mantissa wraps to 0.
83            half_mant = 0;
84            he += 1;
85            if he >= 0x1f {
86                return sign | 0x7c00; // overflowed to inf
87            }
88        }
89    }
90    sign | (he << 10) | half_mant
91}
92
93/// Convert IEEE-754 binary16 bits to `f32` (exact).
94#[must_use]
95pub fn f16_to_f32(h: u16) -> f32 {
96    let sign = ((h & 0x8000) as u32) << 16;
97    let exp = ((h >> 10) & 0x1f) as u32;
98    let mant = (h & 0x03ff) as u32;
99
100    if exp == 0 {
101        if mant == 0 {
102            // Signed zero.
103            return f32::from_bits(sign);
104        }
105        // Subnormal: normalize.
106        let mut e = -1i32;
107        let mut m = mant;
108        while (m & 0x0400) == 0 {
109            m <<= 1;
110            e -= 1;
111        }
112        m &= 0x03ff; // drop the now-explicit leading bit
113        let f32_exp = ((e + 1 - 15 + 127) as u32) << 23;
114        let f32_mant = m << 13;
115        return f32::from_bits(sign | f32_exp | f32_mant);
116    }
117    if exp == 0x1f {
118        // Inf / NaN.
119        let f32_mant = mant << 13;
120        return f32::from_bits(sign | 0x7f80_0000 | f32_mant);
121    }
122    // Normal.
123    let f32_exp = (exp + 127 - 15) << 23;
124    let f32_mant = mant << 13;
125    f32::from_bits(sign | f32_exp | f32_mant)
126}
127
128// ─── bfloat16 ─────────────────────────────────────────────────────────────────
129
130/// Convert an `f32` to bfloat16 bits (round-to-nearest-even).
131#[must_use]
132pub fn f32_to_bf16(value: f32) -> u16 {
133    let bits = value.to_bits();
134    if value.is_nan() {
135        // Preserve NaN (set a mantissa bit in the high half).
136        return ((bits >> 16) as u16) | 0x0040;
137    }
138    // Round-to-nearest-even on the discarded low 16 bits.
139    let lsb = (bits >> 16) & 1;
140    let rounding_bias = 0x7fff + lsb;
141    let rounded = bits.wrapping_add(rounding_bias);
142    (rounded >> 16) as u16
143}
144
145/// Convert bfloat16 bits to `f32` (exact; bf16 is the top 16 bits of an f32).
146#[must_use]
147pub fn bf16_to_f32(b: u16) -> f32 {
148    f32::from_bits((b as u32) << 16)
149}
150
151/// Selects the 16-bit amplitude format.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum HalfFormat {
154    /// IEEE-754 binary16.
155    Ieee,
156    /// bfloat16.
157    Bfloat,
158}
159
160/// A complex amplitude stored as two packed 16-bit reals `(re, im)`.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct HalfComplex {
163    pub re: u16,
164    pub im: u16,
165}
166
167/// A state vector whose amplitudes are stored in a 16-bit format.
168///
169/// Uses half the memory of an `f32` [`StateVector`]; conversions go through
170/// [`HalfStateVector::from_dense`] / [`HalfStateVector::to_dense`].
171#[derive(Debug, Clone)]
172pub struct HalfStateVector {
173    pub amps: Vec<HalfComplex>,
174    pub n_qubits: usize,
175    pub format: HalfFormat,
176}
177
178impl HalfStateVector {
179    /// Pack a dense `f32` state vector into the chosen 16-bit format.
180    #[must_use]
181    pub fn from_dense(sv: &StateVector, format: HalfFormat) -> Self {
182        let amps = sv.amps.iter().map(|a| pack(*a, format)).collect::<Vec<_>>();
183        Self {
184            amps,
185            n_qubits: sv.n_qubits,
186            format,
187        }
188    }
189
190    /// Unpack back to a full-precision `f32` [`StateVector`].
191    ///
192    /// # Errors
193    /// Propagates the dense constructor's qubit-count validation.
194    pub fn to_dense(&self) -> QuantumResult<StateVector> {
195        if self.n_qubits == 0 || self.n_qubits > 30 {
196            return Err(QuantumError::InvalidQubitCount { n: self.n_qubits });
197        }
198        let amps = self
199            .amps
200            .iter()
201            .map(|h| unpack(*h, self.format))
202            .collect::<Vec<_>>();
203        Ok(StateVector {
204            amps,
205            n_qubits: self.n_qubits,
206        })
207    }
208
209    /// Memory footprint in bytes (4 bytes per amplitude vs 8 for `f32`).
210    #[must_use]
211    pub fn bytes(&self) -> usize {
212        self.amps.len() * std::mem::size_of::<HalfComplex>()
213    }
214}
215
216fn pack(z: Complex32, format: HalfFormat) -> HalfComplex {
217    match format {
218        HalfFormat::Ieee => HalfComplex {
219            re: f32_to_f16(z.re),
220            im: f32_to_f16(z.im),
221        },
222        HalfFormat::Bfloat => HalfComplex {
223            re: f32_to_bf16(z.re),
224            im: f32_to_bf16(z.im),
225        },
226    }
227}
228
229fn unpack(h: HalfComplex, format: HalfFormat) -> Complex32 {
230    match format {
231        HalfFormat::Ieee => Complex32::new(f16_to_f32(h.re), f16_to_f32(h.im)),
232        HalfFormat::Bfloat => Complex32::new(bf16_to_f32(h.re), bf16_to_f32(h.im)),
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn f16_exact_powers_of_two_roundtrip() {
242        for &v in &[0.0_f32, 1.0, 2.0, 0.5, -1.0, -0.25, 4.0, 0.125] {
243            let back = f16_to_f32(f32_to_f16(v));
244            assert!((back - v).abs() < 1e-6, "v={v} back={back}");
245        }
246    }
247
248    #[test]
249    fn f16_known_bit_patterns() {
250        // 1.0 → 0x3c00, -2.0 → 0xc000, 0.0 → 0x0000.
251        assert_eq!(f32_to_f16(1.0), 0x3c00);
252        assert_eq!(f32_to_f16(-2.0), 0xc000);
253        assert_eq!(f32_to_f16(0.0), 0x0000);
254        assert!((f16_to_f32(0x3c00) - 1.0).abs() < 1e-9);
255        assert!((f16_to_f32(0x4000) - 2.0).abs() < 1e-9);
256    }
257
258    #[test]
259    fn f16_overflow_to_inf() {
260        let h = f32_to_f16(1.0e30);
261        assert!(f16_to_f32(h).is_infinite());
262    }
263
264    #[test]
265    fn f16_subnormal_roundtrip_small() {
266        // Smallest f16 subnormal ≈ 5.96e-8.
267        let tiny = 6.0e-8_f32;
268        let back = f16_to_f32(f32_to_f16(tiny));
269        assert!(back > 0.0 && back < 1e-6, "back={back}");
270    }
271
272    #[test]
273    fn f16_rounds_to_nearest() {
274        // 1/3 ≈ 0.333…; nearest f16 should be within one ULP (~0.0005 near 0.33).
275        let v = 1.0_f32 / 3.0;
276        let back = f16_to_f32(f32_to_f16(v));
277        assert!((back - v).abs() < 5e-4, "v={v} back={back}");
278    }
279
280    #[test]
281    fn bf16_roundtrip_keeps_exponent_range() {
282        for &v in &[1.0_f32, -1.0, 1.0e20, 1.0e-20, 0.0, 123.5, -0.001] {
283            let back = bf16_to_f32(f32_to_bf16(v));
284            // bf16 keeps the full exponent, so relative error ≤ ~2^-7.
285            if v == 0.0 {
286                assert_eq!(back, 0.0);
287            } else {
288                let rel = ((back - v) / v).abs();
289                assert!(rel < 0.01, "v={v} back={back} rel={rel}");
290            }
291        }
292    }
293
294    #[test]
295    fn bf16_is_truncated_top_half() {
296        // bf16 of 1.0 is the top 16 bits of f32(1.0)=0x3f800000 → 0x3f80.
297        assert_eq!(f32_to_bf16(1.0), 0x3f80);
298        assert!((bf16_to_f32(0x3f80) - 1.0).abs() < 1e-9);
299    }
300
301    #[test]
302    fn state_vector_pack_unpack_ieee() {
303        let inv_sqrt2 = std::f32::consts::FRAC_1_SQRT_2;
304        let sv = StateVector {
305            amps: vec![
306                Complex32::new(inv_sqrt2, 0.0),
307                Complex32::new(0.0, inv_sqrt2),
308            ],
309            n_qubits: 1,
310        };
311        let half = HalfStateVector::from_dense(&sv, HalfFormat::Ieee);
312        let back = half.to_dense().expect("dense");
313        for (a, b) in back.amps.iter().zip(sv.amps.iter()) {
314            assert!((a.re - b.re).abs() < 1e-3, "re {a:?} vs {b:?}");
315            assert!((a.im - b.im).abs() < 1e-3, "im {a:?} vs {b:?}");
316        }
317        // Memory: 2 amps × 4 bytes = 8 (vs 16 for f32).
318        assert_eq!(half.bytes(), 8);
319    }
320
321    #[test]
322    fn state_vector_pack_unpack_bf16_preserves_norm() {
323        use crate::handle::LcgRng;
324        let mut rng = LcgRng::new(2024);
325        let mut amps = Vec::with_capacity(8);
326        for _ in 0..8 {
327            amps.push(Complex32::new(rng.next_normal(), rng.next_normal()));
328        }
329        let mut sv = StateVector { amps, n_qubits: 3 };
330        sv.normalize_inplace();
331        let half = HalfStateVector::from_dense(&sv, HalfFormat::Bfloat);
332        let back = half.to_dense().expect("dense");
333        let norm: f32 = back.amps.iter().map(|a| a.norm_sqr()).sum();
334        // bf16 has ~3 decimal digits; norm stays close to 1.
335        assert!((norm - 1.0).abs() < 5e-2, "norm={norm}");
336    }
337}