第一次看到这个需求描述的时候,我愣了几秒:1250亿参数的稀疏MoE模型,跑在一块24GB显存的RTX 3090上,还要到100+ tok/s,旁边配的双路Xeon v4怎么看都像五年前的老古董。正常想法是这需求要么抄错了参数,要么打算靠魔法实现。但调优这行干久了就明白,所谓极限性能,从来不是某一项黑科技的结果,而是把每一个环节的浪费都抠掉的产物。这篇文章就是一次完整记录,从头到尾拆一下Strata推理引擎在这套组合上做到100+ tok/s的全过程,包括架构怎么设计、算子怎么改、调度怎么调、踩了哪些坑。
这篇内容不是写给只看结论的人看的,而是写给准备在类似低显存、旧平台、大模型组合上做推理优化的人。如果你也有一台老服务器、一块消费级显卡,想把手头的大MoE跑起来并且跑得快,下面这些思路和步骤可以直接参考。我会把涉及到的计算逻辑、参数取舍、实测数据和问题排查全部摆出来,保证每一步都有据可循。
1. 先把目标拆开看:这题到底难在哪
1.1 MoE的“账面参数”和“实际算力”是两码事
很多人一听到1250亿参数,第一反应是这模型至少要几百GB显存,一块3090连零头都装不下。但MoE(Mixture of Experts,混合专家)模型的特殊之处在于,参数虽然多,每个token实际激活的只是其中一小部分专家。比如典型的MoE结构里,一个Transformer层有多个专家FFN,输入token经过路由网络后只挑选top-2或者top-1个专家来计算。也就是说,1250亿参数是“仓库里存货”,每次实际搬出来用的可能只有几十亿参数。
这意味着计算量并非1250亿参数对应的大模型,而是接近一个几十B参数的稠密模型。RTX 3090的FP16算力大概在35 TFLOPS级别,处理这种激活规模是有余力的。真正的难题从来不在于算力不够,而在于权重放不下。24GB显存面对62.5GB的4bit量化权重(1250亿x0.5字节),依然差着一大截。所以某个层面上,这个问题从一开始就不是“怎么算得快”,而是“怎么把数据搬运藏起来”。
1.2 平台约束:24GB显存和PCIe 3.0才是真正的天花板
这台机器的情况很典型:双路Xeon v4平台,DDR4内存,系统内存带宽大概在76GB/s左右;CPU和显卡之间的通道是PCIe 3.0 x16,单向带宽大约16GB/s;显卡本身是RTX 3090,显存带宽936GB/s。这三层带宽放在一起,差距有两个数量级。
如果权重全部放在系统内存里,每次计算前要把当前层的专家权重从内存搬到显存,那么搬运路径就横跨内存总线、CPU内部互联、PCIe总线,最终进入显存。PCIe 3.0 x16只有16GB/s的实测可用带宽,这比DDR4的76GB/s还要低一大截。所以只要权重搬运路径设计得不合理,性能上限立刻被钉死在个位数tok/s,后面所有算子优化都白搭。
这里就引出一个核心结论:全链路调优的瓶颈不在GPU算力,而在CPU和GPU之间的搬运能力。所有方案的设计都要围绕“减少无效搬运、隐藏必要搬运”来展开。
1.3 100 tok/s的物理可行性估算
先做一个粗算。假设推理引擎采用decode方式,每个step处理batch个序列。当batch=8时,每生成一次token,相当于同时为8个序列各生成一个token,整体吞吐是8 tokens per step。如果能做到每step耗时100ms,那么吞吐就是80 tok/s;做到80ms,则到100 tok/s。
100ms听起来很宽裕,但如果每step都要通过PCIe搬运500MB权重,光是搬运就要31ms,再加上内存读取和下一层预取,时间其实非常紧张。所以数学上要过关,必须让每次搬运的权重尽可能小、尽可能重复利用,并且被计算过程完全遮盖。这就是后面提到的双层策略:权重常驻缓存 + CPU预取流水线。
2. 全链路架构:权重怎么在显存和内存之间流动
2.1 三层驻留策略:常住层、热专家池、冷专家区
24GB显存一分钱都不能乱花。在设计Strata的显存布局时,我把模型权重拆成了三个层次:第一层是“常住层”,包括embedding、attention层、shared expert(MoE中常见的共享专家)以及层归一化等,这部分权重在推理过程中几乎每一层都要用,必须一直留在显存里;第二层是“热专家池”,用来缓存一批当前batch高频激活的专家权重;第三层是“冷专家区”,留在系统内存里,按需加载。
显存分配大致是:常住权重约8GB,热专家池10GB,KV cache约4GB,激活和workspace约2GB。实际划分时还要根据模型具体结构做调整,但总体方向是让热专家池尽量大,因为热专家命中率直接决定了需要通过PCIe换入的权重总量。
提示:如果模型每个token固定激活2个专家,一批请求同时跑的时候,不同请求路由到的专家可能各不相同。热专家池太小会导致频繁换出换入,性能断崖式下跌。这个池子的大小不是拍脑袋给定的,要先跑一小段数据,统计路由分布再做决定。
2.2 CPU预取流水线:用双路v4把搬运藏起来
如果每个step都是“GPU先用完当前层,然后等CPU搬下一层权重”,那GPU利用率一定惨不忍睹。Strata的做法是引入预取流水线:GPU计算第L层的时候,CPU同时根据路由结果,把第L+1层可能用到的冷专家权重提前从系统内存搬到显存缓冲。
这里用到了双缓冲机制。显存中预留两块交替使用的搬运区,一块放正在计算的权重,另一块接收下一层预取数据。当GPU切换层时,交换两个buffer的身份,不需要等待搬运完成。实测下来,预取深度选2效果最好,太深会占用显存,太浅盖不住搬运延迟。
双路Xeon v4在这个场景里有优势:CPU线程多,可以单独拿几个物理核跑预取任务,同时主线程继续处理调度和路由计算。用numactl把预取线程绑定在直连该PCIe槽位的CPU上,能减少跨Socket延迟。下面的启动参数是一个比较合理的初始配置:
numactl --physcpubind=0-7,36-43 --membind=0 ./strata_server \ --model weights/ \ --weight-format int4 \ --expert-pool-size 10G \ --prefetch-depth 2 \ --max-batch 16这里特地指定了membind=0,让整个进程的内存优先从第一个NUMA节点分配,避免权重张量跨节点分布导致QPI带宽被大量占用。权重在加载时也应该做NUMA感知的平坦布局,尽量平均分配到两个节点的内存条上,但进程实际访问时只从本地节点读取,减少跨节点流量。
2.3 NUMA亲和与内存布局的细节
踩过几次坑之后,我对NUMA布局的理解比之前深了很多。双路Xeon v4是两个物理CPU,每个CPU直连一部分内存条和PCIe插槽。如果GPU插在CPU0的PCIe总线上,而权重数据恰好分配在CPU1的内存上,那么每次搬运都要走QPI链路,带宽会再打一个折扣。
解决办法是在加载权重时记录每个张量的内存分配位置,并把经常一起访问的张量尽量放在同一个NUMA节点。Strata的权重加载器会先按专家分组,再按组分配内存,避免张量碎片化后跨节点分布。实际效果是,这套调整让整体吞吐提升了15%左右,属于“不花钱但很难发现”的优化。
3. 引擎侧的算子与显存优化
3.1 4bit量化必须按专家独立做
MoE模型的量化不能对整个模型统一做scale和zero-point。不同专家内部的权重分布差异很大,尤其是一些偏冷门的专家可能包含较大的离群值。如果全模型共享量化参数,离群值会把其他专家的有效表示范围压得很低,导致量化误差变大。
Strata的做法是per-expert量化:每个专家独立计算scale和zero-point,权重存储时按int4打包,加载后先反量化成fp16再送进Tensor Core计算。这里没有选择更激进的int8向量量化,因为专家FFN的矩阵乘对精度比较敏感,4bit配合per-expert scale已经能在保持困惑度基本不降的前提下,把权重体积压到最小。
有一个细节是,反量化操作不要单独开kernel。正确的做法是把反量化融合在搬运流程里,也就是权重从系统内存搬到显存的过程中,PCIe传输的是int4压缩数据,到达显存后直接由搬运kernel完成解压和反量化,不额外占用一遍显存带宽。这个看似不起眼的融合,让每层权重加载的有效带宽利用率高了将近一倍。
3.2 专家FFN合并成大矩阵:别让小kernel拖死GPU
MoE层最贵的计算是专家FFN,本质上是两层MLP。如果每个专家单独跑一个小GEMM,kernel启动开销和Tensor Core利用率不足的问题会非常突出,尤其当专家规模不大时,GPU算力可能连30%都用不到。
解决方法是做group GEMM:把当前step路由到同一个专家的所有token拼成一个连续的batch,再把多个被激活的专家权重排在内存连续区域,一次性完成大批量矩阵乘。这样既保证了Tensor Core的大矩阵要求,又减少了kernel启动次数。实测这一步对性能的影响比想象中大,单独就能带来20%以上的吞吐提升。
3.3 KV Cache压缩:别让attention吃掉所有带宽
decode阶段attention计算需要反复读取历史token的KV cache。如果KV cache全部用fp16存储,每次step读取量会随上下文长度线性增长,很快就超过权重搬运成为新的瓶颈。
Strata对KV cache做了8bit量化,并且采用按块scale的方式:每32个token用一个scale因子,降低量化误差。加上模型本身使用GQA结构,KV head数量比Q head少很多,存储量进一步下降。实际效果是KV cache占用缩减到原来的1/4左右,attention读取带宽压力大幅缓解,长上下文的吞吐衰减也慢了很多。
3.4 算子融合与CUDA Graph
推理引擎里最常见的浪费是“算得很快但搬来搬去”。比如RMSNorm算完写一遍显存,QKV投影再读一遍,RoPE再写一遍,一轮下来中间张量来回好几趟。Strata的做法是把同一层内的计算全部融合成一个或多个大kernel,减少中间张量的显存读写。attention部分参考了社区常见的flash attention思路,自研了融合kernel,把QKV投影、RoPE、softmax和输出投影放在一起执行。
CUDA Graph是另一个被低估的选项。启动kernel本身有CPU开销,当batch小、kernel数量多时,这个开销会占到一整个step时间的10%以上。用CUDA Graph把一整个decode step的所有kernel捕获成一个graph后反复回放,启动开销几乎为零。这一步单独让吞吐提升了8%左右。
注意:CUDA Graph对环境要求比较严格,动态shape、动态内存分配都会导致捕获失败。Strata的解决方式是固定batch size,用padding补齐空位,并且把所有内存池在捕获前预分配好。这是对系统侵入性较强的一项改动,但回报也确实可观。
4. 调度层:让请求排队,让搬运有节奏
4.1 连续批处理:别等一批全部结束才收工
最朴素的批处理方式是攒够一批请求,等这批全部生成完毕再换下一批。在这个项目里这种方式完全行不通:一批请求长度不一,长的要跑几十个token,短的几个token就完了,等全部完成会浪费大量GPU空闲时间。
Strata采用连续批处理,每完成一个序列的输出,立刻从等待队列里拉一个新序列补位。这样在decode流程里,batch大小始终保持满载,GPU永远不会因为等某个慢序列而闲置。这属于调度层的基础设施,但为后面的专家分组和热点命中打下了基础。
4.2 按路由结果重新排列token:专家权重要一次喂饱
连续批处理带来一个新的问题:同一批次的token可能路由到完全不同的专家。如果直接按序列顺序逐个专家计算,要么需要反复加载不同专家权重,要么大量Tensor Core闲置。Strata的解法是每次decode step先运行路由网络,拿到所有token在每一层选择的专家编号,然后按专家编号对所有token做分组重排。
重排之后,相同专家下的token在内存里是连续的,可以一次性喂给一个大的group GEMM。这个操作的代价是每个step多一次token维度上的数据搬移,但换来的是权重加载次数大幅减少。尤其当batch里多个序列恰好路由到相同专家时,这批权重只需要从内存加载一次,却为多个token同时服务。这就是“搬运摊薄”的核心机制。
4.3 热点专家调度:把路由局部性变成优势
不同请求虽然理论上可能命中任意专家,但真实流量中专家命中率并不均匀。Strata在调度器里加了一个热点专家统计模块,持续跟踪每个专家的被路由次数。新请求进入队列时,如果当前热专家池命中率已经比较低,就稍微等待一下,优先凑出更多命中热专家的组合再送进GPU。这表面上增加了排队延迟,实际上因为整批吞吐大幅提升,单个请求的整体等待时间反而可能更短。
预填充阶段也不能被忽略。长提示词的prefill计算量很大,如果长时间独占GPU,decode阶段的稳定流量就会出现毛刺。我采用chunked prefill,把长提示切成小块,与decode token混在同一个step里执行。这让GPU资源始终被填满,同时避免prefill任务挤占所有计算资源导致线上解码速度明显抖动。
5. 实测:从10 tok/s到108 tok/s的调优过程
5.1 基线表现:朴素卸载方式只能跑出10 tok/s
一开始我没做任何架构层面的优化,只把权重按4bit量化后放到内存,然后GPU每一层计算前直接同步拷贝该层需要的专家权重。结果很感人,峰值只有10 tok/s,GPU利用率在大部分时间里只有15%左右,剩下的时间全部在等PCIe搬运。这个数据基本验证了前面的分析:不解决搬运问题,其他优化无从谈起。
5.2 分段调整的效果记录
下面这张表记录了我每次关键改动后的实测峰值变化。测试用的负载是固定种子下1000条长度相近的生成请求,统一统计稳定阶段的吞吐,避免首token延迟干扰数据。
| 调整阶段 | 关键改动 | 实测峰值 tok/s | 距离目标 |
|---|---|---|---|
| 基线 | 同步加载、朴素批处理 | 10 | 还差一个数量级 |
| 阶段一 | 连续批处理 + group GEMM | 28 | 翻倍但远不够 |
| 阶段二 | 热专家池 + 冷热分层 | 51 | 开始有奔头 |
| 阶段三 | CPU预取流水线 + NUMA绑核 | 74 | 逼近但不够 |
| 阶段四 | KV cache量化 + 算子融合 | 91 | 还差临门一脚 |
| 阶段五 | CUDA Graph + 调度参数调整 | 108 | 达成目标 |
每个阶段之间并不是简单叠加。比如阶段三如果没有阶段二的热专家池,预取流水线会因为大量冷专家换入而频繁失效,性能提升非常有限。这就是全链路调优的典型特征:越往后越依赖前面打好的基础。
5.3 最终配置和压测输出参考
最终稳定运行的配置大概是这样的:max-batch设成16,prefill chunk大小为2048 token,热专家池10GB,预取深度2,KV cache用8bit。下面是一次典型压测中我从Strata引擎日志里摘出来的关键输出,为了方便阅读做了简化:
[Server] model loaded, weight format: int4 [Server] expert pool size: 10.0 GB, prefetch depth: 2 [Server] max batch: 16, chunked prefill: 2048 [Stats ] step=100 throughput=107.80 tok/s [Stats ] step=200 throughput=108.12 tok/s [Stats ] step=500 throughput=108.03 tok/s [Stats ] step=1000 throughput=106.94 tok/s [Stats ] total generated tokens: 100732 [Stats ] average first-token latency: 621 ms [Stats ] p50 decode latency per step: 141 ms首token延迟600多毫秒,p50单步延迟141ms,对应batch=16时每step产出16个token。这个延迟不算低,但作为吞吐优先的离线批处理场景是可以接受的。如果换成对首token延迟敏感的场景,可以把chunk调小、batch调低,但100+ tok/s的整体吞吐会相应下降。
6. 踩坑记录与排查经验
6.1 常见问题速查表
整理一些我实际遇到过、并且大概率别人也会踩的坑。
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
| GPU利用率周期性掉到0 | 专家权重同步加载,搬运阻塞计算 | 改成双缓冲加CPU预取流水线 |
| 4bit量化后输出质量明显下降 | 全模型共享scale,离群值污染 | 改为per-expert独立量化,敏感层保留8bit |
| CUDA Graph捕获时报非法内存访问 | 动态shape导致kernel不固定 | 固定batch,pad到统一长度,捕获前预分配内存池 |
| 跨Socket后吞吐下降明显 | NUMA节点内存分配错位 | 用numactl锁定CPU和内存节点,权重按NUMA感知分组加载 |
| 长prompt时首token延迟忽高忽低 | prefill和decode互相抢占 | 引入chunked prefill混批 |
| 热专家池命中率一直低于50% | 调度器未感知路由分布 | 新请求优先匹配热专家组合,必要时短等待凑批 |
| int4权重反量化太慢 | 单独开kernel走一遍显存 | 融合进搬运压缩流程,不产生额外显存读写 |
6.2 几条值得写进文档的教训
第一,先算搬运预算,再谈算子优化。不少人一上来就把精力花在实现各种fancy kernel上,结果瓶颈在PCIe,算子优化得再漂亮整体收益也有限。正确顺序是:先量化权重、再设计缓存分层、然后调度策略,最后才轮到算子层和CUDA Graph,这个顺序不能乱。
第二,热专家池的大小需要根据真实负载调整。负载不同,路由分布完全不同。我在测试时见过某些数据集下前30个专家承担了95%的流量,也见过更分散的情况。如果发现命中率上不去,不要直接加池子大小,先检查数据集本身的路由偏斜程度。池子加得太大反而会挤占KV cache的空间。
第三,不要迷信理论带宽。PCIe 3.0 x16标称16GB/s,实际可用带宽受包大小、DMA粒度、NUMA影响很大,实测能到12GB/s已经算不错。同理,DDR4内存标称76GB/s,跨节点访问立刻腰斩。所以方案设计时预留的余量一定要足够,否则实测往往会让你措手不及。
最后的体会
这次调优做下来,我最深的感受是:性能优化本质上是在做“搬运工作”的规划,算力往往只是最后的执行环节。Strata推理引擎在RTX 3090和双路Xeon v4上跑出100+ tok/s,靠的不是某个惊艳的kernel,而是一整套让权重在显存、内存、PCIe之间有序流动的机制。如果你也在做类似的大模型推理优化,尤其是老平台、低显存、大模型这种“困难组合”,建议先画一条数据流动路径,标出每一层带宽的上限,再决定该在哪里下手。方向对了,剩下的工作就是一步一步填坑而已。