TERNARY — {-1, 0, +1} weights as a first-class citizen (TQ2_0) {: #ternary---1-0-1-weights-as-a-first-class-citizen-tq2_0 }¶
Design record, 2026-07-07. Covers the inference kernels, the encoders, the straight-through-estimator training op, the ternary-native evolution-strategy trainer, and GGUF interop. Each claim names the pipeline it covers.
Why TQ2_0 (and not a new format)¶
Fucina already carried ggml's ternary types end-to-end (DType.tq1_0/tq2_0,
GGUF type ids 34/35, cold scalar dot kernels) — decode/matmul only, no
encoder, no fast path, quantized weights frozen under autograd. This work
promotes TQ2_0 (2.0625 bits/weight: 256-element blocks, qs[64] 2-bit
crumbs storing w+1 ∈ {0,1,2}, inline fp16 scale d) to a first-class
format instead of inventing a Fucina-specific one:
- GGUF interop is free: llama.cpp reads/writes the same blocks
(
--dtype tq2_0inexport-ggufemitsgeneral.file_type = 37). - The layout is already SIMD-shaped: within each 32-byte group, crumb lane
Lcovers 32 consecutive activations, so unpack is shift+mask only. - The BitNet-native alternatives (bitnet.cpp
I2_S, block 128/64, per-tensor scale appended after the bits) differ only in bit order and scale placement; measured within ~6% of TQ2_0-class kernels on dot-capable CPUs (arXiv:2502.11880 Table: i7-13700H 3.8B, I2_S 35.04 t/s vs TQ2_0 33.19). - The TL1/TL2 lookup-table kernels (bitnet.cpp) win mainly on CPUs without
int8 dot instructions and on footprint (TL2: 1.67 bpw); they are GEMV-only
and need offline per-shape codegen. Deliberately not ported; recorded
as future work.
tq1_0(1.6875 bpw, base-3^5 packing) stays cold/compat.
The int8 flagship kernel (inference pipeline)¶
src/backend/quant/ternary.zig. The crumbs multiply as unsigned codes:
dot(w, a) = Σ (w+1)·a − Σ a
Q8_K activation blocks already carry bsums (per-16 sums), so Σ a is one
vector fold per 256-block and the hot loop is pure shift/mask + int8 group
dots — no weight multiplications anywhere:
- aarch64:
sdoton 16-byte granules (codes {0,1,2} are valid signed bytes). - x86 AVX-VNNI/AVX512-VNNI:
vpdpbusdon 32-byte granules (codes are u8). - x86 AVX2:
vpmaddubsw+vpmaddwd(+1)— a maddubs pair sum is at most 2·127·2 = 508, so the i16 stage cannot saturate (unlike bitnet.cpp's 4096-element i16 cadence, which is only statistically safe). - everywhere else: the portable
@Vectortwins of those primitives.
Every arm accumulates the exact per-block integer (max |Σ(w+1)a| = 65024,
far inside i32), so all arms are cross-ISA bitwise identical to the cold
scalar reference — pinned by ternary_tests.zig (hot vs cold bitwise) and by
zig build x86dot-check (tq2_0 section; aarch64 sdot, x86 AVX2-maddubs, and
x86 AVX-VNNI arms all hardware-executed 2026-07-07 — see the x86 addendum).
The x4 column-interleaved pack (BlockTQ2_0x4, packMatmulRhsTQ2_0x4,
matmulTQ2_0X4RhsRange) rearranges 4 columns' blocks in 4-byte granules —
same bytes, no padding, n % 4 only — so the by-element sdot accumulates
each column in its own i32 lane and the per-block horizontal @reduce
disappears; the f32 block tail becomes four vector ops with the identical
per-column operation order, so it stays bitwise identical to the row
kernel and the cold path (pinned in ternary_tests.zig). Four independent
per-crumb-plane accumulators keep the dot chains at depth 16 — a single
accumulator serializes all 64 dots behind its latency and measures 1.7x
SLOWER, the load-bearing lesson of this layout. Measured on M1 Max
(bench-ternary interleaved A/B, medians of 100, three runs): 1.06-1.10x
over the row kernel at every m in {1,4,32,128} on both bench shapes;
single-thread m=1 decode moves from ~30% to ~33% of the measured DRAM
ceiling. On x86 the kernel takes a
ymm-granule body — one contiguous 32-byte pack load carries two adjacent
k-groups x 4 columns, activations broadcast dword-wise, vpdpbusd (VNNI)
or vpmaddubsw+vpmaddwd (AVX2) accumulating 8 lanes folded 8→4 once per
block, mirroring the Q4_Kx8 x86 shape; other ISAs run its portable twins.
Cross-ISA bitwise parity is pinned by zig build x86dot-check (tq2_0x4
section), executed natively on M1, under Rosetta 2 (real-x86 portable
tier), and on a validated x86-64 emulator (AVX2 arm) with bit-equal x86
checksums; the
VNNI arm is compile-verified pending AVX-VNNI hardware (the checker's
attestation table has the dated rows).
Tiles process 4 weight rows per activation pass (blockCodeDot4: shared
activation vectors and bsum total), with the standard row/column parallel
split (vector.matmul2DTQ2_0RhsIntoWithConfig); native.zig routes
.ggml_tq2_0 matmuls here (scalar backend intentionally keeps the cold
reference path). Constraint: the contract dim k must be a multiple of 256.
Measured (M1 Max, single thread, ReleaseFast, zig build bench-ternary,
2026-07-07):
| shape | m | cold µs | hot µs | hot/cold | Q4_K µs | dense f32 µs |
|---|---|---|---|---|---|---|
| n=4096 k=4096 | 1 | 1013 | 238 | 4.25x | 525 | 16725 |
| n=4096 k=4096 | 128 | 130767 | 31260 | 4.18x | 69834 | 5983* |
| n=11008 k=4096 | 1 | 2740 | 667 | 4.11x | 1391 | 31527 |
| n=11008 k=4096 | 128 | 352930 | 86815 | 4.07x | 182780 | 8086* |
* dense f32 goes through Accelerate (multi-core AMX) at those shapes; the quant kernels are pinned single-thread here by design. The hot kernel is ~2.1x the tuned Q4_K row kernel at equal shapes — the 2.06-vs-4.5 bpw ratio — and hot-vs-cold checksums match bitwise in ReleaseFast. Scaling is linear in m at ~3 SIMD ops per 16 weights (shift, mask, sdot): the kernel sits at the NEON ALU limit (~250 µs theoretical for the 4096² GEMV vs 238 µs measured), so column-outer re-blocking buys nothing on this target; the multi-thread split is where prefill throughput comes from.
x86 addendum (i9-13950HX Raptor Lake, Linux, single thread, ReleaseFast,
-Dblas=openblas, 2026-07-07):
| shape | m | cold µs | hot µs | hot/cold | Q4_K µs |
|---|---|---|---|---|---|
| n=4096 k=4096 | 1 | 985 | 193 | 5.10x | 924 |
| n=4096 k=4096 | 128 | 127171 | 25323 | 5.02x | 123093 |
| n=11008 k=4096 | 1 | 2701 | 534 | 5.06x | 2664 |
| n=11008 k=4096 | 128 | 346312 | 68714 | 5.04x | 345716 |
5.0–5.1x the cold path and ~4.8x Q4_K on this box (vpdpbusd consumes 32
bytes/instruction). Hardware attestation: full zig build test green
natively (AVX-VNNI arms), x86dot-check PASS both natively and with
-Dcpu=x86_64_v3 (AVX2-maddubs arm, avxvnni=false) — the two arms'
checksums are bit-equal (b1f84dde82d0c0a4); coverage table in
src/x86dot_check.zig. es-ternary-spirals end-to-end on the same box:
100% accuracy in 9250 iterations, 112.7 s (vs M1 Max: 100% in 14750
iterations, 104.4 s).
The mul-free f32 path (training-forward pipeline)¶
For STE training the forward runs exact f32 activations (no activation quantization) yet still multiplication-free, via the sign-plane/zero-plane identity — exact in IEEE fp32:
w·x = (x XOR s) AND m s = 0x80000000 where w == −1 else 0
m = 0xFFFFFFFF where w != 0 else 0
dotTQ2_0F32 fixes a 4-lane accumulation order, so this path is bitwise
reproducible across every ISA (pinned by an order-matched scalar replica in
tests and x86dot-check). It is a correctness-first path: ~15x slower than the
int8 flagship at GEMV (3.5 ms vs 238 µs on the 4096² shape) but exact.
Encoders (both pipelines)¶
quantizeRowTQ2_0Into: ggmlquantize_row_tq2_0_refparity — per-block absmaxd,round-half-away(x/d), crumbnof bytemcovers elementm + n*32of each 128-group.quantizeRowTQ2_0ScaledInto+ternaryAbsmeanScale: the BitNet b1.58 recipe — per-tensord = clamp(mean|W|, 1e-5, ∞),clamp(round(W/d),−1,+1)(arXiv:2504.12285; the 1-bit-LLMs training FAQ). Every block stores the samed, so the result is plain valid TQ2_0.quantizeRowForDType(.tq2_0, ...)routes;gguf.encodeF32/decodeF32gained.tq2_0arms;export-gguf --dtype/--experts-dtype tq2_0works, with non-256-divisible tensors falling back to their source dtype exactly like the other 256-block targets.
STE training op (training pipeline)¶
Tensor.dotTernarySte(ctx, weight, contract_tag) (src/ag/tensor.zig):
latent f32 weight [out, in], forward = absmean-encode + mul-free f32
matmul; backward = dx = gy · dequant(W_q) (the quantized weights, matching
what the forward computed) and dW = gyᵀ·x — the pure identity STE, no
clipping or masking, exactly the BitNet recipe (w + (Q(w) − w).detach()).
The encoded blocks live in the backward node and are freed with it. Pinned
by gradcheck (dx), an explicit STE-identity test (dW ≡ plain matmul VJP),
and exec-scope lifecycle tests.
Ternary-native evolution strategies (training = inference)¶
src/es.zig grows a second slot kind: genomes ARE packed []BlockTQ2_0
(the block scales d are never touched — fix them at init, e.g.
1/sqrt(k·2/3) for uniform trits). No latent floats exist for ternary
slots, so the state you train is byte-for-byte the state you serve, and
members are evaluated through the real int8 flagship kernels.
Adapted from EGGROLL's integer recipe (arXiv:2511.16652, App. H — their finding that sparse single-bin updates improve stability transfers whole):
- perturb: sparse trit flips regenerated from a counter stream in a new
es_tritsRNG domain (a pure function of seed/iteration/member — the existing O(1)-memory contract);max(1, rate·len)flips of ±1 with clamp; antithetic odd members mirror deltas. Restore replays a sparse (index, old-crumb) undo log in reverse — clamping is lossy, so regenerate-subtract cannot work for ternary. - update: the existing reward shaping (z-score / centered-ranks /
antithetic fold) feeds fitness-weighted votes on touched indices; the
top-K by |vote| (ties by index — deterministic) move one bin toward
sign(vote), clamped;
K = round(update_fraction·len / (1 + decay·t)). - Config:
ternary_flip_rate(0.001),ternary_update_fraction(0.005),ternary_update_decay(0.0) — checkpoint contracts, persisted ases_ternary_*inTrainerState. Float and ternary slots coexist in one trainer (biases/scales stay Gaussian-ES floats); the float noise streams are untouched (pinned bitwise by a mixed-trainer test).
zig build es-ternary-spirals is the acceptance demo: a 2→256→256→2 MLP
whose hidden and output layers are packed ternary genomes trained from
random trits by ES — every member evaluation runs quantizeRowsQ8_K +
matmulTQ2_0RhsRange, i.e. the deployed inference path, and the run
self-verifies.
Q2_0 — the Bonsai g128 sibling (addendum, 2026-07-15)¶
DType.q2_0 (ggml type 42, PrismML/Bonsai Q2_0_g128) carries the same
{-1, 0, +1} alphabet in a different envelope: 128-element blocks (fp16 absmax
scale + 32 code bytes, four sequential LSB-first 2-bit codes per byte,
2.125 bpw deployed), decode (q-1)·d with code 3 = +2d wire-contract-only
(the reference encoder emits {0,1,2}). It ships first-class alongside TQ2_0:
parity encoder + decoder, hot mul-free kernels over Q8_0 row activations
(k must only be a multiple of 128 — no Q8_K/256 machinery), the same
Σ(q-1)a = Σq·a − Σa bsum identity with per-row bsum/scale caches shared
across output columns, two LHS rows sharing every weight unpack, and a
fixed 4-lane sub-block float accumulator (one vector FMA per 128-block,
pairwise-folded once per output element — the dotTQ2_0F32 discipline), all
arms bitwise identical to the scalar reference (dotQ2_0RowQ8_0). Prefill
(m >= 192) switches to dequantized f32 k-slice panels on the BLAS GEMM
(beta=1 accumulation across slices, so every GEMM stays full-width with a
contiguous C — the same dequant-to-BLAS split llama.cpp's BLAS backend makes
for quantized prefill), while decode stays on the int8 path. It is the
weight format of Ternary-Bonsai-27B (a ternarized Qwen3.6-27B on the
qwen35 hybrid arch; REFERENCE.md §14.3): embeddings, attention, MLP and
LM head all .q2_0, logit-parity-validated against the PrismML llama.cpp
fork. STE training and ternary-native ES remain TQ2_0-only.
Limits and future work¶
- k (contract dim) must be a multiple of 256 everywhere (block granularity);
dotTernarySterejects other shapes with a clear error. (q2_0inference needs only k % 128 == 0 — its LHS is Q8_0 rows, not Q8_K.) tq1_0remains decode/cold-matmul only.- TL2-style LUT kernels (1.67 bpw, pshufb/tbl) — worth revisiting only for non-dot-product CPUs or footprint-bound deployments.
dotTernaryStere-encodes the latent weight every forward (inherent to STE); a pre-encoded-RHS overload for frozen/tied weights is future work.- The ES vote sort is a full sort of touched entries; a partial top-K select under the same pinned order is a drop-in future optimization, and the flip/undo/vote engine could be factored over an element codec if a second discrete genome kind (int8 EGGROLL, TQ1_0) ever lands.
- GPU legs (Metal/CUDA) deliberately out of scope for this record.
Provenance¶
Kernel-structure and format lineage: ggml/llama.cpp (MIT) — see
docs/THIRD-PARTY-NOTICES.md; local reference clone refs/llama.cpp
(ggml/src/ggml-quants.c, ggml/src/ggml-cpu/arch/{arm,x86}/quants.c).
Recipes: BitNet b1.58 (arXiv:2504.12285 + the 1-bit-LLMs training FAQ),
bitnet.cpp kernel taxonomy (arXiv:2410.16144, 2502.11880), T-MAC LUT
technique (arXiv:2407.00088) — compared against, not ported. ES update:
EGGROLL (arXiv:2511.16652), machinery adapted, no code ported.