Skip to main content

qualia_core_db/inference/
ternary.rs

1//! BitNet b1.58 **ternary** quantization codec — STELLAR §A compression (task #12).
2//!
3//! Ternary packing of weights `∈ {-1, 0, +1}` with a per-tensor **absmean** scale (BitNet 1.58b):
4//! it replaces fused multiply-adds with hardware adds/subtracts in the GEMM kernels and shrinks the
5//! weights to ≈ **1.6 bits each**. This module is the reusable codec; it is applied *during*
6//! transcode by [`crate::p64_weight::transcode_safetensor_to_p64_ternary`] so a P64 image ships
7//! compressed, not as a verbatim blob.
8//!
9//! ## Encoding
10//! * **Quantize** (per tensor): `scale = mean(|w|)`; `t_i = clamp(round(w_i / scale), -1, +1)`.
11//! * **Dequantize:** `w_i ≈ scale · t_i`.
12//! * **Packing:** five trits per byte in base-3 (`3⁵ = 243 ≤ 256`) → `8 bits / 5 trits = 1.6
13//!   bits/weight`. A trit `{-1,0,+1}` is offset to a digit `{0,1,2}`; a byte is
14//!   `d₀ + 3·d₁ + 9·d₂ + 27·d₃ + 81·d₄`.
15//!
16//! The hot-path dequant ([`dequantize_ternary`]) is **zero-heap** (it streams base-3 digits straight
17//! into the caller's `f32` buffer — no intermediate trit `Vec`). The encode path runs at ingest
18//! (cold), where a working `Vec` is acceptable.
19
20/// Trits packed per byte (`3⁵ = 243 ≤ 256`).
21pub const TRITS_PER_BYTE: usize = 5;
22
23/// Engine element-type code for a BitNet-1.58b ternary tensor (well outside the GGML code range,
24/// so it never collides with `F32=0 / F16=1 / Q8_0=8 / Q4_K=12 / BF16=30`). Mnemonic: "1.58b".
25pub const GGML_TYPE_TERNARY_158: u32 = 1158;
26
27/// Bytes needed to pack `count` trits (5 per byte).
28#[inline]
29pub fn packed_trit_len(count: usize) -> usize {
30    count.div_ceil(TRITS_PER_BYTE)
31}
32
33/// Total ternary-blob length for `count` weights: a 4-byte `f32` scale + the packed trits.
34#[inline]
35pub fn ternary_blob_len(count: usize) -> usize {
36    4 + packed_trit_len(count)
37}
38
39/// BitNet 1.58b quantize: per-tensor absmean `scale` + ternary values `∈ {-1,0,+1}`.
40/// A zero (or empty) tensor yields `scale = 0.0` and all-zero trits.
41pub fn quantize_ternary(weights: &[f32]) -> (f32, Vec<i8>) {
42    let n = weights.len();
43    if n == 0 {
44        return (0.0, Vec::new());
45    }
46    let absmean = weights.iter().map(|w| w.abs()).sum::<f32>() / n as f32;
47    if absmean == 0.0 {
48        return (0.0, vec![0i8; n]);
49    }
50    let trits = weights
51        .iter()
52        .map(|&w| (w / absmean).round().clamp(-1.0, 1.0) as i8)
53        .collect();
54    (absmean, trits)
55}
56
57/// Pack ternary values (`i8 ∈ {-1,0,+1}`) into bytes (5 trits/byte, base-3). The final partial group
58/// is zero-padded (decodes back to the requested `count` via [`unpack_trits_into`]).
59pub fn pack_trits(trits: &[i8]) -> Vec<u8> {
60    let mut out = Vec::with_capacity(packed_trit_len(trits.len()));
61    for chunk in trits.chunks(TRITS_PER_BYTE) {
62        let mut byte = 0u16;
63        let mut mul = 1u16;
64        for &t in chunk {
65            byte += ((t + 1) as u16) * mul; // {-1,0,1} -> {0,1,2}
66            mul *= 3;
67        }
68        out.push(byte as u8);
69    }
70    out
71}
72
73/// Unpack `out.len()` trits from packed bytes into the caller's buffer (zero-heap). Returns the
74/// number written (`min(out.len(), packed capacity)`).
75pub fn unpack_trits_into(packed: &[u8], out: &mut [i8]) -> usize {
76    let mut i = 0;
77    'outer: for &byte in packed {
78        let mut b = byte;
79        for _ in 0..TRITS_PER_BYTE {
80            if i >= out.len() {
81                break 'outer;
82            }
83            out[i] = (b % 3) as i8 - 1; // {0,1,2} -> {-1,0,1}
84            b /= 3;
85            i += 1;
86        }
87    }
88    i
89}
90
91/// Dequantize packed ternary → `f32` weights (`scale · trit`) into `out`. **Zero-heap**: digits are
92/// streamed straight from the bytes (no intermediate trit allocation). Writes `out.len()` values.
93pub fn dequantize_ternary(scale: f32, packed: &[u8], out: &mut [f32]) {
94    let mut i = 0;
95    'outer: for &byte in packed {
96        let mut b = byte;
97        for _ in 0..TRITS_PER_BYTE {
98            if i >= out.len() {
99                break 'outer;
100            }
101            out[i] = scale * ((b % 3) as f32 - 1.0);
102            b /= 3;
103            i += 1;
104        }
105    }
106}
107
108/// Encode a weight tensor to a self-describing ternary blob: `[scale: f32 LE][packed trits]`.
109/// (The element count is recovered from the tensor's shape in the container manifest.)
110pub fn ternary_blob(weights: &[f32]) -> Vec<u8> {
111    let (scale, trits) = quantize_ternary(weights);
112    let mut out = Vec::with_capacity(ternary_blob_len(weights.len()));
113    out.extend_from_slice(&scale.to_le_bytes());
114    out.extend_from_slice(&pack_trits(&trits));
115    out
116}
117
118// ── GPU ternary GEMM kernel + its CPU oracle ─────────────────────────────────────────────────────
119
120/// The WGSL ternary-GEMM compute kernel (STELLAR §A). Its CPU parity reference is
121/// [`ternary_gemm_cpu`], which mirrors it byte-for-byte (same trit extraction, add/subtract,
122/// end-scale). The GPU pipeline binds: `0` activations (`f32`), `1` packed trits (`u32` words),
123/// `2` `TernaryParams` uniform, `3` output (`f32`).
124pub const TERNARY_GEMM_WGSL: &str = include_str!("../shaders/ternary_gemm.wgsl");
125
126/// Extract the ternary value `{-1,0,+1}` at linear weight index `k` from packed trits — the exact
127/// operation `ternary_gemm.wgsl::trit_at` performs (5 trits/byte, base-3).
128#[inline]
129pub fn trit_at(packed: &[u8], k: usize) -> i32 {
130    let byte = packed[k / TRITS_PER_BYTE];
131    let pos = k % TRITS_PER_BYTE;
132    let mut b = byte;
133    for _ in 0..pos {
134        b /= 3;
135    }
136    (b % 3) as i32 - 1
137}
138
139/// **CPU oracle for `ternary_gemm.wgsl`.** Computes `out[m][i] = scale · Σ_j trit(W[i][j])·act[m][j]`
140/// where `packed` holds the row-major trits of an `(n_out × n_in)` weight matrix. The weight
141/// contributes by add/subtract only (the BitNet win); the per-tensor `scale` is applied once per
142/// output element. Zero-heap. Strides default to dense (`n_in` / `n_out`) when `0`.
143#[allow(clippy::too_many_arguments)]
144pub fn ternary_gemm_cpu(
145    activations: &[f32],
146    packed: &[u8],
147    scale: f32,
148    n_in: usize,
149    n_out: usize,
150    n_batch: usize,
151    in_row_stride: usize,
152    out_row_stride: usize,
153    out: &mut [f32],
154) {
155    let in_stride = if in_row_stride > 0 {
156        in_row_stride
157    } else {
158        n_in
159    };
160    let out_stride = if out_row_stride > 0 {
161        out_row_stride
162    } else {
163        n_out
164    };
165    for m in 0..n_batch.max(1) {
166        let in_base = m * in_stride;
167        for i in 0..n_out {
168            let row0 = i * n_in;
169            let mut acc = 0.0f32;
170            for j in 0..n_in {
171                let x = activations[in_base + j];
172                match trit_at(packed, row0 + j) {
173                    t if t > 0 => acc += x,
174                    t if t < 0 => acc -= x,
175                    _ => {}
176                }
177            }
178            out[m * out_stride + i] = scale * acc;
179        }
180    }
181}
182
183// ── 2-bit (pow-2) packing + branchless GEMM — GPU-optimal (external-review-driven) ───────────────
184//
185// Base-3 packing (above) is densest (1.6 bit) but the GPU kernel must unpack it with integer `/3`
186// and `%3` — dozens of cycles on Ampere — and a `trit>0/<0` branch causes warp divergence. The
187// 2-bit layout below trades 25% more bandwidth (2.0 bit) for **shift/mask unpack + fully branchless
188// math**. On a GPU the per-weight multiply is free (FMA), so the ternary win is *bandwidth +
189// occupancy*, not MAC-elimination — making this the right layout for the GPU resident path. (Base-3
190// remains the better on-disk/distribution format; the two can coexist — base-3 cold, 2-bit hot.)
191
192/// Trits packed 4-per-byte, 2 bits each: `0b00 = 0`, `0b01 = +1`, `0b10 = -1` (`0b11` unused).
193pub const TRITS_PER_BYTE_2BIT: usize = 4;
194
195/// The 2-bit code for a trit (matches `ternary_gemm_2bit.wgsl`).
196#[inline]
197fn trit_code_2bit(t: i8) -> u8 {
198    if t > 0 {
199        1
200    } else if t < 0 {
201        2
202    } else {
203        0
204    }
205}
206
207/// Bytes to pack `count` trits at 2 bits each (4/byte).
208#[inline]
209pub fn packed_trit_len_2bit(count: usize) -> usize {
210    count.div_ceil(TRITS_PER_BYTE_2BIT)
211}
212
213/// Pack ternary values into 2-bit codes, 4 per byte.
214pub fn pack_trits_2bit(trits: &[i8]) -> Vec<u8> {
215    let mut out = vec![0u8; packed_trit_len_2bit(trits.len())];
216    for (k, &t) in trits.iter().enumerate() {
217        out[k / TRITS_PER_BYTE_2BIT] |= trit_code_2bit(t) << ((k % TRITS_PER_BYTE_2BIT) * 2);
218    }
219    out
220}
221
222/// Trit value `{-1,0,+1}` at linear index `k` from 2-bit packing — the **branchless** mirror of
223/// `ternary_gemm_2bit.wgsl::pair_at` (`(code==1) - (code==2)`).
224#[inline]
225pub fn trit_at_2bit(packed: &[u8], k: usize) -> i32 {
226    let code = (packed[k / TRITS_PER_BYTE_2BIT] >> ((k % TRITS_PER_BYTE_2BIT) * 2)) & 3;
227    (code == 1) as i32 - (code == 2) as i32
228}
229
230/// The branchless 2-bit WGSL ternary-GEMM kernel; CPU oracle is [`ternary_gemm_cpu_2bit`].
231pub const TERNARY_GEMM_2BIT_WGSL: &str = include_str!("../shaders/ternary_gemm_2bit.wgsl");
232
233/// CPU oracle for `ternary_gemm_2bit.wgsl` — same math as [`ternary_gemm_cpu`], 2-bit packing +
234/// branchless accumulation.
235#[allow(clippy::too_many_arguments)]
236pub fn ternary_gemm_cpu_2bit(
237    activations: &[f32],
238    packed: &[u8],
239    scale: f32,
240    n_in: usize,
241    n_out: usize,
242    n_batch: usize,
243    in_row_stride: usize,
244    out_row_stride: usize,
245    out: &mut [f32],
246) {
247    let in_stride = if in_row_stride > 0 {
248        in_row_stride
249    } else {
250        n_in
251    };
252    let out_stride = if out_row_stride > 0 {
253        out_row_stride
254    } else {
255        n_out
256    };
257    for m in 0..n_batch.max(1) {
258        let in_base = m * in_stride;
259        for i in 0..n_out {
260            let row0 = i * n_in;
261            let mut acc = 0.0f32;
262            for j in 0..n_in {
263                // branchless: trit ∈ {-1,0,+1} as f32, then FMA
264                acc += trit_at_2bit(packed, row0 + j) as f32 * activations[in_base + j];
265            }
266            out[m * out_stride + i] = scale * acc;
267        }
268    }
269}
270
271/// Decode a [`ternary_blob`] of `count` weights into `out` (zero-heap dequant).
272pub fn dequantize_blob(blob: &[u8], out: &mut [f32]) {
273    if blob.len() < 4 {
274        for o in out.iter_mut() {
275            *o = 0.0;
276        }
277        return;
278    }
279    let scale = f32::from_le_bytes([blob[0], blob[1], blob[2], blob[3]]);
280    dequantize_ternary(scale, &blob[4..], out);
281}
282
283/// Rebake an on-disk base-3 [`ternary_blob`] (`[scale f32 LE][5-trits/byte]`) into the runtime
284/// **2-bit branchless** VRAM layout consumed by [`ternary_gemm_2bit.wgsl`] / [`ternary_gemm_cpu_2bit`].
285///
286/// D1 (STELLAR §A, measured on A2000): base-3 is the *archive/distribution* layout (1.6 bit, densest)
287/// but on the GPU its `/3`,`%3` unpack makes it **0.85× — slower than F16**; the 2-bit branchless layout
288/// (2.0 bit, shift/mask, divergence-free) is the **1.77×** win. So the live FFN-ternary path rebakes each
289/// base-3 FFN blob to 2-bit **once at resident load** (heap is the sanctioned load-time path; the hot
290/// loop stays zero-heap). `count` = the tensor's element count (from the manifest shape). Returns
291/// `(scale, packed_2bit)`; the dequantized values are bit-identical to the base-3 source.
292pub fn rebake_ternary_blob_to_2bit(blob: &[u8], count: usize) -> (f32, Vec<u8>) {
293    if blob.len() < 4 || count == 0 {
294        return (0.0, Vec::new());
295    }
296    let scale = f32::from_le_bytes([blob[0], blob[1], blob[2], blob[3]]);
297    let mut trits = vec![0i8; count];
298    unpack_trits_into(&blob[4..], &mut trits);
299    (scale, pack_trits_2bit(&trits))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn quantize_absmean_and_trits() {
308        // absmean = (2 + 2 + 0.1 + 0.1 + 0)/5 = 0.84
309        let w = [2.0_f32, -2.0, 0.1, -0.1, 0.0];
310        let (scale, trits) = quantize_ternary(&w);
311        assert!((scale - 0.84).abs() < 1e-5, "scale {scale}");
312        assert_eq!(trits, vec![1, -1, 0, 0, 0]);
313    }
314
315    #[test]
316    fn pack_unpack_round_trips_exactly() {
317        let trits: Vec<i8> = [1, -1, 0, 0, 0, 1, 1, -1, 0, 1, -1]
318            .iter()
319            .copied()
320            .collect();
321        let packed = pack_trits(&trits);
322        assert_eq!(packed.len(), packed_trit_len(trits.len())); // 11 trits -> 3 bytes
323        let mut back = vec![0i8; trits.len()];
324        let n = unpack_trits_into(&packed, &mut back);
325        assert_eq!(n, trits.len());
326        assert_eq!(back, trits);
327        // the known base-3 byte for [1,-1,0,0,0] = digits [2,0,1,1,1] = 2+9+27+81 = 119
328        assert_eq!(packed[0], 119);
329    }
330
331    #[test]
332    fn rebake_base3_to_2bit_preserves_gemm() {
333        // The on-disk base-3 blob rebaked to the 2-bit runtime layout must yield bit-identical trits,
334        // the same scale, and a byte-identical GEMM (A1b: this conversion is the make-or-break design
335        // fact — base-3 on GPU is slower than F16; 2-bit is the win, and it must be lossless).
336        let (n_out, n_in) = (3usize, 8usize);
337        let weights: Vec<f32> = (0..n_out * n_in).map(|i| (i as f32 * 0.37).sin()).collect();
338        let act: Vec<f32> = (0..n_in).map(|i| i as f32 * 0.5 - 1.0).collect();
339
340        let base3 = ternary_blob(&weights);
341        let base3_scale = f32::from_le_bytes([base3[0], base3[1], base3[2], base3[3]]);
342        let base3_packed = &base3[4..];
343
344        let (scale2, packed2) = rebake_ternary_blob_to_2bit(&base3, weights.len());
345        assert_eq!(scale2, base3_scale, "scale must be preserved");
346        for k in 0..weights.len() {
347            assert_eq!(
348                trit_at(base3_packed, k),
349                trit_at_2bit(&packed2, k),
350                "trit {k} mismatch"
351            );
352        }
353
354        let mut out_base3 = vec![0f32; n_out];
355        let mut out_2bit = vec![0f32; n_out];
356        ternary_gemm_cpu(
357            &act,
358            base3_packed,
359            base3_scale,
360            n_in,
361            n_out,
362            1,
363            0,
364            0,
365            &mut out_base3,
366        );
367        ternary_gemm_cpu_2bit(&act, &packed2, scale2, n_in, n_out, 1, 0, 0, &mut out_2bit);
368        for i in 0..n_out {
369            assert!(
370                (out_base3[i] - out_2bit[i]).abs() < 1e-6,
371                "row {i}: base3 {} vs 2bit {}",
372                out_base3[i],
373                out_2bit[i]
374            );
375        }
376    }
377
378    #[test]
379    fn blob_round_trips_with_scale() {
380        let w = [2.0_f32, -2.0, 0.1, -0.1, 0.0, 1.5, -1.5];
381        let blob = ternary_blob(&w);
382        assert_eq!(blob.len(), ternary_blob_len(w.len()));
383        let mut out = vec![0.0_f32; w.len()];
384        dequantize_blob(&blob, &mut out);
385        // reconstruction is scale * trit; for the strong weights it recovers ±scale.
386        let (scale, _) = quantize_ternary(&w);
387        assert!((out[0] - scale).abs() < 1e-5);
388        assert!((out[1] + scale).abs() < 1e-5);
389        assert_eq!(out[4], 0.0); // a near-zero weight quantizes to 0
390    }
391
392    #[test]
393    fn compression_ratio_is_about_1_6_bits_per_weight() {
394        let count = 4096;
395        let f32_bytes = count * 4;
396        let ternary_bytes = ternary_blob_len(count); // 4 + ceil(4096/5) = 4 + 820 = 824
397                                                     // ~1.6 bits/weight => ~20x smaller than f32, ~10x smaller than f16.
398        let ratio = f32_bytes as f64 / ternary_bytes as f64;
399        assert!(ratio > 19.0 && ratio < 21.0, "ratio {ratio}");
400        let bits_per_weight = (ternary_bytes as f64 * 8.0) / count as f64;
401        assert!(bits_per_weight < 1.7, "bits/weight {bits_per_weight}");
402    }
403
404    #[test]
405    fn ternary_gemm_cpu_matches_hand_computation() {
406        // W (n_out=2 × n_in=3) trits row-major: [[1,-1,0],[0,1,1]]; scale = 2.0; act = [1,2,3].
407        let trits: [i8; 6] = [1, -1, 0, 0, 1, 1];
408        let packed = pack_trits(&trits);
409        let act = [1.0_f32, 2.0, 3.0];
410        let mut out = [0.0_f32; 2];
411        ternary_gemm_cpu(&act, &packed, 2.0, 3, 2, 1, 0, 0, &mut out);
412        // out[0] = 2*(1*1 + -1*2 + 0*3) = -2 ; out[1] = 2*(0*1 + 1*2 + 1*3) = 10
413        assert_eq!(out, [-2.0, 10.0]);
414    }
415
416    #[test]
417    fn ternary_gemm_equals_dense_matmul_of_dequantized_weights() {
418        // ternary GEMM must equal a plain f32 matmul over the dequantized weights (scale·trit),
419        // since scale·Σ trit·x == Σ (scale·trit)·x.
420        let (n_in, n_out) = (7usize, 5usize);
421        let scale = 0.37_f32;
422        // arbitrary deterministic trits
423        let trits: Vec<i8> = (0..n_in * n_out).map(|k| (k % 3) as i8 - 1).collect();
424        let packed = pack_trits(&trits);
425        let act: Vec<f32> = (0..n_in).map(|j| (j as f32) * 0.5 - 1.0).collect();
426
427        let mut got = vec![0.0_f32; n_out];
428        ternary_gemm_cpu(&act, &packed, scale, n_in, n_out, 1, 0, 0, &mut got);
429
430        for i in 0..n_out {
431            let mut dense = 0.0_f32;
432            for j in 0..n_in {
433                let w = scale * trits[i * n_in + j] as f32; // dequantized weight
434                dense += w * act[j];
435            }
436            assert!(
437                (got[i] - dense).abs() < 1e-5,
438                "row {i}: {} vs {}",
439                got[i],
440                dense
441            );
442        }
443    }
444
445    #[test]
446    fn ternary_gemm_wgsl_parses() {
447        // naga parse-smoke (same gate render::contract uses for the viewport shaders): catches
448        // syntax/type regressions in the kernel on native CI. GPU pipeline validation is the
449        // wasm `portal`/`wasm-full` build when the kernel is wired into a dispatch.
450        naga::front::wgsl::Frontend::new()
451            .parse(TERNARY_GEMM_WGSL)
452            .unwrap_or_else(|e| panic!("ternary_gemm.wgsl parse failed: {e:?}"));
453    }
454
455    #[test]
456    fn all_zero_and_empty_are_safe() {
457        let (s, t) = quantize_ternary(&[0.0, 0.0, 0.0]);
458        assert_eq!(s, 0.0);
459        assert_eq!(t, vec![0, 0, 0]);
460        let (s2, t2) = quantize_ternary(&[]);
461        assert_eq!(s2, 0.0);
462        assert!(t2.is_empty());
463    }
464}