把 n-gram 表扔到 NVMe 上,我再把五台机器从头到尾测了一遍之后,结论非常明确:默认不要扔。除非你的使用场景恰好踩中“表足够小、完全能被页缓存吞掉”或者“延迟无所谓、只要容量大”这两个极窄窗口,否则用 NVMe 承载 n-gram 表,换来的不是显存释放,而是 P99 延迟翻倍、生成吞吐腰斩,还得搭进去一堆排查 iowait 的工时。
正文里我会直接放出 3090、4090、5090、DGX Spark、Strix Halo 这五台设备的横向对比数据,也会给出我最终采用的配置。这套 Flash Nest 后端是我自己维护的 Qwen3-8B 推理栈,为了减少重复计算,它在传统 KV cache 之外额外维护了一张 n-gram 表。这块表到底放哪,我踩了两个星期的坑。
1. “扔 NVMe”之前:先弄清 n-gram 表在 Flash Nest 里到底是个什么角色
1.1 它不是一张只读 embedding 表,而是一张实时读写的索引表
一听到 n-gram 表,很多人的第一反应是它像词向量表一样,模型启动后固定不变,可以放心扔进大容量存储。实际根本不是。
Flash Nest 的重要加速手段是“重复内容识别”。当请求里的 prompt 片段和历史上见过的某个序列一致时,后端可以直接复用对应的 KV block,跳过一部分预填充计算;同时它还会用 n-gram 匹配出的 token 链条做成候选草稿,送给模型一次性验证,这就是 DD(n-gram based draft decoding”)。为了做到这一点,n-gram 表里保存的不是静态词频,而是一条条类似这样结构的记录:
content_hash: uint64 block_id: int64 kv_cache_version: uint32 draft_token_chain: token_id[...] ref_count: uint32 last_access_time: uint64从这段结构就能看出来,这是一张每秒钟都要被大量并发查、还不断被改的老牌“状态表”。每次命中要更新 ref_count 和 last_access_time,每次有新的重复序列出现要插入新记录,超龄记录要淘汰。它表里归根结底是热数据的索引和指针,不是冷数据本体。
这一点决定了它和 KV cache 卸载是完全不同的物种。KV cache 可以整块按顺序写进 NVMe,因为它的访问模式基本是“先写在后面、再顺序读”;但 n-gram 表在任何时刻都是随机读小对象,平均每次访问可能只读 64 到 256 字节,恰好是存储设备最不擅长应对的负载。
1.2 一块表能到多少 GB?算完你就知道为什么有人动歪脑子
我们从实际数据来估算。Flash Nest 默认把一段 prompt 按 32 token 切成一个 chunk,每个 chunk 的哈希作为 n-gram 表键。每条记录按 256 字节算,那么一个有 200 万条记录的表就是 512 MB,说实话不算大。但真实生产环境下,运营一两周之后表记录数很容易滚到 5000 万到 1 亿条,也就是 12 到 25 GB。
25 GB 这数字放在 3090 和 4090 的 24 GB 显存里,确实让人很有压力。于是“显存不够,NVMe 来凑”的思路就出现了。想法看起来自然:显存放不下,系统 DRAM 也紧张的话,NVMe 总够大,序列局加速表嘛,晚个几十微秒读应该没事?
问题恰恰出在“晚个几十微秒”上。GPU 在 decode 阶段计算一级 KV cache 的时间窗口是亚毫秒级别,一次随机读把这些时间吃掉,整个流水线就等着。我先跑了一个快速微基准,结果吓了我一跳:同样的命中率下,表查询延迟从 DRAM 的不足 1 微秒跳到 NVMe 的 60 到 120 微秒,差距是两个数量级。
1.3 为什么大家总觉得“既然 KV cache 都能放盘,它也能放盘”
最近两年 KV cache offload 到 NVMe 的成功案例很多,尤其是长上下文推理场景。我这边的老读者应该也知道,我写过好几篇关于“长上下文爆显存时怎么把 KV cache 搬到 NVMe”的实操文章,效果也确实不错。
但 KV cache 卸载成功,前提是它的访问是高度可预测的:写入时顺序追加,读取时也是按 block 顺序扫。n-gram 表完全没有这个性质。它是极细粒度的随机查找,每次访问都依赖一个哈希值索引,位置无法预取,也没什么顺序性可言。
简单类比一下:KV cache offload 像把一箱箱货按标签顺序堆在仓库,出库时按顺序取;n-gram 表扔 NVMe 则像是每天几千次让叉车从一万个货架上随机取一颗螺丝钉。仓库再大也没有用,叉车的路程时间已经卡死了整体效率。
所以我的建议是,在动手往 NVMe 上放之前,先确认你后端走的究竟是“顺序读友好”还是“随机小读密集”的工作负载。Flash Nest 的 n-gram 表明显属于后者。
2. 我的五台测试平台与一套可控的对比实验设置
2.1 五台设备,包含了独立显存和统一内存两大阵营
这次测试的选型很刻意。3090、4090、5090 是典型的独立显存平台;DGX Spark 和 Strix Halo 则代表了最近很热的统一内存小主机方向。具体参数如下:
| 平台 | 显存/统一内存 | 规格 | 主机接口 | 官方峰值功耗 |
|---|---|---|---|---|
| RTX 3090 | 24 GB GDDR6X | 936 GB/s | PCIe 4.0 x16 | 350 W |
| RTX 4090 | 24 GB GDDR6X | 1008 GB/s | PCIe 4.0 x16 | 450 W |
| RTX 5090 | 32 GB GDDR7 | 约 1792 GB/s | PCIe 5.0 x16 | 575 W |
| DGX Spark | 128 GB 统一内存 | 约 273 GB/s | NVIDIA C2C 互联 | 约 100 W |
| Strix Halo | 128 GB 统一内存 | 约 256 GB/s | 片上总线 | 约 120 W |
单独把 3090 和 4090 放一起看可能没什么感觉,但注意它们都是 24 GB 显存,卡上放不下 25 GB 的 n-gram 表,这正是最容易产生“扔 NVMe”想法的场景。5090 的 32 GB 相对游刃有余,但也只是“相对”。而 DGX Spark 和 Strix Halo 的 128 GB 统一内存,在容量上反而最宽裕,却又带来了 CPU 和 GPU 共享内存带宽的新问题,后面实测会看到它们呈现出完全不同的表现。
所有平台都装同一套 Ubuntu 22.04、同一份 Flash Nest 代码分支,模型权重统一用 W8A8 量化,KV cache 使用 FP16。目标是把变量压到只剩“存储介质”这一个。
2.2 三种存储档位:L0、L1、L2 分别代表什么
- L0 档位:n-gram 表全部放在 GPU 显存里。这是理想状态,但由于 24 GB 显存的机型装完权重以后剩余空间很有限,我只用它跑小表实验。
- L1 档位:n-gram 表放在系统 DRAM 中,后端通过共享内存映射访问。这是绝大多数生产服务应该使用的默认位置。
- L2 档位:n-gram 表直接放在 NVMe 上。我实现了两套访问方式,一套是 mmap 加预先
madvise,另一套是io_uring异步随机读。两套都测,结果取较好的。
启动参数大致是这样的。L1 模式:
flash-nest serve --model Qwen3-8B-FlashNest \ --quant w8a8 \ --ngram-cache-layer l1 \ --ngram-cache-size 16GiB \ --concurrency 128L2 模式:
flash-nest serve --model Qwen3-8B-FlashNest \ --quant w8a8 \ --ngram-cache-layer l2 \ --ngram-cache-size 64GiB \ --ngram-nvme-path /mnt/nvme/ngram \ --ngram-io-engine io_uring \ --ngram-io-depth 642.3 压测参数:四档重复率,三档命中,中位数结果
为了让“该不该扔 NVMe”这种问题能有数据支撑,我构造了一组混合 prompt 数据集,里面的重复片段占比分别控制在 0%、20%、50%、80%。这个重复率直接决定 n-gram 表的价值:0% 重复时表基本是装饰品,80% 重复时表会成为绝对热点。
每档实验固定 128 路并发请求,跑 3 轮,每轮 10 分钟,取中位数。统计三个核心指标:首 token 延迟(TTFT)、生成吞吐(token/s)、P99 延迟。同时记录整机功耗和 NVMe 盘 IOPS。
需要说明的是,我测试用的 NVMe 是三星 990 PRO 2TB,PCIe 4.0 x4,理论顺序读 7450 MB/s,4K 随机读约 500K IOPS。这已经是消费级里比较能打的盘了。如果你用的是更低端或者更旧的盘,刚才看到的所有数字只会更难看。
3. 实测数据:放入 NVMe 后,吞吐降了四成,P99 翻倍
3.1 最直观的伤害:TTFT 和生成吞吐全面下滑
下面是 50% 重复率、L1 和 L2 对比的完整结果。注意 L2 我只列 io_uring 引擎的结果,mmap 模式因为是同步缺页读取,在 128 并发下表现更差很多,几乎抬不起头。
| 平台 | L1 TPS | L2 TPS | 下降幅度 | L1 TTFT | L2 TTFT | L1 P99 | L2 P99 |
|---|---|---|---|---|---|---|---|
| RTX 3090 | 62.8 | 40.1 | 36.1% | 218 ms | 419 ms | 1042 ms | 1988 ms |
| RTX 4090 | 86.4 | 55.3 | 36.0% | 176 ms | 361 ms | 886 ms | 1764 ms |
| RTX 5090 | 109.7 | 72.6 | 33.8% | 153 ms | 318 ms | 762 ms | 1523 ms |
| DGX Spark | 47.3 | 31.9 | 32.6% | 287 ms | 524 ms | 1739 ms | 2981 ms |
| Strix Halo | 41.8 | 27.6 | 34.0% | 309 ms | 561 ms | 1884 ms | 3227 ms |
看到降幅心里基本就有数了。无论卡多新多贵,L2 相比 L1 大约降低 33% 到 36% 的吞吐。这不是个别卡的问题,而是存储介质访问方式决定的共性结果。即使是我原本以为最能扛住高并发 NVMe 的 5090,TTFT 也从 153 ms 膨胀到了 318 ms,P99 直接翻倍。
更关键的是,硬件越好,这种相对下滑造成的机会成本越高。一张 5090 好不容易跑出来 110 token/s,扔 NVMe 之后只剩下 72 token/s,等于你花几万块买的算力有一小半浪费在等待 SSD 返回几百字节的数据上。
3.2 80% 重复率下会不会翻盘?并没有
抱着“重复率高了命中多,表读取能不能被覆盖”的念头,我把重复率拉到 80% 再测一轮。理论上 n-gram 表命中率能到 75% 以上,但实际上结果仍然不乐观。
以 4090 为例:L1 时 TPS 从 86.4 涨到 103.8,L2 时从 55.3 涨到 68.9。看起来绝对数值都涨了,但两者的相对差距还是 33% 左右。原因是命中 n-gram 表节省的 GPU 算力是实打实的,但 n-gram 表每次查询本身也多了一次随机读延迟;节省下来的算力还不够补偿等待 SSD 的损失。
换句话说,n-gram 命中率高的时候,NVMe 的闲置率看似会降低,实际上磁盘反而更忙了,因为每一次命中都要做一次随机读。盘忙着,卡等着,整个链路就在一种“资源都用上了但就是不快”的诡异状态里。
3.3 功耗也骗人:省下的电费远不够买体验损失
细节里有一个容易误导人的点:L2 档位下 GPU 功耗确实降低了。3090 从 302 W 掉到 281 W,4090 从 412 W 掉到 389 W。原因很简单,GPU 有一大部分时间在空转等数据,自然不费电。但这个“省电”毫无意义,因为单位请求的能耗反而更高了。
计算一下就能明白。3090 在 L1 下每生成 1000 token 耗时约 15.9 秒,功耗 302 W,对应能耗约 4.8 kJ;L2 下耗时约 24.9 秒,功耗 281 W,对应能耗约 7.0 kJ。每 1000 token 的能耗反而上升了 46%。省了峰值功率,赔了服务总量,这种账是不能只看瓦数的。
3.4 DGX Spark 和 Strix Halo:统一内存并没有让 NVMe 更香
这两个小机器值得单独说。很多读者留言问“统一内存是不是可以直接避开设卡瓶颈”,但实测给我的感觉恰恰相反。
DGX Spark 的处理器和 GPU 共用 128 GB 内存,CPU 侧和 GPU 侧的 L1 查询本来就在同一片物理存储上,完全不需要经过 PCIe。按说这是最不该考虑 NVMe 的平台,因为 L1 的访问路径已经非常短了。然而实际测下来,DGX Spark 的 L1 TPS 只有 47.3,反而是五台机器里偏低的。原因是内存带宽被 CPU 和 GPU 共享,n-gram 表频繁查询会某些进程中与模型权重加载抢占内存带宽。
在这种平台上,L2 没有任何吸引力。表放 NVMe 要经过更长的访问链路,而省出的“统一内存”也很可能根本用不上,因为模型权重、KV cache、其他运行时结构都还在占用它。所以统一内存平台的最大优势是“能跑更大的模型”,不是“你可以把表随便扔”。Strix Halo 的结果也完全符合这一点,甚至因为整机内存带宽更紧张,P99 涨得最夸张。
顺带说一句,DGX Spark 部署时我喜欢用 U.2 或 M.2 960GB 企业盘作为系统盘,但千万不要拿系统盘去放 n-gram 表。它的系统本身就要读写大量日志和模型文件,再叠加上 n-gram 表的随机访问,iowait 会高到让你怀疑机器挂了。
4. 从 PCIe 延迟到操作系统缓存,NVMe 的“伪优势”是怎么来的
4.1 一个 60 微秒的随机读,到底是怎么吃掉 GPU 时间的
要理解为什么 60 微秒看似不长,却造成这么严重的性能下滑,得回到 Flash Nest 的 decode 流程来看。GPU 每步 decode 的 kernel 执行时间大约是 0.5 到 1 毫秒。如果后端在 decode 过程中同步等待 n-gram 表查询结果,哪怕只等一次 60 微秒,看起来只占了 6% 到 12%。但问题是,n-gram 表在请求验证链路上通常要查询多次:先查 prompt 前缀,再查候选草稿链,还要查 KV 复用块,一个请求的整体时延可能累积出 15 到 25 次随机读。
我们的性能分析日志也证实了这一点。在 L2 模式下,一个平均 4K 上下文的请求,平均要付出 18 次表访问,累计等待时间超过 1.1 毫秒。这已经相当于 GPU 跑一次完整 decode 的时间了。换句话说,GPU 有近一半的运算周期在等数据,吞吐怎么可能不崩。
而且这里还要考虑一个被人忽略的现象:并发响应路径上的随机延迟是不可压缩的。单核上的 60 微秒延迟,在 128 并发下会与磁盘队列深度纠缠在一起,出现非常毛糙的 P99 抖动。990 PRO 的单队列随机读确实能到 80 微秒左右,但当 128 个线程同时发起读取时,盘内队列和 IO 调度器会把尾部延迟拉到几百微秒甚至一毫秒以上。
4.2 SSD 的随机读瓶颈:高 IOPS 不等于低延迟
很多 NVMe 盘标称 4K 随机读有 500K IOPS,初看觉得绝对够用。但这个指标的测量条件是深度队列下并行读,它的核心依赖是设备能把请求拆成多通道并发处理。延迟反而会在高并发下恶化,因为请求在队列里排队的时间变长了。
我通过 fio 给 990 PRO 做了实际测试:
fio --name=randread --ioengine=libaio --iodepth=32 \ --rw=randread --bs=4k --size=8G --numjobs=4 --group_reporting结果是平均延迟 86 微秒,P99 延迟 430 微秒。注意这是纯裸盘没有加 Flash Nest 应用逻辑的数字。一旦后端还叠加了 mmap 缺页和用户态锁,P99 很容易破毫秒。
对比一下 DRAM 的随机访问延迟,大约是 100 纳秒量级,和 NVMe 差了 600 倍以上。虽然在应用层查 DRAM 哈希表不可能真的只有 100 纳秒,但整体查一条记录通常会落在 500 纳秒到 1.5 微秒之间,NVMe 依然慢了两个数量级。
4.3 mmap 的理想很美,但 Linux page cache 会把内存偷偷吃回来
我知道有些读者会说“用 mmap 不是可以让内核自动缓存热页吗?实际访问量大的页还是会留在 DRAM 里,只有冷页才真正落盘”。这个想法是好的,但实测下来得到的是一个“伪卸载”的尴尬状态。
测试中我把一张 32 GB 的 n-gram 表通过 mmap 映射到 NVMe 上,跑完 20 分钟压力后查看 /proc/meminfo,发现 Cached 涨了大约 14 GB,而自由内存 Shrinker 回收得很快。也就是说,真正被频繁命中的热页绝大多数都被内核悄悄缓存在 DRAM 里,NVMe 上只躺着不常访问的冷数据。这十四 GB 的内存你本来可以用在别的地方(比如扩大 KV cache 池),结果却被一张看似“卸载成功”的 n-gram 表占了。
这就是“伪卸载”的全部含义:你以为把表放 NVMe 能释放 DRAM,其实 Linux 通过 page cache 又把一部分热数据拉了回来。你付出的代价是额外一次内核页缓存查找和缺页中断,得到的收益却约等于零。
如果改用O_DIRECT绕过 page cache,DRAM 倒是省下来了,但所有热页都要重新从盘上读,性能比 mmap 更差。走io_uring时稍微好点,能缓解一部分阻塞,但随机读延迟的本质问题依然在。
4.4 n-gram 表的高频更新会让 NVMe 写放大问题雪上加霜
前面说过表里每条记录都有 ref_count 和 last_access_time,这两个字段每次命中都要更新。如果表直接放在 NVMe 上,后端为了崩溃恢复就得定期把这些改动刷盘。闪存的最小擦除单位远大于一个扇区,哪怕你只改了 256 字节的记录,也要把一个整页甚至整个块重写一遍。
我测了 L2 模式下的写放大情况:在 80% 重复率、128 并发下,NVMe 每秒会产生约 45 MB 写入量,而表格本身只有几百 KB 的变化会刷新。这些写操作还会抢占同一块盘的读带宽,进一步恶化响应延迟。长期运行半年,这种表的寿命损耗和随机 I/O 不均匀磨损也是现实风险。
所以我还是那句话,你把模型 weights 放 NVMe 没问题,那些是冷数据;KV cache 放 NVMe 也得设计好顺序读写;但 n-gram 表这种高频随机读写的结构,放 NVMe 就是纯纯给自己找麻烦。
5. 最终落地方案:DRAM 为主,NVMe 只做持久化与离线大表
5.1 我压箱底的部署准则:表放 DRAM,NVMe 只当备份
综合这轮测试,我最终给线上服务定下的方案是:n-gram 表永远放在系统 DRAM,哪怕 host 内存紧张,也要想尽办法保住这块表;NVMe 只用来持久化快照,在服务重启时做 warm-up 加载。
具体实现上,Flash Nest 后端在启动时把 mmap 指向 NVMe 上的快照文件,然后在后台异步将全部记录加载到 DRAM 中的内存哈希表。加载完成后所有查询只走内存,NVMe 上的快照文件在运行期间不会再次打开。每隔 5 分钟,后端把内存表的增量变化异步刷回磁盘,为的是崩溃恢复和版本升级。
如果你是像我一样自己维护推理栈,可以照这个模式做。如果不方便改代码,也可以保留 L2 模式但只把它用于启动时的预加载,不让运行路径去触碰 NVMe。
5.2 非要把大表扔 NVMe?那就做好这几件事
如果你确实有一个上百 GB 的超级大表,并且确定 DRAM 完全放不下,那么唯一的合理方式是把离线预处理和大批量后台任务交给 L2 模式,而不是让在线服务去踩这个坑。工作时长参数应该尽可能放宽,把并发降到 16 到 32,使用 io_uring 异步实现,并尽量提前做预取。
启动参数可以参考:
flash-nest serve --model Qwen3-8B-FlashNest \ --quant w8a8 \ --ngram-cache-layer l2 \ --ngram-cache-size 128GiB \ --ngram-nvme-path /mnt/nvme/ngram \ --ngram-io-engine io_uring \ --ngram-io-depth 16 \ --ngram-prefetch-size 128MiB \ --max-concurrency 32这里把 io-depth 调低、并发调低,是为了避免磁盘队列深度过高导致 P99 雪崩。同时建议把 n-gram 表的 NVMe 盘和系统盘、日志盘彻底分开,至少用不同分区,有条件就用两块独立的物理盘。企业级 U.2 盘的随机读稳定性通常比消费级 M.2 好,可以优先考虑。
装盘时也给个提醒:如果你用的是很早的主板平台,BIOS 可能没有原生 NVMe 引导支持,需要通过转接卡和额外的引导选项才能认盘。这种老平台跑生产推理会多出很多不可控因素,能用新平台尽量用新平台。
如果整机有多块 NVMe,可以先用 fio 把 4K 随机读的 P99 拉出来对比,优先选 P99 最优的那块盘。
5.3 运行中的状态监控与故障定位
上线之后不能只看吞吐。我建议至少盯三个指标:iostat -x 1的%util、/proc/meminfo的Cached和PageTables、以及 Flash Nest 暴露的 ngram_lookup_ns 指标。
如果%util长期高于 80%,说明表面看起来一切正常,但实际上已经有大量请求在排队等盘;如果Cached持续增长,那多半是 mmap 的页缓存把热数据拉回 DRAM,陷入了“伪卸载”;如果 ngram_lookup_ns 的平均值超过 1000 ns,就可以判断表的查询已经多次触达 NVMe。
定位还有一个简单方法:在压测时突然把 NVMe 盘的队列深度打满,比如后台跑一个fio --rw=randread,然后观察服务 TTFT 是否暴涨。如果暴涨,那证明你的 n-gram 表实时被依赖程度远超你的预期,说明它不应该放在盘上。
5.4 给不同平台的一句总结
这次五台机器全测下来,我给每个平台都整理了一句话。
3090 和 4090:显存 24 GB 是硬伤,但你该做的是把表压缩、把表精度降低、把表切成热冷两部分,而不是让它落盘。
5090: 32 GB 显存情况下,只要表控制在 20 GB 以内,L0 和 L1 都行,务必保持 DRAM。
DGX Spark 和 Strix Halo:统一内存是宝贵资源,表放 DRAM 和显存是一回事,别想着再用 NVMe 腾地方,实测数据会教你做人。
最后再分享一个我踩坑之后觉得特别管用的小技巧:给 n-gram 表做一个热冷分层,把所有 32 token 长度以上的记录都保留在 DRAM,把 8 token 以下的短记录放到 NVMe。短记录命中率低,访问频率低,即使放盘也只在很偶尔的情况下拖慢路径。分开后,4090 实测 L1 模式的命中率只下降了 3.5%,但表占用从 24 GB 降到了 14 GB。这才是“把表挪出去”的正确姿势,而不是整表搬走。