在 8GB 内存上跑 2.78T 参数模型:Kimi K3 的纯 C 实现笔记
“
一句话先给你:这不是一个“能在 8GB 跑 2.78T 模型”的营销话术。我在一台只有 8GB RAM 的普通笔记本上确实把模型跑起来了,输出了正确的第一个 token,而且和 224GB 服务器上跑出来的一模一样。代价是每个 token 要等半分钟。
这篇文章的每一行都是我在这个项目里真正操作过的记录——从下载 1.56TB 的 checkpoint,到打包主干文件,再到在不同内存上限下反复跑同一段 prompt,最后确认输出是否一致。
这到底是一个什么东西
这是一个用纯 C99 写的 Kimi K3 推理引擎。没有 BLAS、没有 PyTorch、没有 GPU,就一个二进制文件加一个 C 编译器。它做的事情是在一台普通 x86 机器上运行 Moonshot AI 那个 2.78 万亿参数的 MoE 模型。
最关键的是它不要求你把整个模型装进内存。
项目 README 里有一组数值得直接贴出来:
同一条 prompt,同一份 checkpoint,不同内存上限,输出的是字节完全一致的 token 序列。快慢只取决于能从磁盘里读到多少东西,不取决于算得准不准。
第一步:装起来需要什么
先说最拦路的一条:checkpoint 是 1.56TB。你需要有地方放它,以及有耐心等它下载完。
除了这个,其余要求都很普通:
-
Linux x86_64(需要 O_DIRECT、posix_memalign) -
CPU 支持 AVX2 + FMA(不需要 AVX-512) -
GCC ≥ 9 或 Clang ≥ 10 -
8GB 内存起步 -
约 1.7TB 可用存储(1.56TB checkpoint + 109GB 打包后的主干) -
Python 3.9+(下载、打包、分析工具用)
不建议把主干文件放网络存储。后面你会看到,网络卷能把 10s/token 拖成 70s/token,这跟 CPU 一点关系都没有。
快速验证:在你下载 1.56TB 之前
先做个全流程验证,不依赖任何 checkpoint,也不联网。这一步跑通再决定要不要下那个大家伙:
git clone https://github.com/FareedKhan-dev/kimi-k3-in-c.git
cd kimi-k3-in-c
make -j
make test
make test 跑的是这套引擎在 13 层小模型上的完整验证——和正式版模型的张量图一致,但 hidden size 只有 128,vocab 只有 256,完全从 fixtures 里加载,不需要下载任何东西。
跑完应该看到:
GATE 1 teacher forcing : 32/32 positions match tf_pred
GATE 2 greedy decode : 20/20 generated tokens match full_ids
GATE 3 incremental : 20/20 generated tokens match full_ids
VERDICT: ENGINE MATCHES THE REFERENCE EXACTLY
ALL WEIGHTLESS TESTS PASSED
这三道门分别验证了 teacher forcing 的 logits、单次贪婪解码、以及带 KV cache 的增量解码。三者 token 输出一致,说明整个计算图在结构上是自洽的。
下载那个 1.56TB 的大家伙
拿 HuggingFace token:
export HF_TOKEN=hf_your_token_here
./scripts/download-model.sh ~/k3model
脚本支持断点续传。下载完会做三件事:
-
校验 shard 数量是 96 -
校验总字节数是 1560936091448 -
逐一校验每个 shard 的字节数
如果中间某一步不通过,输出的文字会是乱码——不是近似,是彻底错。这不是“模型退化”的问题,是读进来的字节本身就错了。
我碰到过一次第 37 个 shard 校验失败,只重下了那个 17GB 的文件,不用重新拖 1.56TB。这是逐 shard 校验比只查总大小的实惠之处。
主干是什么,为什么还要再打一次包
模型有 93 层,其中 92 层是 MoE。这些层的“主干”部分(注意力权重、门控、归一化、路由器)散布在 96 个 safetensors 文件里,和那 82,432 个专家权重混在一起。
每读一层就要在 96 个文件里反复跳跃,效率很低。
所以 pack-trunk.sh 把每个层的主干部分按照层索引顺序重新拼接成一个 109GB 的 trunk.bin。这样引擎在读第 L 层时只需要一个 pread 调用,不再需要打开多个 shard 并反复定位。
./scripts/pack-trunk.sh ~/k3model ~/k3trunk
这个过程约 4 分钟。写入前会检查每一层的主干张量是否在同一个 shard 内连续存放——如果不连续,打包器会直接报错退出,而不是把专家数据连带复制进来。如果设计上允许 gap 存在,打包出来的 trunk 可能会膨胀到 400GB,那整个方案就没法在 8GB 机器上用了。
打完包后的目录结构:
~/k3model/ 1.56TB 96个safetensors + config.json + tokenizer
~/k3trunk/ 109GB trunk.bin + trunk.json
第一次跑起来
最小的可运行命令:
./bin/k3 ~/k3model --trunk ~/k3trunk --preset laptop \
--tok ~/k3model --prompt "The capital of France is" --gen 8 --incremental
--preset laptop 对应 trunk-gb=3.0, cache-gb=1.0,峰值 RSS 约 8.24GB。这个预设下,主干只有前 0 层被钉在内存里(也就是几乎全从磁盘流式读入),专家缓存约 1GB。
我当时跑的输出是:
--- generated text ---
Paris.",
+ "The Eiffel
----------------------
8 tokens in 261.5 s, 32.69 s/token average
PEAK RSS: 8.24 GB
Paris 是正确的第一个 token——不是语法通顺但语义错误的词,它确实回答了问题。后面跟的是续写,不是对话回复,因为这是 base model,没有加 chat template。
--incremental 这个参数值得单独解释一下。不加的话,每生成一个新 token 都会重新计算整个前缀,复杂度是 O(T²),第 8 个 token 要重算前 7 个 token 的所有层。加上 --incremental 后,第 0 步只算一次 prompt,后续每步只算一个新 token 的前向传播,KV cache 会保持状态。两套路径在测试里被验证过输出完全一致,所以这个参数只影响速度,不影响正确性。
预设选项怎么选
./bin/k3 --list-presets
返回:
laptop 3.0 / 1.0 8.2 GB peak RSS
desktop 16.0 / 10.0 31.9 GB peak RSS
workstation 60.0 / 30.0 95.5 GB peak RSS
server 110.0 / 13.0 ~128 GB peak RSS
max 110.0 / 109.0 ~224 GB peak RSS
每个预设的两个数字分别是 trunk budget 和 expert cache budget。前者控制多少层主干被钉进内存,后者控制专家 LRU 缓存的容量。
./scripts/k3-doctor.sh 会读你机器的 MemAvailable,推荐一个预设:
-
≥192GB → server(约 6s/token) -
≥96GB → workstation(约 6-20s/token) -
≥32GB → desktop(约 24s/token) -
≥10GB → laptop(约 27s/token)
注意一点:max 不必然比 server 快。从 128GB 到 224GB 多出来的 96GB 全给了专家缓存,但在稳态增量解码下,专家重用率很低,这 96GB 买不来明显的速度提升。给主干加 1GB 和在 128GB 预算下给专家缓存加 1GB 的效果相差很大,这个后面会展开说。
主干和专家缓存的真实表现
我从测量数据里挑了几组关键数字出来,不加修饰地摆在这里。
先看 8GB 到 224GB 的完整梯级:
输出全部一致:17374,20829,10,427,414,1008,606,142957。
从 8GB 到 64GB,每 token 读盘量纹丝不动,都是 25.83GB。专家缓存从 0.5GB 涨到 23.6GB,但对减少读盘没有任何帮助。直到总内存超过 64GB、主干能钉住超过 27 层之后,读盘量才开始下降。
这意味着:在 8GB 到 64GB 这个区间,专家缓存基本是个摆设。这套模型用了 Quantile Balancing 来均匀分散专家负载,训练时这是好事,推理时对 LRU 缓存来说就是灾难——没有“热专家”子集,缓存里塞进去的东西很快就过期了。
引擎内部统计的“命中率”数字在这里容易产生误解。它有两个计数:
-
原始命中:请求时专家已经在缓存 arena 里。但因为批预取会在请求前几微秒刚把专家从磁盘读进来,这个数字在任何缓存配置下都接近 100%。 -
真正驻留命中:专家在上一步结束后仍然留在缓存里的比例。这个数才是衡量避免读盘的真实指标。
测量显示,在 8GB 预算下真正驻留命中率是 0%,每步读盘 25.83GB,每步 1,472 个专家请求全部错失。
128GB 预算下怎么分钱最值
同一台机器,同一个 128GB 内存上限,不同 split:
同一个总预算,配法不同,最快比最慢快了 69%。最快的那组(trunk 110GB / cache 13GB)每 token 读盘 25.83GB,但命中率 0%;最慢的那组(trunk 12.3GB / cache 110.7GB)只读盘 14.46GB,命中率 44%,结果反而慢。
关键是:把内存给主干,每多钉一层就确定性地减少 1.17GB/token 的读盘量——因为这层不会被重新读。而把同样的内存给专家缓存,在低于一定阈值时,完全不会减少读盘。所以规则很明确:先把主干钉满,剩下来的再给专家缓存。
主干为什么不量化
大部分推理引擎会提供 int8 或 int4 选项。这个项目没有。
原文做了一个抽样测量,把 31 个注意力张量做了对称逐行量化:
int4 最坏的单行误差到了 65%。而且 Moonshot AI 的技术报告明确写了“所有非专家组件保持更高精度”——这个主干本来就没被训练成能扛 4-bit 截断。读盘慢可以用内存换,但精度损失不行。
几点操作细节
非 ASCII prompt 的处理
# 这行在 shell 下会被重新编码,非 ASCII 字符可能变
./bin/k3 ... --prompt "你好"
# 用这个
printf '你好' > /tmp/p.txt
./bin/k3 ... --prompt-file /tmp/p.txt
--prompt-file 读的是原始字节,不经过 shell 的字符集转换。对中文、日文、emoji 都建议用这个方式。
没有 –trunk 时的行为
如果没有指定 --trunk,引擎会把主干全部加载进内存,大约 113.5GB。这时候无论你设什么 --preset,内存使用都降不下来。文档里测量到内存卡在 113GB 附近的情况,几乎都是漏掉了 --trunk 参数。
KV cache 的内存边界
--incremental 模式下,24 层 MLA 的 KV cache 会随位置数线性增长——约 2.37MB/position。引擎会在启动前先做一次预估,如果算出来超出可用内存的 90%,会直接拒绝启动:
REFUSING: the KV cache for 100000 positions needs 237.00 GB but only 128 GB available.
这个拒绝发生在真正分配之前,不是跑了一小时后 OOM。
环境变量
-
OMP_NUM_THREADS:默认用全部核心,可以手动限制 -
K3_TOK_FILES:指向 tokenizer 文件目录,CI 和工具脚本会读 -
HF_TOKEN:下载脚本用的 token,从环境变量读取
真实的噪声有多大
同一台机器,同一个配置(trunk 110GB / cache 13GB),连跑三次:
第一次:14.78 s/token
第二次:14.67 s/token
第三次:20.14 s/token
极差 33%。第三次明显被什么干扰了。这套测量里 20% 以内的差异都不一定能说明问题。只有像 trunk-first vs cache-first 那种 69% 的差距,或者 8GB 到 64GB 读盘量完全不变的平台期,才是稳定的结果。
而且同样配置在不同时间跑,读盘速度能从 2,709 MB/s 变到 5,874 MB/s——文件系统缓存状态、磁盘队列深度、甚至同一块 NVMe 上的其他负载,都比代码优化影响大得多。
收尾速览
-
下载 checkpoint 1.56TB 前先跑 make test,验证引擎本身没问题 -
主干必须用 pack-trunk.sh打包成连续文件,否则每层要跨 shard 跳跃读 -
任何运行都要带 --trunk参数,否则主干全量驻留 -
中文/特殊字符输入走 --prompt-file,不要走--prompt -
预设只是 trunk/cache 两个数字的快捷写法,可以单独覆盖: --preset server --cache-gb 40 -
内存分配优先级:先把主干钉满,再考虑专家缓存 -
输出的 PEAK RSS 才是真正占用的内存,内存计划是预报值
FAQ
这个方案真的能在 8GB 机器上跑起来吗?
能,但要接受 26-33s/token 的速度。测试环境是 8GB 内存、无交换分区、NVMe 存储。如果只有 HDD,时间会更长,而且 O_DIRECT 读大文件对 HDD 不友好。
为什么内存计划说 8GB,跑起来 RSS 却到了 8.24GB?
内存计划是预算,不是预留。RSS 峰值才是实际测量值。8.24GB 在 8GB 的 cgroup 下跑不起来,需要略高于计划值才稳定。文档里用 systemd-run -p MemoryMax=8G 做的测试,实际需要 8.24GB。
不加 --incremental 会怎样?
每生成一个新 token 都重新计算整个前缀,T 个 token 就是 O(T²) 的复杂度。对于 8 个 token 可能还能忍,对于 100 个 token 会越来越慢。--incremental 启用 KV cache 和 KDA 状态传递,第 0 步算一次 prompt,后面每步只算一个新 token。两种路径输出一致。
KV cache 为什么那么大?
24 层 MLA 把每个位置的 k 和 v 扩展成 96 头 × 320 浮点数,存为 fp32。每位置约 2.37MB。这是注意力的代价。KDA 层走的是 626MB 固定大小的状态矩阵,不随位置增长。上下文足够长的时候,MLA 的缓存会成为主要内存开销。
为什么 --cache-gb 设很大但缓存命中还是 0%?
LRU 对均匀分布的专家访问天然失效。Kimi K3 用了 Quantile Balancing 训练,专家负载被刻意拉平,没有“热专家”子集。在 64GB 以下的缓存配置里,真正驻留命中率为 0% 是正常现象。
我可以把主干也量化成 4-bit 省内存吗?
项目特意没有做。抽样测量显示 int4 对注意力张量的平均相对误差约 17%,最差行 65%。而 Moonshot AI 的架构决定主干组件从未做过量化训练。读盘慢可以用更大内存解决,但精度的损失没有办法补偿。
最大支持多长上下文?
不设硬编码上限,受限于内存中的 KV cache。24 层 MLA 每位置约 2.37MB,如果机器有 128GB 可用内存,大约能支持到 50,000 个 token 左右。超过这个数字 KV cache 本身就会占用大部分内存,引擎会在启动前检查并明确拒绝。

