本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
上一篇看完了所有 Transformer 原語,這一篇要看的是把它們編排成一次完整 forward pass 的核心檔案:runner.rs。
整個專案最值得讀的就是這個檔案——大約 170 行的 Rust,把 Llama 的整個推論流程鋪展開來,每一步都看得清清楚楚。
概念一:autoregressive 推論的本質
LLM 推論不是「一次處理整段文字」,而是「一次處理一個 token」:
prompt: "The capital of France"
→ token ids [464, 3139, 286, 4881]
prefill (處理 prompt):
forward(464) → logits (我們不用,但要建 KV cache)
forward(3139) → logits
forward(286) → logits
forward(4881) → logits ← 用這個 logits 抽下一個 token
decode (生成):
sampler(logits) → " is"
forward(" is") → logits
sampler(logits) → " Paris"
forward(" Paris") → logits
... 一直到 EOS每次 forward(token) 是一個 step。step 之間透過 KV cache 累積上下文。
概念二:KV Cache —— 為什麼 attention 要 cache 過去?
Attention 的數學是這樣的:
$$\text{attn}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d}}\right) V$$在 autoregressive 推論時,第 t 個 token 的 Q 只需要和前 t 個 token 的 K/V 做運算(因為未來的 token 還沒出現)。如果每次 forward 都重新計算所有過去的 K/V,那 prefill 階段是 $O(n^2)$,decode 階段每次都是 $O(n)$ 的重做——太浪費了。
KV cache 的作法:每算過一次某個 position 的 K/V 就存起來,下次直接用。這樣每次 forward 只需要算當前這個 token 的 Q/K/V,K 和 V 寫進 cache、Q 拿來和整個 cache 點積。
概念三:Context 和 KV Cache 的關係
很多人聽到「context length 2048」會直覺想成「模型有個地方存著最近 2048 個 token」。這不正確——模型實際上只存了 2048 個 token 被各層 attention 算出來的 K 和 V。Context 的這個容量限制就是 KV cache 的容量限制。
關係可以這樣理解:
context window = 「我能看見多遠的過去」的能力上限
KV cache = 這個能力實際的儲存形式
n_ctx = KV cache 在 position 維度上的大小當你看到 n_ctx = 2048,背後真正配出來的記憶體就是:
kcache: [n_layer][n_ctx × kv_dim] f32
vcache: [n_layer][n_ctx × kv_dim] f32對 TinyLlama (n_layer=22, kv_dim=256) 來說,這是 22 × 2 × 2048 × 256 × 4 = 92 MB。對 Llama-2 7B (n_layer=32, kv_dim=4096) 來說,是 32 × 2 × 2048 × 4096 × 4 = 2 GB——KV cache 比模型本身的量化權重還大。
Token 一旦進入 cache,就再也找不回來了。Context window 滿了之後,要嘛截斷舊 token、要嘛丟掉新 token,沒有第三個選擇——因為原始 token id 早就被 attention 投影成 K/V 之後丟掉了。
概念四:為什麼不存 token 本身?為什麼不存 embedding?為什麼是 K/V?
這個問題的答案會徹底解釋 KV cache 的設計。讓我們從「最樸素」的方案開始往下思考:
方案 A:只存 token id
「我把過去的 token id 都存起來,下次推論時整段重跑就好。」
問題:這個方案下,每次 forward 都要從 token id 開始,重算 embedding、重算 22 層 transformer。對第 t 個 token 來說是 $O(t \times \text{layers} \times \text{matmul})$ 的工作,整個 prompt 的 prefill 是 $O(n^2)$,decode 累積下來也是 $O(n^2)$。完全沒有 cache 的意義。
方案 B:存 embedding(每個 token 對應一個 n_embd 向量)
「那我存 token embedding,這樣可以跳過 embedding lookup。」
問題:embedding 只是 forward pass 的輸入。從 embedding 到「attention 真正用的東西」,還要走完整層的 RMSNorm + Q/K/V 投影。也就是說每層的 attention 看到的是「自己這一層的輸入經過 W_k 和 W_v 投影後的 K/V」,不是 token embedding。如果只存 token embedding:
- 每次 forward 都要重新跑 22 層的 RMSNorm、重新算 K/V 投影。
- 但這些工作的結果和 token 位置 t 無關——每次重算都會得到同一個 K_t 和 V_t。
- 純粹的浪費。
更糟的是:第 1 層的輸入是 token embedding,但第 2 層的輸入是「第 1 層的輸出」。所以你不能用「token embedding」這個單一概念來代表「每一層 attention 需要的東西」。每層之間隔著 attention + FFN 的非線性轉換,「embedding」這個詞只在最開始那一層有意義。
方案 C:存每層的 hidden state(每層都有自己的 [n_layer][n_ctx][n_embd])
「那我存每層的輸出 hidden state?」
問題:你存了 hidden state,但 attention 真正需要的是 K = W_k(hidden_state) 和 V = W_v(hidden_state)。每次 forward 還是要重算 K/V 投影(一個 [n_embd, kv_dim] 的 matvec)。白存了,因為投影沒省到。
而且這個方案佔的記憶體比 K/V cache 大:hidden state 是 n_embd 寬,K/V 在 GQA 下只有 kv_dim = head_dim × n_head_kv 寬(TinyLlama 是 256,比 n_embd=2048 小 8 倍)。
方案 D:直接存 K 和 V(最終答案)
K 和 V 才是 attention 真正消費的東西。$\text{attn}(Q, K, V)$ 的公式裡沒有 hidden state、沒有 embedding、沒有 token id——只有 Q、K、V。把過去的 K 和 V 存起來,attention 就完整了。
而且 K/V 有兩個迷人的性質:
- 它們和 Q 是解耦的:K_t 和 V_t 一旦算出來就和「未來會用什麼 Q 來查它」無關。所以可以放心 cache。
- 它們不需要被未來修改:因為 LLM 是 causal——位置 t 的 K/V 只被 position ≥ t 的 Q 查詢,但這些查詢不會回頭改 K_t/V_t。一旦寫進 cache 就是 immutable 的。
這就是為什麼 KV cache 是 transformer 推論的「最佳化終點」——它剛好存了 attention 需要的最少資訊,再少就會破壞語義,再多就是浪費。
從「資訊保留鏈」看這件事
換個角度:模型做 forward pass 是一條把「token id」逐步轉成「下一個 token 機率分佈」的資訊流。中途有很多中間表示:
token id → embedding → layer 1 hidden → layer 1 K/V → ...
→ layer 2 hidden → layer 2 K/V → ...
...
→ final hidden → logits每一個中間表示都包含了關於這個 token 的某種資訊。對 attention 來說,這條鏈上唯一一個「跨 token 互動」的點就是它把 K/V 拿來算內積。其他每一步(RMSNorm、Q/K/V 投影、FFN…)都是 pure-position 的——只處理當前這個 token 的資料。
所以:
- 那些「pure-position」的步驟不需要 cache,因為每個 token 的計算和其他 token 無關。
- 那個「跨 token 互動」的步驟必須 cache,因為它要看到所有過去 token 的資訊。
而 attention 跨 token 看到的東西就是 K 和 V。所以這就是該被 cache 的東西。
一個簡單的比喻
把 LLM 比喻成一個圖書館:
- Token id 是書的索書號。
- Embedding 是書的封面(提供入口)。
- 每層 hidden state 是書頁的內容(給讀者看)。
- K 和 V 是給其他書「互相引用」用的索引卡。
Attention 是書與書之間的對話。書本身(hidden state)很厚,但對話只透過索引卡(K/V)進行。把對話用過的卡片留在桌上,下次新書進來就可以直接和它們對話——不需要把整本書重新搬出來看。
概念五:為什麼 KV cache 必須是 per-layer,而不只是 per-token?
注意 Runner 的 cache 是 Vec<Vec<f32>>,第一個維度是 layer,第二個才是 token position:
kcache: Vec<Vec<f32>>, // [n_layer][n_ctx × kv_dim]
vcache: Vec<Vec<f32>>,對 22 層的 TinyLlama 來說,這是 22 份獨立的 cache。為什麼不能共用同一份?為什麼一個 token 不只對應一組 K/V,而是對應 n_layer 組?
因為「同一個 token」在每一層的 K/V 是完全不同的東西
關鍵在 forward pass 的結構。回頭看看每一層 attention 在算什麼:
layer 1: x¹ = embedding(token)
k¹ = W_k¹(rmsnorm(x¹)) ← 第 1 層的 K
v¹ = W_v¹(rmsnorm(x¹)) ← 第 1 層的 V
x² = x¹ + attn(...) + ffn(...)
layer 2: k² = W_k²(rmsnorm(x²)) ← 第 2 層的 K(input 不同、權重不同)
v² = W_v²(rmsnorm(x²))
x³ = x² + attn(...) + ffn(...)
layer 3: k³ = W_k³(rmsnorm(x³)) ← 第 3 層的 K
...每一層的 K/V 同時受兩件事決定:
- 這一層的 input hidden state —— 上一層 attention + FFN 完成後傳下來的東西,每層都不同。
- 這一層自己的權重
W_k^l/W_v^l—— 每層獨立訓練的不同矩陣。
也就是說 layer 1 的 K 是「token 在剛 embed 時的樣子被 W_k¹ 看出來的特徵」,layer 5 的 K 是「token 經過 4 層 attention + FFN 整合上下文後的樣子被 W_k⁵ 看出來的特徵」。這兩個 K 既不是同一個東西、也不能互相替代。
換個角度:每一層都有自己的 attention
Transformer 的設計本身就讓每層 attention 是獨立的計算單元。每層問「我這層的 query 和我這層 cache 裡的 key 像不像」,答案決定我這層怎麼把 V 加總起來。底下幾層通常學「文法、近距離 token 關係」,中層學「短語結構」,高層學「語意、長距離依賴」——這些功能只有在它們各自的 K/V 空間裡才有意義。
如果你硬要說「我只想存一份 K/V 給所有層用」,那等於是在說「所有層都做同一件事」——這就退化成一個非常淺的模型了。Transformer 的深度價值就在於每層做不同的轉換,而每層的 K/V 是這個轉換的 fingerprint。
從層之間的依賴鏈看為什麼不能省
更技術一點:layer l 的 K/V 是 layer l-1 的輸出的函式。整個 forward pass 是這樣的串聯:
x¹ x² x³
embed → ─→ attn¹+ffn¹ ─→ ─→ attn²+ffn² ─→ ─→ ...
│ │ │
└─→ k¹, v¹ └─→ k², v² └─→ k³, v³每一層的 K/V 都「只能在這一層用」——下一層的 attention 不會去看 k¹,因為它要看的是經過這一層 transform 過的世界。所以這 22 份 K/V 是 22 個獨立的記憶體,不是一份共用的。
一個 token 在 cache 裡到底佔多少?
把上面所有事情串起來,一個 token 進入 cache 後實際佔的記憶體是:
single token → n_layer × 2 × kv_dim × sizeof(f32)
↑ ↑ ↑
層數 K + V對 TinyLlama (n_layer=22, kv_dim=256, f32) 來說:
22 × 2 × 256 × 4 = 45 KB per token
對 Llama-2 7B (n_layer=32, kv_dim=4096) 來說:
32 × 2 × 4096 × 4 = 1 MB per token
這就是為什麼長 context 推論這麼吃記憶體——不只是 token 數量乘上一個小的數字,而是 token 數量乘上 n_layer × 2 × kv_dim。一個 8K context 的 7B 模型光 KV cache 就要 8 GB,比模型本身還大。
如果 KV cache 可以「per-token only」(共用所有層),同樣的 8K context 只需要 32 MB——但那樣的模型已經不是 Transformer 了。這個「per-layer」的代價,就是 Transformer 之所以是 Transformer 的代價。
「per-layer × per-token」 是 cache 的最小完備形式
可以這樣總結:要讓 attention 在每一層都能正確算出來,你需要的最少資訊是:
| 維度 | 為什麼需要 |
|---|---|
| per-layer | 每層 attention 看的是不同的轉換空間,不能共用 |
| per-token | 每個 token 在每層的特徵都不同(causal mask 之外都會被未來查到) |
| K + V | attention 公式裡的兩個輸入(Q 不需要 cache,因為它只在當前 step 用一次) |
把這三個維度切片乘起來,就是 [n_layer][n_ctx][2][kv_dim] 的 4D 張量——這就是 KV cache 的最小完備形狀。再省任何一個維度都會破壞 attention 的語義;多存任何一個維度都是浪費(因為其他資訊都可以重新計算)。
回到我的 Rust 程式碼:
kcache: Vec<Vec<f32>>, // [n_layer][n_ctx × kv_dim]
vcache: Vec<Vec<f32>>, // [n_layer][n_ctx × kv_dim]
外層 Vec 是 layer 維度、內層的扁平陣列裡 n_ctx × kv_dim 是「token position × K/V 寬度」。這兩個 Vec<Vec<f32>> 加起來就是 attention 完整需要的最小狀態,一塊不多、一塊不少。
Runner struct 的記憶體佈局
pub struct Runner<'a, 'm> {
model: &'m LlamaModel<'a>,
rope_style: RopeStyle,
kcache: Vec<Vec<f32>>, // [n_layer][n_ctx * kv_dim]
vcache: Vec<Vec<f32>>,
pub pos: usize,
// 預先配好的 scratch buffer
x: Vec<f32>, xb: Vec<f32>, xb2: Vec<f32>,
hb: Vec<f32>, hb2: Vec<f32>,
q: Vec<f32>, att: Vec<f32>,
logits: Vec<f32>,
}幾個重要的設計決定:
兩個生命週期 'a 和 'm
'a是 mmap 的生命週期。'm是LlamaModel的生命週期,且必須'm: 'a(model 不能比 mmap 活得更久)。
Runner 借用 &'m LlamaModel<'a>,自己沒有持有任何模型資料,所以這個結構是 cheap to construct——所有大型 buffer 都是 scratch 用途。
KV cache 是 Vec<Vec<f32>> 而不是 Vec<f32>
每層一個獨立的 Vec,而不是把所有層拼在同一個 Vec 裡。這是為了:
- 生命週期分離:不同層的 cache 可以獨立管理(雖然目前我沒這麼做)。
- 記憶體大小靈活:理論上不同層可以有不同的 KV dim(例如某些 MoE 變體),雖然 Llama 沒有這個需求。
代價是多一層間接(access 要走 kcache[l][...])。對 LLM 推論來說 negligible。
一堆 scratch buffer 一次配好
x: vec![0.0; n_embd], // 主 hidden state
xb: vec![0.0; n_embd], // 暫存 a (attention/FFN 內部)
xb2: vec![0.0; n_embd], // 暫存 b (殘差用)
hb: vec![0.0; n_ff], // FFN gate 的中間結果
hb2: vec![0.0; n_ff], // FFN up 的中間結果
q: vec![0.0; n_embd], // 當前 token 的 Q
att: vec![0.0; n_head * n_ctx], // attention scores
logits: vec![0.0; vocab], // 輸出 logits
這些 buffer 在 new() 一次配好,整個推論過程不再 allocate。每次 forward() 進來都直接覆寫這些 buffer。沒有 GC、沒有 alloc 壓力。
對比一下 PyTorch/Candle 的做法:每個 op 回傳新 Tensor,靠 reference counting 回收。對訓練很合理(autograd 需要保留中間值),但對只做 inference 的 runner 來說,預配 + 覆寫是更精簡、更可預測的做法。
演算法核心:forward 的整體結構
forward(token) 的結構是這樣的:
K/V 直接寫進 cache] C2 --> C3[2c. RoPE on Q & current K] C3 --> C4[2d. multi-head attention
Q · K^T / softmax / · V] C4 --> C5[2e. wo projection + 殘差] C5 --> C6[2f. ffn_norm + SwiGLU + 殘差] C6 --> C C --> D[3. final norm] D --> E[4. lm_head: logits]
每個 block 的內部精細化看起來會更清楚:
// attention norm
rmsnorm(&mut self.xb, &self.x, &layer.attn_norm, cfg.rms_eps);
// qkv projections
matvec(&mut self.q, &layer.wq, &self.xb);
let krow = &mut kc[pos * kv_dim..(pos + 1) * kv_dim];
let vrow = &mut vc[pos * kv_dim..(pos + 1) * kv_dim];
matvec(krow, &layer.wk, &self.xb); // K 直接寫進 cache
matvec(vrow, &layer.wv, &self.xb); // V 也是
// RoPE
apply_rope(&mut self.q, pos, head_dim, ...);
apply_rope(krow, pos, head_dim, ...);
// attention
for h in 0..cfg.n_head {
let kv_head = h / gqa; // GQA 對應
// 算 attention scores
// softmax
// 加權 V
}
// 輸出投影 + 殘差
matvec(&mut self.xb2, &layer.wo, &self.xb);
add_inplace(&mut self.x, &self.xb2);
// FFN: x = x + Wdown(silu(Wgate(norm)) * Wup(norm))
rmsnorm(&mut self.xb, &self.x, &layer.ffn_norm, cfg.rms_eps);
matvec(&mut self.hb, &layer.w_gate, &self.xb);
matvec(&mut self.hb2, &layer.w_up, &self.xb);
for i in 0..self.hb.len() {
self.hb[i] = silu(self.hb[i]) * self.hb2[i];
}
matvec(&mut self.xb2, &layer.w_down, &self.hb);
add_inplace(&mut self.x, &self.xb2);演算法核心一:K/V 直接寫進 cache
let krow = &mut kc[pos * kv_dim..(pos + 1) * kv_dim];
let vrow = &mut vc[pos * kv_dim..(pos + 1) * kv_dim];
matvec(krow, &layer.wk, &self.xb);
matvec(vrow, &layer.wv, &self.xb);這四行有個非常重要的設計選擇:K/V 計算的輸出 buffer 直接是 cache 的某一行,而不是先算到 scratch buffer 再 copy 進 cache。這省下了一次 n_embd × 4 bytes 的記憶體拷貝。
matvec 的簽章 fn matvec(out: &mut [f32], ...) 接受任何 mutable slice,cache 的 slice 完全可以塞進去。Rust 的借用檢查器會驗證 kc 和 xb、q 等其他 buffer 不會 alias。
演算法核心二:RoPE 的兩階段套用
apply_rope(&mut self.q, pos, head_dim, ...);
apply_rope(krow, pos, head_dim, ...);注意這裡 RoPE 是套在 Q 和當前的 K 上,不是套在 cache 裡所有 K 上。為什麼?
因為 RoPE 是「絕對位置」資訊(每個 K_t 套用 t 對應的 RoPE),而每個 K_t 在它被計算的那個 forward call 裡套用一次就夠了。等到後面要做 attention 時,cache 裡的 K_t 已經帶著 RoPE,不必再做。
V 不需要 RoPE,因為 attention 對 V 的處理是線性 weighted sum,position 資訊在 Q·K 那裡就已經注入。
演算法核心三:GQA 的 head 對應
for h in 0..cfg.n_head {
let kv_head = h / gqa; // GQA 對應
let q_off = h * head_dim;
let q = &self.q[q_off..q_off + head_dim];
let att = &mut self.att[h * cfg.n_ctx..h * cfg.n_ctx + (pos + 1)];
for (t, score) in att.iter_mut().enumerate() {
let k_off = t * kv_dim + kv_head * head_dim;
let k = &self.kcache[l][k_off..k_off + head_dim];
let mut s = 0.0f32;
for i in 0..head_dim {
s += q[i] * k[i];
}
*score = s * scale;
}
softmax(att);
// ... 加權 V
}GQA 的 trick:在 vanilla MHA(multi-head attention)裡,每個 query head 對應一個獨立的 K/V head。但 GQA 把 query head 分組,每組共用同一個 K/V head:
n_head = 32, n_head_kv = 4 → gqa = 8
query head 0..7 → K/V head 0
query head 8..15 → K/V head 1
query head 16..23 → K/V head 2
query head 24..31 → K/V head 3kv_head = h / gqa 就是這個對應。這個技巧把 KV cache 的大小從 n_head × head_dim × n_ctx 縮成 n_head_kv × head_dim × n_ctx,對長 context 推論很重要——KV cache 是長 context 推論的記憶體大頭。
演算法核心四:attention scores 的記憶體佈局
let att = &mut self.att[h * cfg.n_ctx..h * cfg.n_ctx + (pos + 1)];att buffer 是 [n_head, n_ctx] 的展平。對 head h 的 attention scores 佔 att[h*n_ctx .. h*n_ctx + n_ctx]。但每次 forward 我們只用前 pos+1 個(因為只有過去到現在的 token 有 score)。
這個設計的好處是:buffer 是固定大小(為 worst case n_ctx 配好),不需要 dynamic resize。代價是有些空間沒用到——對 TinyLlama 來說 n_head * n_ctx * 4 = 32 * 2048 * 4 = 256 KB,可以接受。
Rust 用法:借用檢查器與切片
這個 forward pass 裡有大量 &mut 切片操作:
let kc = &mut self.kcache[l];
let krow = &mut kc[pos * kv_dim..(pos + 1) * kv_dim];
matvec(krow, &layer.wk, &self.xb);注意我必須先取 &mut self.kcache[l] 存進局部變數 kc,再對 kc 切片。為什麼?因為 Rust 的借用檢查器需要看到「我們對 self.kcache 的 mutable 借用」是清晰的。
這是 Rust 寫多 mutable buffer 程式碼最常踩的坑之一。如果你寫:
matvec(&mut self.kcache[l][pos*kv_dim..(pos+1)*kv_dim], &layer.wk, &self.xb);而其他地方還有 &self.x、&self.xb 之類的不可變借用,編譯器可能會抗議「不能同時 mutable 和 immutable 借用 self」。先把 mutable slice 提取出來,再做後續操作,是讓借用範圍縮小的標準技巧。
split_at_mut 的那種招數
更進階的場合可能要 split_at_mut——例如同時拿 K cache 和 V cache 的 mutable ref:
let (kc, vc) = (&mut self.kcache[l], &mut self.vcache[l]);
// 這個寫法借用檢查器會接受,因為 self.kcache 和 self.vcache 是不同的 field
但 kcache[l] 和 vcache[l] 是不同的 field,所以可以同時 mutable borrow。如果是同一個 Vec 的兩段切片,就需要 split_at_mut:
let (left, right) = my_vec.split_at_mut(mid);
// left 和 right 都是 &mut [T],但編譯器知道它們不重疊
效能最佳化空間
runner.rs 是整個專案的效能熱點集中地,有大量改進空間:
1. Attention 的 head-parallel
我目前的 attention loop 是 sequential for h in 0..n_head:
for h in 0..cfg.n_head {
// 算 attention scores、softmax、加權 V → 寫進 self.xb 的某段
}每個 head 寫進 self.xb 的不同 slice,是 disjoint 的。可以用 rayon 的 chunks_exact_mut 平行:
self.xb.par_chunks_exact_mut(head_dim).enumerate().for_each(|(h, out)| {
// 算這個 head 的 attention
});但 att buffer 是共享的(self.att),平行版需要每個 thread 獨立的 attention scratch。head 數量通常很小(32-64),fork-join 的 overhead 可能吃掉收益——這個值得 benchmark 但不一定划算。
2. FlashAttention 風格的 fused attention
我目前的 attention 是「先算所有 score、再 softmax、再加權 V」三步。FlashAttention 把它們 fuse 成一個 streaming pass,記憶體占用從 O(n_ctx) 降到 O(head_dim),且 cache 友善。但實作複雜得多——這是 candle/ggml 級別的優化。
3. KV cache 的量化
KV cache 是長 context 的記憶體大頭。對 7B 模型、2k context、F32 cache,每層 cache 是 2 * 2048 * 1024 * 4 = 16 MB,32 層 = 512 MB。如果把 cache 也量化成 Q8(1 byte/element),可以降到 128 MB。llama.cpp 已經支援這個(-ctk q8_0 -ctv q8_0)。
4. Prefill batching
目前 prefill 是 sequential——一個 token 一個 forward。但 prompt 的所有 token 都已知,可以拼成一個矩陣做 batched forward:
seq forward: O(L * (matvec for each layer))
batch forward: O(matmul of [L, n_embd] for each layer)GEMM 的 cache locality 比 GEMV 好得多,prefill 速度可以提升 5-10×。但要重新設計 attention(causal mask 變成必須的)、要修改 KV cache 寫入方式(一次寫 L 個 token)。這是中量級的改動。
5. Speculative decoding
更進階的優化:用一個小的 “draft model” 一次猜 K 個 token,用主模型 verify。如果猜對 K 個就只花了一次 forward 的時間做 K 個 token。這是 inference latency 的革命性技術,但需要兩個模型協同。
6. Continuous batching
如果 runner 要 serve 多個請求,可以做 continuous batching——不同請求的 token 進入同一個 batch,動態加減。但這要求 KV cache 是「per-request」的,記憶體管理更複雜。vLLM 是這條路徑的代表。
一個有趣的權衡:為什麼不寫得更 functional?
我可以把 forward 寫得更 functional,例如把每個 layer 寫成一個閉包、用 fold 串起來。但目前這個 indicative、命令式的寫法反而更容易讀——你能一眼看出每一步在改哪個 buffer、用哪個權重。
LLM 推論的程式碼有個特殊性:90% 的時間在做矩陣運算,10% 在做 control flow。寫得太 functional 會把 control flow 的成本顯露在前景,反而蓋住了真正重要的計算。
總結:runner.rs 的角色
- 概念上:把 token id 映射到 logits 的單一函式,內部管理 KV cache 和層級遞進。
- 實作上:fixed-size scratch buffers + sequential layer loop + manual KV slice manipulation。
- 設計上:把所有複雜性集中在這個檔案裡,其他檔案(
ops、tensor)保持簡單。
下一篇講 tokenizer.rs,把 byte 流變成 token id 的細節。
系列文章:
- tiny-llm-runner 介紹
- (1) config ・ (2) dequant ・ (3) tensor ・ (4) model ・ (5) ops
- (6) runner.rs(本篇)