跑过LLM推理服务的人大概都见过这个场景一张A100上排了几百个请求旁边一张3090的利用率却只有个位数流水线里某个前置stage像血栓一样把整条链路卡死。GPU集群从来不是机房规划图上的整齐划一而是日积月累拼出来的“百家被”——不同代际的卡、不同大小的显存、NVLink和PCIe混在一起真正跑起来才知道有多碎。FlexPipe这个项目做的就一件事让LLM推理流水线在这种碎片化GPU集群里动态换挡按负载变化实时调整模型层在哪些卡上、一个stage放几层、batch推到多高整个过程不停服也不丢请求。这篇文章把这套东西从设计动机到落地细节完整梳理一遍。如果你正在维护一套多卡推理服务或者手里有几台型号不齐的机器想跑大模型又或者只是好奇“流水线并行在真实环境里到底怎么调”那这篇应该能给你一些参考。内容偏工程我会把踩过的坑也一起写出来。1. 碎片化集群和静态流水线为什么水火不容1.1 你集群里的GPU早就不“整齐”了先描述一个很多人不愿意面对的现实所谓GPU集群通常是不同时间、不同批次采购回来的机器拼出来的。有人工智能项目初期买的消费级卡有后来申请下来的数据中心卡中间可能还混着别人用完归还的裸金属机器。这些卡不仅算力不一样显存不一样更麻烦的是互联方式完全不同——同一台机器里两卡之间可能走NVLink跨机器就得走万兆网或者PCIe交换机。这种异构性在跑训练的时候还好因为训练任务时间久、负载均匀你可以在调度层做“同一型号的卡分给同一个任务”。但推理服务不一样它是常驻的随时有请求进来。你不能为了“让任务匹配卡”就把服务停了再重排一次。于是很多人选择了最朴素的做法根据每张卡的情况把模型按层静态切分到多张卡上然后就这样跑着。切分一次永远不换。这个做法在卡型整齐的集群里问题不大但在碎片化集群里坑比想象中多得多。1.2 静态切分的三个死穴第一个死穴是木桶效应。流水线并行的吞吐上限取决于最慢的那个stage不是最快的。假设我把80层模型切成四段A100放30层3090放20层剩下两张24G的卡分别放15层。纸面上算下来每段显存都够但一跑起来3090那段的计算速度明显跟不上上游喂进来的微批次结果A100就算有再大的余力也只能迁就下游的节奏。整条流水线的有效吞吐被一张中端卡压着利用率账面上少了一大截。第二个死穴是显存分配僵化。推理阶段的显存大头有两个模型权重和KV cache缓冲区。权重是静态的但KV cache是动态的它跟着并发请求数和上下文长度走。静态切分的时候你会给每张卡预留一块KV cache空间可预留多少是拍脑袋的。等线上请求一变比如突然来了一批长文档问答上下文长度从2K涨到16KKV cache占用瞬间翻几倍。某个stage的显存先爆系统只能把整条流水线的batch size往下压所有卡跟着一起受罪。第三个死穴是负载波动和静态配置天然错位。在线推理的请求到达率是有峰谷的而且不同时段请求的特征也不一样。可能是早上短query多、下午长prompt多、晚上流式生成多。不同负载形态下最优的stage划分、batch上限、并行度配置都不一样。静态配置只能选一个“平均最优”的点但这个点在任何极端负载下都不是最优的。说得直白一点你开车在市区和高速上用同一个挡位油耗和体验都不会好。这三个死穴加在一起服务的尾延迟和吞吐就变成了玄学白天还好一到高峰期某些卡忙死某些卡闲死排障的时候看监控图整条链路一块红一块绿你明知道问题在哪却拿不到任何能动态调整的手段。2. 换挡思路把流水线配置做成一套可切换的状态FlexPipe要解决的就是让流水线配置不再是“烧死在显卡里的死配置”而变成一种运行时可切换的状态。这个思路听上去简单但落地的时候有几个关键设计点先讲最核心的。2.1 为什么是离散挡位而不是连续自动调节第一反应可能是你都动态了干脆做一个完全自动的最优化器隔几秒就算一次最优切分然后实时调整不就行了这个想法我们一开始也试过很快就放弃了。原因有三个。第一连续调节会让系统进入震荡状态。模型层数、stage数量、batch上限这些参数是相互耦合的你调了stage数KV cache的空间分布就要重新算batch上限也要跟着变。一个参数动了另一组参数又会被推离最优区结果整个系统一直在“改配置”的路上永远稳定不下来。离散挡位相当于在参数空间里选了几个anchor point任何时刻系统都明确知道自己处于哪个挡位、下一个能去哪个挡位不会出现来回横跳的情况。第二离散挡位可解释、可审计。运维和开发同学不需要理解一个复杂的优化器在算什么只需要知道当前是“均衡挡”还是“延迟优先挡”。出了问题回滚也简单从S挡切回B挡就一条命令的事。连续调节要是出了毛病你连上一次改了什么都不知道。第三工程实现成本差一个量级。连续调节意味着每一套stage配置都要预先准备好、动态生成权重迁移计划这对资源协调器的要求极高。离散挡位只需要预定义有限套配置每次切换都走同一套成熟的迁移逻辑稳定性和代码量都可控。2.2 FlexPipe的三种挡位定义我们最终定了三个挡位名字起得比较像车档方便团队沟通。挡位目标典型stage数batch上限策略适用场景D挡Depth吞吐优先尽量把层堆到大显存卡少stage平均层数多高batch吃满大卡整体负载高模型需要常驻所有卡B挡Balanced均衡各stage负载尽量接近中按显存比例分配中等batch留KV缓存余量默认状态负载形态未知S挡Sprint延迟优先流水线填充时间短多stage平均层数少低batch短队列高峰突发短query密集端到端延迟压力大这三个挡位不是凭空定的。我们在离线阶段会把模型在不同stage划分、不同batch上限的组合全部跑一遍benchmark然后画出Pareto前沿。D挡和S挡分别是这个前沿上偏向吞吐端和偏向延迟端的两个点B挡取中间。这么做的好处是任何一次“换挡”都不是盲调而是从预先验证过的配置集合中选一个更合适的风险小很多。2.3 换挡看的四个仪表盘别只盯着GPU利用率决定什么时候换挡是整个系统里最考验“经验”的部分。最直观的指标是GPU利用率但只看它一定会被坑。比如一张卡上的SM忙等率很高那确实是计算密集但有时候GPU利用率虚高是因为主循环在空转等待上游数据SM一直在跑空指令。所以单看利用率会误判。我们实际采用的是一组信号的组合每个信号代表流水线某一维度的健康度排队深度和等待时长每个stage入口都有一个微批次队列队列越长说明上游产出的速度大于下游消费的速度瓶颈已经形成。GPU真实算力占用看SM busy占比和显存占用SM busy高说明这个stage是真的算不过来而不仅仅是等待。KV cache水位每个stage的KV cache缓冲区用了多少水位超过阈值意味着随时会触发显存溢出保护接下来就是被迫降batch。尾延迟趋势统计端到端P99在一个滑动窗口内的变化斜率它反映的是用户可感知的服务质量变化。把这四个信号放进一个规则引擎里就能做到“有依据地换挡”。比如队列深度连续两个观测周期上涨、A100上的SM busy拉满、同时3090的SM busy还不到30%这就明确提示我们瓶颈在不平衡上要么把A100上的一部分层挪到其他空闲卡上要么把batch上限收一收让慢卡不要积压那么多活。2.4 抑抖策略换挡也要有回火现实中任何系统都会有噪声如果信号一抖动就立刻换挡整个集群就乱套了。我们给换挡动作设计了一套抑抖策略思路和TCP拥塞控制的退避很像。每个决策窗口取30秒的采样数据要求在连续两个窗口内触发条件都成立才进入换挡流程。一旦执行完一次换挡系统进入至少3分钟的冷却期冷却期内不再接受新的换挡请求。如果负载在冷却期结束后仍然偏离目标挡位再评估下一次切换。另外我们还提供了“高峰期自动允许、低峰期只提示不执行”的开关因为低峰期没必要为了几个请求折腾一次大迁移提示一下让值班同学知道就行。这套策略跑下来换挡次数比想象中少很多但每一次换挡都打在节骨眼上。回头复盘的时候决策日志里每一条都有明确的触发条件而不是“系统觉得不太行”。3. 换挡执行细节权重迁移、KV cache交接、请求不能断设计思路只是纸上谈兵真正难的是怎么在服务不中断的情况下把一套配置换成另一套。这一章讲执行机制是FlexPipe最花功夫的部分。3.1 双缓冲先铺好路再换道一次换挡要动的是模型层的分布说白了就是把某些transformer层从一张卡搬到另一张卡。最笨的办法是把服务停了、重新加载模型、再切流但这对在线推理服务是不可接受的。我们用了一个类似蓝绿部署的双缓冲机制。协调器决定换挡后不会立刻杀掉旧stage而是在目标卡上预分配显存把需要迁移的层权重先复制过去。这个复制过程用的是cudaMemcpyAsync可以异步执行不阻塞旧stage的推理主循环。如果目标卡和源卡之间走NVLink复制几十GB权重也就一两秒的事就算走PCIe也不会打断请求处理只是占用一部分总线带宽。等权重复制到60%左右我们才在路由层发一个新配置版本把新请求切到新stage路径上。老stage继续服务完手里已有的微批次然后就自然空闲了再回收显存。整个过程旧stage没有被杀死新stage也不是从零开始加载真正的用户侧影响只有一个复制权重的那几秒里总线带宽有竞争端到端延迟会略升。3.2 请求过渡在途请求跑完新请求走新路流水线并行里请求不是一个个独立走的而是一个个微批次在stage之间流动。换挡的时候最关键的问题是已经进入旧stage的微批次怎么办我们是让它们按老路线继续跑完。因为这些微批次已经在流水线里了如果半路掐掉要么丢请求要么强制重新计算代价都很大。具体做法是给路由层加一个配置版本号。请求进入调度器的时候打上当前版本号只有带旧版本号的请求才会被路由到旧路径。新到达的请求统一打新版本号全部走新路径。旧路径上最后一批微批次流空、KV cache全部释放之后旧stage占用的显存才能回收。这个“流空再收”的过程我们设计成一个状态机避免出现显存还没还完就把整卡挪作他用的情况。3.3 KV cache交接不能直接扔KV cache可能是换挡过程中最容易被忽略但最容易出事的部分。旧stage里缓存了当前一批请求的历史token状态如果直接清掉那些请求的后续生成全部要从头重算延迟直接爆炸。我们的做法是KV cache跟着请求走而不是跟着stage走。换挡前先把每个在途请求的最新KV cache导出到显存里的一个临时共享缓冲区跨卡用NVLink传输不落CPU内存省掉PCIe瓶颈然后新stage从缓冲区把KV cache导入自己的显存块。因为这个操作发生在微批次边界上单次迁移的数据量不大端到端影响被控制在几十毫秒内。实际操作中我们发现一个很tricky的细节不能把KV cache整理完再开始复制权重那样会让旧stage“停摆”太长时间。正确顺序是先复制权重异步不阻塞请求等权重复制完了再在下一个微批次边界上切换KV cache路径。两步分开做把不可控的卡壳时间压缩到最短。3.4 显存碎片动态迁移的隐形代价动态加载、卸载stage反复迁移显存碎片化是必然的。我们在这个坑上栽过跟头。某个测试阶段系统跑了一整天迁移了七八次之后某张卡上能分配出来的KV cache连续块只剩刚开始的一半batch明明没动KV cache水位却老是顶着上限。查了半天才发现是显存块碎了——每个stage的加载需要一大块连续显存旧stage释放出来的空间被多次部分占用之后像瑞士奶酪一样全是洞。后来我们改成了“显存池预留”的方案每张卡上划出一块专用显存池所有stage的权重和KV cache缓冲区都从池里申请迁移的时候只换指针不真正释放物理显存。池内部做定期的地址整理把空闲块合并成大块。这个方案让显存水位稳定多了换挡后的batch调整也更快生效。代价是显存利用率稍微降低了一些但换来的是动态调度的可用性值了。4. 实测数据4机混杂集群上的110B模型对比理论讲再多不如看一次实测。我们在某公司的一批存量机器上搭了一个模拟生产环境专门验证FlexPipe的效果。4.1 测试环境集群共4台机器GPU型号故意选得五花八门节点GPU配置互联方式节点A2×A100 80G双卡NVLink节点B1×A800 80G 1×4090 24GPCIe交换机节点C2×3090 24G双卡NVLink节点D1×4090 24G单卡模型是一个约110B参数的对话模型FP16权重约220GB。按这个配置算集群总显存大约256GB权重加完只剩30GB左右的余量给KV cache非常紧张。这正是碎片化集群的真实压力不是“空间够大随便放”而是“刚好能塞进去但毫无冗余”任何负载变化都可能触发显存危机。4.2 对照结果我们拿“静态最优切分”和“FlexPipe动态换挡”做了对比。静态最优切分的意思是我们先用benchmark把所有可能的静态度量组合跑一遍选出最稳的那个配置然后固定跑一整天。这是静态方案的天花板不是随手乱切的水平。指标静态最优配置FlexPipe动态配置稳定期TPStokens/s142185稳定期P99延迟ms560470高峰期P99延迟ms1100650GPU平均利用率61%78%显存利用率74%82%稳定期TPS提升了约30%高峰期P99降低了约41%。这个提升不是靠超频或者压缩KV cache换来的纯粹是把载荷挪到了更合适的GPU上。静态配置里3090那张卡长期是瓶颈FlexPipe在高峰期会把它的部分层挪到空闲的4090和A800上把3090减负整条流水线的节拍就顺了。4.3 一个印象深刻的波动场景测试过程中有一段特别典型的高峰冲击。下午某个时刻一批长上下文批量问答任务突然灌进来上下文长度从2K飙到16KKV cache占用量直接翻倍。静态配置下3090因为显存小KV cache水位率先爆掉系统触发保护机制把batch降到原来的三分之一整条流水线吞吐暴跌P99从560ms一路涨到1.1秒。FlexPipe在同样的冲击下走了一次完整换挡监控发现3090的KV cache水位连续两个窗口超阈值同时A100的SM busy并不高说明有算力闲置。系统在冷却期结束后从B挡升到S挡把3090上的8层模型迁到A800和空闲的4090上重新分配batch上限。整个迁移用了约4秒之后P99在30秒内回落到650ms附近。那次换挡的决策日志到现在我还留着一条一条看下来每一步都清晰可解释。5. 踩过的坑和工程上要补的课FlexPipe从原型到能稳定跑中间踩了不少坑挑几个印象最深的写在这里希望能让你少走弯路。5.1 迁移风暴决策协调比迁移本身更重要早期版本我们给每个stage都配了独立的条件判断逻辑结果出过这么一件事某台A100利用率高想到S挡同时一张3090显存紧张也想换挡。两个判断几乎同时触发各自发起了迁移结果两张卡同时在走NVLink复制权重总线带宽被占满正常推理请求的延迟反而飙了。后来把所有换挡决策收拢到一个全局仲裁器里每次只允许一个迁移任务在跑。每个stage的触发信号先上报到仲裁器由仲裁器统一打分、选目标挡位、发迁移指令。仲裁器还加了一个随机退避两台机器同时上报时一台立即执行另一台退避15~30秒再评估。这个改动之后“迁移风暴”再也没有出现过。5.2 噪声数据导致乱换挡推理延迟是出了名的重尾分布P99本身波动就大。早期我们直接用原始P99做触发信号结果系统在一个小时内换了三次挡全是因为网络抖动造成的一个尖峰。后来改成EWMA指数加权移动平均平滑并且要求连续两个窗口都超阈值才触发误报率降了一个量级。还有一点很容易被忽略跨节点的时钟偏移。如果两台机器的时间基准不一样你把两边的采样数据拼在一起做判断会得出完全错误的结论。所以我们在所有采集agent上强制配了NTP同步并且给每一条采样记录打上单调时钟的seq号而不是墙钟时间。5.3 两个容易被忽略的工程点第一个是路由版本号。换挡之后如果某个客户端或者上游服务还缓存着旧stage的地址它发来的请求会直接打到已经停用的stage上表现为“神秘超时”。我们在路由层强制所有请求带配置版本号版本号不匹配就重新查路由表绝不放行。这个改动小但能救很多命。第二个是权重加载的进程隔离。早期我们在GPU推理进程里直接发起权重复制结果复制几十GB权重的时候Python的GIL和内存分配器把推理主线程卡了好几百毫秒。后来改成用独立进程做加载和传输推理进程只接收“权重已就绪”的信号。这个优化花了半天时间收益非常大。5.4 从换挡到换轨后续演进方向FlexPipe目前只动了流水线并行这个维度但同样的思路可以往两个方向延伸。一个是和自动扩缩容联动换挡解决的是“总量不变配比调整”的问题如果负载持续走高该加机器还是得加机器两者应该协同工作而不是各自为政。另一个方向是支持MoE模型的专家层迁移MoE的专家分发特性和稠密模型差异很大换挡逻辑需要在token路由层面做更多文章。最后想说的动态换挡这件事真正难的地方不在算法也不在工程而是让团队相信“系统会在运行时自己改拓扑”是安全的。我们用了最笨的方法建立信任先跑了一周纯观察模式每一条换挡建议都打在日志里但不执行。值班同学看着那些建议对照当时的监控曲线一条条验证直到确认“系统判断得比人还准”才敢放开自动挡。如果你也想在自建集群上做类似的事我建议从最小的闭环开始先只做“显存水位触发减负”这一条规则在非核心服务上验证一两个月再逐步加复杂度。别一上来就搞全自动多挡位切换那个复杂度会把你吞噬掉的。