本地跑模型:看清那几百 MB 里到底是什么
本文的每个数字都是跑出来的。 环境是 Ollama
0.33.2/ Apple Silicon,验证代码在仓库experiments/model-files/(gguf.mjs直接读文件头,不经过任何推理框架;run.mjs跑质量与 token 对照)。 全程离线,不需要 API key,共约 2.7 GB 磁盘。
把模型跑起来只要两条命令。这一篇讲的是跑起来之后的事:
ollama pull 下来的那几百 MB,具体是什么。
这个问题有实际用处,因为关于量化的说法里有一批是错的, 而它们全都可以用一个 GGUF 解析器当场证伪。
一、GGUF 里存了什么
GGUF 的头部是一段自描述的 key-value,后面跟张量表,最后才是权重数据。
自己写解析器不到 150 行:magic GGUF → version → 张量数 → metadata 条数 →
KV 对 → 每个张量的(名字、维度、类型、偏移)。
qwen2.5:0.5b-instruct-q4_K_M 读出来是:
GGUF v3 · 290 个张量 · 34 条 metadata · 文件 397.8 MB
qwen2.block_count 24
qwen2.embedding_length 896
qwen2.feed_forward_length 4864
qwen2.attention.head_count 14
qwen2.attention.head_count_kv 2 ← GQA:14 个 Q 头共用 2 组 KV
qwen2.context_length 32768
tokenizer.ggml.tokens 151936 个 token
tokenizer.ggml.merges 151387 条 BPE 合并规则
先验一下解析器有没有读歪,否则后面的结论全部作废:
张量名里出现 24 个层号(
blk.0…blk.23),metadata 声明block_count=24→ 对上了。
这一步很便宜,但它是整篇文章唯一的地基。metadata 和张量表是文件里两处独立的信息, 如果我把游标读串了,几乎不可能让这两个数恰好相等。
一层里有 12 个张量:
attn_norm.weight [896] F32
attn_q.weight [896,896] Q5_0 attn_q.bias [896] F32
attn_k.weight [896,128] Q5_0 attn_k.bias [128] F32
attn_v.weight [896,128] Q8_0 attn_v.bias [128] F32
attn_output.weight [896,896] Q5_0
ffn_norm.weight [896] F32
ffn_gate.weight [896,4864] Q5_0
ffn_up.weight [896,4864] Q5_0
ffn_down.weight [4864,896] Q6_K
attn_k / attn_v 是 [896,128] 而 attn_q 是 [896,896] ——
这就是 GQA 在文件里的样子,KV 的宽度只有 Q 的七分之一。
上一篇开头那张形状表,
到这里可以在文件里逐个指出来了。
层外只有两个张量。其中一个值得单独说:
token_embd.weight [896,151936] Q8_0 144.6 MB
⭐ 这一个张量占整个 397.8 MB 文件的 36%。 词表有 151936 个 token, 每个 token 一个 896 维向量。也就是说这个模型三分之一的体积是「词表长什么样」, 和它会不会推理无关。小模型尤其如此 —— 词表大小不随参数量缩小。
二、叫 q4 的那一档,只有 7.5% 是 q4
把所有张量按量化类型汇总:
| 类型 | 张量数 | 字节 | 占比 | 平均每权重 |
|---|---|---|---|---|
| Q5_0 | 132 | 173.2 MB | 44.2% | 5.50 bit |
| Q8_0 | 13 | 146.1 MB | 37.3% | 8.50 bit |
| Q6_K | 12 | 42.9 MB | 10.9% | 6.56 bit |
| Q4_K | 12 | 29.4 MB | 7.5% | 4.50 bit |
| F32 | 121 | 0.3 MB | 0.1% | 32.00 bit |
全档平均 6.35 bit/权重。 名字里的那个 4 不是这个文件的属性。
为什么?看第一节那张表:ffn_down 是 [4864,896] 用 Q6_K,
其余的 [896,*] 全部回退到 Q5_0 / Q8_0。4864 能被 256 整除,896 不能
(896 = 3×256 + 128)。K-quant 的超块是 256 个元素,行长凑不齐就用不了。
把 169 个多维量化张量交叉制表:
| 行长 %256==0 | 行长 %256≠0 | |
|---|---|---|
| 用了 K-quant | 24 | 0 |
| 没用 K-quant | 0 | 145 |
零反例。但这张表还不能下结论,因为它有一个混淆项:
⚠️ 这个模型只有两种行长,而行长和张量角色完全重合 ——
4864 全是 ffn_down,896 全是别的。「256 整除决定了它」和
「只有 ffn_down 用 K-quant」在这份数据上完全无法区分。
拆开的办法是换一个维度不一样的模型。llama3.2:1b-instruct-q4_K_M
的行长是 2048 和 8192,两种都能被 256 整除:
| 行长 %256==0 | 行长 %256≠0 | |
|---|---|---|
| 用了 K-quant | 113 | 0 |
| 没用 K-quant | 0 | 0 |
在 qwen 里回退掉的那些角色 —— attn_q、ffn_gate、ffn_up ——
在 llama 里全部用上了 K-quant。角色没变,结论翻转了。
⇒ 决定因素是行长,不是角色。
这件事的后果可以直接称重:
| 模型 | 标称档位 | 实际平均 |
|---|---|---|
| qwen2.5 0.5B | q4_K_M | 6.35 bit/权重 |
| llama3.2 1B | q4_K_M | 5.18 bit/权重 |
同一个档位名,差 23%。 所以「我用 q4_K_M」这句话, 在不知道模型隐藏维度之前,没有确定的含义。
三、量化省的显存,比文件小的那部分少
三档各载入一次,读 Ollama 的 /api/ps:
| 档位 | 权重文件 | 载入后显存 | 差 |
|---|---|---|---|
| fp16 | 994 MB | 1525 MB | +531 MB |
| q8_0 | 531 MB | 1062 MB | +531 MB |
| q4_K_M | 398 MB | 929 MB | +531 MB |
⭐ 那个固定开销三档一模一样:531 MB。 它是 KV cache 加激活,不随量化档变。
于是「换到 q4_K_M 省 60%」这句话在显存上是不成立的: 文件小了 60%,实际显存只小了 39%。模型越小,这个固定开销占比越大 —— 0.5B 这一档,它已经比权重本身还大了。
(这一轮没有改 context 长度,所以没测那 531 MB 随 context 怎么变。 它在别的 context 设置下是别的数。)
四、判据:「量化不影响质量」要给出差异数
stub 给这一篇定的判据是:
「量化不影响质量」这类说法必须给出差异数;20 题里差 0 题和差 6 题是完全不同的结论。
20 道有唯一短答案的题,temperature=0,三档各跑一遍,以 fp16 为基准:
| 档位 | 逐字节相同 | 答对数 |
|---|---|---|
| fp16 | 20/20(自身) | 14/20 |
| q8_0 | 19/20 | 14/20 |
| q4_K_M | 14/20 | 13/20 |
- 6/20 题里至少有一档的字节与 fp16 不同
- 其中 1/20 题的对错发生了翻转
所以这一组上的答案是:字节差 6/20,对错差 1/20。
⭐ 为什么要同时报这两个数。只报「逐字节相同 14/20」会让 q4 看起来很糟, 但字节不同大多是措辞不同;只报「答对数 13 vs 14」又会掩盖掉 30% 的回答确实变了这件事。两个数测的是不同的东西: 一个是「输出变没变」,一个是「变得有没有代价」。
还要注意 fp16 自己也只答对 14/20 —— 这是个 0.5B 模型。 基准本身不是满分,所以「q4_K_M 答对 13」不能读成「量化让它错了 1 题」, 只能读成「在这一组题上,量化前后的正确数相差 1」。
⚠️ 这个量具测不到的东西同样要说清楚:20 道短答案题, 够不到推理链变长之后的退化。量化对长链条推理的影响是另一个实验, 这一篇没有做。
五、同名模型在不同平台上答案不一样
这是 stub 里列的第三个题目。跑完前面几节,它其实已经有三个互相独立的来源了:
- 文件本身就不同。 「q4_K_M」不指定一个确定的比特分配 —— 同一个档位名在两个模型上是 6.35 bit 和 5.18 bit(第二节)。
- 量化档之间的输出本来就有差异。 同一台机器、同一个 prompt、
temperature=0,q4_K_M 和 fp16 有 6/20 题字节不同(第四节)。 - 服务端配置会改结果。 这是上一篇量的: 开了 KV cache 量化之后,同一个 prompt 的输出取决于你上一个问题问了什么。
三条里没有一条需要「模型有随机性」来解释。它们全都是确定性的, 只是那些输入没有写在你的请求里。
六、tokenizer 的口径不一样
最后一件在文件里的东西:词表。qwen2.5 是 151936 个 token, llama3.2 是 128256 —— 都从 GGUF 头部读出来。
同一段文本,两边各算多少 token(已减掉 chat 模板的固定开销, qwen 是 29、llama 是 25):
| 样本 | 字符 | qwen2.5 | llama3.2 | 比值 |
|---|---|---|---|---|
| 纯中文 | 25 | 15 tok (0.60/字) | 22 tok (0.88/字) | 1.47× |
| 中英混排 | 51 | 19 tok | 19 tok | 1.00× |
| 纯英文 | 69 | 14 tok | 14 tok | 1.00× |
| 代码 | 77 | 35 tok | 35 tok | 1.00× |
中文差 47%,英文一个不差。 这直接决定按 token 计费时的成本 —— 同一份中文文档,换个模型可能贵一半,而这和模型贵不贵无关。
这张表第一次跑出来的时候我以为量具坏了:两个词表大小都不同的 tokenizer, 在三行上给出完全相同的数,看着像「根本没按模型重新分词」。 查原始值之后确认不是 —— 纯中文 15 vs 22,翻倍后 30 vs 44,严格成比例。 英文和代码上的相同是真的相同。
⭐ 但留下的教训是反过来的一条:「总数相同」不等于「口径相同」。 中英混排那行两边都是 19,可里面的中文部分两边切得不一样, 只是英文部分的差异恰好把它抵消了。拿一句话的总 token 数 去验证两个 tokenizer 是否等价,会得出错误结论。
本文没有回答的三个问题:
一是 safetensors。stub 里写的是「safetensors / GGUF 的结构」, 这一篇只做了 GGUF。safetensors 是另一种布局(JSON 头 + 连续张量), 本文一个字节都没读过它,不要把这里的结论套过去。
二是 那 531 MB 随 context 怎么变。三档下它是常数, 但这一轮 context 设置没动过 —— 它大概率是 context 的函数, 而本文只在一个点上采了样。
三是 量化对长链条推理的影响。第四节的 20 题都是短答案, 测得到「答案变没变」,测不到「推到第五步才崩」。 那需要另一组题和另一个判据。