
有人把 2.78 万亿参数的 Kimi K3 跑在了一台只有 8GB 内存的机器上。峰值内存 8.24GB,没有 GPU,没有 BLAS,没有 PyTorch,整个引擎编译出来 176KB,六个 C 文件加 libm 和 OpenMP。
作者在 Reddit 上讲了他为什么写这个:工作里在 32 张 H100 上部署过 K3,后来嫌没法在自己的机器上随手戳一戳,干脆写个引擎把它拆开看。
这个项目叫 kimi-k3-in-c,十月初已经到 v1.1.0,社区贡献了 macOS、Windows 和 NEON 的移植。如果只记住“8GB 跑 2.78T”这句话,其实是浪费。我读完最大的收获是另外三段:作者连着三次推翻自己刚测出来的结论,而且每一次的原因都藏在指标的定义里,不在代码里。
先把账算明白
从参数到内存,中间有四次减法,每一步都能落到字节上。
5,560GB。2.78 万亿个参数全部按 bf16 常驻,这是“参数量乘以 2”得到的数,也是绝大多数人算到这里就放弃的那个数。
1,560GB。这是 checkpoint 真正落盘的体积,1,560,936,091,448 字节。差这么多是因为专家权重出厂就是 MXFP4,一个权重 0.53125 字节,一半来自 4 bit 的尾数,另一半的三十二分之一来自每 32 个元素共用的一个 8 位指数。
113.49GB。K3 有 93 层,第 0 层是普通稠密 FFN,剩下 92 层每层从 896 个专家里选 16 个。把 1.447TB 的专家全部从常驻集合里划掉(82,432 个,每个 17,547,264 字节,正好占整个 checkpoint 的 93%),剩下必须常驻的只有 56,743,648,000 个参数:108.81GB 的稠密 trunk,加 4.70GB 的 embedding 和输出头。
8.24GB。trunk 也不常驻了。做法是把 93 层的 trunk 张量重新打包成一个 109GB 的文件,第 L 层的权重落在固定偏移上,读一层就是一次 pread。每生成一个 token,按 0 到 92 的固定顺序走一遍,读的时候算上一层,读和算重叠。
除了这四步,还有两个细节决定了它真能跑起来。69 层用的是 KDA,一种线性注意力,每个 head 维护一个固定大小的状态矩阵,93 层加起来 626MB,喂十个 token 和喂一百万 token 都是这么多。真正随上下文涨的只有 24 层 MLA 的 KV,每个位置 2.37MB。另外 trunk 是用 O_DIRECT 打开的,绕开 page cache。
33 秒一个 token,这就是答案
内存阶梯是这么测的:用 cgroup 把内存上限钉死,八档从 8GB 到 224GB,swap 上限设成 0。这一步不做,超预算的那一档会去用 swap,你测到的是 swap 带宽,不是内存不够。
结果很干脆。8GB 那档 32.69 秒一个 token,224GB 那档 19.21 秒。28 倍内存换来 1.70 倍速度。从 8GB 提到 64GB,只快 14%。
十二档全部产出完全相同的 token id。
所以这个项目回答的问题不是“能不能用”,是“能不能跑”。半分钟一个 token,问一句话要等十分钟,拿它服务任何东西都是自找麻烦。作者自己在帖子里也是这个口径,说他写这个是为了通过实现来理解架构。
那句“输出在 8GB 和 224GB 下逐位一致”,比 8GB 这个数字有价值得多。它说明整个流式方案是精确的,不是近似,没有一个权重被丢掉或者被压掉。
第一次翻车:命中率 100%,一次都没省
内存阶梯那张表里有一列读盘量。8GB、12GB、16GB、24GB、32GB、48GB、64GB,七档读到的字节数完全相同,每个 token 都是 25.83GB,一个小数点都没动。
而在这段区间里,专家缓存的槽位从 28 个涨到 1,344 个,48 倍。
缓存涨了 48 倍,读盘量一点没变。这说明缓存不是在低效工作,是根本没参与。
原因不在代码,在模型。K3 用了 Quantile Balancing,故意把专家的负载摊平。负载越均匀,LRU 越没用,因为没有热子集值得留,每个专家都差不多冷。
更麻烦的是日志本身会骗人。某次运行打印出来是这样:
requests : 1472 hits 1472 (100.00%) misses 0 evictions 1032
of those hits : 38940 came from the batch prefetch, i.e. read from disk
this token; TRUE resident hit rate 0.00%
同样这 1,472 个请求,命中率 100.00%,下面一行真实常驻命中率 0.00%。因为批量预取在这一个 token 开始前刚从盘上把专家读进 arena,接下来的请求打中它就算命中。字节照样从盘上过了一遍,只是过了两遍之后被叫成了“命中”。
那该看什么?作者给的答案是淘汰率。一个被淘汰的专家,就是没被留住的专家。36.39GB 那一档,一减去 1032 除以 1472,留下 29.89%,对应还有 70.1% 的字节在重读,18.11 除以 25.83。两个完全不同的计数器,算出来的小数点对得上。
这段值得单独记一笔:以后看到任何命中率,先问一句它是从哪儿命中的。从内存命中,和从盘上读得足够快、读进缓存里再被叫醒,是两件完全不同的事,中间可能差着几倍的带宽。
第二次翻车:最快的配置读得最多
既然专家缓存在 36GB 以下基本不干活,那内存该往哪儿放?
作者算了一笔账。trunk 是 108.81GB,每个 token 都要整份重读,这是必然发生的流量。专家每个 token 抽样 25.83GB,这是随机流量。前者是后者的 4.2 倍字节、2.9 倍时间。
然后他做了个很干净的实验:总量固定 128GB,只改分配比例,而且故意从“缓存优先”那头开始扫,让实验一开始就是逆着自己的假设跑的。
| trunk / cache (GB) | 秒/token | 命中率 | 读盘量 |
|---|---|---|---|
| 12.3 / 110.7 | 28.38 | 44.02% | 14.46 GB |
| 30.8 / 92.2 | 25.20 | 42.93% | 14.74 GB |
| 49.2 / 73.8 | 25.69 | 33.97% | 17.06 GB |
| 73.8 / 49.2 | 18.37 | 32.20% | 17.51 GB |
| 98.4 / 24.6 | 19.46 | 0.0% | 25.83 GB |
| 110.0 / 13.0 | 16.80 | 0.0% | 25.83 GB |
同样 128GB,从 28.38 秒降到 16.80 秒,只靠改分配。
但最扎眼的是最后两列。最快那一行命中率 0%,读 25.83GB;最慢那一行命中率 44%,读 14.46GB。赢家多搬了 79% 的专家字节,还快了 69%。
也就是说,如果你盯着命中率去调,会一路走到最慢的那一格。
规则因此变得很简单:先把 trunk 喂饱,喂到全部钉住,剩下的才给专家缓存。一 GB 给 trunk,大约多钉住一层,从必然发生的流量里砍掉 1.17GB;一 GB 给缓存,在拐点以下什么都砍不掉。
作者很克制地标了两句:十二个点分两个总量,Spearman 相关系数分别是 −0.886 和 −0.714,中间有两处倒挂落在噪声里;1.69 这个倍数没有重复三次以上,应该当量级看,别当精确值。
第三次翻车:33% 的噪声
同一份二进制、同一个 prompt、同样的参数,连着跑三次:
replicate 1: 14.78 s/token
replicate 2: 14.67 s/token
replicate 3: 20.14 s/token
均值 16.53,标准差 3.13,散布 33.1%。
这意味着在这台机器上,5% 的优化结果什么都不说明。作者做了一个 2MB 大页的 A/B,用同一个二进制(靠环境变量切换,不是两个版本),从 11.37 秒到 10.76 秒,快了 5.4%,token 序列完全一致。按说这是个能吹的优化,但他的结论是:机制成立,测量不足以证明幅度。
33% 是从哪来的?他把两次不同扫描里配置完全相同的一对跑拿出来对比:钉住的层数一样,槽位一样,请求数一样,淘汰数一样,读的字节也是同样的 374.99GB。一次跑出 2,709MB/s,一次 5,874MB/s,差 2.17 倍。差别不在调度,不在 NUMA,在盘。
最后真正过了噪声门槛的只有两个结论:整个阶梯从头到尾差 70%,trunk 优先在 128GB 差 69%。两个都是噪声底的两倍以上,其余的小台阶全部作废,包括那个“12GB 比 8GB 还慢 4%”的假信号。
顺带一个反常识的发现:这块盘上 O_DIRECT 冷读 3.2GB/s,比走 page cache 的 buffered 2.3GB/s 还快。整个 I/O 设计是被这一次测出来的数定下来的,不是按习惯定的。
为什么 trunk 宁可不量化
trunk 是 108.81GB 的 bf16。做成 int8 能省一半,int4 能省四分之三,别的同类型引擎都给你一个位宽旋钮,这个引擎只有两种权重类型,一个旋钮都没有。
因为量过。31 个 attention 张量,每个抽 384 行,对称逐行量化:int8 的平均相对误差 0.961%,int4 是 17.38%,差 18 倍。更糟的是尾部,int4 最差的行到了 45%、56%、65%。
而且模型的技术报告写得很清楚:只有专家权重做了量化感知训练,非专家的部分留在高精度。这份 trunk 从来不是按 4 bit 训练出来的。
这笔账的结论不难:流式读损失的是秒,而秒可以用内存买回来,阶梯表已经证明了;量化损失的是精度,任何内存预算都买不回来。
我自己的判断
第一,这个仓库最值得抄的不是流式读,是那个不需要大资产的门槛。
make test 不需要权重、不需要网络、不需要 Python,一分钟内建一个 13 层、张量图和正式模型一样的模型,跟 PyTorch 参考逐位对齐,还覆盖贪心解码和带 KV 缓存、KDA 状态的增量解码。你要先下 1.56TB 才能验证引擎对不对,那你大概永远不会验证。
这个顺序值得搬到自己项目里:正确性门槛先立在便宜的地方,大资产后面再说。
第二,别把它当自托管方案。
除了半分钟一个 token,它还要 1.7TB 空闲盘(1.56TB checkpoint 加 109GB 打包 trunk,而且最好在本地 NVMe 上,网络盘慢两倍多)。没有视觉,MoonViT 只有在 config.json 里的规格,一行代码都没有。没有 chunked prefill,上下文上限 32768,两万一千 token 的 prompt 就是一次完整的平方复杂度计算。按这个速度跑一晚上,电费比调 API 贵。
第三,这件事真正说清的是瓶颈的位置。
四成到六成的墙上时间在等盘,每一档都是。前沿 MoE 推理的瓶颈从算力挪到了存储,这个判断对做基础设施的人比做模型的人更有用。你手里的 NVMe 队列深度、随机读大小、盘和盘之间的差异,会直接决定一个万亿级模型在你这里是什么速度。
那几个数字里我最喜欢的是 2,709 和 5,874。同样的工作量,同一个二进制,同一台机器,差别全在盘上。做性能的人迟早会撞上这一格,早撞比晚撞好。
参考来源: