大模型推理服务跑久了,你会发现一个特别“别扭”的现象:流量高峰时 GPU 算力不够用,毛刺一个接一个;流量低谷时卡又闲得发慌,显存和算力全在空转。我们内部对这类问题纠结了很久,最后干脆做了个项目,名字就叫 DynamicTP,全称是 Dynamic Tensor Parallelism,也就是动态张量并行。这篇文章是系列的第三篇,前面两篇讲的是静态张量并行的原理和基线性能,这一篇我重点聊聊“动态”这两个字到底怎么落地:为什么需要它、架构上动了哪些刀子、实现时踩了哪些坑,以及最后上线压测时的真实表现。如果你正在做大模型推理服务的性能优化,或者正在被 GPU 利用率忽高忽低、扩容缩容麻烦、多租户资源争抢这些问题折磨,这篇内容应该能对得上你的胃口。
这个项目解决的并不是“模型怎么跑得更快”这种单点问题,而是“模型服务怎么在负载波动的情况下,仍然保持高吞吐、低延迟、高资源利用率”的系统性问题。传统的做法是服务启动时就固定好 TP 度,比如模型权重切到 8 张卡上,之后无论请求多还是少,这 8 张卡都被这个服务占着。DynamicTP 的思路则是让 TP 度跟着流量走:请求多了,把并行度从 4 扩到 8;请求少了,从 8 缩回 4,多出来的卡拿去跑别的任务。听起来很美好,但真做起来,要处理的细节远超预期。
1. 项目背景与核心痛点:静态 TP 为什么不够用
1.1 一组来自线上服务的真实数据
先摆一组数据。我们有一个基于 70B 参数模型的推理服务,部署在 8 卡 A100 的节点上,TP=8。压测得到的单卡算力利用率平均只有 37%,但 P99 延迟却经常超标。原因很简单:请求是突发的,平均 QPS 不高,但峰值 QPS 是平均值的 4 到 5 倍。按峰值买卡,低谷期全是浪费;按均值买卡,高峰期直接雪崩。
这个矛盾在静态 TP 下无解,因为静态 TP 的资源配置是“启动时一次性决定,运行中不可变更”的。你们可以回想一下自己的服务,是不是也是这样:模型加载时指定 tensor_parallel_size,然后所有请求都走同一套并行配置。机器再多,一个模型服务也只能占着自己那一份资源,别的任务想用也用不了。
1.2 静态 TP 的三个核心矛盾
我总结了静态 TP 在生产环境里的三个核心矛盾,算是这个项目的立项依据:
第一是资源利用率与延迟指标的矛盾。为了扛住峰值流量,你必须按峰值配置资源,但真实流量的长尾效应导致大部分时间资源是空闲的。SLA 要求 P99 低,成本要求利用率高,静态配置下两者只能二选一。
第二是弹性扩容与部署复杂度的矛盾。有人会说,扛不住就扩容、起新实例嘛。但在大模型推理场景里,起一个新实例要加载模型权重、构建 KV Cache、预热 CUDA 上下文,冷启动时间动辄几十秒到几分钟。流量毛刺就几秒,根本等不起。
第三是多模型、多租户场景的资源碎片化问题。一个 8 卡节点上可能同时跑两个模型服务,一个需要 6 卡、另一个需要 2 卡,但静态 TP 只支持 1、2、4、8 这样的 2 的幂次,6 卡资源根本用不起来。如果 TP 能动态调整,这些碎片资源就能重新组合利用。
1.3 DynamicTP 的定位与设计目标
DynamicTP 的目标很直接:让张量并行的并行度可以在服务运行期间动态调整,资源随流量伸缩,同时不牺牲延迟稳定性。我们给这个项目定了四个硬性指标:
- 动态调整期间,已经在处理中的请求不允许中断,延迟不能出现断崖式恶化;
- 并行度扩展或收缩的操作时间,控制在秒级以内;
- 切换完成后,模型输出质量与静态 TP 完全一致;
- 资源释放后,其他任务可以直接使用,无需重启节点。
这几条指标看着简单,实际执行时把我们折腾得不轻。特别是第一条和第二条,它们本质上是矛盾的:又要热迁移又不许停顿,只能在系统架构上动刀子。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构与核心设计思路
2.1 控制平面与数据平面分离
DynamicTP 的第一个核心设计,是把服务的控制平面和数据平面彻底拆开。控制平面负责决策“当前该用多大的 TP 度”,数据平面负责实际的张量并行计算。两者通过内部消息队列通信,所有的并行度调整指令都走控制面,不直接碰数据面。
这个设计是参考了 SDN 的思路。传统分布式系统里,决策和转发耦合在一起,扩展性和灵活性都受限。DynamicTP 把“决策”抽出来之后,我们可以在控制面里做很复杂的调度逻辑,比如基于流量预测的预调整、基于多租户优先级的资源仲裁,这些都不会污染数据面的主路径。
2.2 请求级并行度路由
DynamicTP 的调度粒度是“请求级别”,而不是“服务级别”。每个请求进来之后,由控制面的 Router 模块决定它被路由到哪个 TP 域。比如当前服务有两个 TP 域:一个 TP=8 的域和一个 TP=4 的域。长上下文、优先级的请求进 TP=8 域,短上下文、低优先级的请求进 TP=4 域。
这个设计的好处是,我们不需要在切换并行度时把已经排队的请求全部清空,而是通过“新请求走新域、老请求留在旧域”的方式,自然地完成迁移。等旧域里的请求全部处理完,旧域就可以安全释放资源。这个思路比强行做 KV Cache 在线迁移要稳妥得多,也是我们最终落地的主方案。
2.3 资源池抽象
我们抽象了一个 ResourcePool 的概念。一个节点或者一个集群的 GPU 资源,被划分成若干独立的资源单元,每个资源单元对应一张卡。DynamicTP 在运行时根据 TP 度动态搭建/拆解资源池。资源池可以嵌套,可以重叠,可以弹性伸缩。模型服务只关心自己当前绑定的资源池,不关心底层 GPU 的具体物理位置。
最终实现时,我们用了两层映射:逻辑 TP Rank 到物理 GPU ID 的映射,以及物理 GPU 到 NCCL 通信域的映射。动态调整并行度,本质上就是修改这两层映射表,然后通过 NCCL 通信域的重新初始化来生效。
2.4 设计目标与静态 TP 的关系
需要说明的是,DynamicTP 不是要完全替代静态 TP。在请求模式稳定、流量波动小的场景下,静态 TP 依然是最优解,因为它没有动态调整的开销。DynamicTP 的目标场景是流量波动明显、多模型混部、成本敏感的生产环境。我们在设计之初就把“退化为静态 TP”做成了一种运行模式,让用户可以按需选择。
3. 核心细节解析与实操要点
3.1 TP 度决策:不能只盯着当前 QPS
TP 度怎么定,是 DynamicTP 最核心的问题。我见过不少方案直接用当前 QPS 和 GPU 利用率做阈值判断,比如利用率超过 80% 就扩,低于 30% 就缩。但实测下来这种方式有个问题:QPS 是滞后的指标,等 QPS 涨上来再扩容,延迟毛刺已经出现了。
我们最终用的是“预测 + 反馈”的双闭环策略。预测环用滑动窗口 + 指数加权移动平均预测未来 10 秒的请求量,结合每个请求的平均 prefill 长度和 decode 步数,估算出需要的总算力,再除以单卡算力得到目标 TP 度。反馈环则监控实际 P99 延迟和 GPU 利用率,如果实际延迟超过目标延迟的 80%,强制触发扩容;如果利用率持续 5 分钟低于 40%,触发缩容。
这里有一个参数需要重点调:平滑系数 alpha。alpha 太大,预测值波动剧烈,TP 度会频繁抖动;alpha 太小,预测值太迟钝,流量突变时反应不过来。我们的经验是 alpha 取 0.2 到 0.3 之间比较合适,同时加上一个 3 次抖动以内的滞回区间,避免 TP 度在临界点附近来回横跳。
3.2 通信拓扑重建:最容易被低估的环节
动态调整 TP 度,意味着 NCCL 通信域要重建。我们一开始以为这只是一个重新初始化的问题,结果被现实狠狠教育了。NCCL 通信域的创建涉及 GPU 间 P2P 连接的探测、拓扑排序、带宽测量,这个过程极其耗时,而且不能在有 CUDA 流正在执行时随意做。
我们的解决方案是维护一个“通信域池”。提前创建好不同 TP 度对应的 NCCL 通信域,比如 TP=2、TP=4、TP=8 各预创建一个,调整并行度时直接切换使用,而不是现场创建。这个池子里的通信域可以复用,前提是涉及的 GPU 集合不变。
但这里有个坑:TP 度从 4 扩到 8 时,新加入的 4 张卡可能之前被别的任务占着。所以我们做了资源预留机制,控制面在预测到即将扩容时,提前 3 秒向资源管理器申请目标 GPU,等申请成功后再触发切换。这样就把通信域初始化的时间和资源等待的时间重叠起来,感知上切换更快。
3.3 权重与 KV Cache 的重分布
TP 度变了,模型的权重切分方式也得变。TP=4 时每张卡存 1/4 层权重,TP=8 时每张卡要存 1/8 层权重。我们一开始想在线做权重重分布,但实测发现 70B 模型的权重在 8 卡间重排需要好几个 GB 的显存拷贝,加上 KV Cache 的迁移,整个切换过程要十几秒,完全不可接受。
后来我们换了个思路:不做在线重分布,而是把权重按最大 TP 度(比如 TP=8)常驻显存,TP=4 运行时只是在逻辑上让每张卡管理 1/4 的权重切片,实际数据还是分散在 8 张卡上,只不过 Rank 0-3 和 Rank 4-7 各负责一半的请求。这样切换 TP 度时,权重根本不用动,只需要改一下计算时的索引范围。
KV Cache 的处理更绕。KV Cache 跟请求绑定,所以我们的迁移策略是“不迁移”,等请求自然结束。新请求进来时,如果新 TP 域已经就绪,就直接在新域上分配 KV Cache;旧域上的请求跑完后,旧域的 KV Cache 随之释放。这个方案我们把切换时间从十几秒降到了 2 秒以内。
3.4 动态 Shape 与 CUDA Graph 的博弈
DynamicTP 遇到的另一个麻烦来自 CUDA Graph。静态 TP 下,推理服务通常会为固定的 batch size、序列长度建 CUDA Graph,省掉 kernel launch 的开销。但动态调整 TP 度后,shape 的范围变大了,逻辑上要为每个可能的 shape 组合都建一份 Graph,显存直接爆炸。
我们最终的取舍是:为小 shape 组合建 Graph,大 shape 走 eager mode。实测发现,小 batch 和小序列时 kernel launch 的开销占比最高,CUDA Graph 收益最大;大 shape 时计算本身耗时很长,launch 开销被摊薄了,不用 Graph 差异不大。这个结论在不同模型上应该都成立,你们在自己优化时也可以参考这个思路。
4. 实操过程:从原型到可服务
4.1 第一步:静态基线压测
DynamicTP 的改造不是一上来就做动态,而是先搭建静态 TP 的完整推理服务,做好性能基线。我们当时用的配置是:
- 模型:70B 参数,FP16
- 单卡显存:80GB A100
- 静态 TP 度:4 和 8 各跑一轮
- 压测工具:自研的请求发生器,模拟真实流量分布
基线数据很关键。我们需要知道 TP=4 和 TP=8 在相同请求分布下的 P99、吞吐、显存占用数字,作为动态方案的目标参考。没有基线,后续的所有优化都是盲人摸象。
4.2 第二步:控制面改造——决策模块的落地
控制面的核心是 TP 决策模块,我们给它起了个代号叫 TPPlanner。它每 500ms 跑一次决策循环,输入是请求队列长度、最近 10 秒的 QPS 走势、平均 prefill 长度、GPU 利用率、当前 TP 度,输出是目标 TP 度,以及一个标志位:是否执行切换。
核心的决策逻辑伪代码如下:
python复制def decide_tp_degree(current_qps: float, avg_prefill_len: float, gpu_util: float, current_tp: int) -> int:
# 预估未来5秒的等效算力需求,单位:TFLOPS
predicted_qps = ewma(current_qps, alpha=0.25)
compute_needed = predicted_qps * avg_prefill_len * TOKEN_FLOPS / 0.5 # 0.5是目标算力利用率
per_gpu_fp16_tflops = 312
target_tp = math.ceil(compute_needed / per_gpu_fp16_tflops)
target_tp = min(max(target_tp, MIN_TP), MAX_TP)
# 滞回控制:避免频繁抖动
if current_tp < target_tp and target_tp - current_tp < 2:
# 扩容不犹豫
pass
elif current_tp > target_tp and current_tp - target_tp < 2:
# 缩容需要持续观察,防止误判
if gpu_util > 0.4:
return current_tp
# 延迟反馈兜底
if p99_latency > target_latency * 0.8:
return min(current_tp + 1, MAX_TP)
return target_tp
这段逻辑看起来简单,但从“能跑”到“稳定”之间,需要调很多参数。比如目标算力利用率 0.5 这个值,我们试过 0.3 到 0.7,最后取 0.5 是因为它能在延迟和成本之间达到平衡:低于 0.4,TP 度偏大,资源浪费严重;高于 0.6,扩容不及时,延迟容易突破红线。
4.3 第三步:数据面改造——切换流程
数据面的切换流程,我按时序拆解一下:
TPPlanner决定将 TP 度从 4 扩到 8;- 控制面通知资源管理器,预占 4 张空闲 GPU;
- 新资源绑定到当前服务,构建 TP=8 的 NCCL 通信域(从通信域池里捞现成的);
- 服务启动一个“影子 TP=8 域”,加载同一套权重(权重常驻,零拷贝);
- Router 的流量分配策略切换,新请求全量进入影子域;
- 旧 TP=4 域继续处理存量请求,直到请求数为 0;
- 旧域销毁,释放 4 张 GPU 归还资源池。
整个过程对客户端完全透明。唯一能感知到的变化是,在切换后几毫秒内,新请求的排队时间会略有增加,因为影子域刚启动时有预热成本。
4.4 第四步:上线压测与参数整定
我们把 DynamicTP 部署到测试集群,进行了 72 小时不间断压测。流量曲线模拟了真实的“白天高、夜间低”模式,同时叠加了几次突发的流量尖峰。
压测结果整理如下:
| 指标 | 静态 TP=8 | 静态 TP=4 | DynamicTP |
|---|---|---|---|
| 平均 GPU 利用率 | 37% | 62% | 71% |
| P99 延迟(毫秒) | 118 | 210 | 134 |
| 每小时切换次数 | 0 | 0 | 平均 8.4 次 |
| 切换过程请求中断数 | 0 | 0 | 0 |
| 扩容耗时(秒) | N/A | N/A | 1.8 |
| 缩容耗时(秒) | N/A | N/A | 0.6 |
可以看到,DynamicTP 的 GPU 利用率比静态 TP=8 提升了接近一倍,P99 延迟只比 TP=8 多了 16 毫秒,远优于 TP=4 的表现。这里面的关键就是 TP 度跟随流量动态变化,高峰期跑 TP=8,低谷期跑 TP=4,资源曲线贴合了需求曲线。
4.5 配置参数速查表
我们最终把可调参数收敛成了一张速查表,不同业务可以直接套用初始值,再根据实际情况微调:
| 参数名 | 默认值 | 说明 |
|---|---|---|
EWMA_ALPHA |
0.25 | QPS 平滑系数 |
UTIL_TARGET |
0.5 | 目标算力利用率 |
STABLE_SECONDS |
300 | 缩容观察窗口(秒) |
SWITCH_MIN_INTERVAL |
10 | 两次切换最小间隔(秒) |
P99_BREACH_RATIO |
0.8 | P99 延迟/目标延迟比值阈值 |
PREFETCH_GPU_SECONDS |
3 | 预占 GPU 的提前量(秒) |
SWITCH_MIN_INTERVAL 这个参数尤其重要。我们曾经因为没设这个参数,导致 TP 度在 4 到 8 之间频繁切换,每次切换的通信域重建开销直接拖垮了吞吐。加了 10 秒的最小间隔之后,切换频率从每小时几十次降到了十次以内。
5. 常见问题与排查技巧实录
5.1 问题速查表
这两年的开发迭代中,我们把群里、工单里高频出现的问题整理成了一张速查表,供大家参考:
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 切换时出现 CUDA OOM | 影子域和旧域显存重叠期超限 | 检查切换时的显存水位曲线 | 在旧域释放后,再启动影子域,或者提前压缩 KV Cache 上限 |
| P99 延迟在切换后短暂飙升 | 影子域预热不充分 | 查看切换后首个 batch 的 prefill 耗时 | 预跑几个空请求做 warmup,避免首次执行时触达懒初始化路径 |
| TP 度频繁抖动 | 决策参数过灵敏 | 拉取决策日志,观察 target_tp 变化 | 增加滞回区间,调低 EWMA_ALPHA |
| 缩容后 GPU 利用率反而下降 | 缩容阈值设得太低 | 对比不同 STABLE_SECONDS 下的利用率曲线 | 拉长观察窗口,或引入按小时级别的周期性缩容 |
| NCCL 初始化报错 | 涉及 GPU 集合包含正在使用的资源 | 检查资源池占用状态 | 确保切换前资源预占已完成,且预占 GPU 上没有其他 CUDA 上下文 |
| 切回旧 TP 度时权重加载异常 | 权重常驻逻辑被触发垃圾回收 | 查看显存分配日志 | 权重常驻显存用独立的 CUDA memory pool 管理,禁止系统自动 GC |
5.2 一个从“卡住不动”到“显存爆炸”的排查实录
这里分享一个印象很深的真实问题。有次压测时,扩容触发后整个服务“卡住”了大约 4 秒,然后直接报 CUDA OOM。第一反应是显存不够,但看监控发现显存还有剩余。后来逐个时间点排查,才发现问题出在通信域重建上。
我们的通信域池在启动时预留了 TP=4 和 TP=8 两个 NCCL 通信域。但扩容时,影子域需要同时挂载到旧域的权重引用上,而旧域此时还在处理请求,导致 NCCL 在初始化新通信域时要等待旧域的 CUDA stream 全部排空。这个等待是阻塞式的,而且因为我们的启动顺序是先建通信域、后建权重引用,所以等待时间被加倍了。
解决方法是调整了两个顺序:先建权重引用(纯显存指针操作,不涉及通信),再建通信域。同时把旧域的 CUDA stream 用 cudaStreamSynchronize 显式排空,避免 NCCL 在后台做隐式等待。调整后,切换耗时从 4.2 秒降到了 1.8 秒,CUDA OOM 也消失了。
5.3 容易被忽视的五个工程细节
最后分享几个我们在代码 Review 和线上运维中沉淀下来的细节。这些点单看都不起眼,但忽略任何一个都可能在关键时刻掉链子。
第一,所有与 TP 度相关的指标监控,必须区分“当前生效的 TP 度”和“请求实际使用的 TP 度”。我们踩过坑:TP 度切换完成、但旧域还在处理请求时,监控系统把两项数据混在一起算,导致利用率曲线出现虚假的“断崖下跌”。
第二,动态扩容时,新域的请求要循序渐进地放量,不要一次性全量切换。我们的 Router 默认用“先放 10% 流量,观察 2 秒,再逐步放量到 100%”的爬坡策略。这样做的好处是,如果影子域有问题,可以在放量早期就发现,避免影响面扩大。
第三,KV Cache 的预分配策略要为最大 TP 度预留余量。实测下来,TP=4 切换到 TP=8 时,如果 KV Cache 在旧域里已经占满了总显存的 85%,新域几乎必然 OOM。我们后面的策略是把 KV Cache 上限压到最大负载时的 70%,剩下的显存全部给切换留缓冲。
第四,小心模型权重常驻显存和推理框架默认显存管理策略的冲突。有些框架会在显存不足时尝试释放空闲的权重缓存,这会导致权重重加载,造成长尾延迟。我们的做法是把权重显存用独立的内存池管理,设置 cudaMemPoolSetAttribute 禁止系统回收。
第五,日志和追踪要带上 request_id 和 tp_domain_id 两个维度,这样切换前后同一个请求的处理路径才能串联起来。动态切换阶段出现的很多诡异问题,最后都是靠这两个 ID 关联日志才定位到的。
我个人在实际操作中最深的体会是:动态 TP 的难点从来不在“调整并行度”这一个动作上,而在它打破了很多静态假设——显存分配假设、通信初始化假设、生命周期管理假设。把这些隐性假设一个个找出来、改成显式逻辑,系统才会真正稳定下来。
最后再分享一个小技巧:如果你暂时没办法大规模改造,可以先做一个“轻量版”动态 TP——只在服务启动时多预建几个不同 TP 度的 NCCL 通信域,改造成本就一个配置项,但能为后续的动态调整提前铺好路。这个过渡方案我们内部用了两个月,效果相当不错,也有助于团队逐步理解动态资源调度的细节。
