featured.svg

本文由 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 值會被這樣壓縮:

  1. 找出這 32 個值的絕對值最大值,記為 amax
  2. 算 scale d = amax / 127(int8 的最大正值是 127)。
  3. 每個 f32 值 v 量化成 q = round(v / d),限制在 [-127, 127]
  4. 儲存: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。最終值是:

$$v = d_{super} \cdot s_{sub} \cdot q$$

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 核心包成一個漂亮的抽象。


系列文章: