本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
上一篇看完了 LlamaModel 怎麼把張量組起來,這一篇要看的是 Transformer 真正的「動詞」們:ops.rs。
這個檔案只有 100 行,但它就是整個 Transformer 的所有原語。一個現代 LLM 的所有計算,最終都會被分解成這六種運算的組合。
樂高積木一:RMSNorm
pub fn rmsnorm(out: &mut [f32], x: &[f32], w: &[f32], eps: f32) {
let mut ss = 0.0f64;
for &v in x {
ss += v as f64 * v as f64;
}
ss /= x.len() as f64;
ss += eps as f64;
let scale = (1.0 / ss.sqrt()) as f32;
for i in 0..x.len() {
out[i] = w[i] * (x[i] * scale);
}
}算法:
$$\text{out}_i = w_i \cdot \frac{x_i}{\sqrt{\frac{1}{n}\sum_j x_j^2 + \epsilon}}$$也就是「先把 x 除以它自己的 RMS,再用 w 逐元素 scale」。RMSNorm 是 LayerNorm 的簡化版(沒有 mean-shift、沒有 bias),Llama 用它取代 LayerNorm 是 2019-2020 年的設計趨勢。
為什麼用 f64 累加?
注意 ss 是 f64,不是 f32。這不是隨便寫的:累加大量 f32 值容易產生「精度吞噬」(catastrophic cancellation)。一個 4096 維的 hidden state,每個 f32 大約 1e-1 量級,平方後是 1e-2,4096 個累加起來大概 40-100。在這個量級下,每個新加進來的 1e-2 增量在 f32 上會有相對誤差 ~1e-7 × 100 = 1e-5,4096 次累積下來會放大。
llama.cpp 在這條路徑用了 f64 累加器,我必須跟它對齊——否則 RMSNorm 的輸出會和它差千分之一,雖然每層只差千分之一,但 22 層累積下來輸出 token 就會不一樣。這是「對齊既有實作」的另一個例子。
樂高積木二:matvec —— 用 rayon 平行化
pub fn matvec(out: &mut [f32], w: &TensorView<'_>, x: &[f32]) {
out.par_iter_mut().enumerate().for_each(|(i, o)| {
*o = w.dot_row(i, x);
});
}這是整個專案的效能瓶頸所在,也是最簡潔的一段程式碼。讓我們拆解這幾行做了什麼:
par_iter_mut()來自rayon,把&mut [f32]變成一個平行 iterator。enumerate()加上 row index。for_each(|(i, o)| ...)對每個 row 平行執行 closure。
每個 row 是獨立的(out[i] 只被當前 closure 寫入),所以不需要任何鎖。rayon 的 work-stealing 排程器會自動把 row 切成 chunk 分給可用的執行緒。
為什麼 row-parallel 而不是 element-parallel?
如果改成 element-parallel(每個輸出元素一個 task),task 太細會被 rayon 的 task overhead 吃掉。row-parallel 的 granularity 剛好——每個 task 是一個完整的 dot product(4096 個乘加),夠粗到值得啟動執行緒,又夠細到能平均負載。
Rust 的 par_iter_mut 設計上避免了一個很容易踩的雷:你不能讓兩個 thread 同時寫 out 的同一個位置。par_iter_mut 的型別簽章保證每個 closure 拿到的是 &mut f32(單一元素的 mutable ref),編譯期就排除了 race condition。
樂高積木三:softmax —— max-shift 防止 overflow
pub fn softmax(x: &mut [f32]) {
let mut max = f32::NEG_INFINITY;
for &v in x.iter() {
if v > max { max = v; }
}
let mut sum = 0.0f32;
for v in x.iter_mut() {
*v = (*v - max).exp();
sum += *v;
}
let inv = 1.0 / sum;
for v in x.iter_mut() {
*v *= inv;
}
}數學上,softmax 是:
$$\text{softmax}(x_i) = \frac{e^{x_i}}{\sum_j e^{x_j}}$$但 e^{x} 對大 x 會 overflow(f32 的 exp 在 x > ~88 就溢位)。標準做法是先減 max:
數學上等價(分子分母同乘 $e^{-\max(x)}$),但所有 exp 的輸入都在 $(-\infty, 0]$,永遠不會 overflow。這是寫 softmax 的不可省的步驟——少了它,遇到極端 logits 就會 NaN。
樂高積木四:RoPE —— 相對位置的旋轉
這是最微妙的一個運算。RoPE(Rotary Position Embedding)是 Llama 用來把位置資訊塞進 attention 的方法,數學上是把每個 head 的 Q/K 向量看成「複數對」並旋轉一個和位置相關的角度。
pub fn apply_rope(
vec: &mut [f32], pos: usize, head_dim: usize,
rot_dim: usize, base: f32, style: RopeStyle,
) {
let half = rot_dim / 2;
let n_heads = vec.len() / head_dim;
for h in 0..n_heads {
let off = h * head_dim;
for i in 0..half {
let freq = base.powf(-((2 * i) as f32) / rot_dim as f32);
let theta = pos as f32 * freq;
let (sin, cos) = theta.sin_cos();
let (i0, i1) = match style {
RopeStyle::Llama => (2 * i, 2 * i + 1),
RopeStyle::Neox => (i, i + half),
};
let v0 = vec[off + i0];
let v1 = vec[off + i1];
vec[off + i0] = v0 * cos - v1 * sin;
vec[off + i1] = v0 * sin + v1 * cos;
}
}
}核心想法:每對 (v[i0], v[i1]) 是一個 2D 向量,被旋轉一個角度 $\theta = \text{pos} \cdot \text{freq}_i$。不同的 i 用不同的 freq——i 越小 freq 越大(位置感越強)、i 越大 freq 越小(接近不變)。
頻率公式:
$$\text{freq}_i = \text{base}^{-2i / \text{rot\_dim}}$$base 通常是 10000,rot_dim 是 head_dim(或某個比例)。i = 0 時 freq = 1(每移動一個 position 旋轉 1 弧度),i = rot_dim/2 - 1 時 freq ≈ 1/10000(要 6000 個 position 才旋轉 1 弧度)。
這個多尺度的旋轉讓 attention 既能感知近距離的細微位置差異、也能感知遠距離的大致位置。
為什麼有兩種 style?
let (i0, i1) = match style {
RopeStyle::Llama => (2 * i, 2 * i + 1), // 鄰接成對
RopeStyle::Neox => (i, i + half), // 上下半成對
};這兩個是完全不同的「複數對」配對方式:
Llama:把v[0], v[1]當一對、v[2], v[3]當一對……(鄰接 pair)Neox:把v[0], v[half]當一對、v[1], v[half+1]當一對……(上下 split)
兩者旋轉的數學是一樣的,但配對方式不同。為什麼兩者並存?
歷史上,HuggingFace 的 transformers 採用 NeoX 風格。但 llama.cpp 早期的 convert.py 在轉檔時會把 Q/K 矩陣的每對 row permute 成鄰接格式,這樣就可以用 Llama 風格做 RoPE,記憶體 access 更線性。
新版的 convert_hf_to_gguf.py 不做 permute,所以你必須用 NeoX 風格。搞錯了不會 crash 但模型會講胡話——這是我親自踩過的雷。
sin_cos() 是 f32::sin_cos()——同時算 sin 和 cos,硬體上比分別算快一倍。
樂高積木五:SiLU —— SwiGLU 的活化函數
#[inline]
pub fn silu(x: f32) -> f32 {
x / (1.0 + (-x).exp())
}數學定義:
$$\text{SiLU}(x) = x \cdot \sigma(x) = \frac{x}{1 + e^{-x}}$$$\sigma$ 是 sigmoid。SiLU 也叫 Swish。Llama 的 FFN 用的是 SwiGLU(Swish-Gated Linear Unit):
$$\text{FFN}(x) = W_{down}(\text{SiLU}(W_{gate}(x)) \odot W_{up}(x))$$⊙ 是逐元素相乘。也就是先把 x 過 gate 和 up 兩個投影,gate 過 SiLU 之後逐元素乘上 up,再過 down 投影。比起傳統 ReLU FFN,SwiGLU 多了一個 gate 投影,但實驗顯示效果好得多。
#[inline] 是給編譯器的提示,因為這個函式會在 inner loop 被呼叫成千上萬次,inline 進去能避免函式呼叫的 overhead。
樂高積木六:add_inplace —— 殘差連接
pub fn add_inplace(a: &mut [f32], b: &[f32]) {
for (x, y) in a.iter_mut().zip(b.iter()) {
*x += *y;
}
}最樸素的一個原語:把 b 加進 a。Transformer 的每個 sublayer 都會做:
x = x + Attention(LayerNorm(x))
x = x + FFN(LayerNorm(x))那兩個加號就是 add_inplace。
注意我用 iter_mut().zip(iter()) 而不是用 index。這個寫法的好處是:
- 沒有越界檢查——iterator 自動知道何時停止。
- 編譯器更容易自動向量化——iterator 模式對 LLVM 友善。
- 零成本抽象——這個寫法和手寫 raw loop 編譯出來幾乎一樣的組合語言。
Rust 用法:#[derive] 的小幫手
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RopeStyle {
Llama,
Neox,
}Copy 讓我可以把 RopeStyle 當值傳遞,不需要 &。Eq 讓我可以在 match 比較。Debug 讓 eprintln!("{:?}", style) 可以印出來除錯。不到一秒的工作,省下 10 行 boilerplate——這是 Rust derive 機制的常規用法。
效能最佳化空間
ops.rs 是另一個重要的最佳化戰場。每個函式都有改進空間:
1. RMSNorm 的 SIMD
那個 for &v in x { ss += v * v; } 是個 reduction,可以用 SIMD 平行化:
use std::simd::f32x8;
let mut acc = f32x8::splat(0.0);
for chunk in x.chunks_exact(8) {
let v = f32x8::from_slice(chunk);
acc += v * v;
}
let ss = acc.reduce_sum() as f64;f64 vs f32 累加器的精度問題在 SIMD 環境下變得有趣——AVX-512 有 f64 SIMD,AVX2 沒有。最務實的做法是維持 f32 SIMD reduction,因為實作簡單且誤差可接受(畢竟 4096 個 ~1e-2 量級的數,誤差不會大到讓 token 不一致)。
2. matvec 的 GEMV → GEMM 升級
我目前的 matvec 是 GEMV(matrix × vector),每次 forward pass 對每層做兩次(attention 的 Q + FFN 的 down)。但prefill 階段的 prompt 是序列 token,可以批次處理——把 N 個 token 的 hidden state 拼成 [seq_len, n_embd] 矩陣,做一次 GEMM。
GEMM 比 GEMV 快很多(典型 5-10×),因為它能 reuse 權重(每次 load 一個 row 的權重,可以對 seq_len 個輸入做計算)。但這需要重新設計 Runner——目前是單 token forward,要改成 batch forward。
3. softmax 的 SIMD exp
(*v - max).exp() 是逐元素的,可以 SIMD 化。但 SIMD exp 比較麻煩——需要用多項式逼近(典型用 Cephes 或 Padé approximant 的 SIMD 版本)。sleef crate 有現成的 SIMD math 函式,可以接上。
4. RoPE 的 sin/cos 預計算
每次 forward pass 都呼叫 theta.sin_cos()。但 freq_i 只和 i 有關,theta = pos * freq_i 也可以分解。如果預計算所有可能的 (pos, i) 的 sin/cos 並存成 table,runtime 只需要查表:
let cos_table: Vec<Vec<f32>> = (0..n_ctx).map(|pos| {
(0..rot_dim/2).map(|i| {
let freq = base.powf(-((2*i) as f32) / rot_dim as f32);
(pos as f32 * freq).cos()
}).collect()
}).collect();對 TinyLlama 是 n_ctx × rot_dim/2 × 4 bytes = 2048 × 32 × 4 = 256 KB 的表,啟動時花一次性的時間算好,runtime 都是查表。L2 cache 就能裝下。
5. add_inplace 也可以 SIMD
雖然這是個簡單的逐元素加,但 LLVM 對 iter_mut().zip() 模式的 auto-vectorization 不一定總是會發生。明確的 f32x8::from_slice(...) + f32x8::from_slice(...) 能保證 SIMD 化。
6. 把 forward pass 內整層 fuse 起來
最大的最佳化潛力是 kernel fusion。例如 attention + softmax 可以 fuse(FlashAttention 就是這個思路);rmsnorm + matvec 也可以 fuse 成一個 pass。但 fusion 會大幅複雜化程式碼,違背 tiny-llm-runner 的「易讀」目標——所以這是 candle/ggml 的世界,不是這裡。
一個 Rust 特有的優勢:debug 與 release 的 debug_assert
我每個函式裡都有 debug_assert_eq!(out.len(), x.len()) 之類的。Rust 的 debug_assert! 在 release 會被完全消除——既能在 dev 抓 bug,又不付出 runtime 成本。對 hot path 來說這是個重要的工具。
C/C++ 也有 assert + NDEBUG,但 Rust 把這個習慣做成了語言預設——你不需要記得 #define NDEBUG,cargo build --release 自動處理。
總結:ops.rs 的角色
- 概念上:Transformer 的所有原語(norm、matmul、softmax、RoPE、activation、add)。
- 實作上:純函式、輸出參數、明確簽章、零隱藏狀態。
- 設計上:把派發(量化型別 match)藏在
TensorView::dot_row裡,ops.rs自己只看到&[f32],乾乾淨淨。
下一篇講 runner.rs,那是把所有 ops 編排起來的指揮中心。
系列文章:
- tiny-llm-runner 介紹
- (1) config ・ (2) dequant ・ (3) tensor ・ (4) model
- (5) ops.rs(本篇)