262,188 个 token 的 prompt 喂进去,模型还没读完,进程收到 SIGTERM,退出码 143。
杀它的是我自己写的看门狗:系统可用内存掉到 3%,立刻停。没有答案,没有报错,就是被我按住了。
半小时前,64K 那档刚跑通的时候,我在聊天记录里写的是:「按 64K 数据看,256K 容量上很有希望。」
再往前一天,我还算过一笔更乐观的账:这个模型的 KV 缓存压到 4-bit 之后,就算吃满它模型卡上写的 1M 上下文,权重加缓存一共 62.6 GB,这台 64GB 的 Mac 还能剩 6 GB。
三个说法,一个比一个乐观,全错。更难受的是,64K 那一档的数字已经足够算出 256K 会死,我只是没去算。
想直接拿那个「两档实测推上限」的除法看第 4 章;想知道纸面账差在哪,看第 3 章。
1. 纸面账:一个乘法,看着很稳
模型是 Poolside 的 Laguna S 2.1,118B 总参数的 MoE(内部分很多「专家」,每个 token 只叫醒一部分,这个模型每次大约 8B 在干活)。我跑的是社区的 3-bit 量化版,引擎是 mlx-vlm,机器是 M1 Max 64GB。权重加载完,常驻 49.23 GB。
长上下文吃内存,吃的是 KV 缓存。模型每读一个 token,都要在每个注意力层存一份「这个词的钥匙和内容」,后面每生成一个字都要回头翻一遍。读得越长,存得越多。
这个模型有个省内存的设计:48 层里只有 12 层是全局注意力,要记住全部历史;另外 36 层是滑动窗口,只看最近 512 个 token,存满就覆盖。所以滑窗那 36 层不管上下文多长,加起来就固定 75 MB 左右,可以忽略。真正随长度涨的只有那 12 层。
一个 token 在这 12 层里占多少,直接从模型配置里读数乘出来:
KV 每 token 字节 = 全局层数 × KV 头数 × 每头维度 × 2(K 和 V) × 每个数的字节
= 12 × 8 × 128 × 2 × 2 字节(BF16)
= 49,152 字节 ≈ 48 KiB
这就是个乘法。乘上 1,048,576 个 token,是 48 GiB。加上 49 GB 权重,100 GB 往上,64GB 的机器想都别想。
所以要压。TurboQuant 是一种把 KV 缓存压到 4 bit 左右的方法,按 mlx-vlm 源码里的开销公式,每个数从 2 字节变成:
4/8 + 2/128 = 0.515625 字节
压缩比 3.88 倍。每 token 从 49,152 字节降到 12,672 字节。再算一遍:
1M × 12,672 字节 = 13.3 GB
49.23 + 13.3 + 0.08(滑窗)= 62.6 GB
64 GiB = 68.7 GB,还剩 6.1 GB
我还专门确认过不是「挂了个参数假装量化」:运行时把 12 层全局缓存逐个打出来,全是 TurboQuant 的缓存类型。纸面上这条路是通的。
2. 实测三档
真的喂。每档造一段那么长的填充文本,中间埋一句「暗号是 NEBULA-COPPER-7319」,读完问暗号是什么。同时盯两个内存读数:MLX 自己报的峰值显存,和 macOS 报的系统剩余内存百分比。看门狗规则:系统余量低于 3%,或者交换空间剩不到 96 MB,就杀掉。
| 档位 | 实际 token | 找回暗号 | 预填充速度 | 首字等待 | 尾端出字 | MLX 峰值 | 系统最低余量 |
|---|---|---|---|---|---|---|---|
| 64K | 65,582 | ✅ | 82.4 tok/s | 13 分多钟 | 15.6 tok/s | 51.94 GB | 16% |
| 128K | 131,116 | ✅ | 66.96 tok/s | 32 分 50 秒 | 10.27 tok/s | 54.52 GB | 11% |
| 256K | 262,188 | — | — | 没读完 | — | — | 跌破 3%,被杀 |
预填充就是模型把整段 prompt 先读一遍、还没开始吐字的阶段。读 128K 要半个多小时。
纸面账在 256K 这档预测的总占用是多少?49.23 + 262,188 × 12,672 字节 = 52.6 GB。离 68.7 GB 的天花板还有 16 GB。结果系统余量见底了。
3. 每多读一个 token,到底多吃多少
把 64K 和 128K 两档的 MLX 峰值相减:
每 token 增量 ≥ (54.52 − 51.94) GB ÷ (131,116 − 65,582) token
= 2.58 GB ÷ 65,534
≈ 39.4 KB / token
为什么写「≥」:MLX 这个峰值是进程启动以来的最高水位。模型加载那一刻的峰值是 51.82 GB,64K 那档的 51.94 只比它高一点点,很可能有一部分是加载时留下的水位。真实的 64K 运行峰值只会比 51.94 低,所以这个减法算出来的是下限。128K 那档的 54.52 远高于加载水位,那是真的运行峰值。
拿它跟纸面比:
压缩公式:12,672 字节 / token
实测下限:39,400 字节 / token → 3.1 倍
不压缩的 BF16:49,152 字节 / token
讲人话就是:KV 缓存确实压了 3.88 倍,但峰值内存每 token 的增长,离「完全不压」只差 20%。省下来的那部分,被别的东西吃回去了。
被什么吃了,我没查到。最可疑的是预填充过程里那些跟上下文长度成正比的临时缓冲:我每次按 256 个 token 一块往里读,每一块都要跟前面全部历史做注意力计算,中间结果的大小跟已读长度成正比,而且读的时候大概率要把压缩的历史临时还原出来。这是推测,没拿 profiler 钉死。
但对「这台机器能吃多长」这个问题,不知道根因也不影响:决定你死不死的是峰值,常驻的稳态缓存只是峰值里的一部分。
另一个引擎更极端。我读 oMLX 的源码,它对新请求是先全程用全精度读完,读完再一次性压缩成 TurboQuant,理由是怕量化误差污染预填充。那条路上 1M 的峰值就是没压缩的那 100 GB 往上,压缩只救稳态,救不了第一次读。
4. 两档实测,就能算出它死在哪
这是我当时该做却没做的那个除法:
还能多读的 token ≈ 剩余余量 ÷ 每 token 增量
可用上限 ≈ 最后一档长度 + 还能多读的 token
用系统余量算(看门狗看的就是它):64K 时最低 16%,128K 时最低 11%,每 65,534 个 token 掉 5 个百分点,也就是 3.44 GB,每 token 约 52 KB。从 11% 到 3% 的红线还剩 8 个点,5.5 GB:
5.5 GB ÷ 52 KB ≈ 10.5 万 token
131,116 + 105,000 ≈ 23.6 万 token
预测死在 236K 附近。我喂的是 262K,死了,对上了。
更扎心的是只用 64K 一档也够。那时我手上有两个数:加载完常驻 49.23 GB,64K 跑完峰值 51.94 GB。粗算每 token 41.3 KB(这里混了固定开销,会偏大,但量级对)。256K 还要多读 196,606 个 token,大约 8.1 GB。64K 时系统剩 16%,约 11 GB,扣完只剩 2.9 GB,4% 出头,紧贴 3% 那条线。
这笔账三行就算完,我却说了一句「很有希望」。当时脑子里装的是第 1 章那个 12,672 字节,没拿刚测到的数去替换它。
5. 就算装下了,你也等不起
假设你有 128GB 的机器,内存不是问题了。还剩速度。
首字等待的下限是一个除法:
首字等待 ≥ prompt 长度 ÷ 预填充速度
预填充速度从 64K 的 82.4 掉到 128K 的 66.96,越长越慢。就按 128K 那个已经掉过的速度算,1M 的下限:
1,048,576 ÷ 66.96 ≈ 15,660 秒 ≈ 4.35 小时
这还是乐观的,速度会继续掉。把两档按「每 token 耗时随长度线性涨」外推,1M 要 15 个多小时。外推就是外推,当量级看。
出字速度也一样。尾端出字三个点:4K 时 30.2 tok/s,64K 时 15.6,128K 时 10.27。换成每个字的毫秒数,用 64K 和 128K 两个点拟一条直线:
每个字耗时(ms) ≈ 30.8 + 0.508 × 已读长度(千 token)
拿第三个点验:4K 时算出来 32.9 ms,也就是 30.4 tok/s,实测 30.2。对上了,说明这条直线能用。意思是上下文每多 1000 个 token,每吐一个字就多 0.5 毫秒,因为每个新字都要回头翻一遍全部历史。
往外推:256K 时约 6.1 tok/s,1M 时约 1.8 tok/s。一秒不到两个字,基本是一个一个往外蹦。
我自己在实测之前也估过一次速度,翻车方式值得说一下。当时手上只有模型卡里别人在 M5 Max 上的数:同样 3-bit 版本,64K 预填充 624 tok/s。我按两台机器的内存带宽比(546 对 400 GB/s)缩了一下,等于默认 M1 Max 能跑 457,再外推出 1M 冷读约 4.2 小时。实测 64K 是 82.4,比缩完的数还慢 5.5 倍。中间混了好几个变量:我用了在线压缩、我按 256 一块读、两代芯片的算力差,我没拆开。可以确定的一点是尺子选错了:预填充是一次性算一大块矩阵,主要吃算力,按带宽缩放从一开始就不对。至于「1M 约 4.2 小时」和第 5 章那个 4.35 小时撞得这么近,纯属巧合,别当成验证。
6. 别人告诉你「支持 1M」时,该问什么
这次我手上同时有三个「上下文长度」:
- 模型卡写的
max_position_embeddings = 1,048,576 - 我给评测服务器配的
max_kv_size = 204800 - 实测能安全读完的最长一档:131,116
前两个是许可,第三个才是证据。模型卡的 1M 意思是「位置编码到 1M 还有定义」,跟你的机器毫无关系。我自己那个 204800 更荒唐:评测题全是短问答,压根没喂过长输入,但这个数字躺在配置里,事后很容易被读成「200K 跑通了」。我当时在笔记里专门写了一句别这么读,因为我知道自己会。
所以看到任何「X 机器能跑 Y 模型的 Z 长度上下文」,先问三句:
- 是真喂了 Z 长度的 prompt,还是只是把配置项设成了 Z?
- 报的是加载后的常驻内存,还是读完最长那一档时的峰值?
- 读到 Z 要等多久才吐第一个字?
只报「权重 + 公式算的 KV」而不报峰值的帖子,在长上下文上能虚高好几倍,我这次就是 3.1 倍。只报装得下、不报首字等待的,可以直接当没说,1M 等四个多小时起步,这在交互场景里等于不能用。
7. 这次实验的毛病
- 每档只跑了一次,没有重复。
- MLX 峰值是最高水位,64K 那档的读数被加载水位污染过,所以第 3 章只能给下限。
- 系统余量百分比会受机器上其他进程和 macOS 内存压缩的影响,而且压缩器的行为不是线性的。开跑前交换区里本来就有约 3.3 GB 的东西。
- 256K 是被我自己 3% 的门槛掐掉的。不设门槛,它也许能靠交换区硬撑读完,所以准确的说法是「不能安全读完」,没证明「读不完」。我不想拿整机去换这个答案。
- 只用了 256 一块的读法,没扫块大小。更大的块可能更快,峰值也可能更高。
- 尾端出字速度的样本很小,64K 那档只让它生成了 32 个 token。4K 那个点来自开跑前的冒烟测试,设置未必完全一样。
- 暗号测试只证明它能从长文里捞回一句话,证明不了长文推理质量。
- 多出来的那约 27 KB/token 被什么吃掉,没定位。
- 1M 的所有速度数字都是外推。
8. 下次判断「这个模型在这台机器上能吃多长」,按这个顺序
- 从模型配置读出全局注意力层数、KV 头数、每头维度,乘出每 token 的 KV 字节。有滑窗层的单独算,通常可以忽略。
- 如果压了 KV,乘上压缩率。把结果当成每 token 内存增长的下限,不是估计值。
- 去读引擎源码,看预填充时是边读边压,还是先全精度读完再压。后者的话,第 2 步作废,按不压缩算峰值。
- 实测两档长度,比如 64K 和 128K。每档记峰值内存和系统最低余量。
- 两档相减求每 token 实际增量,跟第 1、2 步比。差好几倍就别信纸面账。
- 剩余余量 ÷ 每 token 增量 + 最后一档长度 = 可用上限。先算这个,再决定要不要喂下一档。
- 首字等待 ≥ 长度 ÷ 最后一档预填充速度。超出你能接受的等待,装得下也没用。
- 出字速度用两档拟「毫秒/字 = a + b × 长度」,再拿第三档验一下,验得上再外推。
- 跑最大那一档前,先挂一个按系统余量自动杀进程的看门狗。
我这台机器的答案是 128K,读一次 32 分 50 秒,够泡两壶茶。