Summary
bitnet.cpp's i2_s ternary CPU dot-product kernel unpacks weights with a 64-weight / 16-byte-group layout, but the i2_s weights in the GGUF are packed in the 128-weight / 32-byte-group layout (the same layout dequantize_row_i2_s uses). The CPU kernel therefore reads every weight matrix in scrambled order, producing wrong-but-finite output on every i2_s mul_mat. Greedy generation collapses to a single repeated token (!!!!…) on any ARM CPU inference path.
Why it's usually hidden
On Apple Silicon, llama.cpp defaults to the Metal backend, which computes i2_s matmuls by dequantizing the weights via to_float — that uses the correct 128/32 layout, so output is fine. Force the CPU path and the bug appears:
# Apple Silicon: add -ngl 0 to force CPU | Jetson / ARM-Linux: CPU-only anyway
./build/bin/llama-cli -m ggml-model-i2_s.gguf -p "The capital of France is" -n 24 --temp 0 -ngl 0
# -> !!!!!!!!!!!!!!!!!!!!!!!!
Model: microsoft/BitNet-b1.58-2B-4T (ggml-model-i2_s.gguf). Reproduces identically on a Jetson Orin Nano (aarch64, L4T R36.5, clang 14 and gcc 11) and on Apple Silicon with -ngl 0.
Root cause
- The GGUF — and
dequantize_row_i2_s (3rdparty/llama.cpp/ggml/src/ggml-quants.c) — pack 128 weights per 32-byte block: byte gp (0..31) holds the 2-bit codes for weights {gp, 32+gp, 64+gp, 96+gp} in bit-fields [7:6], [5:4], [3:2], [1:0].
- The ARM CPU kernels in
src/ggml-bitnet-mad.cpp use #define QK_I2_S 64 and a 64-weight / 16-byte unpack: byte pos (0..15) → weights {pos, 16+pos, 32+pos, 48+pos}.
- Active call path on ARM:
ggml_gemv_i2_i8_s / ggml_gemm_i2_i8_s (ggml-aarch64.c) → ggml_vec_dot_i2_i8_s → ggml_vec_dot_i2_i8_s_1x1.
Evidence
An op-by-op trace (llama-eval-callback) of the CPU forward pass vs. the Metal reference diverges first at op [3] MUL_MAT "Qcur-0" — the first ternary matmul (blk.0.attn_q.weight {2560,2560}, layer 0). Ops [0–2] (GET_ROWS, RMS_NORM, MUL) agree to 6 digits; at op [3] the row sum is CPU +0.962 vs Metal −0.136 (element [0,0]: CPU −1.156 vs Metal −1.461).
In-kernel check on that first matmul: the NEON dot-product itself is arithmetically correct (NEON_sum == scalar_ref exactly). Decoding the same bytes with the 128/32 layout gives −1103 → (−1103 − act_sum)/act_scale × w_scale = −1.456, matching Metal's −1.461 (the 0.3% gap is int8-activation rounding). The 64/16 layout gives −716 → −1.156 (wrong). So the arithmetic is fine; only the unpack layout is wrong.
Fix
Replacing the ARM branch of the ggml_vec_dot_i2_i8_s dispatcher (src/ggml-bitnet-mad.cpp) with a correct 128/32-layout NEON decode (dotprod path + scalar fallback), leaving the x86/AVX path untouched, resolves it. After the fix the Orin produces coherent English and the CPU forward pass is bit-identical to the Metal/dequant path, modulo the expected A8-vs-A16 activation-quantization difference (~0.3–1% per element, since the CPU kernel uses true int8 activations while the Metal fallback dequantizes weights to f16).
Proposed patch — src/ggml-bitnet-mad.cpp (dispatcher ggml_vec_dot_i2_i8_s)
void ggml_vec_dot_i2_i8_s(int n, float * s, size_t bs, const void * vx, size_t bx, const void * vy, size_t by, int nrc) {
#if defined(__ARM_NEON)
// FIX (i2_s ARM layout bug): the i2_s weights in the gguf are packed in the
// 128-weight / 32-byte-group layout (identical to dequantize_row_i2_s, which
// is why the Metal/dequant path is correct). The legacy NEON sub-kernels
// above assume a 64-weight / 16-byte layout (QK_I2_S=64) and therefore read
// the weights in scrambled order, producing wrong-but-finite output on every
// i2_s matmul. Decode with the correct 128/32 layout here.
//
// Both callers (ggml_gemv_i2_i8_s / ggml_gemm_i2_i8_s) pass ONE packed weight
// row in vx and nrc activation columns in vy (column stride = by). Weight
// codes {0,1,2} encode {-1,0,+1}; the +1 offset is corrected downstream via
// act_sums in the mul_mat wrapper. n is a multiple of 128 for this model; a
// scalar tail handles any remainder defensively.
(void) bx;
const uint8_t * x = (const uint8_t *) vx;
const int nblk = n / 128;
for (int col = 0; col < nrc; col++) {
const int8_t * yc = (const int8_t *) vy + (size_t) col * by;
long sumi;
#if defined(__ARM_FEATURE_DOTPROD)
const uint8x16_t mask = vdupq_n_u8(3);
int32x4_t acc = vdupq_n_s32(0);
for (int B = 0; B < nblk; B++) {
const uint8_t * xb = x + (size_t) B * 32;
const int8_t * yb = yc + (size_t) B * 128;
const uint8x16_t blo = vld1q_u8(xb + 0);
const uint8x16_t bhi = vld1q_u8(xb + 16);
// group c=0 (bits 7:6) -> weights B*128 + [0..31]
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(blo, 6), mask)), vld1q_s8(yb + 0));
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(bhi, 6), mask)), vld1q_s8(yb + 16));
// group c=1 (bits 5:4) -> weights B*128 + [32..63]
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(blo, 4), mask)), vld1q_s8(yb + 32));
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(bhi, 4), mask)), vld1q_s8(yb + 48));
// group c=2 (bits 3:2) -> weights B*128 + [64..95]
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(blo, 2), mask)), vld1q_s8(yb + 64));
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(vshrq_n_u8(bhi, 2), mask)), vld1q_s8(yb + 80));
// group c=3 (bits 1:0) -> weights B*128 + [96..127]
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(blo, mask)), vld1q_s8(yb + 96));
acc = vdotq_s32(acc, vreinterpretq_s8_u8(vandq_u8(bhi, mask)), vld1q_s8(yb + 112));
}
sumi = (long) vaddlvq_s32(acc);
for (int k = nblk * 128; k < n; k++) {
int B = k / 128, c = (k % 128) / 32, gp = (k % 128) % 32;
unsigned b = x[(size_t) B * 32 + gp];
sumi += (long)((b >> (6 - 2 * c)) & 3) * yc[k];
}
#else
sumi = 0;
for (int k = 0; k < n; k++) {
int B = k / 128, c = (k % 128) / 32, gp = (k % 128) % 32;
unsigned b = x[(size_t) B * 32 + gp];
sumi += (long)((b >> (6 - 2 * c)) & 3) * yc[k];
}
#endif
s[(size_t) col * bs] = (float) sumi;
}
#else
// ... original x86 / AVX dispatch unchanged ...
if (nrc % PARALLEL_SIZE == 0) {
// ggml_vec_dot_i2_i8_s_1xN(...) etc.
} else {
ggml_vec_dot_i2_i8_s_1x1(n, s, bs, vx, bx, vy, by, nrc);
}
#endif
}
Also affected (not on the read path, currently harmless)
The unused ggml_vec_dot_i2_i8_s_1xN / _Nx1 / _1x4_32W ARM sub-kernels, and the ARM branch of quantize_i2_s, still assume the 64/16 layout. They are dead on the inference read path for this model, but should be aligned to 128/32 to avoid the same bug if i2_s GGUFs are ever quantized on ARM or those kernels are re-activated.
Environment
- Jetson Orin Nano Dev Kit (Ampere, aarch64, L4T R36.5.0, kernel 5.15-tegra); clang 14.0 and gcc 11.4 (both reproduce).
- Apple Silicon (macOS) reproduces with
-ngl 0; the default Metal backend masks it.
- Model:
microsoft/BitNet-b1.58-2B-4T, ggml-model-i2_s.gguf.
Summary
bitnet.cpp's i2_s ternary CPU dot-product kernel unpacks weights with a 64-weight / 16-byte-group layout, but the i2_s weights in the GGUF are packed in the 128-weight / 32-byte-group layout (the same layoutdequantize_row_i2_suses). The CPU kernel therefore reads every weight matrix in scrambled order, producing wrong-but-finite output on every i2_smul_mat. Greedy generation collapses to a single repeated token (!!!!…) on any ARM CPU inference path.Why it's usually hidden
On Apple Silicon,
llama.cppdefaults to the Metal backend, which computes i2_s matmuls by dequantizing the weights viato_float— that uses the correct 128/32 layout, so output is fine. Force the CPU path and the bug appears:Model:
microsoft/BitNet-b1.58-2B-4T(ggml-model-i2_s.gguf). Reproduces identically on a Jetson Orin Nano (aarch64, L4T R36.5, clang 14 and gcc 11) and on Apple Silicon with-ngl 0.Root cause
dequantize_row_i2_s(3rdparty/llama.cpp/ggml/src/ggml-quants.c) — pack 128 weights per 32-byte block: bytegp(0..31) holds the 2-bit codes for weights{gp, 32+gp, 64+gp, 96+gp}in bit-fields[7:6], [5:4], [3:2], [1:0].src/ggml-bitnet-mad.cppuse#define QK_I2_S 64and a 64-weight / 16-byte unpack: bytepos(0..15) → weights{pos, 16+pos, 32+pos, 48+pos}.ggml_gemv_i2_i8_s/ggml_gemm_i2_i8_s(ggml-aarch64.c) →ggml_vec_dot_i2_i8_s→ggml_vec_dot_i2_i8_s_1x1.Evidence
An op-by-op trace (
llama-eval-callback) of the CPU forward pass vs. the Metal reference diverges first at op[3] MUL_MAT "Qcur-0"— the first ternary matmul (blk.0.attn_q.weight {2560,2560}, layer 0). Ops[0–2](GET_ROWS,RMS_NORM,MUL) agree to 6 digits; at op[3]the row sum is CPU+0.962vs Metal−0.136(element[0,0]: CPU−1.156vs Metal−1.461).In-kernel check on that first matmul: the NEON dot-product itself is arithmetically correct (
NEON_sum == scalar_refexactly). Decoding the same bytes with the 128/32 layout gives−1103→(−1103 − act_sum)/act_scale × w_scale = −1.456, matching Metal's−1.461(the 0.3% gap is int8-activation rounding). The 64/16 layout gives−716→−1.156(wrong). So the arithmetic is fine; only the unpack layout is wrong.Fix
Replacing the ARM branch of the
ggml_vec_dot_i2_i8_sdispatcher (src/ggml-bitnet-mad.cpp) with a correct 128/32-layout NEON decode (dotprod path + scalar fallback), leaving the x86/AVX path untouched, resolves it. After the fix the Orin produces coherent English and the CPU forward pass is bit-identical to the Metal/dequant path, modulo the expected A8-vs-A16 activation-quantization difference (~0.3–1% per element, since the CPU kernel uses true int8 activations while the Metal fallback dequantizes weights to f16).Proposed patch —
src/ggml-bitnet-mad.cpp(dispatcherggml_vec_dot_i2_i8_s)Also affected (not on the read path, currently harmless)
The unused
ggml_vec_dot_i2_i8_s_1xN/_Nx1/_1x4_32WARM sub-kernels, and the ARM branch ofquantize_i2_s, still assume the 64/16 layout. They are dead on the inference read path for this model, but should be aligned to 128/32 to avoid the same bug if i2_s GGUFs are ever quantized on ARM or those kernels are re-activated.Environment
-ngl 0; the default Metal backend masks it.microsoft/BitNet-b1.58-2B-4T,ggml-model-i2_s.gguf.