今天想聊一个我们折腾了很久的问题:GPU集群里的卡型号杂七杂八,既有后补的高算力大显存新卡,也有早年买的小显存旧卡,混在一起跑LLM推理流水线时,静态切分总是让整条链路卡在最弱的那一段上。后来我们做了个叫FlexPipe的内部原型,核心思路就一条:让LLM推理流水线在碎片化GPU集群里能“动态换挡”——根据实时负载、每一张卡的实时状态,动态调整流水线的深度、并行度、微批量大小,把以前白白浪费的碎片算力重新用起来。如果你也遇到过“明明还有一堆卡闲着,但延迟就是压不下去”的情况,这篇应该能给你一些可落地的思路。
1. 为什么固定流水线在碎片化集群里总是“卡壳”
1.1 流水线并行不是“层数除以卡数”这么简单
很多刚接触LLM推理优化的同学,第一反应是把模型按层平均切开,每张卡放几层,请求像工厂流水线一样从第一段流到最后一层。这个想法在完全同构的集群里是成立的:每张卡算力差不多,显存差不多,每个stage耗时接近,流水线能跑得比较顺。
但一旦进入混卡集群,问题立刻暴露。流水线并行有一个很要命的特性:每个step的耗时取决于最慢的stage。所有stage是同步推进的,快卡跑完了不能继续下一批,必须在buffer那里等慢卡。结果就是快卡的实际利用率可能只有50%,甚至更低,而慢卡永远处于过载状态。
我印象很深的一次调优,某张旧卡上恰好放了几层attention计算很重的层,单层耗时是新卡的三倍。整条流水线的QPS直接掉到理论值的一半左右。你问能不能让快卡多分几层?理论可以,但显存又不够——旧卡显存小,放不下那么多层权重和KV Cache。这是一个算力和显存互相拉扯的问题,静态划分很难两边都优化。
1.2 碎片化不只是“显存大小不一样”
很多人把“碎片化集群”理解为“显存大小不同的卡混在一起”,但实际部署中碎片化至少有三个层面,我习惯叫它们算力碎片、显存碎片和时间碎片。
算力碎片好理解:不同代际的计算卡,浮点算力可以差好几倍。同样是跑Transformer的FFN层,新卡一个step可能只要30毫秒,旧卡要90毫秒。显存碎片也不只是总容量差异,还有单卡容量不匹配。比如某张卡能放下8层,另一张只能放下4层,如果机械地平均切分,放不下的卡就成了“过不了编译”的约束点。时间碎片则更隐蔽:集群里有一些卡本身空闲,但它们是那种“当前没任务,可下一小时要被其他作业占用”的状态。静态调度器往往不敢用这种时隙不稳定的卡,因为流水线一旦建立,中途少一张卡整个推理服务就得中断。
FlexPipe最初想解决的,就是把这三类碎片都纳入调度考虑,而不是只盯着“哪张卡显存够大”。
1.3 “动态换挡”到底换的是什么
“换挡”这个类比不是随便起的。汽车在平路、上坡、高速上,需要不同档位让发动机保持在高效区间;推理流水线面对不同请求量、不同目标延迟、不同集群可用资源,也需要在多种运行形态之间切换。
在我这个项目里,换挡具体换的是三类东西:第一是流水线的stage数量和stage边界,也就是模型层怎么切、每张卡负责哪些层;第二是并行度组合,比如某个stage能不能放多副本,用数据并行分摊流量;第三是调度参数,最常见的就是micro-batch大小和一次往流水线里灌多少个请求。
固定配置是一条直线,动态换挡是一组曲线。你需要事先准备好几套可切换的“档位”,然后由控制逻辑根据实时监控决定当下应该挂哪个档。这个思想,比“加机器”“减机器”更贴近LLM推理流水线的实际瓶颈。
2. 动态换挡的关键设计:档位模型与决策机制
2.1 档位不是玄学,是几套可量化的流水线配置
做动态换挡第一件事,不是写调度代码,而是先把“档位”定义清楚。我们内部初期定义了三个基础档位:延迟优先档、吞吐优先档、均衡档。每个档位不只是一个名字,而是对应一组完整可执行的流水线参数。
延迟优先档的目标是把P99延迟压到最低。通常采用更浅的流水线,避免请求穿过太多stage,同时把每个stage的micro-batch调小,降低单batch在慢卡上的排队时间。必要的时候,还会给热点stage设置多个副本来并行消费。吞吐优先档则相反,它会加深流水线,把更多小卡充分利用起来,同时调大batch size,让每一张卡都在接近饱和的状态下工作,代价是单个请求的延迟会上升。均衡档在这两者之间折中,适合流量波动不大但对服务质量要求也比较高的场景。
| 档位 | 目标 | 典型stage数 | micro-batch | 多副本策略 | 适用场景 |
|---|---|---|---|---|---|
| 延迟优先档 | P99低 | 浅(例如2-4) | 小(1-4) | 热点stage多副本 | 在线交互、RAG问答 |
| 均衡档 | 延迟/吞吐兼顾 | 中(例如4-8) | 中(4-8) | 关键stage复制 | 混合流量 |
| 吞吐优先档 | QPS高 | 深(例如8-12) | 大(8-16) | 少副本,走深管线 | 离线批量、批处理 |
这个表只是示例,实际档位参数必须结合模型层数和集群拓扑去算。比如模型一共32层,你不可能切出一个15层的stage再配一个17层的stage,除非某张卡特别强,否则stage间负载会失衡。更合理的做法是让每个stage包含的层数不同,但期望执行时间接近。
2.2 换挡决策要看哪些信号
动态换挡最怕的就是“频繁乱换”。如果请求量稍微抖一下就去换挡,系统会一直处于迁移状态,性能反而更差。我们后来用的是滞回触发加平滑信号的组合方案。
监控端每两秒采集一次指标,包括每个stage的利用率、平均排队长度、最近一分钟的实际QPS、P50和P99延迟。决策端不是直接拿原始值判断,而是先做指数移动平均,把瞬时毛刺滤掉。然后看两个核心条件:第一个是瓶颈倾斜度,如果某个stage利用率长期大于90%,而其他stage利用率都低于60%,说明当前档位的负载分配已经严重不均衡,换挡有明确收益;第二个是延迟达标率,如果P99连续超过目标值一定时间,同时总体吞吐还有上升空间,就切到更偏向延迟的档位。
滞回的意思是:不能超过阈值就立刻换,要设置一个“进入区间”和“退出区间”。比如利用率失衡,只有连续3个采样周期都满足条件才触发切换;换完之后,除非新档位也连续3次触发另一组阈值,否则不允许再换回去。这个缓冲极大减少了“乒乓效应”。
2.3 为什么靠“多实例扩缩容”救不了碎片集群
有人会说,既然吞吐不够,多拉几个模型副本,前面加负载均衡不就行了?这在普通Web服务上是对的,但在LLM推理场景里有两个坎迈不过去。
第一个坎是显存。一个模型副本完整加载可能就要几十GB显存。碎片化集群里往往找不到一张足够大的卡去放整个副本,就算能找到,多副本的资源开销也很高,而且副本之间如何路由也需要额外管理。第二个坎是尾延迟。多副本模式请求是水平分发的,每个副本内部仍然是固定流水线,如果某个副本恰好在慢卡上,这部分请求延迟就会很糟。负载均衡无法改变单副本内部的瓶颈。
FlexPipe的做法更像“在模型副本内部做结构优化”:不新起整个副本,而是调整现有流水线,把负载从慢卡往快卡上挪,或者把瓶颈stage拆成两个stage并行处理。这种调整的粒度比autoscaling细得多,也更贴合GPU碎片化的现状。
3. 实操落地:从资源画像到换挡执行的完整链路
3.1 第一步:给集群做“体检”
动态换挡的前提是你必须了解每一张卡的真实能力。这里我强烈建议不要只看显卡标称的“显存容量”,一定要做实际探测。我们会在集群空闲窗口执行一轮轻量探测任务:对每张卡依次跑几个代表性算子,比如大矩阵乘法、注意力计算和全连接层,分别记录耗时和显存占用峰值;再用两两互发数据的方式,粗测任意两张卡之间的互连带宽和延迟。
探测结果会汇总成一个资源画像JSON或YAML文件。比如下面这个简化的格式:
nodes: - node_id: gpu-001 generation: old memory_gb: 24 compute_score: 1.0 max_batch_microbatch: 4 interconnect: to_gpu_002: bandwidth_gbps: 12 latency_us: 5 - node_id: gpu-002 generation: new memory_gb: 48 compute_score: 3.2 max_batch_microbatch: 8 interconnect: to_gpu_001: bandwidth_gbps: 12 latency_us: 5有人会觉得搞这么细是不是过度工程。但我可以负责任地说,如果不做资源画像,“动态换挡”就只是在几个静态配置之间瞎切。你连每张卡能承受多大的micro-batch都不清楚,怎么判断换到吞吐档之后哪张卡先爆显存?
3.2 第二步:根据画像生成候选档位
有了资源画像,下一步是自动计算候选档位,而不是手写死配置。我们用一个简单的“虚拟放置器”——本质上是一个背包搜索加模拟评估器——输入模型层数、每层的权重大小和计算量估算、资源画像,输出几套可行的流水线切分方案。
比如32层的模型,候选方案A可能把层分布分成[4,6,10,12],让算力最强的卡承担最多的层;候选方案B则可能为某几个耗时特别大的层单独成一个stage,然后放两个副本来并行。每一套方案都要在模拟器里跑一遍,估算出理论吞吐和理论P99,再把这些指标连同实际运行参数打包成一个档位配置。
这一步我们踩过最大的坑是:模拟器估算的耗时和真实推理耗时差异很大。主要原因是LLM推理的KV Cache显存分配是动态的,重叠的访存和计算有时候会互相掩盖。所以后来模拟器只负责生成“看起来平衡”的候选,最终档位是否好用,必须靠在线监控反馈来修正。
每个档位配置的核心元素如下:
- stage列表:每个stage包含哪些层、部署在哪张卡上
- 并行副本数:某些stage是否有多个并行实例
- micro-batch大小:单次进入流水线的样本批大小
- 换挡阈值:这个档位适合的QPS区间和目标延迟
3.3 第三步:控制环怎么设计?
动态换挡的背后是一个经典的控制环:监控器采集数据,决策器判断是否需要换挡,执行器负责把流水线从旧档位切到新档位。下面是我简化后的决策伪代码,已经去掉了集群细节,但整体逻辑就是这样:
def maybe_shift_gear(monitor, current_gear, target_latency): stats = monitor.sample() smooth_stats = ema(stats, alpha=0.3) # 1. 判断是否有明显瓶颈倾斜 utilizations = smooth_stats.stage_utilization() max_util = max(utilizations) min_util = min(utilizations) load_imbalance_trigger = max_util > 0.9 and min_util < 0.6 # 2. 判断延迟是否持续超标 latency_over = smooth_stats.p99 > target_latency * 1.2 latency_under = smooth_stats.p99 < target_latency * 0.7 # 3. 用滞回区间做切换 if load_imbalance_trigger and current_gear != "throughput": return "throughput" if latency_over and current_gear != "latency": return "latency" if latency_under and current_gear != "balanced": return "balanced" return current_gear判断逻辑本身不复杂,复杂的是决策之后执行器怎么安全落地。我们的原则是:换挡期间不允许丢失任何正在处理的请求,也不允许把P99拉到不可接受的程度。
执行器实际上分三步走。第一步是“影子预热”:在新目标卡上初始化新stage,加载权重和计算图,但是不接入主流量,先让它跟着旧stage接收同样的请求副本来做预热,确保缓存和显存分配都稳定下来。第二步是“排水”:旧stage停止接收新的micro-batch,但已经在流水线里的请求继续处理完,这个过程也叫drain。第三步才是“切换”:确认旧stage队列清空、新stage影子状态健康之后,把流量入口一次性指向新stage,释放旧stage占用的显存。
这个“先建后切”的流程说起来简单,真正落地要处理很多细节。比如影子预热阶段新stage会多占一份显存,如果集群显存本来就很紧,可能没有空间做预热。我们后来针对这种情况加了“降级模式”:如果找不到足够显存做完整影子预热,就先在目标卡上只分配权重不分配KV Cache池,等切换完成后再用流式方式逐步扩大KV Cache池。
3.4 切换点选择与请求状态迁移
LLM推理和普通无状态服务有一个本质区别:请求在流水线里流动时,每一层产生的KV Cache都放在对应stage的显存里,而且这些缓存是连续的、动态增长的。如果你在请求执行到一半的时候,把某个stage的层迁移到另一张卡,你不仅得迁移模型权重,还得迁移那一时刻所有正在生成序列的KV Cache块。
这个开销非常大。所以我们明确了一个规则:动态换挡只能发生在安全切换点。所谓安全切换点,就是流水线里没有正在执行的in-flight请求,或者stage边界处没有等待处理的micro-batch。为了制造这样的切换点,执行器必须主动限制新请求进入,让流水线快速排空。
听起来像是牺牲了一部分实时性,但实测下来大多数场景可以接受。我们通常选在流量低峰期或请求间隙执行换挡。如果你的业务对延迟特别敏感,可以考虑用双缓冲:保留旧档位的完整副本,切到新档位后旧副本不立刻释放,而是保留几百毫秒,一旦新档位表现异常就切回去。代价是多占一份显存,换来的是一颗“后悔药”。
4. 踩坑实录:动态换挡的五个常见问题与排查
4.1 换挡瞬间延迟毛刺从哪来
第一次灰度切换时,我们一度觉得换挡成功了:平均延迟变化不大,但P99却从50毫秒飙到2秒。后来定位发现,问题出在影子预热阶段。新stage在预热时虽然不接主流量,但它要和旧stage共享GPU的算力和显存带宽,导致旧stage执行变慢,尾延迟升高。
这个问题比想象中隐蔽,因为平均延迟可能只涨了10%,不知道的人会以为是系统偶然抖动。解决办法有两个:一是预热耗时要控制,不要让影子stage长时间空转;二是优先给影子stage分配与其他stage不同的硬件资源,比如利用不同卡上的空闲流处理器,或者限制影子stage的并发,减少对主路径的影响。我们在配置里给每个影子stage加了一个“预热并发上限”,默认只占单个卡算力的20%。
4.2 显存碎片和KV Cache池冲突
换挡之后,stage边界变了,KV Cache池也需要重新分配。如果之前那张卡上的显存被碎片化得很严重,新stage可能无法申请到一块足够大的连续显存。刚开始我们用的是标准显存分配器,结果频繁出现“明明显存总量够,但申请大块失败”的情况。
后来我们对KV Cache池改用预分配加页表化管理的模式,也就是说,在集群空闲时先把大块显存切成固定大小的块池,stage运行中按页取用。这样即使显存物理上不连续,逻辑上也能把KV Cache块组织起来。遇到换挡,新stage优先从块池里取页,旧stage释放的页也可以立即归还给池子复用。
4.3 互连带宽才是隐形瓶颈
资源画像阶段我们测了算力和显存,忽略了卡之间的互连带宽。结果有一次换挡后,某两张卡之间的通信负载大增,导致stage间数据传输耗时翻倍。最麻烦的是它不是持续高负载,而是随着batch size增大周期性爆发,在监控上看就是平均带宽不高、但单次传输延迟很大。
后来我们在档位生成时加入了一个约束条件:任何相邻stage之间的预计通信量,不能超过两张卡互连带宽的70%。计算方式很简单,用每token激活张量的大小乘上micro-batch,再乘上每秒处理的batch数。这个约束会拦截掉一些看起来很美观但通信不现实的切分方案。
4.4 换挡收益为负的情况
动态换挡并不是稳赚不赔。如果你切得太频繁,或者新档位带来的收益还覆盖不了权重更新和KV Cache迁移的开销,整体表现反而更差。我们做过一次统计,一次完整换挡在集群内产生的数据移动量可能达到几十GB,如果是跨卡迁移,还会占掉大量互连带宽。
所以决策器不能只看“现在负载失衡”,还得算一笔账:新档位预估能提升多少吞吐,持续运行多久能把迁移开销赚回来。我们内部设置了一个“最短驻留时间”,每次换挡成功之后至少要稳定运行10分钟,如果10分钟内又触发换挡条件,决策器会把阈值自动调高一级。这个机制虽然简单,但非常有效,换挡频率直接降了一大半。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查思路 | 推荐处理 |
|---|---|---|---|
| 换挡后P99明显升高 | 影子预热与主stage争抢算力 | 检查影子stage算力占用 | 限制预热并发上限 |
| 大块显存申请失败 | KV Cache池碎片化 | 查看显存分配日志 | 改用块池页式分配 |
| 换挡后吞吐不升反降 | 通信带宽超阈值 | 监控stage间传输延迟 | 加通信约束重新生成档位 |
| 频繁来回切换 | 阈值滞回区间太小 | 看触发次数统计 | 提高最短驻留时间 |
| 新档位一接入就报OOM | KV Cache预分配不足 | 检查峰值序列长度 | 动态扩池或降低并发 |
最后一个建议是,给每个档位加上健康标记。如果某个档位在上一次运行中触发了OOM或严重超时,决策器就把它标记为“不健康”,短期内不再切换到它,直到模型发布或资源画像刷新。我们之前就是因为没有这个标记,反复切同一个问题档位,浪费了很多调试时间。
做动态换挡这件事,真正难的不是实现换挡动作,而是知道什么时候不该换。我最大的体会是:先让系统具备快速切换的能力,再小心翼翼地用阈值和滞回让切换变得“稀少但精准”。FlexPipe到现在还只是一个原型,但它让我看清楚了一件事——碎片化集群最缺的不是更高端的卡,而是让不同能力的卡各司其职的调度逻辑。如果你也准备动这个方向,建议从资源画像和档位模拟开始,先把“看得见的跑得不平衡”修掉,再考虑高级的预测式换挡。