用 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 规范手写的字节验证
这个移植版易读,但不快
核心思路
§1 —— GGUF,以及它为什么能 mmap
picoLM 用 mmap 打开模型,从不拷贝任何一个权重。权重留在闪存上,内核只换入 matmul 真正碰到的那些页,多个进程还能共用同一份。11 亿参数能塞进 512 MB 内存,靠的就是这一点。
这个格式的乏味是刻意的:一个文件头、自描述的元数据、一张 (名字, 形状, 类型, 偏移) 的张量索引、按 general.alignment 补齐,最后是一整块不透明数据。没有要解压的东西,也没有要修正的指针。在映射到的基址上加一个偏移,看到的就是权重。
「加载」不等于「读入」
weights 638 MB mapped 并不意味着占用了 638 MB 内存。被映射的页是干净的、由文件支撑的,内存紧张时内核可以直接丢弃,之后再取回来。KV 缓存是脏的匿名内存,根本丢不掉。有了这层不对称,在小设备上把你逼到内存上限的就是上下文长度,而不是参数量。工作原理
§2 —— K-quant
S08 论证过:全部功夫在于一组一个 scale,而不是一个张量一个 scale。K-quant 把这件事又嵌套了一层。一个 Q4_K 超块用 144 字节装 256 个权重:整块一个 fp16 scale 加一个 fp16 最小值;接着是八个 6-bit scale 和八个 6-bit 最小值,每 32 个权重的子块一对,按位打包进 12 字节;最后是 128 字节的半字节数据。
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 都可能是错的
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 —— 张量算子,以及这个移植版唯一做错的地方
picolm/tensor.c 一共十一个函数:一个多线程 matmul,外加 rmsnorm、softmax、rope 和几个一行函数。性能的重头戏全在 matmul 上。它遍历量化后的字节,边解码边累加点积,跨过内存总线的只有那 4-bit 数据。
这个移植版做不到。用 NumPy 表达融合循环意味着回到 Python 逐元素循环,比它想避免的东西还慢。所以这里的 matmul 每次调用都整块反量化一遍。这就是 S08 警告过的那条不融合路径,为可读性而选,并且在可运行文件里被量了出来,没有藏着。
结论没有因为这个妥协而失效
RoPE 的排布方式不是你学的那个
S03 讲的是 GPT-NeoX 约定,维度 i 与 i + D/2 配对,并提醒过还有第二种约定同样叫 RoPE,两者互不兼容。picoLM 用的就是第二种。
# 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 —— 前向传播
下面是整个引擎的浓缩版。先读签名 model_forward(m, token, pos),看看它已经蕴含了这门课多少内容。
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;
}再看移植版,同一件事,只是把缓冲区都拿掉了:
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 装下,前提就是它。
缺席的东西才是重点
生产实践
动手试试
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 —— 分词器、采样器、语法
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 在采样器运行之前就被置成 −∞。
掩码保证「合法」,不保证「完整」
{"name": "ad 对调用方来说和非法 JSON 一样没法用。返回之前先检查 accepting,或者为收尾的括号留出预算。第 8 步
§7 —— CLI,以及这一切的代价
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 += 1picolm.c 里值得引用的就这么多。二十章优化之后,它依然是一个 while 循环:调用模型、挑一个 token、接到后面。这正是这门课开篇提出的第一个论断。
$ python code/s21_picolm.py只需要 NumPy — 查看环境准备.
想跑真实模型,下载任意 Llama 架构的 GGUF,把 CLI 指过去即可。测试套件里被检验的就是同一个加载器:
# 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用 C 扩展或 Numba 实现那条融合路径
vec_dot_q4_K_f32,并与这个移植版里的 NumPy 版本对比。你应该能把 S08 的流量论证复现成一个墙钟数字。 - 2给
quant.py加上 Q5_K 和 Q5_1。两者都在 picoLM 的类型表里、但这里还没实现;块布局就在quant.h。像其它格式一样,用手写字节做校验。 - 3
- 4把 CLI 指向一个真实的 TinyLlama GGUF,再指向同一个模型量化到 Q2_K 的版本,在 temperature 0 下对比输出。这就是你自己版本的 S08「质量与位宽」对照表,跑在一个你拿得住的模型上。
自测
习题
先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。
picoLM 在一台 512 MB 内存的设备上映射了 638 MB 的模型,并且跑起来了。这为什么不矛盾?