Engineering · Research

Custom Ternary Quantization Kernel for llama.cpp / ggml

A CPU-only ternary ({−1, 0, +1}) quantization type and hand-tuned AVX2 kernel, built and evaluated end to end — from initial kernel design through an accuracy failure, its root-cause diagnosis, and an honest speed verdict.

Report date
Target hardware
Lenovo ThinkPad P50
Model tested
MiniCPM5-2B
Bottom line

The kernel works and is correct, but it is not faster than stock. Restricted to the right layers the model stays usable (PPL 94.81, 4.9× stock), yet it runs 5.4× (prompt) and 4.7× (generation) slower than stock Q4_K_M on this CPU.

Use stock Q4_K_M via Ollama / llama.cpp for real inference on this hardware. The value of this project is what it demonstrated, not a deployable speedup.

01Hardware & environment

ComponentSpecification
MachineLenovo ThinkPad P50
CPUIntel Core i7-6820HQ (Skylake, 4 cores / 8 threads) — AVX2 / FMA3, no AVX-512
GPUNVIDIA Quadro M1000M/M2000M (2015 Maxwell) — no tensor cores, negligible llama.cpp/vLLM CUDA-backend support; treated as CPU-only inference target
RAM64 GB — the real ceiling on model size, not the GPU
OS / toolchainWindows, MSVC (Visual Studio 18 2026 generator), CMake
Runtime baseCustom fork of llama.cpp / ggml (llama.cpp-custom), built alongside stock llama.cpp and Ollama for baseline comparison
Why this hardware matters

No AVX-512 rules out the wide-SIMD tricks modern quantization kernels lean on; the 2015-era discrete GPU is not a viable inference accelerator. This is a genuinely CPU-only, AVX2-ceiling optimization problem — closer to the constraints real edge and embedded deployments face than to a modern workstation.

02Goal

Design a custom 2-bit ternary quantization type (tern_custom: 32 weights per block, {−1, 0, +1} codes, 2.5 bits/weight — smaller than Q4_K_M's ~4.5 bits/weight) for ggml, wire it into a working llama.cpp build, and see how close a hand-optimized CPU kernel could get to (or beyond) stock llama.cpp / Ollama throughput, while keeping the resulting model actually usable.

03Kernel build-out (LUT steps 1–6)

Built incrementally, correctness-verified at every step before trusting a speed number.

StepWhat changedEffect
1–3Block format (block_tern_custom: {fp16 d; uint8 qs[8]}, 10 bytes per 32 weights), scalar quantize / dequantize / dot-product, wired into ggml's type-trait tablesCorrect but slow reference implementation
4Activation quantization changed from raw f32 to Q8_0-style int8 blocks (block_tern_q8_act), reusing one quantized activation row across every weight row instead of re-reading f32 every callReduced redundant work per mul_mat call
5Wired TERN_CUSTOM into gguf-py so real GGUF files could be produced from an existing modelMade end-to-end model conversion possible
6Hand-rolled AVX2 2×2 blocked microkernel using ggml's nrc/nrows mechanism (2 weight rows × 2 activation columns per call, amortizing unpack + load across 4 outputs)Correctness-verified 1.89× prompt-processing speedup in isolated testing — see §7 for why this didn't hold up in the real build

Structural dead ends investigated and ruled out

ggml's IQP panel-GEMM path is hardcoded to a fixed list of existing quant types and requires 256-element superblocks, incompatible with tern_custom's 32-weight blocks without a breaking redesign. llamafile_sgemm's templated tinyBLAS kernels are likewise hardcoded to a fixed type list in C++. Both would require substantial upstream-style rewrites, not kernel tuning, to support a custom type — so kernel work focused on the ordinary vec_dot path instead.

04First reality check: custom vs. stock, before any accuracy testing

Once step 6 was benchmarked, the initial throughput comparison against stock Q4_K_M looked like this (subsequently found to be overstated — see §7):

Prompt processing (claimed)
~8.9× slower
than stock Q4_K_M
Generation (claimed)
~6.5× slower
than stock Q4_K_M

The direct question at this point was: “so should I use custom llama or oob [out-of-box] ollama?” The answer at the time was stock Ollama, given the real numbers. That recommendation held for the rest of the project.

05Stretch goal: “make custom at least 4× stock”

Explicitly flagged as very likely unreachable before attempting it, grounded in two things:

  • BitNet.cpp's own published numbers never claim a speedup over stock on this class of comparison — only against fp16/fp32 baselines.
  • A local lookup-table (TL1/TL2-style) experiment on this exact hardware showed only a ~1.6× ceiling over the existing AVX2 kernel — not 4× — because on this older, AVX2-only, no-AVX-512 CPU, memory-latency-bound table lookups don't out-pipeline well-scheduled SIMD compute.

The user chose to keep 4× as a stretch goal and proceed regardless.

06Accuracy investigation: a catastrophic failure, and its real cause

6.1 The discovery

Running llama-perplexity for the first time — previously only throughput had been measured, never output correctness — revealed the model was producing near-random output.

Stock Q4_K_M
PPL 19.45
baseline
Naive full-ternary
PPL 1,380,009
~70,000× worse

6.2 First hypothesis: double quantization

Ruled out

The model had been converted Q4_K_M → dequantize to fp32 → ternary-quantize, compounding two lossy, uncalibrated steps. The quantizer was fixed directly, with every change tested on real numbers rather than assumed.

  • Calibrated per-block threshold — grid search over 7 threshold ratios per block, minimizing MSE, instead of a fixed 0.5× cutoff. Measured 25.7% lower reconstruction MSE on synthetic weight data.
  • Correct scale estimator — per-block scale as mean(|x|) over only the nonzero-coded elements (the proper TWN formula), not the whole block including zeros.
  • GPTQ-style error-feedback compensation — tested, then disabled. Implemented, then measured before shipping: it made MSE worse at every tested feedback strength (+17% at 0.5×, +34% at 0.7×). Real GPTQ's compensation is only valid because it is weighted by a per-layer Hessian from calibration data; two arbitrary adjacent 32-weight blocks have no such relationship, so the “compensation” just injects uncorrelated noise. Shipped disabled by default rather than kept for the sake of matching the original ask.
  • Mixed precision — embeddings, output projection, and attention q/k/v/output projections kept at source precision; only FFN up/gate/down weights ternary-quantized.
Result

All-FFN-tensors-ternary PPL with every fix applied: 1,312,384 — barely moved (5%) from the naive 1,380,009. The quantizer fixes were real and independently verified correct (30 sampled FFN tensors: uniform ~90% cosine similarity between original and reconstructed weights, a normal result for single-layer post-training ternary quantization) — but they were not the actual problem.

6.3 Root cause: 42 layers of compounding error

~18% per-tensor reconstruction error, individually reasonable, compounds through 42 stacked transformer layers' residual stream into near-total signal destruction at the output — the documented failure mode of post-training ternary quantization without fine-tuning. Real BitNet models are trained ternary from scratch or through quantization-aware fine-tuning; this is a fundamental limitation of quantizing a finished model after the fact, not a bug in this project's code.

6.4 The fix: layer-subset quantization, and a hard cliff

Added --ffn-layers to the converter, restricting ternary quantization to a specific range of transformer blocks. Count and position were swept across MiniCPM5-2B's 42 layers.

ConfigLayers quantizedPPLRelative (log scale)
Stock Q4_K_M019.45
Middle 8 (17–24)824.89
Middle 16 (13–28)1645.31
Middle 24 (9–32) — recommended2494.81
Back 24 (18–41)24434.87
Front 24 (0–23)2450,724
Middle 30 (6–35)30192,623
Middle 36 (3–38)361,609,348
All 42421,312,384

Bar length is proportional to log₁₀(PPL); each step to the right is roughly an order of magnitude worse.

Two findings

  • Front layers are catastrophically more sensitive than back layers, which are more sensitive than middle layers, at matched layer count — a >500× spread (front-24 = 50,724 vs. back-24 = 434.87 vs. middle-24 = 94.81) for the same number of layers, just different ones. Early layers build the base representations everything downstream depends on.
  • There is a hard cliff, not a slope. Widening the quantized middle window from “protect 9 layers each edge” to “protect 6” jumps PPL ~2,000× (94.81 → 192,623) for losing protection on just 3 layers per edge — a small number of specific early layers are disproportionately load-bearing for model coherence.
Recommended configuration

--ffn-layers 9-32 — 24 of 42 layers ternary, first/last 9 protected. PPL 94.81 (4.9× stock) vs. ~71,000× for naive full-ternary. Model file: minicpm5-tern-24layers.gguf.

07Throughput of the accuracy-fixed model, and a real compiler bug

7.1 The honest number

Stock Q4_K_MCustom (9–32, 24/42 layers ternary)Full ternary (all 42)
Prompt processing48.0 tok/s8.85 tok/s (5.4× slower)5.386 tok/s (8.9× slower)
Generation15.3 tok/s3.25 tok/s (4.7× slower)2.359 tok/s (6.5× slower)
The core finding

The accuracy-fixed model is slower than stock, not faster — and mechanically it can't be otherwise: reducing the ternary footprint (needed for accuracy) removes FLOPs from the slow custom kernel; it doesn't add FLOPs to the fast stock kernel. “Correct” and “faster than stock” were never fully compatible goals on this hardware with this kernel.

7.2 A real bug found while chasing more speed

Investigating why the custom kernel was so much slower than stock surfaced a genuine, independent bug. ggml-tern-custom.c was compiled as part of the ggml-base CMake target (alongside architecture-generic files like ggml.c), not ggml-cpu — the only target that ever receives CPU arch flags (-march=native / /arch:AVX2). Compounding that, this is an MSVC build, and MSVC signals AVX2 via ggml's own GGML_AVX2 macro, not the GCC/Clang-style __AVX2__ the kernel's guards checked for.

Net effect

Every #if defined(__AVX2__) branch in the kernel — all of LUT steps 4–6's AVX2 work, including the 2×2 blocked microkernel — was dead code in every real llama-bench / llama-perplexity run in this entire project, silently falling back to the scalar reference path. A separate standalone correctness/benchmark harness (CustomKernelTest.exe) never caught this because it set its own AVX2 compiler flag independently of the real build.

Fixed in both places (CMake target-level compile flags, and the source guards widened to check GGML_AVX2 too). The fix was independently verified to have actually compiled in — not just assumed from a rebuild log: the generated MSVC project file was inspected directly for the flag, and object-file timestamps confirmed a genuine recompile and relink, not a stale binary.

7.3 Re-benchmarked: no change

Prompt processing
8.17 tok/s
was 8.85 before the fix
Generation
3.12 tok/s
was 3.25 before the fix

Both are within noise, if anything marginally lower.

Interpretation

A useful negative result. The kernel's real-world bottleneck is not arithmetic throughput, where AVX2 helps: a 32-weight, 10-byte ternary block is small enough that reading it from RAM and unpacking 2-bit codes is likely dominated by memory latency and per-block overhead, not SIMD lane throughput once data is in registers.

The standalone test's 3.33× speedup was measured in a cache-friendly microbenchmark loop reusing the same block repeatedly — a very different access pattern from streaming once through a full weight matrix per token. This is consistent with the earlier ~1.6× lookup-table ceiling (§5): the kernel is memory-bound on this hardware, not compute-bound, and further arithmetic-side tuning has limited headroom.

08Where the project stands

Delivered
A correct kernel
2.5 bits/weight custom type wired end to end into a working llama.cpp build, with a converter that produces a usable model (PPL 94.81, 4.9× stock — not degenerate) when restricted to the right layers.
Not a speed win
4.7–5.4× slower
than stock on this hardware, even after fixing a genuine AVX2-compilation bug. Memory-bound, not compute-bound.
Explicitly rejected
Further tuning
A scoped lookup-table (TL1/TL2-style) mul_mat rewrite measured a ~1.6× ceiling — not enough to close a 4.7–5.4× gap. Decision: stop rather than pursue it.

09Recommendation

Use stock Q4_K_M via Ollama / llama.cpp for actual inference on this hardware. This project's value is what it demonstrated, not a deployable speedup:

  • A working ternary type and kernel built from scratch and correctness-verified at every stage.
  • A real measured ceiling on lookup-table techniques for AVX2-only, no-AVX-512 CPUs.
  • A concrete, reproduced demonstration of why post-training ternary quantization fails without fine-tuning — measured on a real model with a real perplexity sweep, not just cited from papers.
  • A documented, load-bearing distinction between which early transformer layers can and cannot tolerate aggressive quantization.

The codebase and minicpm5-tern-24layers.gguf remain as a working reference for anyone picking up ternary-quantization kernel work on similar hardware later.

Generated from the project's working files (INTEGRATION-CHECKLIST.md, skills.md, architecture.md) in Runtime Optimizations/. To save as PDF: open this page in a browser and use Print → Save as PDF — site navigation is hidden automatically in print.

← All posts Talk to Deca Saas