File size: 9,379 Bytes
28c70af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# `bankML/q2_0.rs` — the ternary Q2_0_g64 kernel

## Summary

`q2_0.rs` multiplies `Q2_0` weights (ggml type 42, group 64) exactly as mainline ggml does at **b11192**, read from
the tag's source. A block is 18 bytes: an f16 scale `d` first, then 16 bytes of 2-bit codes, least significant bits
first. Code `c ∈ {0,1,2,3}` decodes to `(c − 1)·d`, that is {−1, 0, +1, **+2**}: 2.25 bits per weight. The Bonsai
files are ternary (in Ternary-Bonsai-8B the split is 31 % / 38 % / 31 % / 0 %), but the format is not, so every kernel
here accepts code 3.

As with Q1_0, the activation is quantized to `q8_0` first (the same AVX2 quantizer, `q1_0::quantize_row_q8_0`), and
one Q2_0 block meets two q8_0 blocks.

**On x86, llama.cpp b11192 has no Q2_0 kernel.** `arch-fallback.h` renames the generic C to
`ggml_vec_dot_q2_0_q8_0`; there is no repack or sgemm case. The shipped haswell symbol is scalar code with 64 `imul`
per block, used one (row, column) pair at a time for decode and prefill. bankml's AVX2 kernel gives the same bits much
faster. The native forward pass (`forward.rs`) calls `mat_vec_par` for decode and `mat_mul_par` for prompt
micro-batches.

## Technical usage

Constants: `QK2_0 = 64`, `Q2_0_BYTES = 18`.

```rust
pub fn dequantize_row(blocks: &[u8], out: &mut [f32])         // bit-exact with dequantize_row_q2_0
pub fn vec_dot_ref(n: usize, x: &[u8], y: &[u8]) -> f32        // scalar model of the shipped haswell symbol (FMA)
pub fn vec_dot_ref_nofma(n: usize, x: &[u8], y: &[u8]) -> f32  // the same C as the baseline libggml-cpu-x64.so (no FMA)
```

`vec_dot_ref` is the reference for the node's kernel. `vec_dot_ref_nofma` is not used for inference; it is kept so
that both shipped builds of ggml are proven.

### Prepared activations

```rust
pub struct Q8Act2 { /* private */ }
impl Q8Act2 {
    pub fn from_q8_0(n: usize, q8: &[u8]) -> Self
    pub fn quantize(x: &[f32]) -> Self
    pub fn n(&self) -> usize
    pub fn qs(&self) -> &[i8]
}
pub fn vec_dot_act(x: &[u8], a: &Q8Act2) -> f32
pub fn vec_dot_act_scalar(x: &[u8], a: &Q8Act2) -> f32
pub unsafe fn vec_dot_act_avx2(x: &[u8], a: &Q8Act2) -> f32
```

`Q8Act2` lays out a q8_0 activation once per token so that no shuffle touches the weights. Blocks are grouped in
quads (A, B, C, D); a 256-bit register holds the code bytes of A and B, and `(v >> 2r) & 3` gives code `r` of every
byte. The activation stores, per pair and per `r`, the matching quants. Scales (f32) and Σq per q8 block are stored in
the lane order `hadd` leaves. Tail blocks (`nb % 4`) keep a plain layout. Storage is 64-byte aligned.

`vec_dot_act` picks the AVX2 kernel when `q1_0::has_avx2()`, else `vec_dot_act_scalar`. Both have the bits of
`vec_dot_ref` on the q8_0 bytes the activation came from.

### Matrix products

```rust
pub fn mat_vec(w: &[u8], rows: usize, a: &Q8Act2, out: &mut [f32])
pub fn mat_vec_par(pool: &crate::par::Pool, w: &[u8], rows: usize, a: &Q8Act2, out: &mut [f32])
pub fn mat_mul(w: &[u8], rows: usize, cols: &[Q8Act2], out: &mut [f32])
pub fn mat_mul_par(pool: &crate::par::Pool, w: &[u8], rows: usize, cols: &[Q8Act2], out: &mut [f32])
pub unsafe fn vec_dot_1x4_avx2(x: &[u8], a: [&Q8Act2; 4]) -> [f32; 4]
```

`mat_mul*` writes `out[c·rows + r]`, using 1×4 tiles (one row, four columns, codes expanded once) where the CPU has
AVX2. Every element has the bits of ggml's per-pair vec_dot, at any thread count.

## How it is verified

- Unit tests: `dequantize_layout_scale_first_2bit_lsb_first` (all four codes), `ref_matches_dequantized_math`
  (against f64), `act_paths_bit_exact_with_ref_randomized` (3,300 cases: 4-block bodies and tails, q = −128, code 3,
  and activations through the quantizer), `par_bit_exact_with_single_thread`, `mat_vec_and_mat_mul_bit_exact_with_ref`.
- The ggml oracle `oracle_ggml_b11192_real_ternary_bonsai_8b` (`#[ignore]`, needs the model and
  `.models/oracle-ternary` from `testing/ggml_oracle.py`), weights read through `gguf::Mmap`. Recorded
  ([oracles.md](../oracles.md) §1, [PERFORMANCE.md](../PERFORMANCE.md)): all 254 Q2_0 tensors, 8,188,239,872 weights,
  dequantized bit-exact; 762/762 q8_0 rows byte-exact; 762/762 dot products bit-exact against the haswell build (scalar
  model, AVX2 and portable paths); the no-FMA model equals the baseline x64 build in 762/762. The two ggml builds differ
  from each other in 19 of 762, so the oracle does distinguish float orders.
- A/B against the live library (`BANKML_GGML_LIB`): `ab_vs_ggml_q2_0` (real layer-0 weights, every Qwen3-8B
  projection shape and `output`) and `decode_budget_q2_0` (all 253 matmuls of one token, `BANKML_THREADS`, default
  `1,3`). Both check agreement before timing.

```sh
BANKML_GGML_LIB=/path/to/llama-b11192 cargo test --release -- --ignored ab_vs_ggml_q2_0 --nocapture --test-threads=1
```

## Advantages and efficiency

- **A missing kernel, supplied with the same bits.** The ternary gap in llama.cpp b11192 is not a property of ternary
  weights but of the scalar fallback. bankml keeps ggml's float chain
  (`sumi = fma(d1₁, s₁, fma(d1₀, s₀, 0))`, `sumf = fma(d0, sumi, sumf)`, block after block) and reorders only the
  integer sums, which are exact in any order.
- **The activation moves, not the weights.** Weights are read straight from the file layout. Two blocks share one
  256-bit register; codes come out with a shift and a mask, and `maddubs(c, q) − Σq` gives `Σ(c−1)·q` with unsigned
  codes. The activation is permuted once per token (11 µs at n = 4096, 29 µs at n = 12288, about 0.5 % of the matmul
  time).
- **Measured** (laptop, Ryzen 3 3200U, [PERFORMANCE.md](../PERFORMANCE.md)): decode GEMV 9.1–10.6× ggml over four runs
  (`output` 151669×4096: 492 → 51 ms); prefill 1×4 tile 12.3–13.1×. One whole token's 253 matmuls with the model
  resident: 0.231 s at three threads against ggml's 2.185 s (9.45×), 0.435 s against 4.145 s at one thread. At three
  threads bankml's ternary matmuls take less time than ggml's 1-bit ones (0.23–0.25 s against 0.34–0.35 s), although
  the ternary weights are twice the bytes.
- **Where it sits.** About 2× above the measured memory floor (15–17 GB/s on the laptop); compute-bound per core and
  at its instruction-throughput limit on Zen+. Three further experiments (repacked quads, a two-row tile, software
  prefetch) were bit-exact and not reliably faster, and are kept in `testing/experiments/` (0.0.5).
- **Upstream.** The same plain-AVX2 approach was prepared as a C drop-in for llama.cpp (`upstream/`): bit-exact on
  200,000 of 200,000 cases and 3.4× ggml's shipped scalar kernel (0.2.0).
- **Practice.** Run-time feature detection; `#[target_feature(enable = "avx2,fma,f16c")]` functions with documented
  safety contracts behind safe wrappers; `Q8Act2` fields private since 0.0.2 because the kernels trust `n`; aligned
  `Line` storage so no 32-byte activation load straddles a cache line (not measured to matter; kept because it cannot
  cost).

## Design notes

- **Source and binary.** The format and kernels were read from the b11192 tag's source on 2026-09-26. The shipped
  haswell symbol was read from the disassembly: the generic C, unvectorised (64 `imul` per block), with GCC's
  default FP contraction turning `sumi += d1·s` (twice per block) and `sumf += d0·sumi` into `vfmadd231ss`. Decode
  and prefill both run through it one (row, column) pair at a time. This scalar fallback is where bankml's ternary
  speed advantage comes from (an earlier source note put it at 5×; the current measurements are under Advantages).
- **Two shipped builds.** `vec_dot_ref` models the haswell build (FMA). `vec_dot_ref_nofma` models the baseline
  `libggml-cpu-x64.so` (SSE only, no FMA), where each multiply and add rounds separately.
- **No saturation.** In `dot2`, `maddubs(c, q)` with `c ≤ 3` and `q ≥ −128` stays within ±768 per i16 lane, and the
  sum of the four code planes within ±4·768, so the integer sums are exact.
- **`Q8Act2` fields.** 0.0.1 made `n`, `d` and `sum` public, so safe code could send the AVX2 kernels past their
  buffers; they are private since 0.0.2 and set only by `from_q8_0`/`quantize`.
- **`Line` alignment.** The 64-byte alignment was not measured to matter on the dev box, whose run-to-run swings are
  the boost clock (both kernels move together); it is kept because it cannot cost.

## Limitations

- x86_64 AVX2 is the only SIMD path; elsewhere the portable scalar path runs with the same bits. NEON, AVX-512 / VNNI
  and a Zen 3 row are open ([TODO.md](../TODO.md)).
- The oracle uses real weights with synthetic activations; bit-exactness on dumped real activations is open.
- The whole-token figures need the 2.31 GB file resident in the page cache. When memory is short, every token re-reads
  weights from disk and the gates measured 4.5–5.2 s per token for both runtimes ([PERFORMANCE.md](../PERFORMANCE.md)).
- Legacy group-128 Q2_0 files, PrismML's `PQ2_0` and Bonsai 2's rotated basis are refused by the guard
  ([gguf.md](gguf.md)); there is no kernel for them. The ternary GPU kernel is open (TODO 0.5.0).

## See also

- [q1_0.md](q1_0.md) (the quantizer, f16 helpers and `has_avx2`), [par.md](par.md), [gguf.md](gguf.md)
- [../oracles.md](../oracles.md) §1–2 and §6, [../PERFORMANCE.md](../PERFORMANCE.md),
  [../TECHNICAL.md](../TECHNICAL.md) §III.5, §III.8, [../TODO.md](../TODO.md)