ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

千亿MoE模型在RTX 3090上突破100 tok/s:全链路推理优化实战

千亿MoE模型在RTX 3090上突破100 tok/s:全链路推理优化实战

先说明一下,这篇不是什么云上A100“钞能力”方案。前几天我把一个内部使用的1250亿参数MoE底座,跑在了一台双路Xeon v4老服务器加一块RTX 3090的组合上,目标是单机实时推理100+ tok/s。用Strata推理引擎做了完整全链路调优之后,最终稳定输出在110 tok/s附近,峰值冲到过127。这篇就把整个调优过程、算账逻辑、踩坑记录都摊开讲清楚,适合手头只有消费级GPU或者旧服务器、又想把千亿级稀疏模型推上线的团队参考。

先讲透一个前提:MoE模型对显存苛刻,但没那么绝望。总参数量1250亿听着吓人,实际每个token只激活少数专家,真正在解码时跑的计算量只有一小部分。问题在于“容量”和“带宽”:权重文件太大,放不进24GB显存;搬运太慢,喂不满GPU。Strata要做的就是把“放不下”变成“分层放”,把“喂不满”变成“按需预取”,最后把每一步都重叠起来。这就是本文要讲的核心。

1. 项目拆解:千亿MoE为什么敢上24G显存老平台

1.1 硬件底牌与模型规格

先看清楚这台机器是什么货色。双路Xeon v4,约40个物理核心,平台内存通道8根DDR4,总容量256GB。GPU是一块RTX 3090,24GB显存,走PCIe 3.0 x16。严格说这不是为推理优化的平台,PCIe 3.0的单向带宽理论也就16GB/s,实际能跑到12GB/s左右就算不错。和当代PCIe 4.0/5.0平台差了一大截。

但便宜。旧机器、一块二手3090,预算不到一台A100的零头。很多中小团队就是这种处境:想跑大模型,又不想在硬件上砸钱。我选择挑战这个组合,也想验证一个判断——MoE架构天然适合“小显存大模型”场景,关键看工程上怎么把数据流趟顺。

模型侧是不公开的一个内部MoE底座,总参数1250亿。按我们的部署切分口径:共享attention与共享FFN部分约30B参数,剩下约1195B是专家参数,拆成数百个专家,每个专家参数在2B到4B之间,路由采用top-k形式,每个token实际激活3到5个专家。这些数字对后面的算账很重要,我后面所有优化决策都建立在“每步只取少量专家权重”这个前提上。

1.2 显存、算力、带宽的三本账

决定方案前,我把三本硬约束的账先算清楚。

第一本账是显存容量账。模型全量FP16权重约250GB,8bit量化约125GB,4bit量化约65GB。RTX 3090的24GB显存连最小号的4bit版本也装不下。所以结论很直接:必须offload,也就是权重不能全部驻留显存。

第二本是算力账。RTX 3090的FP16稠密算力在35 TFLOPS附近,打开稀疏特性后官方理论值能到70 TFLOPS左右。MoE模型每次前向虽然激活了多个专家,但总计算量并没有按总参数走,按我们模型的激活口径估算,每生成一个token大概需要50-60 GFLOPs的前向计算量。100 tok/s意味着5 TFLOPS左右的有效负载,这个数字3090完全可以承受。换句话说,算力不是瓶颈。

第三本是带宽账,也是最关键的一本。假设4bit权重全部放在系统内存里,每个专家2.3B参数折算成INT4大概1.15GB;每token激活4个专家,理想情况下一次加载4.6GB。要达到100 tok/s,单单权重搬运就需要460GB/s的瞬时带宽,双路Xeon v4的DDR4总带宽也就150GB/s上下,何况还有PCIe 3.0的12GB/s卡在中间。根本没有可能走“每token加载全部激活专家”的路子。

所以真正的设计目标是:尽量让活跃专家“长在”显存里,让低频专家从内存/SSD搬运,同时用预取和批量复用把剩余搬运量藏到计算时间背后。算清楚这三本账,后续每一步优化就都有了明确靶子。

2. Strata 全链路架构:三层存储和异步预取流水线

2.1 按路由热度做冷热分层

MoE很有意思的一点,是权重访问呈现明显的局部性。我统计了路由日志:大约20%的专家承担了80%以上的被激活次数,剩下80%的专家偶尔才被选中一次。这个长尾分布决定了不能对所有权重一视同仁,必须做冷热分层。

Strata最终把权重分到三个层级:第一层是显存,放共享层、共享FFN,以及热度前20%的专家,这部分约占全量权重的35%,折算成4bit大约22GB,可以把显存塞得很满;第二层是系统内存,放中频专家,这些专家占剩余权重的大部分,约40GB-50GB;第三层是NVMe SSD,专门放置热度极低的冷专家,必要时才拉进内存。

表格里看起来很清楚:

层级介质存放内容访问方式
第一层24GB显存共享层+共享FFN+热专家(INT4)常驻,直接参与计算
第二层256GB内存中频专家(INT4)经PCIe预取到显存
第三层NVMe SSD冷专家按需载入内存再预取

别小看这个分层。很多直接照搬“整模型offload到内存”的方案,看起来显存够用,实际会因为带宽瓶颈慢到没法用。我们通过热点分层把每步真正走PCIe的数据量从“数GB”降到“几百MB”,才让后续预取流水线有了发挥空间。

2.2 双缓冲预取:把传输时间藏进计算里

权重分好层之后,下一个问题就是怎么让搬运不要阻塞GPU计算。Strata的核心机制是双缓冲异步预取,原理和工厂里的备料逻辑一样:机器在加工当前这批工件时,工人已经把下一批要用的料备到机器旁边了。

解码循环被设计成三段式伪代码逻辑:

while has_tokens: next_experts = route(next_tokens) # CPU/GPU协同预测下一步路由 prefetch(next_experts) # 异步把下步专家权重搬到显存缓冲区 run_step(current_tokens, current_experts) # GPU计算当前步 swap_buffers() # 双缓冲交替

关键细节是双缓冲而不是单缓冲。Strata在显存里维护两块固定大小的专家暂存区:当前计算步骤用A区,后台预取线程把下一步要用到的权重往B区写;等当前步算完,交换角色,B区变成计算区,A区变成预取目标。这样传输和计算真正重叠。如果只有一个缓冲区,每一步都必须等权重传完才开始算,速度直接被PCIe带宽锁死。

还有一点容易被忽略:预取线程不能等路由结果出来才开始搬。路由本身也有开销。Strata的做法是对下一步token的路由结果做预测,用上一步的专家访问序列与当前batch的hidden state做近似预估,在误差可控的前提下提前把很可能用到的专家搬进缓冲区。这一步对最终速度提升贡献很大,后面实测数据会体现出来。

3. 量化与算子侧的关键调整

3.1 attention 用 8bit,专家用 4bit

权重部署方式确定了,接下来就是到底怎么压位宽。Strata没有对全模型一刀切量化,而是按层类型区别对待:共享attention和共享FFN用8bit,专家FFN用4bit。

原因是解码质量敏感性不同。attention部分直接决定token之间的注意力分布,误差会被自回归过程逐token放大,压到4bit后在评估集上损失比较明显。而专家FFN本质上是高维空间里的局部变换,对精度宽容度更高,4bit引入的扰动在相同校准方案下几乎不影响最终困惑度。实测对比下来,专家4bit比全模型8bit节省近一半容量,质量损失却只多了一点点。

量化细节上,Strata用的是类似GPTQ的逐层校准思路,但针对MoE结构作了调整。专家权重独立校准,组大小设成128,并非所有层都用同一套缩放参数。校准集用真实请求的采样数据,混合同一批次里不同路由结果的token,避免只对热门专家的分布过拟合。这个细节让我踩了一次坑,一开始用普通文本做校准,冷专家量化误差明显偏大,换上真实流量之后才稳定。

另一个容易忽略的量是反量化开销。4bit权重不能直接喂给GEMM,需要先解包成INT8或FP16。这个操作本身在CPU端做还是GPU端做,对整体性能影响极大。我最初在GPU kernel里就地反量化,每个token反复解包,浪费了很多算力。Strata最后选择在CPU预取线程里完成反量化,把专家权重从4bit解包成FP16后放入pinned memory,再整体拷给GPU。代价是CPU端多了一部分计算,好处是GPU端GEMM纯跑FP16,没有反量化余量。

3.2 grouped GEMM 和 CUDA Graph 的取舍

回到计算侧。MoE推理和稠密模型的最大差别在于,每个token激活的专家不一样,导致同一个batch内部token的专家分布是零散的。最笨的写法是把每个专家的计算单独起一个GEMM,专家数量一多,kernel launch开销直接把计算时间淹没。实测一个小矩阵GEMM launch一次要几十微秒,一个batch里几十个专家,光launch时间就上毫秒级,完全不可接受。

grouped GEMM是标准解法:把当前batch里路由到同一个专家的token聚合在一起,形成若干个小矩阵组,用一个kernel调用完成多组GEMM的运算。Strata在CUDA Graph框架里把grouped GEMM包成了固定节点,大大减少了launch次数。

CUDA Graph的取舍也要讲清楚。Graph把一系列kernel捕获为一张图,执行时不再逐个launch,启动开销降到极低。但代价是图结构不能频繁变化,输入的tensor shape、kernel数量、显存指针都必须固定。strata的做法是固定一个最大batch容量,比如16条序列,每条序列预留固定长度token缓存,当实际batch小于容量时用empty token填充。这种方式浪费一点算力,但换来了稳定的执行图,实测中解码延迟抖动明显下降。

需要提醒的是,CUDA Graph对动态显存分配不友好。如果你在Graph内部频繁调用cudaMalloc,捕获阶段就会因为指针变化失败。所以在建图前必须把所有缓冲区一次性分配好,之后整个生命周期内固定复用显存池,这也是后面踩坑环节会再讲的一个点。

4. 调优路径实测:从 9 到 100+ tok/s

4.1 朴素实现和连续批处理

第一部分实测从最朴素的方案开始。不搞任何花活:单请求、每步根据路由结果加载所需专家权重、算完即丢。结果是平均7-9 tok/s,离目标差了十倍。

这个阶段的瓶颈很明显:每生成一个token,都等着PCIe把几个GB的权重慢慢搬进显存。3090算得再快,也只能干等着。解决方案是连续批处理,也就是同时处理多条序列,让不同序列在不同解码进度上交错执行。batch内多个token往往会路由到相同的热门专家,专家权重只需要从显存缓存里取一次,服务多个请求。

从batch=1切成batch=8之后,速度立刻跳到25-30 tok/s。原理也很简单,虽然每个序列每一步路由到的专家集合不同,但热门专家被反复命中的概率很高。同一个专家权重加载一次后,可以被同一步内所有需要它的token共享。batch越大,权重复用率越高,单位token的搬运成本就越低。这一步让我确认了一个判断:3090配MoE模型要跑得快,核心就是复用和并发,不是单纯堆算力。

4.2 预取、热缓存和量化生效之后

连续批处理把速度拉到25-30,但离100还很远。接下来把第一节的冷热分层和第二节的异步预取真正跑起来。

先是显存Buffer和双缓冲预取上线。预取线程在GPU算当前步时提前拉取下一步的专家权重,速度从30爬到了45-55 tok/s。增长主要来自PCIe传输和GPU计算的重叠,GPU等待搬运的时间大幅缩短。

然后热专家缓存上线。显存里常驻前20%热专家,使大量路由命中不需要走PCIe,速度进一步到70-80 tok/s。这个阶段我重点盯了路由统计,发现连续批处理下热门专家命中率超过70%,也就是说大部分权重访问在显存内就解决了,PCIe只需要处理那些冷门专家的偶发访问。

量化收益也在这个阶段体现。全模型4bit/8bit混合方案让每步搬运数据量比FP16削减了约60%,传输时间明显减少。到这里,100 tok/s已经不是一个幻觉了。

4.3 最后的算子层冲刺

从75到100+,最后一段靠的是算子层优化。这一步最磨人,但效果也最直接。

CUDA Graph上线后,kernel launch开销基本归零,配合grouped GEMM把专家计算的矩阵调度理顺,平均延迟又降了将近20%。再加上把CPU预取线程的向量化反量化逻辑优化好,整体稳定到了100+,峰值跑到过127。我记录了一组典型实测数据:

阶段关键改动平均解码速度
朴素实现单请求,无预取7-9 tok/s
连续批处理batch=8,专家复用25-30 tok/s
异步预取双缓冲,CPU预加载45-55 tok/s
热专家缓存显存常驻热专家70-80 tok/s
算子层优化CUDA Graph + grouped GEMM100+ tok/s

这里补一句实话:为什么最终稳定在110而不是更高?瓶颈其实已经不在显存和PCIe,而在CPU预取线程的反量化吞吐。想把速度再推上去,要么把更多专家驻留显存,要么换更新的CPU平台扩大内存带宽,单靠3090本身还有余量,但平台拖了后腿。

5. 踩坑实录:几乎翻车的几个细节

5.1 反量化卡在了CPU

第一个坑就是反量化开销被我严重低估。预取线程在CPU端把4bit专家权重反解成FP16,一开始用普通循环写,一个中等规模的专家反量化就要几十毫秒。多线程批量任务积累下来,CPU直接把PCIe预取饿死,GPU等权重,速度不升反降。

解法分两层。第一层是把反量化循环向量化,用AVX2指令集一次处理多个int4值,吞吐提了几倍。第二层是反量化提前做,利用连续批处理中同一专家权重被反复命中的特点,把反量化结果缓存在内存里,下一次命中不需要重新解包。这种缓存对热门专家收益极高。

5.2 显存碎片化和固定槽位

第二个坑出在显存管理上。热专家缓存如果按需求动态分配释放,跑一段时间后显存碎片化严重。cudaMalloc虽然通常能兜底,但在高并发下经常出现“明明还有几GB剩余,分配却失败”的怪现象。更麻烦的是CUDA Graph要求指针稳定,动态分配根本没法入图。

解决方法是显存固定槽位池:在引擎初始化时把整个显存池一次性分配好,热专家和双缓冲buffer分布在固定地址区间。专家缓存采用类似页表的方式管理,按专家ID映射到固定槽位,不频繁malloc/free。这个改动上线后,显存分配错误和延迟毛刺基本消失。

5.3 NUMA绑定差点把带宽砍半

第三个坑和平台相关。双路Xeon v4是典型的NUMA架构,3090插在CPU0的PCIe槽位上,但如果预取线程在CPU1的内存节点上分配pinned memory,那么权重数据从内存到GPU要跨CPU间互联,延迟和带宽都惨不忍睹。实测H2D拷贝带宽能从接近12GB/s掉到6GB/s甚至更低。

排查的时候一度怀疑是PCIe链路限速,后来用numactl工具查看内存分配位置才定位到问题。最后的做法是绑定预取线程、显存缓冲池和pinned memory到同一个NUMA节点,确保数据路径不跨节点。这个改动在老旧服务器上效果特别明显,性能恢复后再测,带宽回到了正常值。

5.4 cudaMemcpyAsync 为什么不生效

第四个坑是异步拷贝“假异步”。代码里写的是cudaMemcpyAsync,数据却不是从独立Stream走的,拷贝仍然排在当前计算Stream之后,看起来代码没问题,实际根本没有任何重叠。这就是典型的“API写对了,逻辑没对”。

排查方法是在预取线程中记录每次copy的起止时间,发现传输开始时间和当前计算kernel结束时间完全对齐,就说明被串行化了。修复方式是把预取相关的cudaMemcpyAsync放到独立的copy Stream,并通过cudaEvent与计算Stream同步。这样做之后,传输才真正和计算重叠起来。

5.5 长时间运行的稳定性问题

最后一个坑不在性能,而在稳定性。长时间推理跑下来,路由分布会随着用户请求倾向漂移。一开始统计的热门专家前20%,跑几小时之后可能只剩15%命中率。如果引擎不动态更新热专家名单,速度会逐渐掉下来。

Strata最后做了在线路由统计:每隔一段时间从路由日志中重新计算专家热度,并动态调整显存驻留名单。这个机制让长时间运行的速度曲线保持平稳。此外KV cache在长连接下也会涨,需要定期做老请求的驱逐,否则显存会缓慢泄漏。

6. 适用边界与我的个人体会

6.1 这套方法能平移到什么场景

Strata这套调优方案并不是万金油,它有明确的适用边界。最适合的场景是MoE架构、并且专家权重访问有明显长短尾分布的模型。只要符合这个特征,即使参数规模更大,也可以套用“冷热分层+异步预取+批量复用”的思路。

如果换成稠密模型,效果会大打折扣。稠密模型的每个参数对每个token都会被访问,不存在“只加载部分专家”的稀疏红利,显存放不下就是放不下,预取再多也是搬运全量权重,带宽根本顶不住。所以不要指望这套方法能救所有大模型。

对硬件平台也有一些偏好。PCIe带宽越高越好,但实践中只要PCIe 3.0时代之后的中端平台,配合优化的预取机制,都能获得不错的收益。反过来,如果是极端老的内存平台,内存带宽本身过低,冷热分层能缓解但不会根治。

6.2 我对全链路调优的一点体会

我做这个项目最大的感受是:推理优化是一个“系统性工程”而非单个技巧的堆叠。很多人上来就调batch、换量化、开Graph,结果每一项都做了,速度还是上不去。原因就是木桶短板无处不在:CPU反量化没优化,搬运就会卡住;NUMA没绑,带宽掉一半;显存动态分配,Graph直接崩。Strata的最终成型的流程,不是某一个改动带来的,而是把显存容量、系统带宽、CPU吞吐、kernel效率压在一起整体博弈出来的。

如果你也要在有限的硬件上跑大模型,我的建议是先坐下来把容量账和带宽账算清楚,找到整个链路最短的那块板,而不是急着上CUDA Graph之类的进阶特性。先把连续批处理和显存分层落地,再逐步深入算子优化,这个顺序比盲目跟风稳妥得多。希望这篇调优实录能帮你少走几段弯路,也欢迎在评论区交流你们在类似硬件上跑模型过程中的具体问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进