ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

n-gram表别扔NVMe!实测吞吐降四成P99翻倍

n-gram表别扔NVMe!实测吞吐降四成P99翻倍 把 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 链条做成候选草稿送给模型一次性验证这就是 DDn-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 309024 GB GDDR6X936 GB/sPCIe 4.0 x16350 WRTX 409024 GB GDDR6X1008 GB/sPCIe 4.0 x16450 WRTX 509032 GB GDDR7约 1792 GB/sPCIe 5.0 x16575 WDGX Spark128 GB 统一内存约 273 GB/sNVIDIA C2C 互联约 100 WStrix Halo128 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 2TBPCIe 4.0 x4理论顺序读 7450 MB/s4K 随机读约 500K IOPS。这已经是消费级里比较能打的盘了。如果你用的是更低端或者更旧的盘刚才看到的所有数字只会更难看。3. 实测数据放入 NVMe 后吞吐降了四成P99 翻倍3.1 最直观的伤害TTFT 和生成吞吐全面下滑下面是 50% 重复率、L1 和 L2 对比的完整结果。注意 L2 我只列 io_uring 引擎的结果mmap 模式因为是同步缺页读取在 128 并发下表现更差很多几乎抬不起头。平台L1 TPSL2 TPS下降幅度L1 TTFTL2 TTFTL1 P99L2 P99RTX 309062.840.136.1%218 ms419 ms1042 ms1988 msRTX 409086.455.336.0%176 ms361 ms886 ms1764 msRTX 5090109.772.633.8%153 ms318 ms762 ms1523 msDGX Spark47.331.932.6%287 ms524 ms1739 ms2981 msStrix Halo41.827.634.0%309 ms561 ms1884 ms3227 ms看到降幅心里基本就有数了。无论卡多新多贵L2 相比 L1 大约降低 33% 到 36% 的吞吐。这不是个别卡的问题而是存储介质访问方式决定的共性结果。即使是我原本以为最能扛住高并发 NVMe 的 5090TTFT 也从 153 ms 膨胀到了 318 msP99 直接翻倍。更关键的是硬件越好这种相对下滑造成的机会成本越高。一张 5090 好不容易跑出来 110 token/s扔 NVMe 之后只剩下 72 token/s等于你花几万块买的算力有一小半浪费在等待 SSD 返回几百字节的数据上。3.2 80% 重复率下会不会翻盘并没有抱着“重复率高了命中多表读取能不能被覆盖”的念头我把重复率拉到 80% 再测一轮。理论上 n-gram 表命中率能到 75% 以上但实际上结果仍然不乐观。以 4090 为例L1 时 TPS 从 86.4 涨到 103.8L2 时从 55.3 涨到 68.9。看起来绝对数值都涨了但两者的相对差距还是 33% 左右。原因是命中 n-gram 表节省的 GPU 算力是实打实的但 n-gram 表每次查询本身也多了一次随机读延迟节省下来的算力还不够补偿等待 SSD 的损失。换句话说n-gram 命中率高的时候NVMe 的闲置率看似会降低实际上磁盘反而更忙了因为每一次命中都要做一次随机读。盘忙着卡等着整个链路就在一种“资源都用上了但就是不快”的诡异状态里。3.3 功耗也骗人省下的电费远不够买体验损失细节里有一个容易误导人的点L2 档位下 GPU 功耗确实降低了。3090 从 302 W 掉到 281 W4090 从 412 W 掉到 389 W。原因很简单GPU 有一大部分时间在空转等数据自然不费电。但这个“省电”毫无意义因为单位请求的能耗反而更高了。计算一下就能明白。3090 在 L1 下每生成 1000 token 耗时约 15.9 秒功耗 302 W对应能耗约 4.8 kJL2 下耗时约 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 --namerandread --ioenginelibaio --iodepth32 \ --rwrandread --bs4k --size8G --numjobs4 --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 cacheDRAM 倒是省下来了但所有热页都要重新从盘上读性能比 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 我压箱底的部署准则表放 DRAMNVMe 只当备份综合这轮测试我最终给线上服务定下的方案是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 --rwrandread然后观察服务 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。这才是“把表挪出去”的正确姿势而不是整表搬走。
返回列表