NVMe 带宽才是隐形天花板:30 秒一词背后的磁盘账

NVMe 带宽才是隐形天花板:30 秒一词背后的磁盘账

【免费下载链接】kimi-k3-in-c A 2.78-trillion-parameter Kimi K3 running inference on a single CPU in 8.24 GB of RAM. Portable C99: no BLAS, no framework, no GPU. 【免费下载链接】kimi-k3-in-c 项目地址: https://gitcode.com/GitHub_Trending/ki/kimi-k3-in-c

一台只有 8.24 GB 内存的机器,跑通了 2.78 万亿参数的 Kimi K3。这条消息传播时最容易被忽略的细节是:它并没有变快,它是把"容量墙"换成了一道"带宽墙",然后用 30 秒一个词的价格,向 NVMe 交过路费。项目文档里 8 GB 档的实测是 32.69 s/token,生成的一句话是 "The capital of France is Paris.",而同样这台机器在 128 GB 且分配得当的情况下是 16.80 s/token。两者输出的 token id 逐字节相同:17374, 20829, 10, 427, 414, 1008, 606, 142957。速度差异几乎全部来自"这一 token 有多少字节必须从磁盘上再读一遍"。

本文只算这一笔账:字节/token 怎么读,主干多留 1 GB 究竟省下多少读盘,4 KiB 对齐与 O_DIRECT 到底改变了什么,以及换一块普通 SSD 会退化到什么程度。

一、字节/token:比 s/token 更可靠的指标

Kimi K3 的 checkpoint 是 1.56 TB,其中 82,432 个路由专家占 1.447 TB(每个专家恰好 17,547,264 字节),剩下的稠密主干加嵌入/输出头是 113.49 GB。引擎的策略是把专家留在磁盘上按需流式读取,把主干打包成单个 trunk.bin(108.81 GB,93 层)单独管理。

于是一个 token 的磁盘开销分成两项,二者性质完全不同:

  • 主干 108.81 GB:93 层在每一 token 里按同一顺序全部走一遍,没有稀疏性可利用,是"必然发生"的流量。
  • 路由专家 25.83 GB:每层从 896 个专家里选 16 个,是"被采样"的流量。

docs/PERFORMANCE.md 里那台参考机(AMD EPYC 7763,228 GiB 内存,3.2 TB NVMe)在 workstation 预设下打印的分解是:

I/O share of wall clock: 71.1%  (trunk 62.4 s + experts 34.2 s of 135.8 s)
trunk [final]
  pinned 48/93 layers, ring 1 slots
  read 368.65 GB in 62.40 s (5908 MB/s)

一 token 135.8 秒,其中 96.6 秒在等盘。这个比例不是某一档的特例:benchmarks/memory-ladder.sh 的 12 档预算里,io_share 从 8 GB 档的 57.3% 到 224 GB 档的 40.9%,全程没有低于 40% 的时刻。换句话说,即使把内存堆到 224 GB,这台机器仍有四成时间在等 I/O。

同一个二进制在 8 GB 到 224 GB 之间,I/O 占比始终在四成以上

这就是为什么本项目所有性能结论都要先过一道"噪声闸门"。同一配置连跑三次:14.78、14.67、20.14 s/token,均值 16.53,离散度 33.1%。而两次配置完全相同、连 binds 744, hits 420 与 374.99 GB 读取量都逐项吻合的运行,磁盘速率却是 2,709 MB/s 对 5,874 MB/s。33% 的抖动不是调度抖动,是设备。既然计时器本身有三分之一的方差,用"字节/token"这种计数器指标下结论,比用秒表稳得多——它不受任何调度抖动影响。

二、主干多驻留 1 GB,省掉的是 1.17 GB/token 的确定流量

主干 108.81 GB 除以 93 层,平均每层 1.17 GB。由于每 token 都要完整走一遍,钉住一层就等于从每 token 的必读流量里删掉它。所以那条经验法则的推导只有一行:给主干 1 GB ≈ 多钉住一层 ≈ 每 token 少读 1.17 GB。

较反直觉的是另一边。专家缓存从 28 个 slot 涨到 1,344 个 slot(48 倍),gb_read 在七档预算上纹丝不动地停在 25.83 GB。这不是缓存实现得差,而是 K3 的路由器用 Quantile Balancing 训练,刻意把专家使用频率摊平;没有热点子集,LRU 就没有东西可以留下。直到 arena 涨到约 36 GB,保留率才跳到 29.89%,字节数从 25.83 降到 18.11。

专家缓存在约 36 GB arena 之前完全不参与,读取字节数是一条水平线

于是固定总预算、只改切分,就得到了一组"赢了字节却输了速度"的对照:

trunkcaches/tokenexpert 命中GB read/token
12.3110.728.3844.02%14.46
73.849.218.3732.20%17.51
110.013.016.800.0%25.83

128 GB 总内存不变,1.69 倍差距。最快的那一档,专家缓存命中率是 0.0%,每 token 多读 79% 的专家字节,却快了 69%。 因为主干 108.81 GB 对专家 25.83 GB 是 4.2 倍字节、2.9 倍时间,而专家流量在缓存不满 36 GB 时根本无法被削减。

固定总预算下,把内存给主干是 1.69 倍

这条结论的强度需要说清楚:切分扫描不是严格单调的,128 GB 序列里有两处倒挂、32 GB 有一处,全部落在 33% 噪声带内。真正撑住方向的是两个独立预算下的秩相关(Spearman ρ = −0.886 与 −0.714)加上机制本身,1.69 这个倍数则是单样本端点比较,未做三次复现——方向可信,幅度近似。

工程上因此有一条硬规则:--trunk-gb 优先填满,剩下的再给 --cache-gb。这也解释了为什么 max 预设(110 / 109,约 224 GB)反而比 server(110 / 13,约 128 GB)慢:多出来的 96 GB 落在噪声之内,而 128 GB 分对了就是 16.80 秒,224 GB 分错了是 19.21 秒。

28 倍内存只换来 1.70 倍速度,且大部分台阶在噪声带内

三、4 KiB 对齐与直读:省下的不是 372 KiB,是整条读路径

打包脚本 tools/pack_trunk.py 把每一层的 run 头尾都对齐到 4096 字节,93 层合计不到 372 KiB 的填充。这个代价几乎为零,但它买到的东西决定了整条 I/O 路径能不能成立。

原因在 src/io/k3_trunk.c 的打开逻辑里:

tr->direct = 1;
tr->fd = open(p, O_RDONLY | O_DIRECT);
...
if (tr->direct && want != K3_TRUNK_ALIGN) {
    fprintf(stderr, "k3_trunk: trunk.json reports align %lld, expected %d; "
                    "falling back to buffered reads (repack to enable O_DIRECT)\n", ...);

O_DIRECT 要求 offset、length 和缓冲区地址三者同时对齐(K3_TRUNK_ALIGN 4096)。这不是为了省那点填充,而是因为主干是全模型唯一"page cache 帮不上忙"的东西:每层每 token 读一次,读进来就不再复用,缓存只会把每个字节拷贝两次,并把 cgroup 里其他有用的页挤出去。注释里记着实测:在 32 GB 内存上限下,缓冲读从不受限时的 6,553 MB/s 塌到 1,878 MB/s。专家路径同理,src/io/k3_st.h 记录的是 6,400 MB/s 的盘在缓冲路径上只跑到 1,247 MB/s。

O_DIRECT 需要首尾都对齐,代价是每层不到 4 KiB 的填充

直读还带来两个次生设计,都在源码里能查到:

其一,各设备的"直读是否更快"必须先测量。 参考机上 O_DIRECT 冷读 3.2 GB/s、缓冲热读 2.3 GB/s,docs/data/environment.txt 特意标注"这与通常预期相反"。所以 src/io/k3_portable_io.h 里把三个平台的语义差异分别封装:Linux 用 open() 的 O_DIRECT,Darwin 用事后 fcntl(F_NOCACHE),Windows 只能在 CreateFile 时加 FILE_FLAG_NO_BUFFERING,于是拦在 open() 里。任一路径失败都退回缓冲读——牺牲速度,不影响正确性。

其二,对齐的代价会跑进内核。 k3_alloc_direct() 把 O_DIRECT 目标缓冲区按 2 MB 边界分配并 madvise(MADV_HUGEPAGE),理由写在注释里:一个 2.37 GB 的 ring slot 用 4 KiB 页就是 57.8 万个页,每个 token 要读 93 层,约 5,380 万次 get_user_pages 页钉住/释放,每次几百纳秒,量级上是每 token 十秒左右、且完全不计入引擎自己的 I/O 计时器。换 2 MB 页把次数降 512 倍。A/B 实测 11.37 → 10.76 s/token(5.4%),落在噪声带内,因此项目只声称机制成立、不声称幅度。

队列深度是同一类问题的另一半。原来的 load_run() 是一条顺序 pread,设备只看到 QD=1;现在按 64 MiB 分块交给 OpenMP 并行发起,块大小是 K3_TRUNK_ALIGN 的整数倍,因此每块的 offset/length 依旧对齐。注释给出的对比是 3.1 GB/s(顺序)对 6.7 GB/s(并行,专家路径本来就这么读)。同时 --trunk-ring N 把预取环深度做成参数:默认 2 个 slot,一个正在算、一个在飞;第 3 个 slot 再花 2.37 GB,换来读线程不必每层都停下来等。单 slot 时异步读线程被直接禁用——k3_trunk.c 记录了原因:只有一个 slot 时,预取会覆盖调用方正在计算的层,运行成功、无任何诊断,只是输出变成另一串流畅但错误的 token(32609 2329 146429 ...)。

批量 pread 让设备保持忙碌,串行读只能让它空转

四、换一块普通 SSD 会退化到什么程度

先给一个只依赖字节数的算术下界:约 135 GB/token ÷ 设备持续带宽。

设备持续带宽每 token 理论下界说明
6,000 MB/s~22 s参考机引擎实测的 trunk 持续速率 5,373–6,064 MB/s
3,200 MB/s~42 s参考机 dd bs=4M iflag=direct 冷读
772 MB/s~175 s网络卷顺序读

表格后两行是算术推演,不是实测;实测的部分在下面。

参考机上 8 GB 档实测 32.69 s/token,比 3.2 GB/s 的算术下界还快,因为它跑出了 4 GB/s 以上的有效速率——这正是分块并行与 O_DIRECT 带来的。同一个二进制换到网络卷上,实测从 31.71 掉到 70.62 s/token(2.2 倍),而源码一行未改。docs/TUNING.md 的措辞是"设备带宽 6 倍差距就是吞吐 6 倍差距,比本文档里任何调优决定都大"。

存储带宽的八倍落差,引擎在每一档都是 I/O 受限

这意味着三件事:

一、普通 SATA SSD 上,"30 秒一词"会变成"几分钟一词"。 按 135 GB/token 的字节数线性外推,500 MB/s 量级的盘对应 4 分钟以上的每 token 下界。这不是引擎退化,是它把内存问题如实转换成了带宽问题——docs/data/memory-ladder.tsv 里 8 GB 档的 io_share 是 57.3%,224 GB 档仍有 40.9%,中间没有任何一档是 CPU 受限的。

二、盘的选型不能靠 dd。 专家路径是随机 17.55 MB 的 O_DIRECT 读、最多 16 个在飞;dd 是单流 QD=1 的顺序读,两者在网络存储上分歧极大。项目自带 tools/devbw.py,直接按一个真实专家的字节数(SZ = 17547264)在 QD 1/4/16 下测:

python3 tools/devbw.py ~/k3model/model-00001.safetensors

QD1 与 QD16 的差距,就是这个设备上"批量预取还能不能换成速度"的余量;两者相等,说明设备不吃队列深度,预取白做。

三、双盘布局会改写调优规则。 上面所有"主干优先"的结论都假设主干与专家抢同一条带宽。若 trunk.bin 在一块盘、checkpoint 在另一块盘,主干读就落在一条空闲设备上、几乎免费地与计算重叠,继续钉层的收益迅速衰减。此时真正该看的阈值是引擎启动时自己打印的那行:

ring held at 1 slot: a second slot needs X.XX GB and the trunk budget is Y.YY GB,
so reads are NOT overlapped with compute. Raise --trunk-gb above X.XX GB to enable it.

把 --trunk-gb 设到刚好越过这个数,剩下的预算全部给 --cache-gb,因为在分盘布局下专家所在的设备才是唯一瓶颈。

结语:把内存当旋钮,把带宽当天花板

这个项目最容易误读的地方,是把它当成"8 GB 跑 2.78T"的容量魔术。容量那一半确实做完了:主干流式化把 315 GB 的硬门槛变成了可调旋钮,从 8 GB 到 224 GB 输出的 token 逐字节一致。但旋钮的另一端接的是 NVMe:每 token 约 135 GB 的确定流量,8 GB 档 57.3% 的墙上时间在等盘,128 GB 分配得当(16.80)能打赢 224 GB 分配失当(19.21),而同一份代码在本地 NVMe 与网络卷之间就是 2.2 倍的差距。

所以真正该抄的不是"把模型塞进 8 GB",而是这个顺序:先用 ./scripts/k3-doctor.sh 量清设备落在哪个区间,再用 python3 tools/devbw.py 看队列深度还有没有余量,然后把内存优先喂给主干、把 --trunk-gb 顶到第二个 ring slot 之上,最后才考虑专家缓存——在 36 GB arena 以下,它连一个字节都不会替我省。

【免费下载链接】kimi-k3-in-c A 2.78-trillion-parameter Kimi K3 running inference on a single CPU in 8.24 GB of RAM. Portable C99: no BLAS, no framework, no GPU. 【免费下载链接】kimi-k3-in-c 项目地址: https://gitcode.com/GitHub_Trending/ki/kimi-k3-in-c

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值