本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
上一篇講完了 config.rs,今天要進入整個 tiny-llm-runner 真正會被執行幾百萬次的核心:dequant.rs。
如果說 config.rs 是入口閘,那 dequant.rs 就是引擎艙。它做兩件事:把 GGML 的量化 byte 流還原成 f32、以及在不還原成 f32 的情況下直接做點積。後者才是 LLM 推論的真正瓶頸——一個 7B 模型的一次 forward pass,這些函式會被呼叫上百萬次。
概念一:什麼是量化?為什麼要做?
LLM 的權重原本是 f32(甚至 f64),但 7B 模型的 f32 權重就要 28 GB,這對筆電是個天文數字。量化就是把高精度的浮點數壓縮成低精度的整數,例如:
- f32(4 bytes)→ int8(1 byte)= 4× 壓縮
- f32(4 bytes)→ int4(0.5 byte)= 8× 壓縮
代價是精度損失,但事實證明 LLM 對這種損失的容忍度很高——4-bit 量化的模型在大部分 benchmark 上只會掉 1-2% 分數。
但天真地把每個 f32 都對應到一個 int8 是不夠的,因為 LLM 權重的數值範圍很大、分佈不均勻。block-wise quantization 就是解這個問題的:把權重切成固定大小的 block(例如 32 個元素一組),每個 block 各自有一個 scale 因子。
概念二:Q8_0 —— 最簡單的 block 量化
Q8_0 一個 block (34 bytes) = 1 個 fp16 scale + 32 個 int8 quant一個 block 的 32 個原始 f32 值會被這樣壓縮:
- 找出這 32 個值的絕對值最大值,記為
amax。 - 算 scale
d = amax / 127(int8 的最大正值是 127)。 - 每個 f32 值
v量化成q = round(v / d),限制在[-127, 127]。 - 儲存:2 bytes 的 fp16 scale + 32 bytes 的 int8。
還原(dequantize)就是反過來:v = q * d。我的 dequant_row_q8_0 函式做的就是這件事:
pub fn dequant_row_q8_0(q: &[u8], out: &mut [f32]) {
let n_blocks = out.len() / QK8_0;
let mut qp = 0; let mut op = 0;
for _ in 0..n_blocks {
let d = read_f16(&q[qp..qp + 2]); // 2-byte fp16 scale
qp += 2;
for i in 0..QK8_0 {
out[op + i] = (q[qp + i] as i8) as f32 * d;
}
qp += QK8_0;
op += QK8_0;
}
}關鍵點:每個 block 共用一個 scale。這是壓縮的本質——你不能對每個值都帶一個 scale(那就退回到 fp16 了),但對「分佈相似」的一群值共用 scale 是合理的近似。
演算法核心:直接對量化資料做點積
但解壓再做點積太浪費了。一個 Q4_0 row 解壓出來是 4096 個 f32(16 KB),如果每次 matvec 都解壓,記憶體頻寬會吃緊。我的解法是 fused dequant + dot——直接在量化資料上做內積:
pub fn dot_q8_0(q: &[u8], x: &[f32]) -> f32 {
let mut acc = 0.0f32;
let n_blocks = x.len() / QK8_0;
for _ in 0..n_blocks {
let d = read_f16(...); // block scale
let mut s = 0.0f32;
for i in 0..QK8_0 {
let qi = q[qp + i] as i8 as f32;
s += qi * x[xp + i]; // 整數×浮點,scale 還沒乘
}
acc += d * s; // 整個 block 共用一個 scale
}
acc
}數學上的等價性:
$$\sum_{i=0}^{n-1} (q_i \cdot d) \cdot x_i = d \cdot \sum_{i=0}^{n-1} q_i \cdot x_i$$把 scale 提到求和外面,每個 block 只要乘一次,省下 (QK8_0 - 1) 次乘法。對 Q8_0 來說是省 31/32 ≈ 97% 的 scale 乘法。
Q4_0 的 nibble unpack
Q4_0 把兩個 4-bit 值打包進一個 byte:低 nibble 和高 nibble。但它們對應的 logical 位置不是相鄰的——這是個容易踩的坑:
for i in 0..QK4_0 / 2 { // i = 0..16
let byte = q[qp + i];
let lo = (byte & 0x0F) as i32 - 8; // logical position i
let hi = (byte >> 4) as i32 - 8; // logical position i + 16
s += lo as f32 * x[xp + i];
s += hi as f32 * x[xp + i + QK4_0 / 2];
}也就是說,第 0 個 byte 的低 nibble 對應第 0 個 logical 位置,但它的高 nibble 對應第 16 個 logical 位置。這不是隨便的設計——這個排列方式讓 SIMD 可以一次 load 16 bytes 然後同時拆出 32 個值,硬體友善度很高。但對純標量實作來說,你只要小心 + QK4_0 / 2 這個 offset 不要寫錯。
-8 是因為 Q4_0 的 nibble 代表的是有號數,範圍是 [-8, 7],存的時候加 8 變成 [0, 15],讀的時候減 8 還原。對稱量化(symmetric quantization),沒有 zero point。
演算法核心:Q6_K 的超級複雜度
Q6_K 是另一個世界。它不是簡單的「scale + quant」,而是雙層 scale:
Q6_K 一個 block (210 bytes) = 256 個元素
ql: 128 bytes (低 4 bits × 256)
qh: 64 bytes (高 2 bits × 256)
scales: 16 i8 (每 16 個元素一個 sub-scale)
d: 2 bytes (super-scale, fp16)每個 6-bit 值是把 ql 的 4 bits 和 qh 的 2 bits 拼起來、減 32(範圍 [-32, 31])。每 16 個元素共用一個 i8 sub-scale,整個 256 元素共用一個 fp16 super-scale。最終值是:
dequant_q6_k_block 函式做的就是逐位元組重組這個結構:
let q1 = ((ql[l] & 0xF) as i32 | ((qh[l] & 3) as i32) << 4) - 32;
// ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
// 低 4 bits 高 2 bits
這段程式碼幾乎是逐字翻譯自 ggml 的 C 實作——我必須保證 byte-level 一致,否則 dequant 出來的值會和 llama.cpp 不一樣。這是「對齊既有實作」優先於「寫出最 Rust 的程式碼」的典型案例。
Rust 用法:unit test 對拍 dot 與 dequant+dot
我在這個檔案裡寫了三組單元測試,都長同一個樣子:
#[test]
fn q4_0_dot_matches_dequant_then_dot() {
let q = ...; // 構造一個 block
let x = ...; // 構造一個輸入向量
let got = dot_q4_0(&q, &x);
let mut deq = vec![0.0; 64];
dequant_row_q4_0(&q, &mut deq);
let expected: f32 = deq.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
assert!((got - expected).abs() < 1e-3);
}這是「互為驗證」的測試策略:我有兩條獨立的程式碼路徑算同一件事(dot_* 和 dequant + dot)。如果它們的結果不一致,至少有一個是錯的。這比寫一堆 magic number 對拍還要強——因為你不必相信 magic number 是對的,只要兩條路徑對齊就好。
這也是為什麼我能對自己的 Q6_K 解碼有信心:dot 和 dequant 走完全不同的迴圈結構,但結果在 1e-3 容差內一致,那 6-bit 重組邏輯幾乎不可能兩邊都錯成同樣的方式。
Rust 用法:debug_assert! vs assert!
注意我的 dot 函式裡用的是 debug_assert_eq!:
debug_assert_eq!(x.len() % QK8_0, 0);
debug_assert_eq!(q.len(), (x.len() / QK8_0) * Q8_0_BLOCK_SIZE);debug_assert! 在 release build 會被編譯掉,完全不消耗執行時間。對 hot path 來說這個區分非常重要——在 dev build 抓 bug,但在 release 不付成本。
但要小心:這意味著 release build 在輸入錯誤時可能會 silently 讀到越界、或得到錯誤結果。所以這種 assert 應該只放在內部 invariant 上,外部輸入仍然要用真正的 assert! 或回傳 Result。
效能最佳化空間:這才是大頭
dequant.rs 是整個專案最有效能優化空間的檔案。我目前的實作是純標量,但業界常見的最佳化包括:
1. SIMD 向量化(最大效能槓桿)
dot_q8_0 的 inner loop 是個經典的「整數 × 浮點數累加」:
for i in 0..QK8_0 {
let qi = q[qp + i] as i8 as f32;
s += qi * x[xp + i];
}這是 32 個獨立的 fma(fused multiply-add)。x86 上用 AVX2 一次可以做 8 個 f32 fma,AVX-512 一次 16 個。也就是說 SIMD 版本可以快 8-16×。具體上會這樣寫(用 std::simd 的 portable API):
use std::simd::{f32x8, num::SimdFloat};
let mut acc = f32x8::splat(0.0);
for chunk in 0..(QK8_0 / 8) {
let q_lane = load_i8_to_f32x8(...);
let x_lane = f32x8::from_slice(&x[xp + chunk * 8..]);
acc = q_lane.mul_add(x_lane, acc);
}
let s = acc.reduce_sum();llama.cpp 在這條路徑上花了很多力氣,所有量化型別都有手寫的 AVX2/AVX-512/NEON kernel。
2. Q4_0 的 SIMD 友善 unpack
Q4_0 那個「低 nibble 對應前 16、高 nibble 對應後 16」的詭異排列,目的就是為了 SIMD:你可以用 _mm256_and_si256(vec, mask_0x0F) 一次拿出所有低 nibble,shift 一下拿出所有高 nibble。沒有 SIMD 你會覺得這個排列很煩,有 SIMD 你會慶幸 ggml 當初這樣設計。
3. f16 解碼的硬體加速
我的 read_f16 是純軟體實作(透過 half crate)。但 x86 從 Ivy Bridge 開始就有 F16C 指令集(vcvtph2ps),可以一次把 8 個 fp16 轉成 f32,硬體做。Apple Silicon 上有對應的 NEON 指令。SIMD 版本通常會把 fp16 → f32 也內聯到 inner loop 裡。
4. Cache blocking
LLM 推論的特殊之處在於:權重很大,但每次 matvec 只用一次(因為輸入 x 變了)。這意味著 L2/L3 cache 對權重幾乎沒幫助——下次再算這個 row 已經是下一個 token、輸入不同了。但對 輸入向量 x 來說,cache 是有意義的:如果一個 row 很長,你會多次讀 x。Cache blocking 可以把 x 切片,每次處理 row 的一小段,提升 x 的 L1 命中率。
5. Multi-row fusion
目前 matvec 對每個 row 獨立呼叫 dot_row。但如果你一次處理 4-8 個 row,可以把對 x 的 read 攤銷掉——這就是 GEMM 比 GEMV 快的核心原因。具體上這需要把 inner loop 改成:
for chunk in chunks_of_x {
let x_simd = load(chunk);
for r in 0..ROWS_PER_TILE {
acc[r] += dot(weight[r][chunk], x_simd);
}
}這個重排能把 x 的 load 從 n_rows 次降到 n_rows / ROWS_PER_TILE 次。對 LLM 來說 n_rows 通常是 4096 或更大,這個優化能再帶來 2-4× 加速。
6. K-quants 的支援(Q4_K_M 才是現代主流)
最後最大的效能槓桿其實不是 micro-optimization,是換 quantization 格式。現代 llama.cpp 的 Q4_K_M 在同樣大小下精度比 Q4_0 高得多,硬體上也不會慢。但實作 Q4_K 比 Q4_0 複雜很多(雙層 scale 加上額外的 min-max 編碼),所以我目前還沒做。
總結:dequant.rs 的角色
這個檔案是**「對齊 ggml 規範」的純技術活**——演算法不漂亮、Rust 慣用法不漂亮、但正確性第一。每一行都要和 ggml 的 C 實作 byte-by-byte 對齊,因為任何一個 off-by-one 都會讓你的模型輸出和 llama.cpp 不一樣。
下一篇我會講 tensor.rs,看看怎麼用 Rust 的 lifetime 系統把這些 dequant 核心包成一個漂亮的抽象。
系列文章:
- tiny-llm-runner 介紹
- (1) config.rs
- (2) dequant.rs(本篇)