featured.svg

本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。

上一篇看完了 TensorView 那層薄殼,這一篇要看的是 model.rs:把那些 view 組合成一個有結構、有層級LlamaModel。說穿了,這一篇講的就是「怎麼把一堆散裝的張量收拾成一個模型」。

GGUF 把所有張量存成一個扁平 list,每個張量就靠字串名字來認。但 Llama 其實是個層級結構:每一層有 9 個張量、整個模型有 N 層再加上幾個全域張量。model.rs 要做的,就是這個 flat list → tree 的對應囉。

概念一:Llama 的張量命名規則

打開任何一個 Llama 架構的 GGUF,你會看到這些張量名字:

token_embd.weight              # token embedding 表
output_norm.weight             # 最後的 RMSNorm
output.weight                  # lm_head(有時不存在 = tied embeddings)

blk.0.attn_norm.weight         # 第 0 層的 attention 前 RMSNorm
blk.0.attn_q.weight            # Q 投影
blk.0.attn_k.weight            # K 投影
blk.0.attn_v.weight            # V 投影
blk.0.attn_output.weight       # output 投影
blk.0.ffn_norm.weight          # FFN 前 RMSNorm
blk.0.ffn_gate.weight          # SwiGLU 的 gate
blk.0.ffn_up.weight            # SwiGLU 的 up
blk.0.ffn_down.weight          # SwiGLU 的 down
blk.1.attn_norm.weight
... (重複 N 次)

這個命名約定是 llama.cpp 訂的。每一層有 9 個張量,前綴是 blk.{layer_id}.,後綴對應 attention 或 FFN 裡的某個元件。model.rs 要做的,就是照這個規則去 GGUF 裡按名字撈出來——說起來不複雜,但魔鬼都藏在細節裡。

概念二:n_ff、n_layer 與 Transformer block 的關係

從上面的張量命名其實可以看到一個很整齊的結構:每一個 blk.{l} 就是一個完整的 Transformer block,整個模型就是 n_layer 個這種 block 疊起來。不過每個 block 內部到底裝了什麼?n_ff 又在這裡扮演什麼角色呢?

一個 Transformer block 的兩個子層

每個 block 內部有兩個 sublayer,依序執行:

  1. Attention sublayerattn_norm → Q/K/V 投影 → RoPE → attention → attn_output 投影 → 殘差。
  2. FFN sublayer (SwiGLU)ffn_normffn_gateffn_up 兩個並列投影 → silu(gate) * upffn_down 投影 → 殘差。

attention 負責「跨 token 的資訊互換」,FFN 負責「在每個 token 內做非線性變換」。實證上這兩件事都很重要——你不能只有 attention(會少了 token 內部的資料豐富度),也不能只有 FFN(每個 token 各做各的、誰也不理誰,哪來的上下文)。兩個少一個都不行啦。

n_ff 是什麼?

n_ff(也寫作 feed_forward_lengthintermediate_size)是FFN 的中間維度。具體來說:

hidden state x        : 形狀 [n_embd]            = TinyLlama 的 2048

ffn_gate(x)  : matmul → 形狀 [n_ff]              = 5632   ↑
ffn_up(x)    : matmul → 形狀 [n_ff]              = 5632   │ FFN 內部「膨脹」的維度
silu(gate) * up       : 形狀 [n_ff]              = 5632   ↓

ffn_down(...) : matmul → 形狀 [n_embd]           = 2048   (回到 hidden 大小)

也就是說 FFN 把資料先膨脹再壓回去

n_embd → n_ff → n_embd
 2048    5632    2048   (TinyLlama)
 4096    14336   4096   (Llama-3 8B)

膨脹後的 n_ff 維度,才是 FFN 真正「思考」的地方。在這個更高維的空間裡套上 SiLU 非線性,再投影回 n_embd——先撐開、再收回來,大概就是這麼回事。

為什麼 n_ff 比 n_embd 大?

理論上 FFN 並沒有規定中間維度要多大。不過實證上幾乎所有 Transformer 都選 n_ff ≈ 4 × n_embd(對 SwiGLU 來說則是 ≈ 8/3 × n_embd ≈ 2.7 ×,因為 SwiGLU 比傳統 FFN 多一個投影,作者得讓總參數量持平)。我想直覺大概是這樣:

  1. 更高維度提供更多「特徵組合」的空間。一層 FFN 等於是「在更寬的空間裡做一張 lookup table」,太窄就學不到那些複雜的模式了。
  2. 大部分模型容量其實集中在 FFN,不是 attention。Attention 的權重矩陣是 [n_embd × n_embd](每個 Q/K/V/O 各一個),FFN 則是 [n_embd × n_ff](gate/up/down 三個)。當 n_ff = 4 × n_embd 時,FFN 一個 block 大概佔了 12 × n_embd²,attention 佔 4 × n_embd²——FFN 整整是 attention 的 3 倍喔。

n_layern_ff 是兩個獨立的 scaling 維度

這兩個參數常常被一起調,可是它們的角色其實完全不同:

維度 作用 像什麼
n_layer 深度」——疊幾層 block 思考的「步驟數」
n_ff 寬度」——每個 FFN 內部多大 每一步「能展開多少特徵」

兩者的權重總量都會直接影響模型大小:

總參數量 ≈ n_layer × (4·n_embd² + 3·n_embd·n_ff)
                    ↑                ↑
                 attention         FFN (SwiGLU 三個矩陣)

對 TinyLlama 1.1B:

22 × (4·2048² + 3·2048·5632)
= 22 × (16.8M + 34.6M)
= 22 × 51.4M
≈ 1.13B

換成 Llama-3 8B:

32 × (4·4096² + 3·4096·14336) ≈ 32 × (67M + 176M) ≈ 7.8B

數字算下來跟實際模型大小對得上(那點差距,就是 token embedding 和 norm 那些零頭佔的)。算數學算到這裡,是不是覺得這些大模型其實沒那麼神秘了?

在 GGUF 命名裡的對應

n_ff 直接決定了 FFN 三個權重矩陣的形狀:

blk.{l}.ffn_gate.weight  : [n_embd, n_ff]    → 投影到中間維度
blk.{l}.ffn_up.weight    : [n_embd, n_ff]    → 投影到中間維度
blk.{l}.ffn_down.weight  : [n_ff, n_embd]    → 投影回 hidden 維度

n_layer 則決定了這組張量會出現幾次——從 blk.0.* 一路到 blk.{n_layer-1}.*,每層各有獨立的一份權重。所以這兩個數字的乘積(再加上 attention 那部分),就決定了整個模型 9 × n_layer 個 block 級權重的數量。

把這個關係搞懂之後,你再回頭看 LlamaConfig 裡的 n_layer = 22n_ff = 5632,腦子裡就能立刻浮現出「FFN 中間有 5632 維、總共 22 個這樣的 block 串起來、每個 block 的 FFN 佔 ~34.6M 參數、整個 FFN 部分大概 760M 參數」——這就是 Llama 模型 size 的來源啦。

演算法核心:建立 name → TensorInfo 的索引

第一步,就是把扁平 list 轉成一個可以查的 HashMap:

let by_name: HashMap<&str, &TensorInfo> =
    tensors.iter().map(|t| (t.name.as_str(), t)).collect();

這個 one-liner 用上了 Rust collection 一個我覺得頗漂亮的特性:collect() 會根據目標型別自動選擇收集方式HashMap<K, V>Iterator<Item = (K, V)> 收集,自然就會建出一張 hash table。

注意這裡的型別是 HashMap<&str, &TensorInfo>——key 和 value 都是借用,完全沒有任何 String/Vec 拷貝。整個索引大概只佔幾 KB(每個 entry 不過是兩個指標 + 一個 hash),相對於 4 GB 的權重來說,根本可以當作不存在。

演算法核心:tied embeddings 的容錯

let output = match by_name.get("output.weight") {
    Some(info) => TensorView::from_info(info, blob)?,
    None => token_embd,
};

這段在處理一個 LLM 的小細節:有些模型會讓 lm_head 和 token embedding 共用同一份權重(這招叫做 “tied embeddings”)。這樣就能省下 vocab_size × n_embd 的權重——以 TinyLlama 1.1B 來說大概是 32000 × 2048 × 4 bytes = 262 MB,省這麼一大塊下來,實在是很有感。

GGUF 在這種情況下會直接省略 output.weight,所以 runner 得自己知道要 fallback 回 token_embd.weight。注意我這裡直接 = token_embd——因為 TensorViewCopy,這不過是一次 32-byte 拷貝,沒有任何生命週期的問題。要是換成 Box<TensorView>Rc<TensorView>,這一行可就麻煩多了。

演算法核心:分批載入 norm 權重

fn load_f32_vec(by_name: &HashMap<&str, &TensorInfo>, name: &str, blob: &[u8])
    -> Result<Vec<f32>>
{
    let info = by_name.get(name)?;
    if info.tensor_type != GgmlType::F32 {
        bail!("expected {name} to be F32, got {:?}", info.tensor_type);
    }
    let elems: u64 = info.dimensions.iter().product();
    let mut out = vec![0.0f32; elems as usize];
    dequant::dequant_row_f32(&blob[start..end], &mut out);
    Ok(out)
}

注意這裡的策略:RMSNorm 的權重直接 dequant 成 Vec<f32>,可是 Q/K/V/FFN 矩陣卻保持 TensorView

為什麼要區別對待呢?因為它們的大小差距實在太大了:

  • RMSNorm 權重:n_embd 個 f32,TinyLlama 是 2048 × 4 bytes = 8 KB,整個模型 N 層 × 2 個 norm + 1 個 final,大概 360 KB。
  • 注意力矩陣:[n_embd, n_embd] 個 Q4_0 元素,TinyLlama 是 2048 × 2048 × 0.5 bytes = 2 MB,整個模型大概 4 GB。

對 norm 來說,多花這 360 KB 換來「forward pass 不必每次重新解碼」,實在是太划算了。分清楚什麼東西該 eager dequant、什麼該 lazy dequant,就是這個檔案最關鍵的設計決定

而且 norm 權重一定得是 F32(這個 bail 不只是規範性的檢查,根本就是事實——llama.cpp 從來不量化 norm,因為 norm 的數值範圍小到不值得量化啦)。

Rust 用法:lifetime 在 struct 上的擴散

pub struct LayerWeights<'a> {
    pub attn_norm: Vec<f32>,
    pub wq: TensorView<'a>,
    pub wk: TensorView<'a>,
    pub wv: TensorView<'a>,
    pub wo: TensorView<'a>,
    pub ffn_norm: Vec<f32>,
    pub w_gate: TensorView<'a>,
    pub w_up: TensorView<'a>,
    pub w_down: TensorView<'a>,
}

pub struct LlamaModel<'a> {
    pub config: LlamaConfig,
    pub token_embd: TensorView<'a>,
    pub output_norm: Vec<f32>,
    pub output: TensorView<'a>,
    pub layers: Vec<LayerWeights<'a>>,
}

每個 struct 都帶著 'a,因為它們全都間接持有 TensorView<'a>。這個 'a 就這麼一路傳到 LlamaModel 那一層,意思很明確:整個 LlamaModel 都不能比 mmap 活得久

這在實務上會長成這樣:

fn main() -> Result<()> {
    let mmap = unsafe { Mmap::map(&file)? };       // mmap: 'mmap
    let model = LlamaModel::load(..., &mmap[...])?; // model: LlamaModel<'mmap>
    let mut runner = Runner::new(&model, ...);     // runner 借用 &model

    // 現在 runner、model、mmap 全部都借用鏈到 mmap 上
    // 編譯器保證它們的生命週期是 mmap ⊃ model ⊃ runner
}

要是你一個不小心,想把 modelmain 回傳出去:

fn load_model() -> LlamaModel<'???> { ... }   // 編譯失敗

編譯器會二話不說直接拒絕——因為 'a 一定得繫結到某個外部的生命週期。這就是 Rust 在「結構體借用資料」這個模式上的招牌動作了。

Rust 用法:用 format! 拼字串 vs 用 const generics

for l in 0..config.n_layer {
    layers.push(LayerWeights {
        attn_norm: load_f32_vec(&by_name, &format!("blk.{l}.attn_norm.weight"), blob)?,
        wq:        view(&by_name, &format!("blk.{l}.attn_q.weight"), blob)?,
        ...
    });
}

這段每呼叫一次 format! 就會 allocate 一個 String。對 22 層的 TinyLlama 來說是 22 × 9 = 198 次 allocation,整個 load 過程大概幾百 KB——對 startup 來講根本微不足道。

當然啦,也有個更省的寫法:用 write! macro 寫進一個重複利用的 buffer:

let mut buf = String::with_capacity(64);
for l in 0..config.n_layer {
    buf.clear();
    write!(buf, "blk.{l}.attn_norm.weight").unwrap();
    let attn_norm = load_f32_vec(&by_name, &buf, blob)?;
    ...
}

不過這個改動是拿可讀性去換那 0.1 ms 的啟動時間,根本就是過度最佳化的典型例子。我還是選可讀性,謝謝。

演算法核心:embed 函式的 row-major 觀念

pub fn embed(&self, token: u32, out: &mut [f32]) {
    self.token_embd.dequant_row(token as usize, out);
}

這雖然只有一行,但裡頭藏了個微妙的細節:token embedding 矩陣的 row 是 n_embd、token 數量則是 dim1。也就是說:

token_embd shape (GGUF dim 順序): [n_embd, vocab_size]
                                    ^^^^^^  ^^^^^^^^^^
                                    row 寬度  row 數量

我們對 token id t 做 lookup,其實就是「拿第 t 個 row」這麼簡單。所以 dequant_row(token, out) 就直接把該 token 的 embedding vector 寫進 out 裡。

這個 embedding lookup 是整個 forward pass 裡唯一會做 dequant、而不是 dot 的地方。想想也很合理:embedding 是「直接拿值」,而不是「拿值去算內積」,所以這裡 dequant 是免不了的。

效能最佳化空間

model.rs 不在 hot path 上——它一輩子也只在啟動時跑那麼一次。不過還是有幾個方向值得想一想:

1. 平行載入 norm 權重

目前 load_f32_vec 是序列的,22 層 × 2 個 norm + 1 個 = 45 次小 dequant。可以用 rayon 把它們平行掉:

use rayon::prelude::*;
let norms: Result<Vec<_>> = (0..n_layer).into_par_iter()
    .map(|l| load_f32_vec(&by_name, &format!("blk.{l}.attn_norm.weight"), blob))
    .collect();

但 norm 權重實在太小了(每個才 8 KB 左右),平行化的 overhead 搞不好比實際工作還重——折騰半天,不見得會更快喔。

2. 把 token_embd 也 eager dequant 成 f32

目前 embed 每次都得去 dequant 一個 row。以 7B 模型的 prefill 為例,假設 prompt 有 100 個 token、每個 token 都 dequant 一次 row,那就是 100 次 small dequant call。

可是如果你乾脆 eager 把整個 token embedding 解壓成 f32,會吃掉 vocab_size × n_embd × 4 bytes——TinyLlama 是 256 MB,到了 Llama-3 8B 就是 128k × 4096 × 4 = 2 GB 了。這筆交換在小 vocab 模型上划算,在大 vocab 模型上就划不來了。一個折衷的辦法是用 LRU cache:只快取最近用過的那 K 個 token 的 embedding 就好。

3. 自訂 hashmap 改善 locality

HashMap<&str, &TensorInfo> 的 default hasher 是 SipHash,安全歸安全,就是慢了點。對啟動時的 lookup 來說,其實可以換成 ahashfxhash

use std::collections::HashMap;
type FastMap<K, V> = HashMap<K, V, ahash::RandomState>;

不過這同樣是「啟動時間 0.5 → 0.3 秒」這種等級的微優化,相對於 mmap warmup 那段時間(看檔案大小,可能要好幾秒),實在算不上什麼痛點。

4. 預先驗證所有張量都存在

目前如果某一層的 attn_q.weight 不見了,要等到 load 到那一層時才會 bail。其實可以在 LlamaModel::load 一開頭,就先用一張 list 把所有預期的張量都驗一遍:

let expected: Vec<String> = (0..n_layer).flat_map(|l| {
    [
        format!("blk.{l}.attn_norm.weight"),
        format!("blk.{l}.attn_q.weight"),
        ...
    ]
}).collect();
for name in &expected {
    if !by_name.contains_key(name.as_str()) {
        bail!("missing {name}");
    }
}

這對使用者體驗其實是有差的——一次把所有缺的東西都列出來,總比讓人一個一個慢慢踩雷來得友善吧。

一個被隱藏的設計選擇:為什麼 norm 是 Vec<f32> 而不是 &[f32]

理論上 norm 也可以是個 view:

pub attn_norm: &'a [f32],   // 直接借用 mmap 的 bytes(如果 alignment 對齊)

這樣連 dequant 都省了,只要把 mmap 的 bytes 重新解釋成 &[f32] 就好。聽起來很美對吧?可是有兩個問題擋在前面:

  1. endianness:GGUF 規定 little-endian,現代電腦也確實都是 LE,但 Rust 的 align_to 並不保證能安全地做這個轉換。
  2. alignment:mmap 是 page-aligned 沒錯,可是 norm 張量的 offset 不一定剛好 4-byte 對齊。

所以更安全的做法是讓 dequant_row_f32f32::from_le_bytes,每個元素都老老實實明確地做 byte 轉換。代價就是一次性的解碼成本(一個 7B 模型大概 1 MB f32 norm,啟動時花個幾毫秒就解完了)。

如果未來真想做 zero-copy norm,可以先驗一下 alignment、再用 bytemuck::cast_slice——那是一個相當成熟的「safe transmute」套件,不必自己造輪子。

總結:model.rs 的角色

  • 概念上:把扁平的 GGUF 張量 list,對應回結構化的 Llama 模型樹。
  • 實作上:HashMap 索引 + 字串拼接 + 條件性 fallback(tied embeddings)。
  • 設計上:分清楚什麼該 eager dequant(norm,小又常用)、什麼該 lazy dequant(大矩陣)。

說到底,model.rs 本身的程式碼並不長,可是它逼著你想清楚每一份權重「該用什麼姿勢拿出來」——這種「小檔案、大決定」的感覺,我想正是讀別人程式碼最有意思的地方吧。下一篇就要來聊 ops.rs 了,那是 Transformer 真正的「樂高積木」,敬請期待囉 :-)

系列文章: