Skip to main content

versorc_math/quantization/
clifford_3bit.rs

1// ============================================================================
2// VERSORC-MATH: CLIFFORD 3-BIT GEOMETRIC QUANTIZER & DOD CACHE-LINE PACKAGING
3// License: Apache-2.0
4//
5// Combines Clifford Dyadic Versor Isometry (R in Spin(N)) with Optimal
6// 3-Bit Lloyd-Max Scalar Centroids for Isotropized Normal Distributions N(0, sigma^2).
7//
8// 1. Dual Isometry: <V^-1 w_rot, x> == <w_rot, V x>.
9//    Rotates activation x ONCE in O(N log N) additions/subtractions;
10//    weights remain packed in memory and NEVER undergo inverse transforms at inference.
11// 2. Hardware DOD Packaging:
12//    - Clifford3BitWord64: 16 weights (48 bits) + 1 BF16 scale (16 bits) = 64 bits (8 bytes).
13//    - Clifford3BitCacheLine64: 8 words = 128 weights in exactly 64 bytes (1 L1d cache line).
14// ============================================================================
15
16use crate::versor::DyadicWalshVersor;
17
18/// Precomputed Lloyd-Max centroids for N(0, 1) standard normal distribution (3-bit symmetric, 8 levels).
19pub const LLOYD_MAX_3BIT_CENTROIDS: [f32; 4] = [
20    0.245_086_3, // Code 0: 0.2451 sigma
21    0.756_041_4, // Code 1: 0.7560 sigma
22    1.343_918_2, // Code 2: 1.3439 sigma
23    2.152_014_2, // Code 3: 2.1520 sigma
24];
25
26/// Precomputed Lloyd-Max decision boundaries for N(0, 1).
27pub const LLOYD_MAX_3BIT_BOUNDS: [f32; 3] = [
28    0.500_563_8, // Boundary 0-1
29    1.049_979_8, // Boundary 1-2
30    1.747_966_2, // Boundary 2-3
31];
32
33/// Single 64-bit universal storage word.
34/// Contains exactly 16 3-bit quantized weights (48 bits) + 1 BF16 scale (16 bits).
35/// Total: 64 bits (8 bytes). Zero bit waste.
36#[repr(transparent)]
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub struct Clifford3BitWord64(pub u64);
39
40impl Clifford3BitWord64 {
41    /// Encodes 16 float weights into a single 64-bit word.
42    /// - Low 48 bits: 16 x 3-bit quantized codes (1 sign bit + 2 magnitude bits).
43    /// - High 16 bits: BF16 scale factor.
44    pub fn pack(weights: &[f32; 16], scale: f32) -> Self {
45        let inv_scale = if scale.abs() > 1e-12 { 1.0 / scale } else { 1.0 };
46        let mut bits: u64 = 0;
47
48        for (i, &w) in weights.iter().enumerate() {
49            let normalized = w * inv_scale;
50            let sign_bit = if normalized < 0.0 { 1u64 } else { 0u64 };
51            let abs_val = normalized.abs();
52
53            let mag_code = if abs_val < LLOYD_MAX_3BIT_BOUNDS[0] {
54                0u64
55            } else if abs_val < LLOYD_MAX_3BIT_BOUNDS[1] {
56                1u64
57            } else if abs_val < LLOYD_MAX_3BIT_BOUNDS[2] {
58                2u64
59            } else {
60                3u64
61            };
62
63            let code_3bit = (sign_bit << 2) | mag_code;
64            bits |= code_3bit << (i * 3);
65        }
66
67        let bf16_scale = f32_to_bf16(scale) as u64;
68        bits |= bf16_scale << 48;
69
70        Self(bits)
71    }
72
73    /// Encodes 16 float weights into a single 64-bit word using an exact orthogonal least-squares scale:
74    /// alpha* = <w, c_norm> / ||c_norm||^2.
75    /// Strictly minimizes reconstruction error ||w - alpha* c_norm||^2.
76    pub fn pack_least_squares(weights: &[f32; 16]) -> Self {
77        let sum_sq: f32 = weights.iter().map(|&w| w * w).sum();
78        let std_dev = (sum_sq / 16.0).sqrt().max(1e-8);
79        let inv_std = 1.0 / std_dev;
80
81        let mut bits: u64 = 0;
82        let mut dot_wc = 0.0f32;
83        let mut norm_c_sq = 0.0f32;
84
85        for (i, &w) in weights.iter().enumerate() {
86            let normalized = w * inv_std;
87            let sign_bit = if normalized < 0.0 { 1u64 } else { 0u64 };
88            let abs_val = normalized.abs();
89
90            let mag_code = if abs_val < LLOYD_MAX_3BIT_BOUNDS[0] {
91                0usize
92            } else if abs_val < LLOYD_MAX_3BIT_BOUNDS[1] {
93                1usize
94            } else if abs_val < LLOYD_MAX_3BIT_BOUNDS[2] {
95                2usize
96            } else {
97                3usize
98            };
99
100            let code_3bit = (sign_bit << 2) | (mag_code as u64);
101            bits |= code_3bit << (i * 3);
102
103            let sign_f = if sign_bit != 0 { -1.0f32 } else { 1.0f32 };
104            let c_norm = sign_f * LLOYD_MAX_3BIT_CENTROIDS[mag_code];
105            dot_wc += w * c_norm;
106            norm_c_sq += c_norm * c_norm;
107        }
108
109        let alpha_ls = if norm_c_sq > 1e-12 { dot_wc / norm_c_sq } else { std_dev };
110        let bf16_scale = f32_to_bf16(alpha_ls) as u64;
111        bits |= bf16_scale << 48;
112
113        Self(bits)
114    }
115
116    /// Extracts the BF16 scale factor as FP32.
117    #[inline(always)]
118    pub fn scale(&self) -> f32 {
119        let bf16 = (self.0 >> 48) as u16;
120        bf16_to_f32(bf16)
121    }
122
123    /// Unpacks all 16 reconstructed float weights into the destination buffer.
124    pub fn unpack(&self, out: &mut [f32; 16]) {
125        let scale = self.scale();
126        for i in 0..16 {
127            let code = ((self.0 >> (i * 3)) & 0x7) as usize;
128            let sign = if (code & 0x4) != 0 { -1.0f32 } else { 1.0f32 };
129            let mag_idx = code & 0x3;
130            out[i] = sign * LLOYD_MAX_3BIT_CENTROIDS[mag_idx] * scale;
131        }
132    }
133
134    /// Direct dot product of the packed 16 weights with an unpacked FP32 activation slice.
135    /// Operates without intermediate memory allocation.
136    #[inline(always)]
137    pub fn dot_with_slice(&self, x: &[f32; 16]) -> f32 {
138        let scale = self.scale();
139        let mut sum = 0.0f32;
140        for i in 0..16 {
141            let code = ((self.0 >> (i * 3)) & 0x7) as usize;
142            let sign = if (code & 0x4) != 0 { -1.0f32 } else { 1.0f32 };
143            let mag_idx = code & 0x3;
144            let w = sign * LLOYD_MAX_3BIT_CENTROIDS[mag_idx] * scale;
145            sum += w * x[i];
146        }
147        sum
148    }
149}
150
151/// 64-byte Cache-Line Block of 128 weights.
152/// Holds exactly 8 x Clifford3BitWord64 words = 128 weights.
153/// Size is exactly 64 bytes (1 L1d cache line) and 64-byte cache-line aligned.
154#[repr(C, align(64))]
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct Clifford3BitCacheLine64 {
157    pub words: [Clifford3BitWord64; 8],
158}
159
160impl Default for Clifford3BitCacheLine64 {
161    fn default() -> Self {
162        Self {
163            words: [Clifford3BitWord64(0); 8],
164        }
165    }
166}
167
168impl Clifford3BitCacheLine64 {
169    pub const CAPACITY: usize = 128;
170
171    /// Packs 128 float weights into a single 64-byte cache line block.
172    /// Automatically computes optimal per-16 sub-block standard deviation scales.
173    pub fn pack_128(weights: &[f32; 128]) -> Self {
174        let mut words = [Clifford3BitWord64(0); 8];
175        for b in 0..8 {
176            let chunk_weights: &[f32; 16] = weights[b * 16..(b + 1) * 16].try_into().unwrap();
177            words[b] = Clifford3BitWord64::pack_least_squares(chunk_weights);
178        }
179        Self { words }
180    }
181
182    /// Unpacks all 128 float weights into destination slice.
183    pub fn unpack_128(&self, out: &mut [f32; 128]) {
184        for b in 0..8 {
185            let chunk_out: &mut [f32; 16] = (&mut out[b * 16..(b + 1) * 16]).try_into().unwrap();
186            self.words[b].unpack(chunk_out);
187        }
188    }
189
190    /// Returns a direct 64-byte slice view of this cache line block.
191    #[inline(always)]
192    pub fn as_bytes(&self) -> &[u8; 64] {
193        unsafe { &*(self as *const Self as *const [u8; 64]) }
194    }
195
196    /// Converts a slice of cache lines into a contiguous byte slice without allocations.
197    #[inline(always)]
198    pub fn slice_as_bytes(slice: &[Self]) -> &[u8] {
199        unsafe {
200            std::slice::from_raw_parts(slice.as_ptr() as *const u8, slice.len() * 64)
201        }
202    }
203
204    /// Casts an aligned slice of bytes into a slice of 64-byte cache lines.
205    #[inline(always)]
206    pub fn slice_from_bytes(bytes: &[u8]) -> Result<&[Self], String> {
207        if bytes.len() % 64 != 0 {
208            return Err(format!("Byte slice length {} is not a multiple of 64", bytes.len()));
209        }
210        if bytes.as_ptr() as usize % 64 != 0 {
211            return Err(format!(
212                "Byte slice pointer {:p} is not 64-byte cache-line aligned",
213                bytes.as_ptr()
214            ));
215        }
216        let count = bytes.len() / 64;
217        Ok(unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const Self, count) })
218    }
219
220    /// Evaluates direct dot product of this 128-weight cache line against an activation slice.
221    #[inline(always)]
222    pub fn dot_128(&self, x: &[f32; 128]) -> f32 {
223        let mut total = 0.0f32;
224        for b in 0..8 {
225            let chunk_x: &[f32; 16] = x[b * 16..(b + 1) * 16].try_into().unwrap();
226            total += self.words[b].dot_with_slice(chunk_x);
227        }
228        total
229    }
230}
231
232/// High-level Geometric 3-bit Quantized Tensor with Dyadic Versor Isometry.
233#[derive(Debug, Clone)]
234pub struct Clifford3BitTensor {
235    pub rows: usize,
236    pub cols: usize,
237    pub cache_lines: Vec<Clifford3BitCacheLine64>,
238}
239
240impl Clifford3BitTensor {
241    /// Compiles an unquantized FP32 weight matrix into the AOT rotated Clifford 3-bit representation.
242    /// Each row is first rotated by `versor` in-place, then packed into 64-byte cache lines.
243    pub fn compile_aot(weights: &[f32], rows: usize, cols: usize, versor: &DyadicWalshVersor) -> Self {
244        assert_eq!(weights.len(), rows * cols);
245        assert_eq!(cols % 128, 0, "Columns must be a multiple of 128 for cache-line alignment");
246        assert_eq!(versor.dimension(), cols);
247
248        let lines_per_row = cols / 128;
249        let mut cache_lines = Vec::with_capacity(rows * lines_per_row);
250        let mut row_buffer = vec![0.0f32; cols];
251
252        for r in 0..rows {
253            let row_slice = &weights[r * cols..(r + 1) * cols];
254            row_buffer.copy_from_slice(row_slice);
255
256            // Isotropic rotation: R(w)
257            versor.apply_in_place(&mut row_buffer);
258
259            for l in 0..lines_per_row {
260                let chunk: &[f32; 128] = row_buffer[l * 128..(l + 1) * 128].try_into().unwrap();
261                cache_lines.push(Clifford3BitCacheLine64::pack_128(chunk));
262            }
263        }
264
265        Self {
266            rows,
267            cols,
268            cache_lines,
269        }
270    }
271
272    /// Evaluates Matrix-Vector Product y = W * x using Dual Isometry:
273    /// <W_row, x> == <R(W_row), R(x)>.
274    /// The activation x is rotated ONCE in O(N log N); weights are operated on directly in 3-bit packed cache lines.
275    pub fn matvec(&self, x: &[f32], out: &mut [f32], versor: &DyadicWalshVersor) {
276        assert_eq!(x.len(), self.cols);
277        assert_eq!(out.len(), self.rows);
278
279        // 1. Single activation rotation: x_rot = R(x)
280        let mut x_rot = x.to_vec();
281        versor.apply_in_place(&mut x_rot);
282
283        // 2. Direct streaming dot products over 64-byte cache lines
284        let lines_per_row = self.cols / 128;
285        for r in 0..self.rows {
286            let mut row_dot = 0.0f32;
287            let line_offset = r * lines_per_row;
288            for l in 0..lines_per_row {
289                let cache_line = &self.cache_lines[line_offset + l];
290                let chunk_x: &[f32; 128] = x_rot[l * 128..(l + 1) * 128].try_into().unwrap();
291                row_dot += cache_line.dot_128(chunk_x);
292            }
293            out[r] = row_dot;
294        }
295    }
296}
297
298/// Converts an IEEE 754 f32 float to BF16 (Brain Floating Point 16-bit).
299#[inline(always)]
300pub fn f32_to_bf16(val: f32) -> u16 {
301    let bits = val.to_bits();
302    // Round to nearest even
303    let rounding_bias = 0x7FFF + ((bits >> 16) & 1);
304    ((bits.wrapping_add(rounding_bias)) >> 16) as u16
305}
306
307/// Converts a BF16 bit pattern back to IEEE 754 f32 float.
308#[inline(always)]
309pub fn bf16_to_f32(bits: u16) -> f32 {
310    f32::from_bits((bits as u32) << 16)
311}