拿到“算法-infra协同”这个题目,我第一反应是:这俩词放在一起,本身就是一线研发团队每天都在经历的拉扯。我在算法岗和基础设施岗都待过,见过太多因为“算法说infra太慢、infra说算法不会用”导致的无效加班。这篇就把我实际踩过的坑、验证过的打法、沉淀下来的协作框架一次说透。无论你是刚入门的新人,还是被跨团队协作折磨的研发,这篇都能给你一套能直接落地的思路。
1. 内容整体设计与思路拆解
1.1 “算法”和“infra”为什么总在吵架
先讲一个我遇到的真实场景。线上有个推荐服务,算法同学迭代了一版新模型,离线AUC涨了2个点,特别兴奋地上线。结果呢?服务端p99延迟直接从120ms飙到800ms,线上指标不升反降。算法同学的第一反应是“infra的机器又抖了”,infra同学的排查结论是“你这模型结构太离谱,一个请求跑50次循环神经网络,谁扛得住”。
这种互相甩锅的场面,但凡在大厂待过的人都见过。根源在于:算法同学习惯把模型当做一个数学函数,输入特征输出分数,至于这个函数在机器上怎么跑、吃多少显存、耗时多少,是“infra的事”;而infra同学习惯从资源维度看问题,CPU要降、内存要省、吞吐要稳,至于模型精度降了0.5个点是不是天塌了,是“算法的事”。
两边都没错,但缺的是一个共同语言。算法-infra协同要做的事,说白了就是建立一套“从模型结构到资源消耗”的翻译机制,让算法知道每一次结构改动背后的算力成本,让infra知道每一次资源限制背后的精度损失。没有这个机制,协同只是开会时喊的口号。
1.2 协同的本质是围着三类资源转
我在实际推动协同落地时,习惯把问题收敛到三个维度:计算资源、存储资源、带宽资源。任何一个算法模型跑在infra上,消耗的无非就是这三样,不存在第四种。
计算资源好理解,CPU的算力、GPU的算力。存储资源包括内存、显存、磁盘,模型参数、中间激活、特征缓存都要占地方。带宽资源最容易被忽视,但往往是真正的瓶颈——数据从磁盘读到内存、从内存拷到显存、多机之间通信同步梯度,每一步都在消费带宽。很多算法同学设计的模型在单机单卡上跑得飞快,一到分布式训练就慢成蜗牛,问题几乎都出在带宽上,梯度同步的通信量把网络打满了。
把问题收敛到这三个维度之后,协同就从“感觉谁有问题”变成了“量化看哪里紧”。比如算法说模型推理慢了,infra不要急着加机器,先看是算力不够还是数据加载太慢。如果GPU利用率只有20%,加再多卡也没用,瓶颈在数据管道;如果GPU利用率95%以上,那才是真算力不够。这一套量化思维,是整个协同模式的地基。
2. 核心细节解析与实操要点
2.1 性能剖析:先量化再优化,别凭感觉做事
我见过太多团队一上来就优化代码,结果忙活一周发现GPU利用率从20%涨到22%,毫无意义。真正的第一步一定是性能剖析,把时间花在哪、资源用在哪摸清楚。CPU上的利器是perf,GPU上是Nsight Systems或者PyTorch自带的torch.profiler。
用torch.profiler你很快就能看到一张表,里面有每个算子的耗时、显存占用、CUDA kernel执行时间。我印象很深的一次,一个注意力模块的耗时占了整个模型推理的43%,但参数占比不到2%。我们花了一个下午把它换成了FlashAttention的变体,推理时间直接砍掉35%。如果没有profiler数据,我根本不会知道注意力是这个模型的命门。
做剖析要注意采样条件。不要用太小的batch去测,也不要开各种优化开关去测,最好的方式是贴近线上真实流量分布。我一般会录一段线上真实请求的副本,回放来做剖析,这样得出的结论才有参考价值。另外,剖析本身会引入性能开销,尤其是细粒度kernel追踪,会对测量结果产生干扰,建议先用粗粒度跑一遍锁定嫌疑区域,再对局部做细粒度分析。
2.2 算子融合与访存优化:少搬一次数据,快一大截
这里有个很反直觉的经验:很多时候GPU计算速度足够快,真正拖后腿的是数据搬运。GPU的算力是过剩的,但显存带宽是有限的。一个算子把数据从显存读进寄存器,算完写回显存,下一个算子再读一遍,这一来一回的时间,可能比计算本身还久。
算子融合的思路就是把多个算子合并成一个,让中间结果直接留在寄存器或者共享内存里,不用反复读写显存。最常见的是把卷积后面的激活函数融进去,比如Conv+ReLU合成一个kernel,还有LayerNorm前面那几个均值方差计算也可以合并。PyTorch里可以用torch.compile,开启后它会自动做算子融合和图优化,我实测在BERT类模型上能拿到1.3到1.6倍的加速,改动量几乎为零。
就算你没有用编译器的条件,手动融合也能做。把多个小矩阵乘法合并成一个大矩阵乘法,把BatchNorm在推理时折算进前面卷积的权重里,这些手工优化虽然土,但在生产环境里稳定可靠。核心原则就一句话:数据少搬一次,性能就进一步。
2.3 数据管道的隐藏瓶颈:GPU在等饭,还是饭在等锅
有一次我在排查一个训练任务,GPU利用率只有12%,所有人都以为模型结构有问题,排查了三天,最后发现数据加载线程只有两个,磁盘读取队列整天都是空的。模型再强也敌不过“等数据”的命,这就像厨师再快,备菜的也就一个人,菜不上来锅就得空烧。
数据管道的设计有几个关键参数:预取的数量、加载线程数、数据增强是否放在GPU上做。我的习惯是先用nvidia-smi dmon看GPU利用率和显存拷贝情况,如果发现GPU计算和拷贝的吞吐有间隙,优先加大预取缓冲,其次增加worker数。PyTorch的DataLoader里面num_workers不要盲目调大,这个参数要根据磁盘IOPS和内存带宽来定。我有一次把num_workers从8调到32,性能反而下降,因为线程切换开销盖过了并行收益。
更现代的做法是让GPU自己处理数据增强,比如用NVIDIA DALI把解码、裁剪、归一化全放在GPU上执行,CPU只做最轻量的IO调度。这套方案在图像类任务上效果极好,数据管道彻底不再是瓶颈,GPU利用率能稳定到90%以上。
2.4 模型量化与混合精度:用最小精度损失换最大性能提升
聊推理优化,绕不开量化。FP32的模型直接跑FP16,显存占用砍半,速度提升一截,最重要的是精度损失通常小到可以忽略。这里要提醒一个坑:不是所有算子都适合FP16。我碰到过BatchNorm在FP16下数值不稳定,最后把BatchNorm留在FP32精度下跑,问题才解决。混合精度不是一刀切,而是逐层看敏感度,敏感层保留高精度,其余层用低精度。
再进一步是INT8量化。这个步子迈大了容易扯着蛋,训练后量化做不好精度能掉3到5个点。我的建议是优先用量化感知训练,在训练阶段就模拟量化误差,让模型自己适应低精度表示。权重和激活的分布也要提前摸清楚,如果发现某些层的激活值范围特别大,就得先做截断处理,否则量化后的误差会失控。
量化和上面说的算子融合不冲突,可以先合并算子再用低精度跑,效果叠加。我也试过把融合后的模型进一步做知识蒸馏,让小模型逼近大模型的精度,再对这个精炼后的小模型做量化,整个流程跑下来,模型体积能缩小到原来的十分之一,推理速度提升5倍以上,线上精度和原版只差了0.1个点的AUC。这应该算是我在算法-infra协同上做得最漂亮的一个案例了。
3. 实操过程与核心环节实现
3.1 从零搭建一个可观测的推理服务
光讲原理不够,我拿一个文本分类服务来走一遍真实流程。需求是:模型已经训练好,要给线上提供一个HTTP接口,要求p99延迟小于100ms,QPS先按500设计。模型本身是一个中型Transformer,直接上肯定超时,需要做一系列配合优化。
第一步是“裸奔”测基准。我先把模型用最简单的方式包装成一个HTTP服务,不做任何优化,然后压测得到基线数据。基线大概是:单请求延迟210ms,QPS只能到80,GPU利用率最高冲到45%。这个基线数据是整个优化工作的坐标原点,后续每一步改动,都要拿它来对比。
第二步上动态批处理。Transformer这类模型最怕batch为1,GPU的并行能力完全发挥不出来。我在服务层加了动态批处理队列,请求进来先等1.5ms,凑够一批或者在窗口时间到期后一起推理。单这一项,QPS从80涨到了320,p99延迟反而降到了95ms,因为单位时间处理的请求多了,排队时间自然短了。
第三步做模型量化。把权重从FP32换成FP16,延迟再次下降,GPU利用率稳定到了70%。到这里p99已经在70ms附近,QPS可以打到500了。整个过程用到的工具就是PyTorch的JIT、TorchServe的动态批处理配置,以及一个自定义的HTTP网关。
3.2 动态批处理的关键参数选择
动态批处理听起来简单,参数没调好会翻车。第一个参数是最大batch size,设太小发挥不出GPU并行能力,设太大单个请求要等很久才能凑满,反而把延迟拉高。我一般通过压测找拐点,把batch从4、8、16、32、64一路测过去,找到延迟和吞吐的平衡点。这个模型实测下来,batch 32是个甜点。
第二个参数是最大等待时间。等待时间设太长,交互式场景的体验会崩。我用过一个策略:先看线上请求到达的分布,如果流量有明显的波峰波谷,我就在高峰期把等待时间调低,优先保证延迟,低峰期调高,尽量凑大batch。这个动态策略用下来,整体吞吐比固定等待时间高了12%左右。
还有一个容易忽略的点:动态批处理框架内部要做好padding。因为batch里每个请求的文本长度不一样,模型需要统一到相同长度才能并行,padding到最大长度会浪费算力。我用的是按长度分桶的策略,把长度相近的请求放进同一个batch,padding的比例从45%降到了12%,总体延迟又优化了一截。
3.3 缓存设计与特征复用:把算过的结果存下来
推理服务有大量重复计算。同一个用户短时间内多次请求,很多中间结果其实是一样的。我做了两层缓存:第一层是最终结果缓存,同一个输入内容的请求直接返回历史结果,用LRU策略控制缓存大小,命中率大概28%;第二层是特征缓存,模型中间的特征向量存下来,遇到相似请求直接复用,这一个操作让每次请求的推理计算量又少掉15%。
缓存不是随便加个字典就行。并发环境要考虑缓存击穿和雪崩。我在网关层给缓存加了分布式锁,同一个key同时只有一个请求去真正计算,其他请求等待结果。缓存过期时间加了随机扰动,避免大批key同时失效把数据库和模型服务打垮。这套东西上线后,服务的整体QPS上限从500冲到了900,很多请求直接从缓存返回了,根本没走到模型那一层。
当然缓存会带来一致性问题。模型更新后,旧缓存的结果要不要作废?我的做法是缓存key里带上模型版本号,模型一旦切换,所有新旧缓存自动隔离,流量灰度到新版本时,让旧缓存自然过期,不影响新模型的判断逻辑。
3.4 弹性伸缩和资源预算:Infra的算盘怎么打
作为infra这一侧,我最关心的是资源成本和稳定性,算法同学关心的是效果上限,这中间需要一个“资源预算”机制来对齐。我给每个模型服务设了三个档位:最低保障、日常水位、峰值上限。最低保障是保证服务不挂的资源,日常水位是应对正常流量波动的资源,峰值上限是宁可牺牲一点延迟也不允许超出的资源红线。
这三个档位的数值,不是拍脑袋定的,是通过压测和容量预估算出来的。压测得出单个实例能扛的QPS,再拿日常和峰值流量除以单实例能力,就可以算出最低需要的实例数。我给这个模型服务预留了冗余,日常水位是16个实例,每秒能扛800QPS,峰值流量再翻一倍到1600QPS时,有备用资源池可以调度。
弹性伸缩的自愈逻辑也很关键。我写了一个简单的算法,根据队列积压量而不是CPU使用率来触发扩容。因为模型服务的特点是CPU可能不高,但请求已经堵在队列里了。队列积压超过阈值就扩容,低于阈值持续几分钟就缩容,伸缩的冷却时间设在3分钟,避免频繁抖动。这套机制上线后,大促期间的资源成本比往年降了大概三成,服务稳定性反而更好了,一次都没有因为容量不足挂掉。
4. 常见问题与排查技巧实录
4.1 显存不够用:不是加卡,先看谁在吃
很多算法同学一遇到“CUDA out of memory”第一反应就是换大显存的卡,或者开模型并行,但其实多数情况下是这个坑:显存碎片化或者中间激活没有及时释放。我排查过一个案例,模型本身只有2GB参数,但显存报错说需要20GB,一看就是中间激活的峰值太高。
排查工具我推荐nvidia-smi盯实时显存,再用torch.cuda.max_memory_allocated看峰值,然后用torch.profiler定位到具体是哪个算子把显存顶上去的。常见的解法有几个:减小batch size但增加微批次数量,用梯度累积来保持整体训练效果不变;把中间激活用checkpoint技术重新计算而不是全部保存,显存占用能大幅下降,代价是训练时间会多出约20%。
另外要注意显存碎片。PyTorch缓存分配器有时会出现这样的情况:总量足够,但空隙太多凑不出一块连续的。遇到这种情况,torch.cuda.empty_cache()可以缓解,更彻底的是在代码里显式用torch.cuda.memory_reserved()监控,发现碎片率过高就把缓存重置一下。实在不行才考虑换卡,顺序别搞反。
4.2 模型速度时快时慢:排查CPU与GPU之间的等待链
线上推理服务最恶心的一个问题:时快时慢,飘忽不定。同样的请求,上一秒延迟80ms,下一秒变成200ms。这种抖动大多数时候不是模型本身的问题,而是CPU与GPU之间的协作节奏乱了。
我一般按这个顺序排查:先看CPU占用率,如果CPU持续打满,说明预处理和后处理是瓶颈,数据在CPU侧排队;再看GPU利用率的波动情况,如果GPU利用率一会儿90%一会儿30%,说明数据供给不稳定,问题在IO或者数据管道;如果GPU利用率一直很高,但延迟依然抖动,那可能是多个实例共享同一块GPU,算力被邻居抢占了,这时候要么限制每个实例的算力配额,要么换独立GPU部署。
还要看CPU和GPU的工作深度是不是匹配。CPU负责数据加载和预处理,GPU负责计算,两者之间需要一个异步队列做缓冲。PyTorch的DataLoader用prefetch_factor来设置预取深度,num_workers决定了并行加载能力。调优的目标是让CPU永远比GPU快半拍,GPU永远有活干,一旦出现CPU等GPU或者GPU等CPU的情况,延迟就会开始飘。
4.3 推理结果和离线评测不一致:精度差异从哪来
这是最隐蔽的坑。算法同学离线评测AUC是0.85,线上推理结果一验证只有0.81,怎么找都找不到原因。这种情况十有八九是训练和推理的数据处理逻辑没对齐,或者精度和数值环境变了。
数据处理逻辑不一致的例子:训练时做了归一化,用的是全量数据的均值和方差,但线上推理时用的是batch内的统计量;训练时用了数据增强,线上推理时没关;分词器的版本号线上和离线不一样,导致同一个句子被切成不同的token序列。这些问题排查起来极其费时间,我的建议是从入口开始,把线上一个个请求的输入特征dump下来,和离线同一批请求的特征做逐字段比对,差异会很快浮出水面。
数值精度也是一样,训练用FP32、推理用FP16,大部分case没问题,但在极端数值分布下会有偏差。可以分层做精度对比,逐层计算输出的余弦相似度,找到差异最大的layer,再决定是调整量化策略还是把这层保留高精度。我处理过的一个BERT模型,就是因为LayerNorm在FP16下精度损失过大,导致线上和离线差了0.4个点,把那层单独切回FP32就完全解决了。
4.4 超时重试导致雪崩:一个请求变100个
最后一个要重点提醒的问题:超时重试引发的雪崩效应。这是个我亲历的事故。线上服务偶发超时,客户端加了重试机制,超时就重新发一个请求。结果某个实例因为磁盘IO抖动,响应慢了1秒,触发了大量重试,瞬间10倍的流量打过来,把服务直接打垮了,紧接着雪崩又引发了集群层面的故障。
到现在我仍然坚持这样的设计原则:重试一定要有上限,通常不超过2次,且每次重试要有递增的退避时间;最好再加一点随机扰动,防止所有客户端在同一时间重试。但更关键的是,重试必须限制在“不安全的失败”上。如果请求已经到达服务端,只是响应超时,服务端可能已经执行完逻辑了,这个时候客户端重试就可能导致重复写入。要在接口层面做幂等设计,用请求ID做去重,这个ID在客户端生成,服务端在窗口期内只处理一次。
脑海里的那根弦永远是:任何系统都可能出故障,关键是故障出现时不要让小问题滚成大灾难。我见过太多服务不是因为本身能力不够挂掉的,而是被各种重试、补偿、回调打了个措手不及。协同工作做得再好,如果连最基本的流量治理都没做扎实,就像建了一栋装修精美的房子,地基却不稳,早晚要出事。
聊到这里,我心里还是有很多感触的。算法和infra的协同,说到底不是技术问题,是人的问题。我在两边都待过,知道算法同学背负着指标压力,也理解infra同学肩负着稳定性责任。这两个目标天然有冲突,但真正优秀的团队不是让一方迁就另一方,而是建立一个能持续翻译和转译的共同框架。我常用的一个笨办法是:每周固定一次“性能晨会”,两边坐在一起,把上周上线的模型跑一遍profiler,逐层过耗时和资源,谁的问题谁认领,下周二来复盘。这个会议每次都很有价值,很多冲突都被消灭在萌芽里了。
再分享一个小技巧:给算法同学一个自助的性能诊断面板,让他们在上线前自己就能看到自己模型的资源画像,不用每次来问infra。这个面板后来成了我们团队的标配,算法同学上线的自觉性高了很多,很多明显的问题在开发阶段就被发现和优化掉了,根本走不到上线的环节。我觉得这就是协同的最高境界——不是解决问题,而是让问题根本不会出现。