featured.svg

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

現在的大型語言模型(LLM)動輒幾十 GB,如果要把它們跑在一般的電腦或筆電上,通常都會使用經過量化(Quantization)的模型格式。而在這些格式中,由 llama.cpp 開源專案所主導的 GGUF (GPT-Generated Unified Format) 毫無疑問是當今最主流的霸主。

身為一個對底層原理充滿好奇的工程師,每次看到一個龐大的二進位檔案,心裡總會癢癢的:「這檔案裡面到底裝了什麼?如果我自己用 Rust 寫一個解析器,要怎麼開工?」

於是,這次我動手實作了一個純 Rust 的 GGUF 模型解析器:llm-gguf-parser

這篇文章就來跟大家聊聊,在實作這個二進位解析器時,我做出的幾個關鍵設計決定,以及我們如何利用 Rust 的強型別與記憶體管理特性,優雅地拆解這個龐然大物。


設計抉擇:為什麼要這樣實作?

在開始動筆寫程式之前,有幾個技術難題需要克服:

1. 檔案太大,記憶體會爆掉!怎麼辦?

LLM 模型檔案的容量非常驚人。如果直接用 std::fs::read 把整個檔案一口氣讀進記憶體,一般電腦的記憶體(RAM)肯定會瞬間被塞滿,接著被系統強制結束(OOM Crash)。

為了解決這個問題,我使用了 記憶體映射(Memory Mapping),也就是 memmap2 這個套件:

  • 零拷貝讀取(Zero-Copy Reading):我們只是將硬碟檔案對應到處理器的虛擬位址空間。當我們需要某一段資料(例如 Tensor 的名稱或某個 Metadata 數值)時,我們可以直接「切片」(Slice)檔案緩衝區,不需要在記憶體中配置新的空間。
  • 隨選載入(On-Demand Loading):作業系統的虛擬記憶體管理器非常聰明,它只會將我們真正讀取到的硬碟分頁(通常是檔頭的 Metadata 區塊)載入到 RAM。而佔了檔案 99.9% 空間的 Tensor 權重數值,在解析過程中根本不會被讀入記憶體,完美避開了記憶體不足的問題。

2. 不需要大砲!拒絕複雜的解析框架

雖然 Rust 有很強大的解析器組合子框架(如 nom)或序列化套件(如 serde),但對於 GGUF 這樣結構非常規律、順序明確的格式來說,引入這些厚重的套件反而會增加學習曲線與編譯時間。

因此,我決定回歸本質:手寫一個簡單的順序讀取器(Reader)。 我們只要包裝一個指向 Byte 切片的指標,每次讀完資料就把指標往後移動。因為 GGUF 規定使用小端序(Little-Endian)儲存,我們可以直接利用 Rust 標準函式庫內建的字節轉換方法(如 u32::from_le_bytesu64::from_le_bytes),編譯器會把這些直接翻譯成硬體層級的指令,速度極快而且直覺好懂。

3. 處理 GGUF 歷史版本演進

GGUF 規範在發展過程中經歷了三個版本:

  • Version 1:早期設計給 32 位元系統使用,Metadata 鍵值對和 Tensor 的數量是用 u32 來儲存。
  • Version 2 & 3:隨著模型規模暴增,為了支援擁有成千上萬個 Tensor 的巨型模型,數量改用 u64 儲存。

為了讓解析器有足夠的相容性,我們必須在程式一開始讀取版本號,然後動態決定接下來讀取計數時要使用 4 個字節(u32)還是 8 個字節(u64)。


GGUF 檔案格式一覽

在看程式碼之前,我們先來看看 GGUF 在硬碟上的位元組佈局:

區段 大小 說明
Magic Bytes 4 Bytes 必須是 ASCII 的 'G' 'G' 'U' 'F' (0x46554747)
Version 4 Bytes 小端序 u32(目前常見為 2 或 3)
Tensor Count 4 或 8 Bytes 檔案中包含的張量(Tensor)總數
Metadata Count 4 或 8 Bytes 鍵值對(KV)形式的 Metadata 數量
Metadata KVs 變動大小 一系列的 (Key字串, 數值型態ID, Value) 資料
Tensor Metadata 變動大小 每個張量的名稱、維度、資料型態以及在檔案中的偏移量(Offset)
對齊填充 (Padding) 變動大小 用於對齊張量資料區段的邊界
張量權重資料 (Data) 變動大小 模型的原始權重數值(佔據 99.9% 空間)

核心實作

接下來,我們來看看如何用 Rust 把這個格式描述出來。

1. 用強型別定義 Metadata 型態

GGUF 支援非常豐富的資料型態。我們可以用 Rust 的 enum 來表達這些型態,不僅安全,還能附帶資料:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum ValueType {
    Uint8 = 0,
    Int8 = 1,
    Uint16 = 2,
    Int16 = 3,
    Uint32 = 4,
    Int32 = 5,
    Float32 = 6,
    Bool = 7,
    String = 8,
    Array = 9,
    Uint64 = 10,
    Int64 = 11,
    Float64 = 12,
}

#[derive(Debug, Clone, PartialEq)]
pub enum Value {
    Uint8(u8),
    Int8(i8),
    Uint16(u16),
    Int16(i16),
    Uint32(u32),
    Int32(i32),
    Float32(f32),
    Bool(bool),
    String(String),
    Array(ValueType, Vec<Value>), // 陣列型態,內部帶有元素列表
    Uint64(u64),
    Int64(i64),
    Float64(f64),
}

當我們在解析 Metadata 時,我們會先讀出一個 u32 代表型態 ID,接著用 TryFrom<u32> 轉換成 ValueType,最後依據型態讀取對應長度的位元組,包裝成 Value enum。

值得注意的是 Value::Array 的設計——它內部帶有元素型態(ValueType)和元素列表(Vec<Value>),這意味著陣列的解析是遞迴的:先讀出元素型態 ID,再讀出陣列長度,然後逐一呼叫 read_value() 讀取每個元素。如果今天是「字串陣列」,每個元素就會進入 ValueType::String 分支;如果是「陣列的陣列」,理論上也可以遞迴下去(雖然 GGUF 規範裡沒有這種用法)。

另外,Bool 在檔案裡其實是 1 byte(0x00 = false,非零 = true),所以解析時用 read_u8()? != 0——這是 GGUF 規範的設計,不是 Rust 的 bool(1 byte),而是把 C 語言的慣例忠實對應過來。

2. 強型別的量化格式:GgmlType Enum

除了 Metadata 的型別系統,GGUF 還定義了一套模型量化(Quantization)格式,用 GgmlType 表示。這個 enum 有 30 個變體,涵蓋了從原始的 FP32 到各種「K 量化」和「IQ 量化」格式:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(non_camel_case_types)]
#[repr(u32)]
pub enum GgmlType {
    F32 = 0, F16 = 1,           // 原始浮點
    Q4_0 = 2, Q4_1 = 3,         // 4-bit 量化(基本款)
    Q5_0 = 6, Q5_1 = 7,         // 5-bit 量化
    Q8_0 = 8, Q8_1 = 9,         // 8-bit 量化
    Q2_K = 10, Q3_K = 11,       // K 量化家族(block-wise)
    Q4_K = 12, Q5_K = 13,
    Q6_K = 14, Q8_K = 15,
    Iq2Xxs = 16, Iq2Xs = 17,    // IQ 量化(integer quantization)
    Iq3Xxs = 18, Iq1S = 19,
    Iq4Nl = 20, Iq3S = 21,
    Iq2S = 22, Iq4Xs = 23,
    I8 = 24, I16 = 25,          // 整數型態
    I32 = 26, I64 = 27, F64 = 28,
    Iq1M = 29,
}

你可能注意到 ID 4、5 是空的——這不是漏掉,而是 GGML 規範裡本來就沒有分配這些 ID(歷史因素,早期有些格式被廢棄了)。用 #[repr(u32)] 標記讓每個變體的判別式(discriminant)直接對應檔案裡的 type ID,TryFrom<u32> 則確保遇到未知型態時優雅地報錯。

這些量化格式的差異在於「每個 block 有多少元素、scale factor 怎麼存、量化精度到哪裡」——例如 Q4_0 是把 32 個 FP16 權重壓成 4-bit,一個 block 佔 18 bytes;Q4_K 則是更進階的版本,用 super-block 加 sub-block 的雙層結構來提升精度。但對於一個解析器來說,我們不需要知道每種格式的解碼細節——只要能正確讀出「這個 tensor 是哪種格式」就夠了,後續的推理引擎才會根據格式去解碼權重。

另一個實作細節:GgmlType 有獨立的 Display impl,把 Rust 的 camelCase 變體名稱轉成更可讀的字串(例如 Iq2Xxs"IQ2_XXS"),這樣 CLI 輸出才不會讓人一頭霧水:

impl fmt::Display for GgmlType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let name = match self {
            Self::Iq2Xxs => "IQ2_XXS",
            Self::Iq4Nl => "IQ4_NL",
            // ...每個變體手動對應
        };
        write!(f, "{}", name)
    }
}

3. 手寫 Byte Reader:用 Const Generics 消除重複

Reader 的核心巧妙之處在於 read_array<const N: usize>()——利用 Rust 的 const generics,一個方法就能處理所有固定大小的讀取:

struct Reader<'a> {
    data: &'a [u8],
    offset: usize,
}

impl<'a> Reader<'a> {
    // 讀取指定長度的 Slice——所有讀取的最終入口
    fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], ParserError> {
        let slice = self.data
            .get(self.offset..self.offset + len)
            .ok_or(ParserError::UnexpectedEof {
                bytes: len,
                offset: self.offset,
                len: self.data.len(),
            })?;
        self.offset += len;
        Ok(slice)
    }

    // Const Generics 技巧:一個方法服務所有固定大小讀取
    fn read_array<const N: usize>(&mut self) -> Result<[u8; N], ParserError> {
        let slice = self.read_bytes(N)?;
        Ok(slice.try_into()?)
    }

    fn read_u32(&mut self) -> Result<u32, ParserError> {
        Ok(u32::from_le_bytes(self.read_array()?))  // N = 4
    }

    fn read_u64(&mut self) -> Result<u64, ParserError> {
        Ok(u64::from_le_bytes(self.read_array()?))  // N = 8
    }

    fn read_f32(&mut self) -> Result<f32, ParserError> {
        Ok(f32::from_le_bytes(self.read_array()?))  // N = 4
    }

    // 完全相同模式,只是 N 不同!
    fn read_u16(&mut self) -> Result<u16, ParserError> { /* N = 2 */ }
    fn read_i32(&mut self) -> Result<i32, ParserError> { /* N = 4 */ }
    fn read_f64(&mut self) -> Result<f64, ParserError> { /* N = 8 */ }

    // 讀取 GGUF 格式的字串(先讀長度 u64,再讀 UTF-8 位元組)
    fn read_string(&mut self) -> Result<String, ParserError> {
        let len = self.read_u64()? as usize;
        let bytes = self.read_bytes(len)?;
        String::from_utf8(bytes.to_vec()).map_err(ParserError::from)
    }
}

看到玄機了嗎?read_u32read_u64read_f32……全部都是 self.read_array() + from_le_bytes() 的組合,差別只是 N 的值。如果不用 const generics,你就要為每種大小各寫一個 read_bytes 變體——程式碼會非常囉唆。有了 read_array<const N: usize>(),編譯器會在編譯期把每個呼叫 monomorphize 成固定大小的讀取,效能和手寫完全一樣,但程式碼乾淨多了。

這個設計非常直觀。如果檔案不小心毀損或讀取到一半檔案結束了,get(..) 會回傳 None,我們就能拋出一個漂亮的 ParserError::UnexpectedEof,而不會讓程式崩潰。

4. 解析主流程

有了 Reader 後,我們就能像寫食譜一樣,按照步驟解析 GGUF:

pub fn parse_gguf(data: impl AsRef<[u8]>) -> Result<GgufFile, ParserError> {
    let mut reader = Reader::new(data.as_ref());

    // 1. 檢查 Magic Bytes (必須是 "GGUF")
    let magic = reader.read_u32()?;
    if magic != 0x46554747 {
        return Err(ParserError::InvalidMagic(magic));
    }

    // 2. 讀取版本號
    let version = reader.read_u32()?;
    if version != 1 && version != 2 && version != 3 {
        return Err(ParserError::UnsupportedVersion(version));
    }

    // 3. 依據版本讀取 Tensor 數量與 Metadata 數量
    let (tensor_count, metadata_kv_count) = if version == 1 {
        (reader.read_u32()? as u64, reader.read_u32()? as u64)
    } else {
        (reader.read_u64()?, reader.read_u64()?)
    };

    // 4. 迴圈讀取所有的 Metadata 鍵值對
    let mut metadata = HashMap::with_capacity(metadata_kv_count as usize);
    for _ in 0..metadata_kv_count {
        let key = reader.read_string()?;
        let val_type_u32 = reader.read_u32()?;
        let val_type = ValueType::try_from(val_type_u32)?;
        let val = reader.read_value(val_type)?;
        metadata.insert(key, val);
    }

    // 5. 迴圈讀取所有 Tensor 的描述資訊
    let mut tensors = Vec::with_capacity(tensor_count as usize);
    for _ in 0..tensor_count {
        let name = reader.read_string()?;
        let dimensions_count = reader.read_u32()? as usize;
        let mut dimensions = Vec::with_capacity(dimensions_count);
        for _ in 0..dimensions_count {
            dimensions.push(reader.read_u64()?);
        }
        let tensor_type_u32 = reader.read_u32()?;
        let tensor_type = GgmlType::try_from(tensor_type_u32)?;
        let offset = reader.read_u64()?;

        tensors.push(TensorInfo {
            name,
            dimensions,
            tensor_type,
            offset,
        });
    }

    Ok(GgufFile {
        version,
        metadata,
        tensors,
    })
}

這個主流程清晰展現了二進位檔案結構解析的精髓:順序讀取、條件分支、結構映射

5. 錯誤處理:帶上下文的 ParserError

Reader 的每個讀取操作都可能失敗——檔案截斷、格式不符、編碼錯誤。我們用 thiserror 定義了 6 種精確的錯誤型態:

#[derive(Error, Debug)]
pub enum ParserError {
    #[error("unexpected end of file reading {bytes} bytes (offset: {offset}, len: {len})")]
    UnexpectedEof { bytes: usize, offset: usize, len: usize },

    #[error("invalid GGUF magic bytes: expected 0x46554747 ('GGUF'), found 0x{0:08X}")]
    InvalidMagic(u32),

    #[error("unsupported GGUF version: {0}")]
    UnsupportedVersion(u32),

    #[error("unknown value type ID {0}")]
    UnknownValueType(u32),

    #[error("unknown GGML type ID {0}")]
    UnknownGgmlType(u32),

    // 以下兩個用 #[from] 自動轉換
    #[error("invalid UTF-8 string: {0}")]
    InvalidUtf8(#[from] std::string::FromUtf8Error),

    #[error("slice conversion failed: {0}")]
    TryFromSlice(#[from] std::array::TryFromSliceError),
}

其中最值得說的是 UnexpectedEof——它不只告訴你「檔案結束了」,還告訴你當時想讀多少 bytes、在什麼偏移量、檔案總共有多大。當你在除錯一個截斷的 GGUF 檔案時,這三個數字可以省下大量的猜測時間。背後的實作靠的是 slice::get(..) 回傳 Option,而不是直接索引——直接索引會 panic,對於一個解析器來說,panic 比回傳錯誤糟糕太多了。

6. 顯示大陣列的巧思:Value 的 Display

GGUF 模型的 Metadata 裡,有些陣列非常龐大——例如 tokenizer 的詞彙表可以有 32000 個 entry。如果 Display 把整個陣列印出來,你的終端機會被洗版。所以我們做了一個小小的截斷:

Self::Array(t, v) => {
    write!(f, "[Type: {:?}, Len: {}, Elements: [", t, v.len())?;
    for (i, elem) in v.iter().take(5).enumerate() {
        if i > 0 { write!(f, ", ")?; }
        write!(f, "{}", elem)?;
    }
    if v.len() > 5 {
        write!(f, ", ... +{} more", v.len() - 5)?;
    }
    write!(f, "]]")
}

只顯示前 5 個元素,附上剩餘數量——這樣你一眼就能知道「這是什麼型態的陣列、有多長、大概裝什麼」,而不會被好幾千行輸出淹沒。


CLI 工具:實戰解析 LLM 模型

解析器本身是個 library(lib.rs),但專案也附了一個 CLI 工具(main.rs),讓你可以直接對 GGUF 檔案做查詢:

# 基本用法:顯示檔頭資訊 + Metadata 摘要
cargo run -- path/to/model.gguf

# 顯示所有 Tensor 的名稱、維度、型態
cargo run -- path/to/model.gguf --tensors

# 過濾 Metadata,只看關鍵字含 "context" 的項目
cargo run -- path/to/model.gguf --query context

CLI 用 clap 的 derive 模式定義了三個參數:

#[derive(Parser, Debug)]
#[command(author, version, about = "A parser for GGUF model files")]
struct Args {
    /// Path to the GGUF model file
    file: String,

    /// Print all tensor names, dimensions and types
    #[arg(short, long)]
    tensors: bool,

    /// Filter metadata keys by a query string
    #[arg(short, long)]
    query: Option<String>,
}

--query 非常實用——GGUF 模型的 Metadata 通常有幾百個 KV pair,全部印出來根本找不到你要的東西。用 --query tokenizer 就只會顯示 key 裡包含 “tokenizer” 的項目,例如 tokenizer 的詞表大小、BOS/EOS token ID 等等。

執行時,檔案透過 memmap2 做 memory map,然後把 &mmap 直接傳給 parse_gguf()

let file = File::open(file_path)?;
let mmap = unsafe { Mmap::map(&file)? };
let gguf = parse_gguf(&mmap).map_err(|e| anyhow::anyhow!("failed to parse: {}", e))?;

unsafe 是因為 OS 無法保證 mmap 期間檔案不會被外部修改,但對於唯讀的解析工具來說,這是完全安全的用法。


單元測試:如何在記憶體裡手搓一個迷你 GGUF?

寫二進位解析器最麻煩的就是測試。我們不可能在跑 CI 測試時真的去下載一個好幾 GB 的模型檔案。

我的作法是:在記憶體中手動建構一個「迷你 GGUF 檔案」的二進位切片

藉由將代表 Magic、版本、數量、字串長度與資料的位元組依序推入一個 Vec<u8>,我們就能建立一個合法的 GGUF 記憶體資料,並直接餵給解析器驗證:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_gguf_mock() {
        let mut data = Vec::new();

        // 1. Magic Bytes: 'GGUF'
        data.extend_from_slice(&0x46554747u32.to_le_bytes());
        // 2. Version: 3
        data.extend_from_slice(&3u32.to_le_bytes());
        // 3. Tensor count: 1
        data.extend_from_slice(&1u64.to_le_bytes());
        // 4. Metadata KV count: 2
        data.extend_from_slice(&2u64.to_le_bytes());

        // Metadata 1: Key "general.architecture" -> "llama"
        let key1 = "general.architecture";
        data.extend_from_slice(&(key1.len() as u64).to_le_bytes());
        data.extend_from_slice(key1.as_bytes());
        data.extend_from_slice(&8u32.to_le_bytes()); // Type: String (8)
        let val1 = "llama";
        data.extend_from_slice(&(val1.len() as u64).to_le_bytes());
        data.extend_from_slice(val1.as_bytes());

        // Metadata 2: Key "llama.context_length" -> 2048
        let key2 = "llama.context_length";
        data.extend_from_slice(&(key2.len() as u64).to_le_bytes());
        data.extend_from_slice(key2.as_bytes());
        data.extend_from_slice(&4u32.to_le_bytes()); // Type: Uint32 (4)
        data.extend_from_slice(&2048u32.to_le_bytes());

        // Tensor 1: Name "token_embd.weight" (Dims: [4096, 32000], Type: F16, Offset: 0)
        let t_name = "token_embd.weight";
        data.extend_from_slice(&(t_name.len() as u64).to_le_bytes());
        data.extend_from_slice(t_name.as_bytes());
        data.extend_from_slice(&2u32.to_le_bytes()); // Dims Count: 2
        data.extend_from_slice(&4096u64.to_le_bytes());
        data.extend_from_slice(&32000u64.to_le_bytes());
        data.extend_from_slice(&1u32.to_le_bytes()); // Type: F16 (1)
        data.extend_from_slice(&0u64.to_le_bytes());

        // 執行解析
        let gguf = parse_gguf(&data).unwrap();
        assert_eq!(gguf.version, 3);
        assert_eq!(gguf.metadata.len(), 2);
        assert_eq!(gguf.tensors.len(), 1);
        assert_eq!(gguf.tensors[0].name, "token_embd.weight");
    }
}

這個測試案例不僅能確保我們的讀取指標和位元組轉換百分之百正確,也能讓我們在重構解析器時,擁有極大的信心!


結語

寫這類低階二進位格式的解析器,是深入理解系統程式設計(Systems Programming)的最佳途徑之一。在這個專案中,我們重新溫習了以下核心觀念:

  • 記憶體映射(Memory Mapping) 避開大檔案的 I/O 與記憶體配置瓶頸,作業系統的虛擬記憶體管理器幫你做 on-demand paging。
  • Const Genericsread_array<N>() 一個方法服務所有大小的固定讀取,編譯期 monomorphize,零成本抽象。
  • 強型別 Enum(ValueType / GgmlType) 把二進位檔案的型態 ID 映射到 Rust 的型別系統,搭配 TryFrom 在未知 ID 時優雅報錯。
  • 帶上下文的錯誤處理 UnexpectedEof 攜帶 offset 和 len,比單純的 panic 或 io::Error 有用得多。
  • 手寫 Reader 替代了厚重繁複的解析框架,用標準函式庫的 from_le_bytes 直接對應硬體指令,效能達到極致。
  • 動態分支處理 解決了 GGUF 版本演進的向下相容性。
  • 記憶體模擬二進位切片 實現了高效且無外部相依性的單元測試。

整個解析器只有 414 行(含測試),4 個依賴——證明了在 Rust 裡,有時候最精簡的做法就是最好的做法

如果你也對 LLM 的底層檔案格式感興趣,不妨也嘗試自己寫一個簡單的解析器,相信你也會在 byte 與 offset 的移動之間,找到屬於工程師的單純樂趣!

參考資源