Exp 2026 06 12 Int8 W8A8 - oRKLLM/ork-driver GitHub Wiki
Experiment — int8 W8A8 path (the practical default)
Date: 2026-06-12 · Status: ✅ KEPT (backend default) · Path: ggml-ork
Hypothesis
fp16/W16A16 (the first cut) works but uses 2 bytes/weight and the NPU MAC is same-precision. Hypothesis: int8 W8A8 — per-channel int8 weights + per-row int8 activations, int32 accumulate, then dequant d[m][n] = aScale[m]·bScale[n]·C[m][n] — halves weight bytes at acceptable accuracy and is the natural target from GGUF Q8_0.
Implementation
- Commit:
0eabf5f(oRKLLM/llama.cpp-rockchip,ork-backend); default unlessORK_QUANT=4. - Symbol:
ggml-ork.cpp : ggml_backend_ork_mul_mat_i8.
Per-channel weight quant (cache-miss path, done once per weight):
float mx = 1e-9f;
for (int k = 0; k < K; k++) { float v = fabsf(f32[(size_t) n*K + k]); if (v > mx) mx = v; }
float s = mx / 127.0f; ow.bscale[n] = s;
for (int k = 0; k < K; k++) { // B[k][n] = src0[n][k]
int q = (int) lrintf(f32[(size_t) n*K + k] / s);
bi[(size_t) k*N + n] = (int8_t) (q > 127 ? 127 : q < -127 ? -127 : q);
}
// ... ow.w = ork_mm_pack_i8(ctx->npu, K, N, bi);
Per-row activation quant (SIMD-friendly inner loops) → NPU run → int32→fp32 dequant:
for (int m = 0; m < M; m++) {
const float * yr = y + (size_t) m*K; int8_t * ar = ai + (size_t) m*K;
float mx = 1e-9f;
for (int k = 0; k < K; k++) { float v = fabsf(yr[k]); mx = v > mx ? v : mx; }
as[m] = mx / 127.0f;
const float inv = 127.0f / mx;
for (int k = 0; k < K; k++) {
float q = yr[k] * inv;
int qi = (int) (q + copysignf(0.5f, q)); // branchless round
ar[k] = (int8_t) (qi > 127 ? 127 : qi < -127 ? -127 : qi);
}
}
if (ork_mm_run_i8(ctx->npu, ow.w, M, ai, ci)) return false; // int32 result in ci
// dequant: d[m][n] = aScale[m] * bScale[n] * C[m][n]
const float * bs = ow.bscale.data();
for (int m = 0; m < M; m++) {
const float rs = as[m]; const int32_t * cr = ci + (size_t) m*N; float * dr = d + (size_t) m*N;
for (int n = 0; n < N; n++) dr[n] = rs * bs[n] * (float) cr[n];
}
supports_op gates K%32==0 && N%64==0 (N%64 satisfies both int8 %32 and int4 %64).
Benchmark (standardized — 2026-06-13)
Qwen3-1.7B-w8a8, llama-bench -r 2, DDR 2112 / CPU 2.4 GHz. int8 is the default path; compared against the pure-CPU baseline and the int4 alternative (all NPU arms ORK_MINM=1; hybrid is the shipped default).
| Config | pp512 | tg128 |
|---|---|---|
int8 hybrid (ORK_MINM=32, shipped) |
135.28 ± 0.97 | 9.52 ± 0.04 |
int8 all-NPU (ORK_MINM=1) |
131.42 ± 2.29 | 5.03 ± 0.15 |
int4 all-NPU (ORK_QUANT=4) |
3.66 ± 0.01 | 2.53 ± 0.00 |
pure CPU (ORK_OFF=1) |
35.49 ± 0.23 | 9.43 ± 0.18 |
| librkllmrt W8A8 (closed, same scheme + model) | 183.9 | 11.71 |
librkllmrt uses the same W8A8 scheme — so this is a like-for-like quant comparison. The closed runtime is 1.36× prefill / 1.23× decode ahead of the shipped hybrid.
Profile (all-NPU int8, reference): quant 1% / NPU run 98% / dequant 1%, 386 µs/matmul — the path is submit-bound, not quant-bound.
Results
- Coherent output; ~1% matmul error vs fp32; half the weight bytes of fp16.
Verdict
✅ The practical path and the backend default. int4 (W4A4) is experimental; fp16 was the superseded first cut. Decode rate is ~10 tok/s regardless of model size here — the tell that it's submit-floor-bound, not compute-bound.