推荐系统GPU优化实战:形状重塑与执行均匀化,让算力账单瘦身
推荐系统做到一定规模以后有一个问题会像幽灵一样缠着你模型效果还在涨但线上推理的算力账单涨得更快。我见过太多团队花大力气调模型结构、刷AUC却对GPU利用率长期徘徊在20%左右视而不见。尤其是推荐系统进入Scaling时代之后模型越来越大、特征越来越稀疏、序列越来越长靠堆GPU卡来扛流量已经完全扛不住了。这篇文章我想聊的就是推荐计算在GPU上从“能跑”到“跑得快”的关键——形状重塑与执行均匀化。这两个词听着抽象但它们直接决定了你的GPU到底是在做计算还是在陪跑。如果你正在做推荐系统的推理优化、离线训练加速或者单纯被GPU账单压得睡不着这篇文章应该能给你一些能直接落地的思路。1. 推荐系统撞上GPU为什么“搬过去”不等于“跑得快”1.1 推荐模型的算力画像与CV/NLP截然不同很多人第一次把推荐模型迁到GPU上时会拿CV或NLP的经验来套。这套经验在Transformer类模型上确实好用但到了推荐系统这里就失灵了。原因在于推荐模型的计算画像跟CV/NLP完全不同。CV模型和NLP模型是典型的“厚计算、薄特征”输入是规整的图片或token序列整个网络由大量稠密的矩阵乘法GEMM堆叠而成计算强度高、访存模式规整这恰好是GPU最擅长的场景。而推荐模型恰好相反它是“薄计算、厚特征”输入侧是海量稀疏的类别特征和变长序列主要计算集中在Embedding查找和特征交叉上模型主体往往只有几层MLP或浅层Transformer。这意味着推荐系统在GPU上的行为模式和CV/NLP完全不同。CV模型跑起来GPU的SM流式多处理器几乎全程满载而推荐模型跑起来GPU经常处于“既饿又忙”的状态——SM在等数据从显存里搬过来或者在处理大量不规则的小张量。这就引出了一个核心矛盾GPU是为大规模同构计算设计的而推荐系统的计算天生就是异构、稀疏、不规整的。1.2 GPU喜欢什么又讨厌什么要理解推荐模型在GPU上为什么跑不快得先搞清楚GPU的脾气。GPU本质上是一台“吞吐优先”的机器它的设计哲学是用大量的线程并行执行相同的指令来掩盖访存延迟。所以GPU最喜欢三件事一是计算形状规整所有线程处理同样大小的数据块二是计算路径统一同一个warp32个线程为一组内的线程基本不产生分支三是内存访问连续相邻线程访问相邻地址这样一次内存事务就能取回整块数据。反过来GPU最怕的也是三件事小算子太多导致kernel启动开销占比过高、warp内分支发散导致并行度打折扣、随机访存导致显存带宽浪费。这三件事恰好是推荐模型在GPU上的默认表现。比如Embedding查找本质上是随机访问一个大表每个样本查的key不一样、命中的id也不一样GPU的cache命中率很低再比如变长的用户行为序列每个样本长度不同如果直接塞进同一个batch计算图的形状根本没法静态推断。我见过一个典型的CTR预估模型模型本身只有几十层算子但线上用PyTorch直接推理时每秒启动的kernel数量超过两万个每个kernel平均耗时只有十几微秒。这种情况下GPU真正的计算时间可能只占10%剩下的时间全花在kernel启动和内存搬移上了。这种状态下去谈算力谈的其实是浪费。1.3 算力经济学视角GPU利用率低是最大的成本黑洞聊算力经济学就得先把账算明白。假设你线上集群有50张GPU卡每张卡按云厂商标准定价大约是每小时几十块钱按容量计费那么一年下来仅GPU的硬件成本就是一笔大数目。如果利用率只有20%等于你花了100%的钱只买到20%的算力——剩下的80%都在空转。更麻烦的是推荐系统是有流量高峰的。双十一、春节这类大促场景下流量可能是平时的5到10倍如果GPU利用率本身就很低你就只能靠横向扩容去扛峰值而这些临时扩容的机器在活动结束后就闲置了。反过来如果能把单卡吞吐提升2到3倍同等流量下需要的卡数就能砍半这才是算力经济学里最直观、最硬核的收益。那为什么GPU利用率会这么低根子上还是刚才说的形状不规整和执行不均匀。接下来我拆开聊。2. 形状重塑把“歪瓜裂枣”的数据整成GPU爱吃的样子2.1 推荐系统里的形状不规整从哪来先说说形状不规整是怎么回事。你在PyTorch里定义一个模型输入张量的shape往往带有一个动态维度比如[batch_size, seq_len, hidden_size]其中seq_len是变长的。PyTorch本身能处理这种情况因为它是动态图但GPU后端在编译优化、算子调度时遇到动态shape会非常难受——很多优化手段都需要在编译期知道精确的shape才能做。推荐系统里形状不规整的来源主要有四个变长序列特征用户历史行为序列长度不一致。有人过去一个月点了50个商品有人只点了3个这部分特征长度差异巨大。特征缺失不同样本命中的特征数量不同。有的样本有一堆交叉特征有的样本只有基础特征。多任务多塔结构多任务模型里不同任务塔的输入维度、输出维度不一样导致计算图分支形状不统一。批量推断时的候选集差异粗排、精排阶段每个请求的候选item数量往往不同。这些不规整的shape直接丢给GPU会导致什么问题举个例子假设一个batch里有32个样本其中30个序列长度是102个序列长度是200。如果你按最大长度做padding会有大量计算花在填充的无效数据上如果你不padding直接塞进变长计算图GPU的调度器就没法静态规划执行计划每次都要走动态shape的慢速路径kernel启动开销更高。2.2 形状重塑的几种典型手法形状重塑Shape Reshaping的核心思想很简单把不规整的数据整理成规整的张量让GPU后端能够用最高效的方式去执行。具体有几种常用手法。**第一种是padding到固定长度。**这是最直接的做法。设定一个固定的序列长度L所有样本的序列特征要么截断到L要么填充到L。填充部分用mask标记计算时忽略掉。这个做法简单粗暴但有个关键参数要选好L取多少。L太大计算浪费严重L太小长序列信息丢失。实际操作中我的经验是先用分位数统计线上数据取P95或P99的长度作为L然后配合截断策略这样既能覆盖绝大多数样本又不会浪费太多算力。**第二种是bucketing分桶。**不同样本的序列长度差异很大的时候全局padding到固定长度是巨大的浪费。更聪明的做法是把长度相似的样本分到同一个桶里每个桶内部padding到该桶的最大长度不同桶之间用不同的batch。这样长序列样本之间的计算不会拖累短序列样本。我做过一个实验把一个序列长度从10到5000不等的模型用分桶策略处理后单batch内最大padding长度从5000降到了800左右计算量直接省了一半以上。分桶的缺点是会增加工程复杂度——你要在数据流水线里做在线分桶还要维护多个batch的调度状态。**第三种是Embedding查找矩阵化。**推荐系统里最耗时的往往是Embedding查找尤其是大规模稀疏特征。常规做法是对每个特征做独立的gather操作这在GPU上效率很低因为gather是非连续访存且每次gather的规模可能很小。我常用的优化手段是把同一特征域内所有样本的Embedding查找合并成一个大的gather操作或者把多个特征域的查找结果拼接成一个大矩阵再统一送入后续层。这样做不仅减少了kernel调用次数还能让内存访问更加连续提高cache命中率。**第四种是稀疏特征densify。**对于基数特别大的类别特征比如商品ID直接在GPU上维护一个超大Embedding表会占用太多显存同时随机访问效率很低。一种替代方案是使用某种hash映射把高维稀疏特征压缩到固定大小的Embedding表中配合多hash冲突缓解这就是Facebook提出的Quotient-Remainder Trick那一类方案。这类方法本质上也是在“重塑形状”——把不可控的稀疏访问变成可控的稠密访问。2.3 形状重塑的代价与边界形状重塑不是免费的午餐它有两个比较明显的代价。第一个代价是padding带来的计算浪费。你padding进去的那些数据最终都需要用mask来屏蔽计算虽然不参与最终的loss计算但中间的矩阵乘法、Attention计算仍然会把这些填充位置算进去。经验数据显示如果padding比例超过30%性能就会明显打折扣。所以选择L的取值时要特别谨慎我见过有人一股脑把序列padding到1024结果线上数据95%的序列都不到200算力浪费严重这种情况下就应该果断用分桶策略替代全局padding。第二个代价是工程复杂度上升。形状重塑往往需要对数据流水线、模型代码、推理框架做配套改造。比如你在训练时做了分桶那么推理时也要保证同样的分桶逻辑你在离线用固定L训练线上推理也必须用同一个L否则效果会有偏差。这个一致性维护起来是挺烦人的但为了算力收益这笔账是划算的。3. 执行均匀化让GPU的每一个SM都别闲着3.1 执行不均匀的根源任务切分与分支发散形状重塑解决的是“数据好不好看”的问题接下来要解决“活干得匀不匀”的问题。一台GPU上有几十到上百个SM每个SM上面可以并发跑很多warp。所谓执行均匀化Execution Homogenization就是让这些SM和warp的工作量尽量均衡避免某些SM忙死、某些SM闲死。执行不均匀的根源主要有两个。第一个是任务切分不均匀。GPU执行矩阵乘法时会把输出矩阵切成很多小块分配给不同的SM去算。如果输出矩阵的形状本身就不规整比如某个tensor的维度是13素数切分后就容易出现部分SM分配到的工作量明显少于其他SM。我在Nsight Compute里见过一个真实案例一个形状为[17, 512, 128]的中间张量参与GEMM时由于17无法被常见的切分粒度整除最终导致SM利用率只有理论值的60%左右。类似的case还有各种odd shape比如hidden_size取144而不是128虽然模型设计者可能只是随手定的但GPU跑起来就吃亏。第二个是分支发散。GPU是SIMT架构一个warp内的32个线程在同一时刻必须执行同一条指令。如果代码里有if-else分支且同一warp内不同线程走了不同分支GPU只能把两个分支串行执行这被称为warp divergence分支发散。在推荐模型里分支发散无处不在特征缺失要判断、序列长度要判断、不同任务要判断。每判断一次就是在提醒GPU“你慢一点”。3.2 均匀化三板斧算子融合、统一路径、CUDA Graph执行均匀化的目标就是消除或掩盖上述的不均匀让GPU始终处于高水位运行状态。实践中我总结了三板斧。**第一板斧算子融合。**推荐模型里大量计算是elementwise操作比如bias加法、激活函数、LayerNorm。这些操作计算量不大但每个都是独立的kernel启动开销占比很高。算子融合就是把这些操作合并到相邻的计算密集型kernel里。最常见的做法是把bias加法和激活函数融合进前面的GEMM或Attention kernel中把LayerNorm的均值、方差计算跟前面的矩阵乘法一起做。通过算子融合可以把原来一个forward pass的上百次kernel启动压缩到几十次甚至十几次。这个优化在任何框架里都收益明显PyTorch 2.0之后可以用torch.compile自动做一部分融合但涉及自定义算子时还得手工处理。**第二板斧统一执行路径。**推荐模型的多任务结构天然会产生分支路径比如MMoE里有多个expert每个expert有自己的网络分支。代码写得直观一点就会在forward里写多个if-else或循环分支。从可读性角度没问题但从GPU执行角度分支会导致同一batch内不同任务走不同计算路径既容易发散也破坏kernel执行的连续性。我的做法是把不同expert或不同任务的权重拼接成一个大矩阵统一走一次GEMM再按索引切分结果。这本质上是把“多个小矩阵乘法”合并成“一个大矩阵乘法”对GPU来说是一个形状规整、执行连续的大kernel效率高得多。**第三板斧CUDA Graph。**CUDA Graph的原理是把一整个模型的计算流程捕获成一张图之后每次推理只需要启动一个图节点而不是逐个启动所有kernel。这个优化对推荐系统尤其有效因为推荐模型的kernel数量非常多kernel launch的开销占比远高于大模型推理。实测中我用CUDA Graph优化过一个多任务推荐模型仅kernel启动开销就从每batch 800微秒降到了不到100微秒。需要提醒的是CUDA Graph对动态shape不友好捕获时输入输出内存地址是固定的所以一般和形状重塑配合使用——先把形状固定下来再用Graph去捕获执行流程。3.3 从kernel级别到模型级别的均匀化调度执行均匀化不仅体现在单个kernel内部还体现在模型整体执行流程上。一个容易被忽略的环节是CPU侧的调度开销。GPU kernel执行前CPU需要准备数据、下发指令。如果CPU和GPU的执行流水线没有重叠好GPU就会周期性“空转”等数据——表现为kernel之间的gap特别大。对推荐系统来说因为很多操作比如Embedding查找前的特征预处理、序列padding都是在CPU侧完成的所以很容易出现CPU成为瓶颈、GPU吃不饱的情况。解决办法是尽量把数据预处理也搬到GPU上或者在CPU侧做多线程异步预处理保证GPU一侧始终有活干。另一个环节是显存分配的均匀性。动态shape导致显存频繁分配和释放会产生大量显存碎片。GPU显存碎片化到一定程度后即使总剩余显存充足也可能分配不出连续大块内存导致进程崩溃或者触发昂贵的显存整理操作。所以形状重塑和显存池化往往是配套的——先把shape固定下来然后为该shape分配一块复用的静态显存避免频繁的显存分配。我们线上服务常用的手段是显存池memory pool加预分配策略效果立竿见影。4. 实操一次推荐模型GPU推理优化实录4.1 项目背景与性能基线前面讲了不少理论这一节我拿一个实际项目来走一遍完整的优化流程。这是一个典型的CTR预估模型结构是Embedding层 3层MLP 一个多任务输出层特征包括200多个稀疏ID特征和20多个稠密数值特征用户行为序列长度在1到200之间变化。线上用PyTorch直接做推理单次请求batch size平均为64部署在4张A10上流量高峰期CPU直接被打满。第一件事是做性能基线分析。我用Nsight Systems抓了一份线上推理的profiling数据结果非常有代表性单个batch推理平均耗时约3.8mskernel数量高达2400多个其中一半以上是小于10微秒的小kernelGPU内核SM利用率平均只有17%CPU侧的预处理耗时占比约15%这部分成了流水线的短板显存占用约6.2GB但有效数据不足2GB其余大多是小张量碎片。这三组数据基本判了死刑——不仅GPU在空转CPU还在拖后腿。接下来开始动刀。4.2 优化步骤全记录**第一步形状重塑。**先把变长序列特征处理掉。我统计了线上真实流量里的序列长度分布P95是128P99是512。因为流量大部分集中在128以内我决定用分桶策略长度小于128的样本走小桶padding到128大于128的走大桶padding到512。同时把尾部长于512的极端样本直接截断。这一步之后所有进入模型的张量形状都固定了为后续CUDA Graph和算子融合铺平了道路。**第二步Embedding查找合并。**把200多个稀疏ID特征的Embedding查找从独立的200多次gather操作合并成按特征域分组的大规模gather。同时调整了Embedding表的存储布局从按特征维度连续存储改成按特征ID连续存储尽可能提高访存连续性。这一改动让Embedding部分的耗时下降了大约40%。**第三步算子融合。**把MLP里每个Linear后面的bias加法和ReLU激活融合进GEMM kernel里。这一步PyTorch的torch.compile部分自动完成了但多任务输出层里的一些自定义算子我手动做了融合。这个步骤让kernel数量从2400多个降到了400多个效果非常显著。**第四步CUDA Graph捕获。**形状固定之后CUDA Graph就排上用场了。我把整个模型前向过程捕获成一张CUDA Graph线上推理时直接启动graph执行。这一步让单batch推理耗时从3.8ms降到了1.9ms几乎减半。**第五步CPU流水线优化。**最后处理CPU侧瓶颈。把特征预处理的逻辑改成多线程异步执行让CPU准备下一batch数据的同时GPU执行当前batch的推理。同时把原来在CPU上做的序列padding操作全部搬到了GPU上。这一步让端到端的吞吐又提升了20%左右。4.3 优化后效果与取舍优化后的完整数据如下指标优化前优化后提升幅度单batch推理耗时3.8ms1.5ms60%下降kernel数量2400约32087%下降GPU SM利用率17%52%3倍单卡QPS420950126%提升显存占用6.2GB4.8GB22%下降这组数据是在相同流量分布下测出来的没有牺牲模型效果。整个优化过程大概花了两周时间其中一半时间花在做性能剖析和验证各种改造不影响模型精度上。有一点要提醒优化后的模型更“脆”了。CUDA Graph固定了shape和显存地址一旦线上流量出现远超预期的长序列超出预分配长度的部分会被截断可能会带来效果回退。所以我们在线上做了兜底策略——当检测到截断比例超过阈值时自动切换回动态shape的慢速路径保证线上效果优先。这是一个工程上的取舍不能只盯着性能指标看。5. 常见问题与排查技巧实录5.1 典型问题速查表做GPU推理优化时我几乎每次都遇到下面几个老面孔整理成一张速查表方便对照常见现象根本原因排查手段解决办法GPU利用率低但CPU跑满CPU侧预处理成为瓶颈用Nsight Systems看CPU/GPU时间线重叠情况预处理搬到GPU或多线程异步化显存占用持续增长动态shape导致显存碎片化打印空闲显存块数量观察有无大块连续内存显存池化预分配静态内存kernel数量上万但都很短算子粒度过小、kernel launch开销占比高用Nsight Compute统计kernel平均耗时算子融合CUDA Graph序列特征越长推理越慢且不是线性增长padding浪费计算统计batch内实际数据与padding数据比例分桶策略动态padding同一个模型在不同GPU上性能差异巨大GPU架构差异导致最优shape不同对比不同GPU的Nsight Compute数据针对目标GPU单独调形状参数5.2 几个容易被忽视的细节第一个细节是数值精度的影响。推荐模型通常对精度不太敏感FP16推理基本无损失。换成FP16之后显存占用直接减半计算吞吐也能提升不少。但要注意Embedding部分的精度——Embedding的梯度在训练时可能比较小FP16的表示范围比较大但如果Embedding表里的值分布特别散精度损失可能比预期大。稳妥的做法是先做一个离线评估用FP16跑一遍验证集对比AUC和线上点击率指标确认损失在可接受范围内再上线。第二个细节是多实例隔离。如果一张卡上部署多个推理实例比如多模型或模型多副本显存分配和SM资源分配就需要做隔离。推荐用MPSMulti-Process Service或者CUDA的算力隔离机制避免多个实例互相抢占导致整体吞吐下降。我踩过坑两个model instance同时跑各自都能接受合在一起吞吐反而比单独跑还低就是因为SM资源抢占严重后来用MPS设置算力配额才解决。第三个细节是动态shape带来的隐性问题。有些框架即使支持动态shape也会在后台偷偷做很多额外的工作——比如重新编译kernel、缓存查找、内存重分配。这些工作不会直接显示在kernel耗时里但会拖慢端到端延迟。所以有时候你优化半天发现性能还是没有质的提升不妨检查一下是不是有动态分支在干扰。最直接的办法就是强制把所有shape都固定下来看问题是否消失。第四个细节跟推荐系统特有的数据流水线有关。推荐场景下线上数据分布会随时间漂移。你今天按P95取序列长度128做padding三个月后用户行为变长、P95变成了256如果不定期重新统计并调整分桶参数padding截断率会悄悄上升最终影响推荐效果。我在线上专门加了一个监控统计每天被截断的样本比例超过阈值自动告警。这个监控看着不起眼但真的能救命。最后说一个我自己的体会。很多人做GPU优化一上来就盯着kernel调这其实是走偏了。我先花了两周时间做profiling搞清楚了瓶颈到底在CPU、访存、kernel启动还是SM计算然后才开始动手。结果证明这恰恰是最值钱的时间——因为推荐模型的问题通常不是某一处算得慢而是整条流水线配合得差。把流水线调顺了往往比单点优化带来的收益大得多。这个内容后续还可以扩展的方向是把形状重塑的思路延伸到训练侧对稀疏特征做更激进的组合压缩或者在多卡推理时把不同的任务塔拆分到不同的GPU上进一步做异构算力分配。推荐系统的算力优化和CV/NLP不一样它更碎片化、更依赖业务数据分布但正因为如此每一分优化都更容易转化成真金白银的成本节约。