在 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 里有一组数值得直接贴出来:

机器 RAM 每 token 时间
普通笔记本 8 GB 26.5 s
高配笔记本 32 GB 24.2 s
台式机 64 GB 19.8 s
工作站 128 GB+ 5.6 s

同一条 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

脚本支持断点续传。下载完会做三件事:

  1. 校验 shard 数量是 96
  2. 校验总字节数是 1560936091448
  3. 逐一校验每个 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 的完整梯级:

总内存 钉住层数 专家缓存 s/token 每 token 读盘量
8 GB 0 0.49 GB 32.69 25.83 GB
16 GB 3 4.39 GB 32.21 25.83 GB
32 GB 11 10.80 GB 31.44 25.83 GB
64 GB 27 23.59 GB 28.60 25.83 GB
96 GB 43 36.39 GB 24.40 18.11 GB
128 GB 60 49.19 GB 29.40 17.51 GB
224 GB 90 108.98 GB 19.21 14.53 GB

输出全部一致: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:

trunk cache s/token
12.3 GB 110.7 GB 28.38
30.8 GB 92.2 GB 25.20
49.2 GB 73.8 GB 25.69
73.8 GB 49.2 GB 18.37
98.4 GB 24.6 GB 19.46
110.0 GB 13.0 GB 16.80

同一个总预算,配法不同,最快比最慢快了 69%。最快的那组(trunk 110GB / cache 13GB)每 token 读盘 25.83GB,但命中率 0%;最慢的那组(trunk 12.3GB / cache 110.7GB)只读盘 14.46GB,命中率 44%,结果反而慢。

关键是:把内存给主干,每多钉一层就确定性地减少 1.17GB/token 的读盘量——因为这层不会被重新读。而把同样的内存给专家缓存,在低于一定阈值时,完全不会减少读盘。所以规则很明确:先把主干钉满,剩下来的再给专家缓存。

主干为什么不量化

大部分推理引擎会提供 int8 或 int4 选项。这个项目没有。

原文做了一个抽样测量,把 31 个注意力张量做了对称逐行量化:

精度 平均相对误差
int8 ~1%
int4 ~17%

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 本身就会占用大部分内存,引擎会在启动前检查并明确拒绝。