通过MoE模型只加载活跃专家权重的思路,用C99从NVMe流式读取,在3.23GB内存下运行DeepSeek-V4-Flash,生成速度1.6-1.7秒/token,无量化压缩。
让这一切成为可能的诀窍
DeepSeek-V4-Flash 有 2840 亿参数,占用约 160GB 磁盘空间。我的笔记本没有 160GB 内存,甚至没有 32GB。
但它还是跑起来了。峰值内存:3.23GB。配上 GPU 和一些余量,生成速度达到每 token 1.6–1.7 秒。不需要把模型量化压缩,不需要租用多 GPU 机器。只有 C99,按需从 NVMe 流式读取权重。
这篇文章讲的是 deepseek-v4-in-c 实际上是如何工作的,以及——更有用的是——构建它时遇到的三个 bug,这些 bug 教给我的比那些顺利通过的部分多得多。
DeepSeek-V4-Flash 是一个混合专家模型:每层 256 个专家,共 43 层,但任意给定 token 上只有前 6 个专家实际参与计算。这就是全部的游戏规则。你不需要 160GB 常驻内存——你只需要这个特定 token 的路由决策所触及的检查点的那一部分,算下来每次前向传播约 3.2GB。
所以我选择流式处理而不是加载模型。每个 token,路由器决定需要哪些专家,引擎只从磁盘读取那些专家到 LRU 缓存。给你更多 RAM,缓存就更大,命中率更高;几乎不给内存也仍能正确运行,只是更慢,每次从磁盘重新读取更多数据。
流式层不是我从零构建的——它移植自 Fareed Khan 的 kimi-k3-in-c,后者对 Kimi K3 做了同样的事情。safetensors 读取器、磁盘流式处理器、内存规划器,大约 40% 的代码库——直接移植过来。剩下的 60% 不是:DeepSeek-V4 和 Kimi K3 没有任何共享的数学运算,所以每个 kernel 都得参照 DeepSeek 自己的参考实现重新写。
为什么我不信任它,直到我能证明它
手写推理 kernel 有一个让人不舒服的事实:一个交换了半个字节或索引错了缩放因子的模型仍然会产生看起来正常的文本。流畅的输出不是正确实现的证据。它只是意味着 bug 还没灾难到完全破坏语言建模的程度。
所以在我相信任何自己的基准数字之前,我对照 PyTorch 从三个层级检查了正确性:
每个 kernel——14 个 kernel,逐一检查,误差容许 5e-7
每个 block——三种 layer 类型,46 个位置,相同容差
端到端——一个真实的(微型的)deepseek_v4 检查点通过实际的 C 加载器和实际推理路径运行,在每一个位置的 argmax 都相同的情况下,与 PyTorch 的匹配精度达到 2.9e-6
PyTorch 侧是从零重新实现 DeepSeek 的 inference/model.py,不是从我的 C 代码衍生的。如果我用自己实现构建参考,两个都可能悄悄地对同一个误解达成一致。
我对 C 代码本身更进一步:标量路径、OpenMP 路径和 AVX2 路径之间的位级完全一致。不是"差不多"——而是字节级一致,通过固定的 16-累加器归约树和 -ffp-contract=off 在运行时强制检查,实际上用相同输入运行两条路径然后 diff 比特位。GPU 进不了那个俱乐部——warp 级归约顺序不是以同样方式确定的——所以它用相对误差加 argmax 匹配来约束,而 CPU 保持作为真值基准。
实际上静默破坏的优化
这是我最懊恼交付出去、又最庆幸捕获到的 bug。
从磁盘读取专家使用了 O_DIRECT,它要求文件偏移、读取长度和目标缓冲区都落在 4096 字节对齐边界上。检查点的 tensor 并不是天然对齐的,所以我第一个版本把每次读取扩展到最近的对齐窗口,放入一个 staging 缓冲区,然后 memcpy 实际载荷到缓存槽。一次 12.75MB 的拷贝,每轮运行约 5000 次。我把它当作廉价操作忽略掉了——"在无缓冲读取节省的约 10ms 面前'约 1ms'"——但实际上根本没去测量它。
我测量了。读取本身是 2.81ms。拷贝每次专家花费 3.60ms。拷贝不是舍入误差,它是 22% 的成本。
修复看起来很简单:分配缓存槽时 4096 字节对齐并留一点余量,把每次读取放在偏移量上——那个偏移量的对齐余数与实际 tensor 相匹配,这样扩展窗口恰好落在数据该在的位置——无需拷贝。我在那个修复上的第一次尝试是错的。它把每次读取放在下一个具有匹配余数的偏移量上,没有考虑到一个对齐窗口可以在实际载荷开始前最多 4095 字节处开始。所以第二个 tensor 的"对齐"读取可能向后延伸,静默覆盖了前一个 tensor 的字节。
每个现有测试都通过了。每一个。以下是原因:我的正确性检查是把缓存与缓存比较——串行模式对照并发模式——两者以相同方式静默破坏数据,所以比较没有发现任何问题。更糟的是,破坏版本看起来是个大胜利:缓存命中率从 52.6% 飙升到 95.5%,因为破坏让路由坍缩到极少的几个专家上。磁盘读取从 61GB 降到 5.76GB。看起来我找到了 5 倍加速。唯一露馅的是生成的 token ID 是错的。
我用两种方式修复了它:一个新的检查机制,用一条与快速路径零共享代码的普通缓冲 pread 做对照(这样它就不会以同样方式失败),以及一个测试 fixture,它真实地镜像了真实检查点分割专家 tensor 的方式——三个 scale tensor 在一个磁盘区域,三个 weight tensor 在约 341MB 之外且对齐余数不同。我的合成 fixture 一直是连续存储的,这意味着 bug 的触发条件在测试中压根没出现过。真正的修复、正确验证:磁盘时间降 16%,墙钟时间降 12%。
我记下的教训:如果两个被比较的东西可以同样地失败,你的测试什么都证明不了。我需要一条零共享代码的参考路径。
一个被错误解释的 30 倍减速
打开 --gpu 将 dense trunk 的 FP8 矩阵移入 VRAM。朴素地看,这对仍在 CPU 上运行的东西应该是免费的——GPU 做 GPU 的事,CPU 做 CPU 的事。但并非如此。我在一个 GPU 忙碌时对 CPU 独占的 FP4 matmul 做基准测试,看着吞吐量从 119 GF/s 跌到 3.9 GF/s。30 倍崩溃。
我的第一个理论:CUDA 的默认同步模式让调用线程原地空转而不是睡眠,所以那个线程与我的 OpenMP worker 争抢 CPU 周期。我切换到 cudaDeviceScheduleBlockingSync,它会让线程睡眠而不是空转。这应该能修复它。
实际上并没有——5.0 GF/s 而不是 3.9。仍然是 25 倍崩溃。
真正修复它的方法:从用于驱动 GPU 的线程预留一个 CPU 核心,让 OpenMP 使用其余的核心。这彻底消除了崩溃,任意同步模式都可以。我现在的最佳猜测是 GPU 的 DMA 流量与一个内存带宽受限的 kernel 争抢 DRAM 带宽——但我没有测量过,所以我不把它作为事实写在 README 里。修复有效。我最初找到的解释是错的,我宁愿说出来而不是假装第一次就猜对了。
我想诚实地说一个注意事项:这种崩溃在微基准测试中比真实模型里大得多。合成基准测试让 GPU 饱和地连续运转;实际模型每层只接触 GPU 几次。在真实生成中,保留一个核心值得 10–15%,而不是 3 倍。我之前一版 README 基于一次热身基准测试高估了这一点,我想明确标记出来,而不是让一个膨胀的数字留下来。
热状态比我预期的更重要
说到那次热身的测试——这是我这篇文章中最不值得骄傲的、需要进行更正的地方,也是如果你是做磁盘受限基准测试的人整篇文章中最有用的。
我在一个全新重启、空闲、接电的机器上重新跑了 README 中的每个数字。与在持续负载 24+ 小时后测得的数字相比,差距不是我想的 ±20%:
这个模式回过头看是有道理的:一个配置越磁盘受限,机器发热时就越受影响,因为顺序 O_DIRECT 吞吐量本身在热负载下会下降(冷的时候 5.3 GB/s 对 热的时候 4.4 GB/s,直接测量到的)。低预算运行几乎是纯磁盘 I/O,所以它们受的影响最严重。
我现在把任何单一计时当作在固定热状态下 ±20%、跨热状态高达 3x 来对待——而且我用专用微基准测试(bench/matmul_bw.c)来解决实际的 kernel 改进,而不是通过计时一次完整生成运行来衡量,因为一次 70 秒的端到端运行根本无法在那么多噪声中解析出 12% 的 kernel 改进。
真正重要的那个 flag:--budget
如果你自己尝试,真正需要理解的 flag 是 --budget——它为 trunk 加上专家缓存设置了总 RAM,弄错了不仅仅让事情变慢,还可能让缓存在数学上永远无法命中。
一次前向传播在所有层中接触 258 个专家——约 3.21GB。低于这个数字,LRU 缓存会在其所属层回来之前就驱逐每个条目,所以永远无法命中。我在一个旧默认值上直接测量了这一点:10,320 次请求,0 次命中,一轮运行从磁盘读取了 128GB。
--budget expert cache hit rate disk read
8 GB 1.6 GB 0% 128 GB
12 GB 5.6 GB ~40% ~80 GB
16 GB 9.6 GB 53% 61 GB
如果你在阈值以下,引擎现在会打印一个警告指出该阈值,因为我自己就遇到过这种情况,在理解为什么我"优化过"的缓存什么都没做之前。
make # CPU-only build
make test # 20 gates, no model weights required
# pack the checkpoint once
python3 tools/pack_trunk.py ~/models/dsv4-flash ~/dsv4-trunk
python3 tools/pack_tokenizer.py ~/models/dsv4-flash ~/dsv4_tok.bin
./bin/dsv4 ~/models/dsv4-flash \
--trunk ~/dsv4-trunk \
--tok ~/dsv4_tok.bin \
--prompt "The capital of France is" \
--gen 25 --budget 16 --gpu
--chat 应用了 DeepSeek-V4 真实的 prompt 格式——我得从检查点自己的 encoding_dsv4.py 里提取它,因为它不在大多数 tokenizer 工具查找的地方(tokenizer_config.json)。--think 在模型回答前打开一个推理块。CUDA 是自动检测的;没有 nvcc 的情况下 build 仍然成功,--gpu 只是报告找不到设备而不是失败。
DeepSeek-V4-Pro(61 层,384 个专家,约 865GB 检查点)被门控控住且在测试套件中计划了,但从未实际运行——我没有足够的磁盘来跑它。一个计划门控已经捕获了一个真实的 bug:一个形状验证规则,它恰好因为巧合对 Flash 算出了正确答案(等式两边都坍缩到 4096),但会在 Pro 的维度上静默失败。一个只测 Flash 的测试套件永远不会捕获到那个。
批处理 prefill 还不存在。Prompt token 一次一个地通过,与生成的 token 相同,每个约 1 秒——所以一个长的 prompt 对延迟的影响远大于生成速度。我已经测量了批处理会解锁的专家复用上限(200+ token prompt 下高达约 8 倍的权重复用),但实现批处理 kernel 是未来工作,不是我已经交付的东西。
工具调用循环。模型正确地发出了它的工具调用格式,但实际上在其上驱动一个 agent 循环是比我还没构建的 CLI 高一层的东西。
我会告诉做类似事情的人的话
手写 kernel 的流畅输出不是它正确的证明。获取一个独立编写的参考实现并检查位级一致性,而不是凭感觉。
如果你的测试比较的两个东西可以同样地失败,它什么都证明不了。我需要一条零共享代码的参考路径来捕获我那个最严重的 bug。
在你"优化"之前测量你觉得贵的东西。我的 memcpy 占成本的 22%,不是我假设的 1ms。
报告你基准测试运行的热状态,否则不要信任它们。代码在这里,Apache-2.0:github.com/ronak-create/deepseek-v4-in-c