跳到正文
LLM 推理
S21收官之作·495

用 Python 重写 picoLM

picoLM 用 2944 行 C,在一块 10 美元、256 MB 内存的板子上跑起了 11 亿参数的模型。逐模块把它移植到 NumPy,这门课的每一章都能在里面找到,连它有意省掉的那两章也算在内。

  • GGUF 与 mmap
  • K-quant
  • 融合反量化
  • SentencePiece BPE
  • 语法掩码
  • 移植

为什么重要

问题

二十章下来,你写的都是零件。这些零件加起来能否让你读懂别人的引擎,是另一个问题;要回答它,只能挑一个引擎,从头重建一遍。

picoLM 小,这正是选它的理由:七个模块、2944 行 C11,零依赖,一块 10 美元、256 MB 内存的板子就能跑 11 亿参数的模型。一个下午读得完,而它加载的 GGUF 文件和 llama.cpp 用的是同一批。

所以这一章是一次移植。七个 Python 模块对应它的七个 C 模块,每一个都拿原版来校验,而不是拿我们自己「它大概是这么干的」的想象来校验。

行 C11
2944
七个模块,零依赖
用 Python 只需这么多
43%
其余是 SIMD、线程,以及手工管理的内存
反量化器逐位精确
8/8
对照按 ggml 规范手写的字节验证

这个移植版易读,但不快

每个 token、每个权重矩阵都要跑一次 NumPy matmul,拼不过融合的 int4 kernel,预期差两个数量级。它复现的是 picoLM 的输出,不是它的性能。两者有差距的地方,可运行文件会把差距量出来,而不是含糊过去。

核心思路

§1 —— GGUF,以及它为什么能 mmap

示意图磁盘上的 GGUF
磁盘上的 GGUF —— 一次 MMAP,零拷贝文件头magic · 版本 · 张量数 · 元数据数元数据架构 · 维度 · 头数 · rope · 分词器词表张量索引名字 · 形状 · 类型 · 偏移按 general.alignment 补齐张量数据 —— 一整块不透明数据token_embdQ4_Kblk.0.attn_qQ4_Kblk.0.ffn_downQ6_Koffset 0offset 0x2A0000offset 0x2C4000matmul(x, W_raw, …) — reads these bytes in placemmap() 把整个文件映射进来。只有某次 matmul 碰到某个权重时,它才真的被读入。11 亿参数能挤进 512 MB 内存,靠的就是这一点。
每个张量都是已知偏移处的一段字节。格式里再没有别的东西,权重也因此可以「映射」而不必「加载」。

picoLM 用 mmap 打开模型,从不拷贝任何一个权重。权重留在闪存上,内核只换入 matmul 真正碰到的那些页,多个进程还能共用同一份。11 亿参数能塞进 512 MB 内存,靠的就是这一点。

这个格式的乏味是刻意的:一个文件头、自描述的元数据、一张 (名字, 形状, 类型, 偏移) 的张量索引、按 general.alignment 补齐,最后是一整块不透明数据。没有要解压的东西,也没有要修正的指针。在映射到的基址上加一个偏移,看到的就是权重。

「加载」不等于「读入」

weights 638 MB mapped 并不意味着占用了 638 MB 内存。被映射的页是干净的、由文件支撑的,内存紧张时内核可以直接丢弃,之后再取回来。KV 缓存是脏的匿名内存,根本丢不掉。有了这层不对称,在小设备上把你逼到内存上限的就是上下文长度,而不是参数量。

工作原理

§2 —— K-quant

示意图Q4_K 超块
Q4_K —— 256 个权重,144 字节d(fp16)2 Bdmin(fp16)2 Bscales · 12 B八个 6-bit scale 和八个 6-bit min,按位打包qs · 128 B —— 256 个半字节子块 032 × 4-bit·32 × 4-bit·32 × 4-bit·32 × 4-bit·32 × 4-bit·32 × 4-bit·32 × 4-bit子块 732 × 4-bitscale 0 · min 0scale 1 · min 1scale 2 · min 2scale 3 · min 3scale 4 · min 4scale 5 · min 5scale 6 · min 6scale 7 · min 7w = d × scale[子块] × q − dmin × min[子块]诀窍在于嵌套:超块共用一个 fp16 scale,每 32 个权重的子块再各带一个 6-bit scale。算上 scale,每权重 4.5 bit。
256 个权重共用一个 fp16 scale,其中再嵌套八个 6-bit scale。嵌套换来的是每权重 4.5 bit,同时保住离群值。

S08 论证过:全部功夫在于一组一个 scale,而不是一个张量一个 scale。K-quant 把这件事又嵌套了一层。一个 Q4_K 超块用 144 字节装 256 个权重:整块一个 fp16 scale 加一个 fp16 最小值;接着是八个 6-bit scale 和八个 6-bit 最小值,每 32 个权重的子块一对,按位打包进 12 字节;最后是 128 字节的半字节数据。

code/picolm/quant.py(节选)python
def dequantize_q4_K(raw, n: int) -> np.ndarray:
    """144 bytes -> 256 weights (4.5 bpw)."""
    blk, nb = _blocks(raw, Q4_K, n)
    d, dmin = _fp16_field(blk, 0), _fp16_field(blk, 2)
    packed, qs = blk[:, 4:16], blk[:, 16:144]

    sc = np.empty((nb, 8), dtype=np.float32)
    mn = np.empty((nb, 8), dtype=np.float32)
    for j in range(8):
        if j < 4:
            sc[:, j] = packed[:, j] & 63
            mn[:, j] = packed[:, j + 4] & 63
        else:                       # six-bit fields straddle byte boundaries
            sc[:, j] = (packed[:, j + 4] & 0xF) | ((packed[:, j - 4] >> 6) << 4)
            mn[:, j] = (packed[:, j + 4] >> 4) | ((packed[:, j] >> 6) << 4)

    q = qs.reshape(nb, 4, 32)
    out = np.empty((nb, 4, 64), dtype=np.float32)
    d_, dm_ = d[:, :, None], dmin[:, :, None]
    out[:, :, :32] = d_ * sc[:, 0::2, None] * (q & 0xF) - dm_ * mn[:, 0::2, None]
    out[:, :, 32:] = d_ * sc[:, 1::2, None] * (q >> 4) - dm_ * mn[:, 1::2, None]
    return out.reshape(-1)

遍历 j 的那个别扭循环来自格式,不来自移植:前四个子块的 6-bit 字段各占自己的字节,后四个则跨越字节边界。写错了,权重看起来仍然合理,模型也照样能跑,只是输出悄悄变差。

要对照规范验证,连 C 都可能是错的

一个自洽的反量化器什么也证明不了。可运行文件用按 ggml 规范手写的字节块校验全部八种格式,而这道校验抓出了一个真实的 bug。把 picoLM 自己的 quant.c 编译出来、在同一批随机字节上对拍,六种格式逐位一致,Q2_K 和 Q3_K 不一致。picoLM 读这两种格式时字段偏移和位序都错了,llama.cpp 生成的 Q2_K/Q3_K 文件会被它悄悄解错。所以移植版跟随的是 ggml 这个格式权威,而不是它出发时那份 C。
Q2_K 每权重 bit
2.62
256 个权重 84 字节
Q4_K 每权重 bit
4.50
不是 4.0;scale 不是免费的
Q6_K 每权重 bit
6.56
ffn_down 和 output 通常落在这里

动手观察

§3 —— 张量算子,以及这个移植版唯一做错的地方

示意图融合的 dequant + dot
融合的 DEQUANT + DOT —— S08 的道理,写在 C 里先反量化,再 MATMULint4 权重在磁盘上,已 mmapfp32 副本写进内存,再读回来matmul4× the trafficof never quantizing融合:在寄存器里解码并直接累加int4 权重在磁盘上,已 mmapvec_dot(q, x)从不离开寄存器累加器1× the traffic不融合的路径既要读 int4 权重,又要写出并读回一个 fp32 张量,比压根不量化搬得还多。这就是「我量化完反而更慢了」。
不融合的路径既要读 int4 权重,又要写出并读回一份 fp32 副本,搬运量比压根不量化还多。

picolm/tensor.c 一共十一个函数:一个多线程 matmul,外加 rmsnormsoftmaxrope 和几个一行函数。性能的重头戏全在 matmul 上。它遍历量化后的字节,边解码边累加点积,跨过内存总线的只有那 4-bit 数据。

这个移植版做不到。用 NumPy 表达融合循环意味着回到 Python 逐元素循环,比它想避免的东西还慢。所以这里的 matmul 每次调用都整块反量化一遍。这就是 S08 警告过的那条不融合路径,为可读性而选,并且在可运行文件里被量了出来,没有藏着。

结论没有因为这个妥协而失效

S08 的观点是:量化只有在反量化被融合时才划算。反例就在同一个仓库里跑着:这个移植版读的是 4-bit 权重,却比 fp32 NumPy 还慢,原因正是那一章给出的那个。

RoPE 的排布方式不是你学的那个

S03 讲的是 GPT-NeoX 约定,维度 ii + D/2 配对,并提醒过还有第二种约定同样叫 RoPE,两者互不兼容。picoLM 用的就是第二种。

不是 S03 的那种排布python
# picoLM / llama.cpp: rotate the pair (x[2i], x[2i+1])
for i in range(half):
    q0, q1 = qh[i * 2], qh[i * 2 + 1]
    qh[i * 2]     = q0 * cos[i] - q1 * sin[i]
    qh[i * 2 + 1] = q0 * sin[i] + q1 * cos[i]

# S03 / GPT-NeoX: rotate the pair (x[i], x[i + D/2])
out[:half] = x[:half] * cos - x[half:] * sin
out[half:] = x[half:] * cos + x[:half] * sin

两者都正确,S03 也没说错。两种约定之间只差 Q 和 K 权重矩阵的一次置换,convert_hf_to_gguf.py 在转换时就把它做掉了。HuggingFace 检查点存的是 NeoX 顺序的权重,GGUF 存的是已经置换过的,所以 llama.cpp 的交错旋转算出来的注意力分数完全一样。你需要哪种布局,取决于加载的是哪个文件,与架构无关。Llama 的 GGUF 文件要交错式;用错了,模型照样输出通顺、自信、却错得不易察觉的文本。手写加载器产出「几乎能用的垃圾」时,先查这一项。

亲手实现

§4 —— 前向传播

示意图一个 token,一个位置
MODEL_FORWARD(TOKEN, POS) —— 一个 TOKEN,一个位置查嵌入表只反量化一行× 层数RMSNormQ/K/V 投影RoPE(交错式)把 K,V 写入缓存(fp16)在 cache[0..pos] 上做在线 softmax从不分配分数数组输出投影 + 残差SwiGLU 前馈 + 残差KV 缓存 · FP16 · [层, 序列, KV_DIM]t0t1t2t3t4pos最后一次 RMSNorm → logits整份代码里没有 batch 维度。一个用户、一个核心:S06、S09、S10、S11 因此缺席,它也因此只有 2944 行。
一次调用一个 token 是 S01;用 `pos` 索引持久缓存是 S05;fp16 存储是 S08;在线 softmax 是 S12;n_kv_heads 是 S03。五章内容,一个签名。

下面是整个引擎的浓缩版。先读签名 model_forward(m, token, pos),看看它已经蕴含了这门课多少内容。

picolm/model.c(整个引擎,浓缩版)c
float *model_forward(model_t *m, int token, int pos) {
    dequantize_row(embd_row, s->x, dim, w->type_token_embd);

    for (int l = 0; l < c->n_layers; l++) {
        rmsnorm(s->xb, s->x, s->attn_norm_w[l], dim);
        matmul(s->q, s->xb, lw->attn_q, dim, dim, lw->type_attn_q);
        /* … K, V … */
        rope(s->q, k_tmp, head_dim, n_heads, n_kv_heads, cos_pos, sin_pos);

        for (int d = 0; d < kv_dim; d++)
            key_pos_fp16[d] = fp32_to_fp16(k_tmp[d]);   /* the cache is fp16 */

        for (int h = 0; h < n_heads; h++) {
            /* online softmax — no att[] buffer is ever allocated */
            for (int t = 0; t <= pos; t++) { /* … */ }
        }
        /* … output projection, SwiGLU, residuals … */
    }
    rmsnorm(s->x, s->x, s->output_norm_w, dim);
    matmul(s->logits, s->x, w->output, dim, c->vocab_size, w->type_output);
    return s->logits;
}

再看移植版,同一件事,只是把缓冲区都拿掉了:

code/picolm/model.py(同一件事)python
def forward(self, token: int, pos: int) -> np.ndarray:
    c = self.cfg
    cos_pos, sin_pos = self.cos[pos], self.sin[pos]
    x = quant.dequantize_row(raw_row, c.n_embd, emb.qtype)

    for l in range(c.n_layers):
        xb = tensor.rmsnorm(x, self.attn_norm[l])
        q = tensor.matmul(xb, *self._w(f"blk.{l}.attn_q.weight"), c.n_embd, c.n_embd)
        k = tensor.matmul(xb, *self._w(f"blk.{l}.attn_k.weight"), c.n_embd, c.kv_dim)

        q = tensor.rope_interleaved(q, c.n_heads, c.head_dim, cos_pos, sin_pos)
        k = tensor.rope_interleaved(k, c.n_kv_heads, c.head_dim, cos_pos, sin_pos)

        self.key_cache[l, pos] = k.astype(np.float16)   # the cache is fp16
        self.val_cache[l, pos] = v.astype(np.float16)

        for h in range(c.n_heads):
            g = h // c.kv_mul                # the KV head this query shares
            scores = (kh[:, g, :] @ qh[h]) * inv
            out[h] = tensor.softmax(scores) @ vh[:, g, :]
        # … output projection, SwiGLU, residuals …

    x = tensor.rmsnorm(x, self.output_norm)
    return tensor.matmul(x, *self._w(self.output_name), c.n_embd, c.vocab_size)
  • 一次调用一个 token:S01 的循环,二十章之后原样未变。
  • `pos` 索引一份持久缓存:S05;K 存的是 RoPE 之后的版本,因为缓存中 token 的位置永不改变。
  • 缓存是 fp16 的:S08,用在缓存而不是权重上。在 TinyLlama 的完整 2048 上下文下,约 88 MB 减半到约 44 MB,这是小板子实实在在能感到的余量。
  • C 里的注意力是在线 softmax:S12。picoLM 的注释说得很直白:att[] 缓冲区被删掉了,从不分配分数数组。(移植版保留了朴素的两遍 softmax,好让算术保持可读。)
  • `n_kv_heads < n_heads`:S03 的 GQA。缓存能小到用 fp16 装下,前提就是它。

缺席的东西才是重点

整份代码里没有任何 batch 维度,没有块管理器、没有调度器、没有连续批处理,也没有分块 prefill。S06 以及 S09 到 S11 全部缺席。picoLM 服务的是单核上的单个用户,这些机制一个都回不了本,所以它是 2944 行而不是 20 万行。推理引擎的体量由并发需求决定,与模型无关。

生产实践

动手试试

picoLM 打出的招牌是:11 亿参数的模型能在一块 10 美元、256 MB 内存的板子上跑起来;它的硬件表里还列着 Pi Zero 2 W,15 美元、512 MB。验证这一行。把参数量设成 1.1B、格式设成 Q4_K、设备选 Pi Zero,然后把上下文长度往上拉,看看先崩掉的是什么。

模拟器装得下吗?
装得下吗?
权重格式
设备
权重(mmap)
590 MB
KV 缓存(常驻)
44 MB
每权重 bit
4.50
每 token 的 KV 字节
22 KB
设备内存
512 MB
余量
289 MB
  • 装得下 —— picoLM 能跑
  • 很紧 —— 大概率会换页
  • 装不下

权重是 mmap 进来的,不必全部常驻;KV 缓存却必须常驻。正是这个不对称让 picoLM 把它存成 fp16,也让真正把你逼到内存上限的是上下文长度,而不是参数量。

层数 22 · kv 头数 4 · 头维度 64

有两点值得注意。把 KV 缓存从 fp16 换成 fp32,损失的余量比把权重从 Q4_K 换成 Q6_K 还多,因为缓存常驻而权重不常驻。到了 16k 上下文,无论选哪种权重格式都装不下:光缓存就吃掉了板子三分之二的内存。picoLM 为此提供了 --ctx 开关,而 S05 那套算术值得你带到每一台新设备上。

继续学习

§5–§6 —— 分词器、采样器、语法

示意图SentencePiece BPE
SENTENCEPIECE BPE —— 分数最高者胜归一化' ' → ▁,并在开头补一个起点:每个字符一个 token字节兜底:<0xHH>合并循环合并分数最高的相邻对"the cat"▁the▁catthecat▁thescore −4▁catscore −5S02 的字节级 BPE 选的是**最低**的 merge rank,SentencePiece 选的是**最高**的 score。循环一样,排序键不同,切出来的 token id 也不同。
先归一化,再让每个字符各成一个 token,然后贪心合并。和 S02 是同一个循环,只是排序键换了。

S02 写的是字节级 BPE,属于 GPT 一脉:merge 带 rank,rank 最低者胜。Llama 的 GGUF 文件带的是另一支,SentencePiece 词表,每个 token 带一个 score,score 最高者胜。同一个循环,不同的排序键,不同的 token id。

  • 空格变成 (U+2581),并且开头还要一个,于是 "Once"" Once" 切出来完全一样。
  • 词表里没有的东西一律退回 <0xHH> 字节 token,词表因此无所不包。
  • 合并循环贪心,复杂度是二次方。这没关系:每个请求只跑一次,字符串又短。

采样器 105 行,就是 S04 本身:temperature 为 0 时贪心,否则 temperature → softmax → top-p。它累加到 cum >= top_p 为止,保留跨过阈值的那个 token。这就是 S04 里那个 + 1,出现在生产代码中。

175 行的语法模块,用四分之一的篇幅做到了 S15 描述的事:一个针对 JSON 的字符级下推自动机,再通过「这个 token 要吐出的每个字符是否都能让机器活下去」提升到 token 层面。非法 token 在采样器运行之前就被置成 −∞。

掩码保证「合法」,不保证「完整」

语法掩码让畸形 JSON 不可达,但它不会让模型收尾。在 40 token 的上限下,可运行文件显示有 30% 的运行在对象中途撞上预算,而一个被截断的 {"name": "ad 对调用方来说和非法 JSON 一样没法用。返回之前先检查 accepting,或者为收尾的括号留出预算。

第 8 步

§7 —— CLI,以及这一切的代价

示意图十九章之后,那个循环
PICOLM.C —— 十九章之后,那个循环加载 + mmap分词prefill:对每个 prompt token 调 forward(t, pos)DECODE:FORWARD(TOK, POS++) 直到 EOS语法掩码(可选)采样流式输出字节forward()它依然是 S01 那个 while 循环。此后每一章都是对这个形状的优化,而 picoLM 只保留了对单用户真正划算的那几项。
加载、分词、prefill,然后就是 S01 开篇那个 while 循环。中间的一切,都变成了一个个模块。
code/picolm/run.py(节选)python
for pos, t in enumerate(ids):          # prefill: one position at a time
    logits = model.forward(t, pos)

out_ids, pos = [], len(ids)
while len(out_ids) < max_tokens and pos < model.cfg.max_seq_len:
    row = grammar.mask_logits(logits, state, pieces) if grammar else logits
    nxt = sampler(row)
    if nxt == tok.eos_id:
        break
    out_ids.append(nxt)
    stream.write(dec.push(nxt))
    logits = model.forward(nxt, pos)
    pos += 1

picolm.c 里值得引用的就这么多。二十章优化之后,它依然是一个 while 循环:调用模型、挑一个 token、接到后面。这正是这门课开篇提出的第一个论断。

在本地运行
从零构建一个真实的 GGUF 文件,再映射回来,并在它上面跑完整个引擎:用手工计算的量化块对照格式规范校验、量出交错式与 NeoX 两种 RoPE 的差异、分词器往返、带缓存不可变性检查的前向传播、采样器与语法行为,以及与 C 的行数对比。
$ python code/s21_picolm.py
预期输出: 每个反量化器与手写字节完全一致、两种 RoPE 排布差异超过 0.1、前缀重放逐位一致、200/200 的样本符合语法且截断率被如实报告,以及一份对照 picoLM 2944 行的行数统计。

只需要 NumPy — 查看环境准备.

想跑真实模型,下载任意 Llama 架构的 GGUF,把 CLI 指过去即可。测试套件里被检验的就是同一个加载器:

跑一个真实模型bash
# any Llama-architecture GGUF works — TinyLlama is the one picoLM targets
cd code
python -m picolm.run --model tinyllama-1.1b-chat.Q4_K_M.gguf \
                     --prompt "Once upon a time" -n 40

# grammar-constrained, so the output is JSON or nothing
python -m picolm.run --model tinyllama-1.1b-chat.Q4_K_M.gguf \
                     --prompt "Describe a cat as JSON" --json

第 9 步

接下来

没有了。课程到此结束。

你写过一个朴素循环并让它变快;给显存做过分页、共享和压缩;做过批处理、调度和抢占;打破过「一次前向一个 token」的规则,也约束过模型被允许说什么;把模型切分到多张 GPU,把 prefill 拆到不同机器。然后你拿别人的引擎重建了一遍,结果发现它是由同样的零件组成的。

接下来最自然的两步:再移植第二个引擎,看看它的作者做了哪些不同的选择;或者把这一个带到 picoLM 没去过的地方。在这个前向传播之上加一个块管理器和一个调度器,你就有了树莓派上的 S20 雏形。

练习

  1. 1
    用 C 扩展或 Numba 实现那条融合路径 vec_dot_q4_K_f32,并与这个移植版里的 NumPy 版本对比。你应该能把 S08 的流量论证复现成一个墙钟数字。
  2. 2
    quant.py 加上 Q5_K 和 Q5_1。两者都在 picoLM 的类型表里、但这里还没实现;块布局就在 quant.h。像其它格式一样,用手写字节做校验。
  3. 3
    给这个移植版装上 S06 的块管理器和 S10 的调度器,用一份映射好的模型同时服务两个并发请求。量一量交叉点在哪:并发到多少时,分页才开始赚回它自身的复杂度?
  4. 4
    把 CLI 指向一个真实的 TinyLlama GGUF,再指向同一个模型量化到 Q2_K 的版本,在 temperature 0 下对比输出。这就是你自己版本的 S08「质量与位宽」对照表,跑在一个你拿得住的模型上。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

picoLM 在一台 512 MB 内存的设备上映射了 638 MB 的模型,并且跑起来了。这为什么不矛盾?

得分 0/6