featured.svg

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

上一篇我把 tiny-llm-runner 拆開來介紹完之後,自然會冒出一個問題:

Rust 圈不是已經有一個叫做 candle 的專案了嗎?Hugging Face 親兒子、CUDA / Metal 後端齊全、連 Llama / Mistral / Whisper / Stable Diffusion 都跑得起來——那我這個小小的 tiny-llm-runner 到底有什麼存在意義?

這篇文章就是要正面回答這個問題。比較的重點不是「誰比較好」——candle 在功能、效能、生態系上樣樣都把我的玩具碾壓——而是想看一件更有意思的事:當你給自己訂的目標不一樣時,最後寫出來的架構會差到什麼程度?

兩個專案都用 Rust 寫、都吃 GGUF、都能跑 Llama,但你只要把它們的 Cargo.tomlsrc/ 並排放,就會發現它們其實在解非常不同的問題。


先把比較的基準擺正

要公平比較,得先承認兩者的「設計願景」根本不在同一個量級:

維度 tiny-llm-runner candle
目標 教學版:每一行 forward pass 都要看得懂 通用 ML 框架:要當 PyTorch 在 Rust 的對應物
範圍 只跑 Llama 1/2/3 推論 跑各式架構(Llama、Mistral、Mixtral、Whisper、SD…)
後端 只有 CPU + rayon CPU(可選 MKL/Accelerate)、CUDA、Metal
量化格式 5 種(F32/F16/Q8_0/Q4_0/Q6_K) 完整 GGML 量化家族 + 自家 QTensor
訓練 沒有 有(autograd)
程式碼規模 < 2,000 行 數萬行(多個 crate)

tiny-llm-runner 拿來和 candle 比,差不多就像是拿一台「自製真空管收音機」去和一台「現代多媒體電視」比較——兩者都會發出聲音,但它們存在的理由完全不同。所以下面我不會去比效能或功能(那只會是一面倒),而是要看「為了達成各自的目標,他們在哪些設計選擇上做了不一樣的取捨」。


差異一:Tensor 抽象的厚度

最容易看出兩者哲學差異的地方,是「Tensor 這個型別到底是什麼」。

tiny-llm-runner:32-byte 的薄殼

我的 TensorView 全部就只有這幾個欄位:

pub struct TensorView<'a> {
    pub data: &'a [u8],   // 直接借用 mmap 的 bytes
    pub ggml_type: GgmlType,
    pub dims: [u64; 4],
    pub n_dims: usize,
}

不是一個 Tensor——它是「mmap 裡某段 byte 的解釋方式」。它沒有 device、沒有 dtype 抽象(只有 GgmlType 列舉)、沒有自動微分的計算圖、沒有 broadcasting 規則、沒有 lazy execution。它只暴露兩個方法:dot_rowdequant_row

candle:完整的 PyTorch 風格 Tensor

candle_core::Tensor 的概念負擔則完全是另一個世界。它要追蹤:

  • Shape:可變維度的張量形狀,支援 broadcasting。
  • DTypeF32F16BF16U8U32I64F64
  • DeviceCpuCuda(...)Metal(...),每個張量都「住」在某一個裝置上。
  • Storage:實際資料的 reference-counted handle,支援共享和切片。
  • Op tracking:autograd 要用——每個 a + b 都會記下「我是從 a 和 b 加出來的」這條邊,反向傳播時才能還原計算圖。

換句話說,candle::Tensor 是一個有身分證的對象,它知道自己住哪、長什麼樣、從哪裡來。TensorView 則是一個臨時護照,過了這個 scope 就什麼都不剩了。

這個差異會傳染到整個 codebase

當你的 Tensor 抽象很厚時,所有運算都得透過它走。candle 裡你寫 x.matmul(&w),回傳的是新的 Tensor,背後可能是 CPU 的 BLAS、CUDA 的 cuBLAS、或 Metal 的 MPS——你都不需要知道。

但當你的抽象很薄(像我)時,matvec 直接吃 &mut [f32]&TensorView,沒有任何派發層,整個函式 inline 起來幾乎沒有 overhead

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);   // 直接 match ggml_type 派發
    });
}

代價是:這個 matvec 永遠只會在 CPU 上、只能算 f32×量化、只能做 row-parallel。要支援 GPU?沒辦法。要支援 batch-2 matrix×matrix?得重寫。


差異二:權重從 GGUF 到記憶體的路徑

這條路徑兩邊走得很不一樣,也是兩種哲學最具體的展現。

tiny-llm-runner:mmap → 不解壓 → row-by-row dot

我的 LlamaModel::load 只做兩件事:

  1. 把 GGUF 的 tensor 列表掃一遍,建一個 name → TensorInfo 的 HashMap。
  2. 對每個我需要的張量名(token_embd.weightblk.0.attn_q.weight…),建一個 TensorView

整個過程沒有讀任何權重資料——TensorView 只記住「這個張量住在 mmap 的哪一段 bytes」。等到 forward() 真的呼叫 dot_row(i, x) 時,才會去讀那一個 row 的 bytes、解碼、做內積。

整個生命週期裡,Q4_0 的權重就一直是 Q4_0 在硬碟上躺著,從來沒有被解壓成 f32 矩陣放在 RAM 裡。一個 Q4_0 的 7B 模型大約是 4 GB,如果你解壓成 f32 就是 28 GB——這對筆電來說是天差地別。

candle:QTensor → QMatMul → 編譯期分派

candle 的處理方式精緻很多。它有專門的 candle_core::quantized 模組,裡面有:

  • QTensor:量化張量的內部表示。
  • QMatMul:把量化權重和 f32 啟動值相乘的高階介面。
  • gguf_file::Content:解析 GGUF 檔。

載入流程大致是:

  1. gguf_file::Content::read(...) 把整個 metadata 讀進來。
  2. 對每個張量,用 tensor.qmatmul(...)tensor.dequantize(...) 取得 QTensor
  3. QTensor 包進 QMatMul,當作一個可呼叫的層。

QMatMul 在 forward 時會根據量化型別、後端(CPU/CUDA/Metal)派發到對應的 SIMD/CUDA kernel。CPU 上是 AVX2/NEON 手工最佳化過的;CUDA 上是專門的 dequant+gemm fused kernel。

兩條路的代價

tiny-llm-runner 來說,「不解壓」是一個雙面刃:

  • ✅ 記憶體用量極低、不需要 warmup。
  • ✅ 大檔案啟動秒開(mmap 立即返回)。
  • ❌ 沒有任何 SIMD/向量化——我的 dot_q4_0 是純標量迴圈,比 candle 慢一個數量級以上。

candle 來說,多走一層 QTensor 抽象的代價也很實在:

  • ✅ 同一份程式碼可以在 CPU/CUDA/Metal 上跑。
  • ✅ SIMD 最佳化由底層 kernel 統一處理。
  • ❌ 程式碼複雜度暴增——要看懂 QMatMul::forward 在哪個分支會做什麼事,得跨好幾個 trait、好幾個 cfg flag。

差異三:模型架構是寫死的還是組裝的

這個維度大概是最直觀的「規模差距」。

tiny-llm-runner:把 Llama 寫死進去

我的 runner.rs 整個 forward pass 是一段大約 100 行的指令式程式碼,每一行都假設模型是 Llama

// attention norm → qkv → RoPE → GQA attention → output proj
rmsnorm(&mut self.xb, &self.x, &layer.attn_norm, cfg.rms_eps);
matvec(&mut self.q, &layer.wq, &self.xb);
matvec(krow, &layer.wk, &self.xb);
matvec(vrow, &layer.wv, &self.xb);
apply_rope(&mut self.q, pos, head_dim, cfg.rope_dim_count, ...);
// ... 以下省略

要支援 Mistral?得改 RoPE 的 sliding window mask。要支援 Mixtral?得加 MoE 路由邏輯。每加一個架構,就是改 runner.rs 一次

candle:用 nn::Module trait 組合

candle 走的是 PyTorch 風格:每一層是一個實作了 Module trait 的 struct,forward 是它的方法。Llama 的整個架構在 candle_transformers::models::llama 裡是這樣組起來的:

// 大約的形狀(為了清楚我簡化過)
pub struct Block {
    rms_1: RmsNorm,
    attn: CausalSelfAttention,
    rms_2: RmsNorm,
    mlp: Mlp,
}

impl Module for Block {
    fn forward(&self, x: &Tensor) -> Result<Tensor> {
        let h = (x + self.attn.forward(&self.rms_1.forward(x)?)?)?;
        let h = (&h + self.mlp.forward(&self.rms_2.forward(&h)?)?)?;
        Ok(h)
    }
}

每一層都是獨立的元件,換一個架構只是換組合方式。Mistral 用的是同一套 RmsNormMlp,只是把 CausalSelfAttention 換成支援 sliding window 的版本。Mixtral 把 Mlp 換成 SparseMoeBlock

VarBuilder:把名字和張量解耦的關鍵

candle 在「載入權重」這件事上有一個我覺得很漂亮的抽象:VarBuilder。它是一個 hierarchical 的權重來源,可以這樣用:

let vb = VarBuilder::from_gguf("model.gguf", &device)?;
let block_0 = Block::load(vb.pp("blk.0"), &cfg)?;
let block_1 = Block::load(vb.pp("blk.1"), &cfg)?;

vb.pp("blk.0") 會回傳一個「prefix 是 blk.0 的子 builder」,子 builder 再去要 attn_q.weight 時,實際上會去找 blk.0.attn_q.weight。這讓模型的層級結構和權重的命名空間自然對應——你不用在每一層的 load 函式裡手動拼字串。

我的 model.rs 則是另一個極端:

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)?,
        // ...
    });
}

直接把 layer index 用 format! 拼進去。簡單粗暴,但只能應付「我知道我要找的張量叫什麼名字」這種情境。換一個架構,這整段都得改寫。


差異四:autograd —— 有沒有反向傳播

這個是最大、也最一刀切的差異。

candle 是一個訓練框架——它的 Tensor 在做運算時會自動建立計算圖,呼叫 loss.backward() 就能拿到所有參數的梯度。這也是為什麼它的 Tensor 必須記住「我從哪些 Tensor 來」、必須有 reference counting、必須處理生命週期。

tiny-llm-runner 完全不知道反向傳播是什麼。所有運算都是純函式式的:吃 &[f32] 進去、寫 &mut [f32] 出來,過了就忘。沒有梯度、沒有計算圖、沒有 backward。

這個取捨的影響比想像中還深:

  • 沒有 autograd → 不需要 op 物件 → 不需要 trait object → 不需要 reference counting → 不需要 Arc<Tensor> → 不需要 thread-safe 設計。
  • 結果就是:我可以用 &mut [f32] 切來切去,編譯器幫我擋住所有別名問題,程式碼長得像極了 C,但完全沒有 unsafe

換句話說,「我只做推論」這個決定,讓整個 codebase 的型別系統可以瘦一個量級。


差異五:執行模型 —— eager 還是 graph?

tiny-llm-runner 是純 eager:每個 matvec 呼叫就是一個立即發生的計算。沒有圖、沒有 fusion、沒有調度器。

candle 大致也是 eager 的(這也是它和 PyTorch 對應的點),但它的 Tensor 抽象為未來的 graph 最佳化留了空間。已經有些情境下 candle 會做 op fusion(例如 softmax + attention mask 在某些 backend 上是同一個 kernel)。

這個差異的代價是:candle 在 hot path 上,每個運算都要過一層型別/裝置/dtype 派發。對小張量來說這個 overhead 不可忽略;對大張量(典型 LLM 推論)來說相對微不足道。我則是從定義上就不可能有這個 overhead——因為我根本沒有派發。


一張表總結兩者的設計選擇

flowchart LR subgraph TLR[tiny-llm-runner] T1[mmap bytes] T2[TensorView
32 bytes] T3[matvec/rmsnorm/
RoPE 純函式] T4[Runner.forward
寫死 Llama] T5[只 CPU + rayon] T1 --> T2 --> T3 --> T4 --> T5 end subgraph C[candle] C1[mmap bytes] C2[QTensor / Tensor
多 backend] C3[Module trait
Linear/RmsNorm/...] C4[VarBuilder 載入
架構組合] C5[CPU + CUDA + Metal
autograd] C1 --> C2 --> C3 --> C4 --> C5 end

兩個都是同一條路徑——bytes → tensor → ops → model → backend——但每一段抽象的厚度差距,乘起來就是「兩千行 vs. 數萬行」、「一個架構 vs. 一打架構」、「教學版 vs. 生產級」的差距。


那什麼時候該用哪一個?

我對自己的定位很清楚:

candle 當你在做以下事情:

  • 想要在 Rust 裡跑非 Llama 的模型(Whisper、SD、各種 vision encoder)。
  • 需要 GPU 加速(CUDA / Metal)。
  • 想要 fine-tune,或在 Rust 裡訓練。
  • 想要把同一份模型程式碼跑在多種 backend 上。

tiny-llm-runner 這種東西的價值:

  • 教學:把每一個量化格式、每一個 RoPE 變體、每一個 attention 細節都自己摸過一遍。
  • 可審計:當 forward pass 出 bug 時,你不需要去翻 30 個檔案、跨 5 個 trait——全部就在 runner.rs 那 100 行裡。
  • 超低依賴:整個 Cargo.toml 只有 anyhowclaphalfmemmap2rayon,再加我自己的 llm-gguf-parser。沒有 BLAS、沒有 CUDA toolkit、沒有 LLVM。
  • 特殊嵌入場景:如果你要把推論塞到一個極小的環境(IoT、WASM、安全沙盒),tiny-llm-runner 這種「沒有外部依賴的 forward pass」是個還算合理的起點。

我從這次比較中學到的事

寫了 tiny-llm-runner 再去看 candle 的程式碼,最大的收穫不是「我發現 candle 哪裡寫得不好」(事實上它寫得很好),而是讓我清楚看到「為了通用性需要付出多少抽象成本」

candle 的每一層抽象——TensorDeviceDTypeModuleVarBuilderQMatMul——都不是憑空長出來的,都是在解一個「如果不抽象,使用者就會痛」的問題。但反過來說,如果你的使用情境不需要那種通用性(例如「我只想跑 Llama Q4_0、只在 CPU 上、只做 inference」),那這些抽象對你而言就是純粹的閱讀負擔。

這也是為什麼我覺得 tiny-llm-runner 並不是 candle 的「失敗版」——它是在另一個座標點上的一個合理選擇。抽象是手段,不是目的。 當你的目標是「我要把 forward pass 看懂」時,零抽象的版本反而是最對的設計。

下次如果有空,我想拿 tiny-llm-runner 的 forward pass 對 candle 的 models::llama::Llama::forward 做一個逐行對照——應該會是另一篇有趣的文章。


原始碼:

相關閱讀: