先说个结论:这套组合跑起来之后,我自己都有点意外。Strata 推理引擎是我从去年年底开始折腾的个人推理项目,目标很直接——让一个 1250 亿参数的 MoE 模型跑在真正消费级的硬件上,而且不是那种“烧高香才能出一句”的演示级别,而是能稳定跑到 100+ tok/s 的生成速度。机器配置看起来有点寒酸:一张 RTX 3090,24GB 显存;两颗 Xeon v4,老掉牙的 Broadwell 架构,DDR4 内存,PCIe 3.0 平台。很多人一开始觉得这个目标不可能,因为 125B 模型哪怕量化到 4bit,权重也要 60 多 GB,显存根本装不下。但 MoE 模型有个特性:总参数大,每次 token 真正激活的参数却小。只要把“总盘子”和“每个 token 的盘子”分开算,这个问题就从显存问题变成了带宽问题。这篇文章就把整个调优过程、每一步的取舍依据,以及复现时容易踩的坑,一次性写清楚。
1. 先别急着动手:想清楚 1250 亿参数到底“卡”在哪
1.1 MoE 模型的账要分开算
最常见的一个误区,是拿 125B 总参数直接去套显存容量。FP16 权重约 250GB,INT4 权重也要 62GB,24GB 的 3090 看起来完全没戏。但 MoE(Mixture of Experts)模型不是这么算的。它的设计思路是:每一层准备一大批“专家”FFN,每次来一个 token,先用 router 网络算出该去哪些专家,只让其中 Top-2 个专家真正参与计算,其余专家全部闲着。我手上这个 checkpoint 还不是每层都做 MoE,前 2/3 是普通 dense attention,后 1/3 替换成带 64 个专家的 MoE 层,Top-2 路由。这样总参数虽然是 125B,但每个 token 前向时真正需要读取和计算的“激活参数”,大概只有 6-8B,其中 attention 和 embedding 占一半,被选中的专家权重占另一半。
这个性质直接决定了优化方向:既然每个 token 只需要一小部分专家,那就没必要把 125B 全部塞进显存,只要保证“每个 token 要用到的局部参数”能快速拿到就行。更准确地说,推理的 decode 阶段是典型的内存带宽瓶颈任务。每生成一个 token,计算量并不大,但必须把该 token 所需的权重从某处搬到计算单元。所以这个项目的本质,不是“怎么省显存”,而是“怎么让每 token 的搬运量最小、搬运路径最快”。想清楚这一点,后面很多决定都顺理成章了。
1.2 给这台老设备算算带宽天花板
做系统优化前,我习惯先把硬件的理论带宽列出来,心里有个数。RTX 3090 的显存带宽约 936GB/s,这是 GPU 内部访问 HBM 的速度;CPU 侧是双路 Xeon v4,每颗 CPU 四通道 DDR4-2400,双路加起来理论内存带宽大概 150GB/s 左右,实际跑到 90-110GB/s 就算正常。CPU 和 GPU 之间只有 PCIe 通道,这里有个特别容易忽略的坑:Xeon v4 平台只支持 PCIe 3.0,3090 虽然本身支持 PCIe 4.0 x16,但插在这块主板上只能降级跑 3.0 x16,单方向数据传输实际带宽撑死 13-14GB/s。算带宽的时候很多人默认 PCIe 4.0 的 32GB/s,在 v4 平台上直接打对折。
把这个数字代进去看目标:100 tok/s 意味着平均每个 token 只有 10ms 的时间预算。如果每个 token 要从 CPU 内存搬 2GB 的权重过来,PCIe 需要 200GB/s 的速率,显然不现实。换个角度,100 tok/s 下每 10ms 一个 token,PCIe 每 10ms 能传输约 140MB;也就是说每 token 从 CPU 侧新加载的权重必须控制在 150MB 以内。而一个专家的 INT4 权重很容易就有 400-600MB,单个 token 独立加载权重这条路根本走不通。唯一的出路是让权重搬运尽量服务多个 token——这就是连续批处理、专家预取这些技术存在的根本原因。硬件天花板决定了,这不是一个 kernel 优化题,而是一个系统调度题。
2. 全链路优化的四个关键决策
2.1 权重放哪:GPU 住共享层,DRAM 住专家层
第一个决策是权重怎么分布。原则很朴素:每个 token 都必须用的参数(embedding、attention、layernorm、LM head)留在 GPU 显存里;只有被路由选中的专家才需要的参数,放在 CPU 内存里,用的时候再取。全部权重 INT4 后大约 62GB,其中专家部分占大头,约 50GB 放在 CPU 内存;attention、embedding 等共享层约 12GB,但 GPU 上只需要常驻其中真正每步都要碰的那部分,约 5-6GB。KV cache 量化到 8bit 后预留 6GB,热专家缓存给 8GB,剩下的给计算 buffer 和临时变量,满打满算刚好压在 24GB 以内。
有人会问:既然每个 token 都要跑 attention,为什么不把所有专家都放 GPU?原因很简单——放不下。50GB 的专家权重至少需要两张 3090 才能全量装下。但 MoE 的妙处在于,专家都在 CPU 内存里也没关系:如果请求命中了 GPU 上的热专家缓存,就不用走 PCIe;只有缓存没命中的专家才需要走慢速链路。所以显存布局的优化目标,是让热专家缓存尽可能覆盖高频访问的专家集合,让冷专家传输成为小概率事件。现实中 MoE 的热度分布非常歪,少数十几个专家承担了大部分流量,这个特性后面帮了大忙。
2.2 量化方案:别碰 FP16,也别盲目上 4bit
第二个决策是权重量化。FP16 250GB 在 CPU 内存里都嫌挤,而且读取走 PCIe 更慢,直接排除。INT8 125GB 仍然偏大,CPU 内存虽然放得下,但搬运量还是太高。最终选了 INT4 group-wise 量化,group size 128,也就是每 128 个权重共享一组 scale 和 zero point。为什么对 decode 阶段这么执着于位数?因为 decode 是带宽敏感任务,权重每减少一半位数,每 token 的搬运量就少一半,这是指数级的收益。128 的 group size 是精度和反量化开销之间的平衡点:group 太小 scale 太多浪费显存,group 太大量化误差会明显上升。
在 GPTQ 和 AWQ 之间,我最后选了 GPTQ 校准路线。AWQ 对激活敏感 channel 的搜索很强,但 MoE 专家 FFN 的输入分布相对稳定,专家数量又多,逐个专家单独校准的工程成本很高。GPTQ 用统一校准集跑一遍,质量损失可以接受。实测下来,INT4 量化后模型困惑度相对 FP16 只上升 0.3-0.5 个点,对于这个私有 checkpoint,误差已经很值得。KV cache 单独量化到 8bit,显存占用从 12GB 压到 6GB,生成质量几乎无损。这两个量化动作叠加,既解决了显存放不下的问题,又让每 token 的搬运量从 FP16 的 250GB 级别降到了 INT4 的 62GB 级别。
2.3 调度策略:为什么连续批处理几乎是必选项
第三个决策是服务端的调度器。静态批处理的问题在于:batch 必须等满了才一起送进 GPU,等待期间 GPU 空闲;batch 内部如果序列长度参差不齐,短的拖累长的。连续批处理不同,它把显存里的 KV cache 按 slot 管理,每个 step 完成一个请求就立刻释放 slot,新请求马上插入。这样 GPU 每时每刻都在处理“当前能处理的最大 batch”,权重搬运也随之被摊薄。这个概念在 LLM 推理里已经不算新词,但真正落地时,困难在于要维护好每个请求的生成状态、显存 page 分配和调度队列。
对 MoE 推理来说,连续批处理还有一个独到的好处:同一个 batch 里的多个请求,很可能路由到同一个专家。比如一个 batch 有 8 个 request,Top-2 路由下来三个 request 都选中了专家 A,那专家 A 的权重从 CPU 取一次,却能服务三份 token。当 batch 越大、请求分布越集中,这种共享带来的 PCIe 节省越明显。我们最终把平均有效 batch 维持在 6-8,生成的 token 之间共享专家权重非常频繁。这时候 100 tok/s 的瓶颈已经不再是 PCIe,而变成了 GPU 上的矩阵运算和 kernel 效率。
2.4 预取与异步:让 PCIe 和 GPU 重叠干活
第四个决策是把传输和计算重叠起来。GPU 的 H2D 拷贝本来就是异步的,但默认情况下很多人写代码时是“先拷贝,等拷贝完,再算”,于是每一轮都在等 PCIe。我们改成双 stream 流水线:一个 CUDA stream 专门负责权重搬运,另一个 stream 专门负责矩阵计算。上一轮的权重在算的同时,这一轮需要的权重已经在另一个 stream 上拷贝了。配合 host 端 pinned memory(锁页内存),DMA 传输的效率和稳定性会明显提升。
还做了一个两步预取窗口:解码是逐 token 推进的,上一步的 router 输出能告诉我们下一步大概率会用到哪些专家,于是可以在上一步计算的间隙提前启动 H2D。严格来说不需要精确预测,只要把当前 step 需要的权重放在计算 stream 启动之前发完,再把下一步可能用到的专家放进预取队列,就能让 PCIe 一直处于忙碌状态。做这一步时要注意事件同步,不能为了 overlap 引入数据竞争,否则会出现“算的和拷的不是同一个版本”的诡异问题。这步做完,GPU 利用率从 50% 左右拉高到 80% 以上,推理速度从 35 tok/s 直接翻到 70 多。
3. 实操过程:从 2 tok/s 到 100+ tok/s 的四阶段记录
3.1 基线:全量 FP16 走 PCIe,只有 2-3 tok/s
刚开始我偷懒,直接用 FP16 权重全部驻留 CPU 内存,每个 token 把所有需要的权重复制到 GPU 再算。那时每 token 要拷贝 10GB 以上的数据,attention 和专家全都算上,PCIe 3.0 撑死 13GB/s,理论极限也就每秒 1 个 token,实测 2-3 tok/s 还是因为部分层在 GPU 上做了缓存。做个 profile 一看,kernel 执行时间不到 10%,90% 都在等 cudaMemcpyAsync——严格说是等在拷贝完成之后才发 kernel 的坏习惯上。这也说明问题的根源:不是算不动,是搬不过来。
这个基线阶段虽然慢,但很有价值。它让我确认了两件事:第一,decode 阶段确实不吃算力,吃带宽;第二,PCIe 这条窄路是绕不开的,必须把每次搬运的价值做到最大。所以后续所有优化都围绕一个主线:让“每 140MB 的 PCIe 传输”尽可能多地产出 token。回头看,如果没有这个基线数据,后面的决策很容易跑偏到盲目调 kernel 或者堆显存上去。
3.2 第一轮优化:INT4 量化 + 热专家缓存,跑到 35 tok/s
把权重全部换成 INT4 之后,CPU 内存占用降到 62GB,每 token 从 CPU 拉取的数据量直接减半。同时我在显存里划了一块热专家缓存,用 LRU 策略缓存最近访问的专家权重。把注意力放在路由分布上之后,发现一个有意思的现象:MoE 模型的访问热度极其不均衡,Top-10 的专家在真实流量里被选中的概率超过 60%。把最热的十几个专家的 INT4 权重常驻在显存后,大部分请求根本不用走 PCIe,速度来到 35 tok/s 左右。
但问题也很明显:GPU 经常因为等待冷专家的数据传输而空转。那边的 PCIe 在传数据,这边的 SM 做完上一个 step 之后就在闲着。测了一下 GPU 利用率,平均值只有 45% 左右;再看时间线,一大段一大段的空白都对应着 cudaMemcpy 的等待。这时候我知道,光靠缓存还不够,必须把等待时间藏起来。于是进入下一轮。
3.3 第二轮优化:连续批处理 + 双 stream 预取,来到 70-80 tok/s
这一轮把单请求逐 token 的服务模型改成连续批处理。先实现一个最简单的 slot 调度器:维护一个 KV cache slot 池,每个 step 从 waiting queue 中尝试补充新请求,并淘汰已完成请求。这比静态 batch 灵活得多,有效 batch 在 6-8 之间浮动。紧接着实现双 stream,把预取逻辑跑通:计算 stream 处理当前 batch,传输 stream 提前把下一 batch 需要的专家权重从 CPU 拉到 pinned buffer,再在 kernel 启动前做一次 event 同步。效果立竿见影,速度稳定在 70-80 tok/s。
这里有一个关键细节:连续批处理不是简单地把请求堆在一起,调度器要感知到专家路由的一致性。如果 batch 里 8 个请求全部命中 GPU 热缓存,那这个 batch 几乎不消耗 PCIe;如果某个 batch 恰好都是冷门专家请求,PCIe 压力就会瞬间拉满。所以我把调度队列按“专家亲缘性”做了粗排序,让相似请求尽量凑到同一批。这个排序并不复杂,但对 PCIe 吞吐的稳定性帮助很大,长尾延迟的方差肉眼可见地变小了。
3.4 第三轮优化:投机解码 + 内核手写,冲上 100+
70-80 tok/s 看起来不错,但离 100 还有距离。接下来上了投机解码:用一个 1-2B 的小模型当 draft model,先生成 4 个候选 token,再让 125B 主模型一次验证这 4 个 token。验证时虽然要处理 4 个 token 的 attention,但专家权重的读取量并没有变成 4 倍——一个 batch 里多个 draft token 可能路由到同一个专家,权重只取一次,而这正是 MoE 投机解码最香的地方。实测接受率在 0.55-0.65 之间,等效加速比约 1.8x。draft model 放在 GPU 上,从计算 buffer 里划出 1GB,让它与主模型独立 stream 并发跑,不占用 CPU 到 GPU 的宝贵传输带宽。
最后一公里是手写 decode 阶段的 INT4 GEMV kernel。标准数学库对 batch=1 这类 M 维度很小的矩阵乘非常不友好,大量时间花在指令发射和无效的反量化流程上。我按照 group-wise INT4 的存储格式,把权重按 group 连续排布,在 kernel 里先解量化再乘加,同时把多个专家权重合并成一个大 buffer 做批量调度,提升 cache 命中率。这个 kernel 单独又把带宽利用率拉了近 30%。三段优化叠加后,速度最终稳定在 100-115 tok/s。在 3090 上能稳定跑出这个速度,已经超出我先前的预期。
4. 常见问题与踩坑实录
4.1 PCIe 传输速率为什么提不上去
很多人第一步就卡在“明明用了 cudaMemcpyAsync,为什么带宽还是上不去”。最常见的坑是没锁页。普通 malloc 出来的 host memory,GPU 要拷贝时得先经过 pageable→pinned 的转换,传输效率会大打折扣。改成 cudaHostAlloc 或 cudaMallocHost 之后,DMA 才能直接吞吐。另一个坑是传输粒度太小:每次只拷 50MB 不如一次拷 400MB 连续内存,因为启动开销在总时间里占比很大。把多个专家权重打包成连续的大 buffer 一次拷入,传输效率会好很多。
4.2 显存不够用怎么腾
如果连续批处理把 KV cache 撑爆,优先做 KV cache 量化,从 FP16 换成 8bit 能直接省一半。还不够就把热专家缓存缩小,让更多显存留给 KV cache slot。实践里我会监控两个指标:KV cache 使用率和热专家缓存命中率。命中率低于 70% 的缓存该缩就缩,不必心疼。注意不能把所有显存都塞满,3090 上留给 CUDA context、驱动预留和临时 buffer 至少 2-3GB,否则 kernel 一启动就 OOM,而且 OOM 出现的位置经常让人摸不着头脑。
4.3 双路 CPU 的 NUMA 陷阱
双路 Xeon v4 是典型的 NUMA 架构,GPU 挂在某颗 CPU 的 PCIe controller 底下。如果线程和内存分配到了另一颗 CPU,访问本地内存要走 QPI 总线,带宽损耗可能高达 20%-30%。处理办法是启动时用 numactl 把推理进程绑在 GPU 所在 NUMA node,并配合 hugepages 减少 TLB miss。这一步很多人不做,结果就是 CPU 内存带宽明明够了,实际 PCIe 吞吐却差一截。我自己一开始也没注意,后来发现为什么双路内存带宽那么高,H2D 却跑不上去,排查半天才想起是跨 NUMA 访问。
4.4 量化之后生成质量崩了怎么办
如果 INT4 之后模型输出明显变差,先检查校准集是不是和业务场景太远。MoE 模型的专家本来就是“术业有专攻”,校准集偏差会被专家路由放大。可以先用 AWQ 的思路,只对每个专家 FFN 的前后若干敏感权重保留更高精度;或者干脆把最容易崩的几层保留 FP16 混合精度。我在实际项目里发现,专家层用 INT4、attention 的 q/k/v 投影保留 INT8,这个组合在质量与速度之间最舒服。如果还崩,把 group size 从 128 改成 64,虽然权重体积变大,但精度立刻上一个台阶。
4.5 速度忽高忽低、长尾延迟难看
热专家缓存被冷流量冲掉时,下一批请求会集体走 PCIe,速度瞬间掉一半。解决办法是给热专家缓存做“锁定”,把模型校准阶段统计出的高频专家设为 pinned 缓存,冷门新流量只能占用未锁定的缓存空间。另外调度器要给长序列让路,我对超过 2048 token 的长序列降低优先级,避免一个长尾请求拖垮整个 batch 的延迟。最终 P99 延迟控制在 25ms 以内,在消费卡上算是一个还不错的水平。
5. 给后来者的一点私人建议
5.1 复现这条路线时的顺序建议
如果只有一张消费卡想复现这个效果,我建议严格按照“先量化、再缓存、再批处理、最后投机解码”的顺序来做,不要一开始就磨 kernel。每一步的收益都可以量化,每走一步你都会更清楚瓶颈在哪儿。先量化是为了把搬运量降到最低;再缓存是为了减少 PCIe 命中失败;连续批处理和预取解决的是搬运与计算的重叠;投机解码则是把搬运成本进一步摊销。千万不要在一开始就手写 kernel——在权重传输和调度都没理顺之前,kernel 优化像是在错误的管道上灌水,效果会大打折扣。
5.2 这个方案还能延伸到哪里
这套思路放到更大的硬件上也成立,只是参数变了。如果换成双卡,专家缓存可以按卡分片,PCIe 吞吐变成两倍,同样 125B 模型可以往 200+ tok/s 走。如果换成支持 PCIe 4.0 的新平台,CPU 到 GPU 的传输带宽翻倍,热专家缓存甚至可以再做小一点。如果再配合多级缓存、把高频专家做权重合并,长尾冷专家走异步加载,整体延迟还能进一步压缩。MoE 推理本质上是一场“数据搬运调度”的战争,谁把搬运量压缩得更狠、把搬运时间藏得更好,谁就能在同样的硬件上跑出令人意外的速度。这在 3090 上验证过了,在别的平台上大概率也成立。