news 2026/10/21 4:03:59

多设备协同计算实战:如何将AI推理吞吐从300提升到1000+ QPS

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多设备协同计算实战:如何将AI推理吞吐从300提升到1000+ QPS

如果你维护过一个7x24小时在线的AI推理服务,大概率经历过这种时刻:业务方丢过来一个QPS目标,说翻倍就翻倍,而单张加速卡的计算能力已经顶在瓶颈上。这次实战的背景是某视觉理解模型的服务化改造,模型十几亿参数,单卡推理时延能压在15ms以内,但吞吐一直在300QPS上下晃悠。后来我把服务从单卡改成多设备协同计算,把CANN里的设备管理、集合通信、流式执行这些能力真正落到线上,才把瓶颈打开。这篇内容是把当时从方案选型、模型切分、算子调优到线上验证的完整过程梳理一遍。如果你做的是推理系统、部署优化或者AI工程化,这里面的数据和绕过坑的地方可以直接参考。

1. 为什么多设备协同计算是推理系统绕不过去的坎

1.1 单卡算力的三个隐形瓶颈

先说结论:单卡推理的瓶颈,通常不是算力本身,而是另外三个更隐蔽的地方。

第一个是显存容量和算力之间的错配。模型权重推理时是固定常驻显存的,1.5B参数的半精度权重就要占到约3GB,再加上中间激活和KV缓存,留给动态batch的空间被压缩得很厉害。想要高吞吐就得加大batch,但batch加大的代价是单个请求的排队时间和计算时间一起涨,P99时延立刻失控。这就像仓库很大但装卸通道太窄,货再多也进不去。

第二个是时延和吞吐的跷跷板效应。在线推理是延迟敏感场景,业务通常卡的是P99而不是平均时延。我们把batch从8调到32,单卡吞吐确实上去了,但P99从十几毫秒直接飙到五六十毫秒,这种数据根本没法交付。单卡想同时拿到高吞吐和低时延,本质上是一件互斥的事。

第三个是弹性问题。单卡方案在容量规划上几乎没有缓冲,QPS目标上调百分之三十,整台机器就要换卡或者加机器,而不是在软件层把算力池化。我们这次的目标是800 QPS、P99小于25ms,算了一下单卡batch=4时每batch耗时约12ms,理论峰值也就333 QPS,要达到目标得把单卡效率再拉快2.5倍,根本不现实。所以多设备协同是唯一的出路。

1.2 三种典型协同方式怎么选

多设备协同不是一句空话,落实下来有三种路线:数据并行、模型并行、流水线并行。很多同学第一反应是数据并行,因为训练里最常见,但推理场景要重新掂量。

数据并行是每张卡放完整模型,输入按batch切分,最后结果聚合。优点是实现简单,模型放得下且算力吃不满时很有效。缺点是模型权重重复存放,显存利用率低,而且每个请求的中间结果都要聚合一次,通信开销会吃掉一部分收益。

模型并行是把模型本身切成两半,按层或按算子放到不同设备上。它解决的是“模型放不下”或“单卡算力不够”的问题,但层间数据要实时搬运,通信频率高。

流水线并行是模型并行的进阶,把输入再切成微片,让前一层设备和后一层设备像流水线一样同时干活。它把通信和计算重叠起来,是大模型推理和高吞吐场景最实用的形态。

我们选的组合是“层级切分 + 微批流水 + 多组并行”:每个协同组两张卡,模型按层切成两段,组内做流水推理,整机四张加速卡组成两个协同组,入口层把请求轮流分发到两组。这样既拿到模型切分的显存收益,又通过流水把通信时间藏起来。

1.3 项目环境与性能基线

硬件平台是一台四卡AI推理服务器,每张卡显存充裕,跑CANN软件栈,设备间走PCIe通信。模型是一个视觉理解模型,约1.5B参数量,输入是多模态特征拼接后的序列。改造前的基线数据:单卡batch=4,平均时延11ms,P99时延15ms,吞吐约333 QPS,设备利用率只有四成多。

这个基线的核心问题不是卡不够快,而是batch被时延红线压死了。batch=4时设备算力根本吃不满,但再加batch,P99就突破25ms的交付门槛。要打破这个死循环,只能让多个设备同时参与计算,把单请求的关键路径拆开,同时整体吞吐还能翻倍。

2. 协同计算的底座:CANN设备管理与集合通信原理

2.1 设备、上下文和流的抽象模型

CANN在多卡编程里给开发者的是三个核心抽象:逻辑设备、上下文(Context)和流(Stream)。你可以把设备想象成一间车间,Context是车间里的一套工作台,Stream是工作台上的一条传送带。每个线程要干活,必须先在某个设备上创建Context,再往Context里挂Stream,所有算子调用都是投递到Stream上异步执行的。

多卡推理最容易犯的错误是上下文没切换干净。最初在单进程里同时操作两张卡,只调了一次设设备的操作,结果张量分配到了错误的设备上,后面的算子全部报设备不匹配。正确做法是每张卡独立创建Context,对哪个设备操作,就先把当前线程切换到对应的Context。

下面是一段关键调用骨架,这套流程在真实工程里我会封装成设备上下文类,每个实例管一张卡:

// CANN多设备初始化关键调用示意 aclInit(nullptr); aclrtSetDevice(0); aclrtCreateContext(&ctx0, 0); aclrtSetDevice(1); aclrtCreateContext(&ctx1, 1); aclrtCreateStream(&stream0, ctx0); aclrtCreateStream(&stream1, ctx1); // 在各自设备上分配显存,注意跨设备指针不能互相访问 aclrtMalloc(&buf0, intermediateSize, ACL_MEM_MALLOC_HUGE_FIRST); aclrtMalloc(&buf1, intermediateSize, ACL_MEM_MALLOC_HUGE_FIRST); // 使用结束后对称释放:aclrtFree / aclrtDestroyStream / aclrtDestroyContext aclFinalize();

还有一个实践细节:线程和Stream的绑定关系要固定。推理服务里我会开一个线程池,每个线程只处理自己绑定的Stream队列,避免多个线程并发往同一个Stream投递算子导致顺序错乱。这个坑在单卡时代不明显,多设备协同时代一踩一个准。

2.2 集合通信在推理中的真实开销

提到多设备,很多人的第一反应是AllReduce。其实推理和训练不一样,没有反向传播,梯度AllReduce基本用不上。层级切分的场景里,设备之间主要做的是“把上一层输出搬到下一层”,点对点的Send/Recv或者轻量级的集合通信就够用了。

通信开销有多大?取决于中间激活的体积和设备间带宽。以我们的模型为例,主干中间层输出如果是FP32,体积在100MB量级。PCIe环境下稳定带宽实测在12GB/s左右,光搬运一次就要8ms左右。这个数字放在25ms的时延预算里是致命的,所以中间激活全部转成FP16是第一优先级,体积砍半,传输时间也砍半。配合后面要讲的微批流水,通信时间才能被真正藏起来。

踩过的一个典型坑是:切分点选在了输出通道特别大的那一层,中间激活300MB,通信成了绝对瓶颈,双卡吞吐甚至不如单卡。后来把切分点往输出维度更小的层挪,通信压力立刻降下来。切分点不能只看模型结构是否“对称”,更要看实际通信量。

2.3 计算与通信重叠的流水设计

通信延迟不可怕,可怕的是通信期间计算在空等。想解决空等,就得让计算和通信在时间轴上重叠。

具体做法是把batch切成若干微片,比如batch=12切成三片,每片4。设备0计算完第一片,把中间激活丢给设备1,自己马上开始算第二片,而此时设备1正在算第一片的后半段。稳定下来后,设备0和设备1大部分时间都在并行计算,通信由专门的传输引擎搬运,不再插入计算关键路径。

我把它理解成一个双工流水线:上游的设备0像加工车间,下游的设备1像组装车间,中间是一个传送带。传送带本身要时间,但只要上游一直在供件,组装的产出节奏就由上游车间的节拍决定,而不是由传送带决定。这套机制在CANN里落地的时候,我会用两个Stream,一个跑计算算子,一个跑数据传输,两者独立排队,靠事件同步保证依赖顺序。

这里要提醒一句:微片不是越小越好。微片颗粒度太细,同步次数会指数增加,设备之间的启动开销就把收益吃光了。我们实测batch=12切三片是最优,切六片反而吞吐下降15%。这个参数要根据实际模型和带宽现场调,没有通用值。

3. 落地方案:模型切分与推理架构实现

3.1 切分点怎么选才不会拖后腿

层级切分的第一步是找切分点。逻辑上很简单:前N层放设备0,后M层放设备1,但真正选点时要算两笔账。

第一笔账是通信体积。切分点那个位置的张量形状决定了每次要搬多少数据。我们要选的是中间激活体积最小的点,而不仅仅是层数的一半。比如视觉Transformer模型里,某个阶段的序列长度可能已经压缩,输出张量小得多,把切分点放在那里,通信成本会低很多。

第二笔账是负载均衡。切完两段要估算各自的前向耗时,尽量让两段都在相近的时间内完成。流水线的整体吞吐取决于最慢的那一段,一头重一头轻等于白白浪费另一张卡。最初把embedding和前面几层整个放在设备0,后面头部和分类头放在设备1,结果设备0明显偏忙,流水线时快时慢。后来把分类头挪到设备0尾部,把一部分靠后的Transformer层交换给设备1,两段耗时才接近持平。

还有一个容易被忽略的问题:动态shape会让每个请求重新推导图结构,切分后两边都要重推,时延抖动加剧。我们干脆在入口把所有输入pad到固定长度,把动态shape问题彻底按死,代价只是浪费一点算力,但时延稳定性换来很大的提升。

3.2 请求分发、动态batch与组间调度

入口调度是整个系统的神经中枢。我们的方案是两级的:第一级是组间负载均衡,把请求轮流分到两个协同组;第二级是组内动态batch,把到达该组一小段时间窗口里的请求拼成一个batch,一次推进流水线。

动态batch的大小可以用一个很朴素的公式估:目标QPS乘以允许的凑批等待时间。我们目标单组400 QPS,等待窗口15ms,那batch大约就是6到8。实际取12,因为两个流水组在并行,每组承担的QPS约400到500,窗口内到齐12个请求的几率足够高。

凑批等待必须设上限。宁可batch小一点提前发车,也不要为了凑满batch把请求压到超时。我们设了两道保险:窗口时间到15ms立即推理,同时batch到达12也立即推理。这样既能吃到动态batch的算力红利,又能把P99钉在红线以内。

这里还有一个工程细节:请求ID要跟着batch走。两个协同组并行推理后,结果返回顺序和请求进入顺序完全无关,后端必须按请求ID做归并再回给调用方。一开始只按队列顺序返回,结果客户端有些请求等不到结果,排查了半天才发现是归并逻辑缺失。

3.3 权重加载与内存复用的取舍

模型切分后,每张卡只需要加载自己负责的那部分权重,不需要重复放完整模型。这对显存是很大的释放,也让更大的batch有了空间。

权重加载有讲究。我们要求两张卡加载顺序一致、版本一致,否则初始化时很容易出现其中一边参数缺失却没有任何报错,推理结果全错。上线流程里加了一步启动自检:两张卡各算一个固定输入,比对关键层的输出哈希,不一致直接拒绝启动。

显存方面,推理服务不像训练那么大开大合,但也有自己的坑。频繁的显存申请和释放会产生碎片,跑一段时间后明明显存总量够,新请求却分配不到连续块。我的做法是启动时一次性按峰值batch分配一块大显存池,内部自己管理偏移量,算子内部只做内存偏移复用,不再向设备申请新的分配。这个改动让长时间运行的稳定性提升非常明显。

4. 算子级性能调优:把每张卡的效率榨干

4.1 profiling先行,别靠直觉调参

多设备推理出现性能问题,第一件事不是改参数,而是拉profiling数据。我用CANN自带的profiling工具抓设备时间线,重点看三类指标:算子计算耗时、设备间数据传输耗时、设备空闲等待时间。

有一组经验可以参考,如果传输等待占比超过30%,先优化切分点和中间激活字节数;如果是单个大算子占80%以上的计算时间,考虑算子融合和更高效的实现;如果是大量小算子启动开销,固定shape能减少很多重编译。

我把一次优化前的profiling数据摊开来看,发现设备1有大约35%的时间在等待中间激活到达,这明显是流水没打通。顺着这个线索去查,才发现是发送队列预取深度太小,设备0算完了,数据还在排队。调整预取长度后,等待时间直接降了一半。

4.2 中间激活精度降级:性价比最高的一步

我们把切分点传输的中间激活从FP32改为FP16,这一步的收益大得惊人。传输时间减半,显存占用减半,流水线的周期因此被压缩。对最终精度的影响,我们在验证集上做了对比,指标变化可以忽略,业务上完全可以接受。

这个操作在训练场景可能不敢做,因为梯度传播很敏感,但推理只有前向计算,中间激活的精度敏感度低很多。当然要验证,不能盲改。我们设计了一个小流程:改了精度之后,拿线上同分布的样本跑一轮推理,对比分类输出的置信度差,只要最大偏差小于某个阈值就算通过。

这里注意一个顺序问题:精度降级要在调完切分点和batch之后再做,因为它会改变通信量,反过来会影响切分点和batch的最优值。性能调优很多时候是一个迭代过程,每改一个变量,前面的参数可能要重新回看。

4.3 算子融合和内存池带来的稳定收益

算子层面的优化,我用得最多的是两类。一类是算子融合,把LayerNorm、残差加法这类逐元素小算子合成单个算子,减少kernel启动和中间显存读写。另一类是固定shape后启用算子编译缓存,同一个shape的算子只编译一次,后面全部复用。

算子融合这件事有个误区:不是调用的算子越少越好,而是看访存次数。小算子每个都要读一遍中间结果、写一遍中间结果,融合后这些读写就不用发生了。在访存受限的模型上,这类优化经常能拉出10%到20%的性能收益。

显存池是稳定性层面的关键。推理服务进程长期跑,请求峰值会导致显存申请潮汐一样涨落,频繁分配释放必然带来碎片。我们启动时就按最大batch的中间激活总量一次性申请好,后面所有算子都在池内复用偏移。这个改造之后,连续压测72小时再没出现过显存相关的报错。

5. 服务化部署与稳定性保障

5.1 进程模型和线程模型怎么搭

多设备推理的服务化,我建议按“一个协同组一个进程”的方式拆分。每个进程管理两张卡,内部是主线程加工作线程池。主线程负责设备上下文、Stream队列和生命周期管理,工作线程只负责接收请求、执行前向、返回结果。

这种模型最容易出的问题就是线程和Stream的绑定。同一时刻多个线程往同一个Stream里投递算子,虽然会排队,但顺序一旦依赖事件等待,很容易死锁。我的做法是一个工作线程固定对应一组Stream,请求通过队列分发给线程,线程内部严格按自己的Stream执行。这个设计说起来简单,但在多路并发请求的压测下很稳。

进程内还要注意资源回收的顺序:先停止接收新请求,等存量请求全部推理完,再释放Stream和Context,最后做全局清理。如果直接强制退出,设备上的残留上下文会让下一次初始化报错,只重启进程还解不掉,必须重置设备才能恢复。

5.2 故障转移:从双卡协同自动降级到单卡

多设备系统把可用性风险也变多了,一张卡出问题,整个协同组就不能正常工作。我们在设计上做了一层降级策略:每张卡除了自己的那部分权重,还预存了另外一段的关键层权重快照。这样设备1挂了,设备0虽然来不及切换成完整模型,但可以降级成一个截断模型服务,吞吐大幅下降但不至于全组中断。

故障检测靠两层:一层是卡侧的心跳上报,另一层是业务侧的超时熔断。某个协同组连续几个请求都超时,入口就自动把流量切到健康组,并对故障组做隔离。这个过程对调用方是无感的,调用方感知到的只是整体容量暂时下降。

这个降级方案有代价,就是多占了一点显存做快照。但对于一个7x24的在线服务,我认为这个代价值得。稳定性不是上线后才考虑的,而是架构设计时就要预留的后门。

5.3 容器部署和设备绑定

多卡容器部署有一个很容易踩的坑:设备索引漂移。容器重启后,物理设备在容器内的编号可能变化,如果服务配置里写死的是索引,会出现启动时设备冲突或者张量分配错卡。

解决办法是启动时通过设备UUID而不是索引来绑定。先读取机器上所有设备的UUID列表,再按配置里的映射关系初始化需要的设备。我们还在启动脚本里加了一个前置检查,遍历所有的设备索引做一次简单算子执行,哪个索引失败,直接标记该组不可用,不让服务带病启动。

容器内的显存限制也要设置好。多卡共享同一块物理显存资源,如果不下发资源配额,某个实例的异常内存增长会拖垮同机其他实例。我们统一设置了显存上限和超限自动重启机制,配合健康检查,整体稳定性才真正兜底。

6. 优化效果与线上数据对照

6.1 四个阶段的性能演进

整个优化按四个阶段迭代,每步都有明确的数字变化:

阶段平均时延P99时延吞吐QPS设备利用率
单卡基线11ms15ms33345%
双卡层级切分,无流水25ms32ms42052%
加微批流水与计算通信重叠18ms24ms56068%
双组并行加FP16中间激活19ms25ms102076%

从单卡到双卡切分,吞吐只涨了26%,原因是通信时间直接插在计算路径上,P99还恶化了。加上微批流水后,通信被藏起来,P99回到24ms,吞吐冲到560。最后双组并行加FP16减负,整体吞吐破千,P99仍压在25ms以内,完全满足业务800 QPS的交付要求。

这里值得说的是“无流水”那段看起来是失败的,但它验证了切分本身的正确性。如果切分后吞吐没掉太多,说明通信和计算还没有根本性冲突,后面加流水才有意义。调优过程中的失败试验不是白做,它帮我们锁定了真正的问题边界。

6.2 三个看起来对但实际反效果的操作

第一件是盲目加大batch。我们曾以为batch越大设备利用率越高,结果batch从16提到32,设备利用率没涨,P99却从24ms飙到40ms以上。原因是单个batch占用的显存带宽已经饱和,算力并没有增加,排队时间反而全加到请求头上。

第二件是微片切得过多。batch=12切三片时吞吐最优,切六片反而掉15%。同步和启动开销在微片尺寸变小时是超线性增长的,理论上的流水粒度越细越好并不成立。

第三件是传输优先级调得过高。我们把数据搬运的优先级调到最高,想着通信越快越好,结果CPU被传输中断打满,导致其他线程来不及喂数据,整条流水反而变慢。通信调度要和计算调度一起做均衡,单点最优不等于全局最优。

7. 常见问题与排查速查

这一节把这次实战里碰到的高频问题整理成表格,遇到同类情况可以直接照方抓药:

现象原因处理办法
设备初始化失败上次进程未释放上下文,设备被占用重置设备或重启后再做清理,确保没有残留Context
张量分配到错误的卡Context切换不干净,设备设置混乱每个设备独立Context,操作前显式切到对应Context
多线程并发投递算子偶发卡死多个线程共享同一个Stream线程固定绑定Stream,不要在多个线程里并发操作同一Stream
集合通信或Send/Recv死等两个卡没有同时进入通信调用统一由事件同步保证两卡同时发起,通信调用要成对出现
动态shape导致时延抖动每次请求重新推导和编译入口统一pad到固定长度,禁用动态shape
长时间运行突然OOM显存频繁分配释放产生碎片启动时创建显存池,内部偏移复用,不做频繁申请
容器重启后设备错乱设备索引漂移用设备UUID绑定,启动脚本增加设备自检

排查的时候有一个顺序原则:先看profiling,再看日志,最后才改代码。原因很简单,profiling能直接告诉你瓶颈在哪一端,盲目改参多半靠猜,猜中概率很低。我见过不少团队在OOM问题上直接调batch大小,结果调了半天还是OOM,实际是显存碎片问题,profiling一眼就能看出来。

还有一个容易被忽视的小技巧,压测前先做一次“冷启动预热”。多卡推理首次执行时,算子编译、显存池初始化都会拖慢前几十个请求,如果直接拿这批数据算压测结果,数字会严重失真。我习惯在正式压测前先跑500个请求预热,再开始采集数据。

这次做完,我最大的体会是:分布式推理的瓶颈,往往不在你看不见的通信路径上,就在于你认为理所当然的小细节里。CANN把多设备抽象封装得很干净,但真正决定系统上限的,是切分点的通信量、流水的重叠设计、请求调度的超时策略这些组合拳。如果只让我留一条建议:任何优化动工之前,先花半天把profiling数据拉出来看一遍。你的直觉在分布式环境里大概率会骗你,但数据不会。经验可以慢慢积累,踩坑姿势早点学会,系统抗风险的能力会强很多。这套方案后续还可以往更大规模扩展,更多的协同组之间调度策略、更细粒度的切分自动寻优,都还有文章可做。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 4:02:31

LangChain + EasyOCR 图片文字提取与结构化 JSON 输出实战

想做真正能落地的 AI 应用,绕不开一个听起来不起眼、但真上手会让人头大的环节——从图片里把文字抠出来。这活儿看着简单,实际做起来全是细节:图片清晰度不够、中英混排、表格错位、文字顺序乱,每一处都能让你改到怀疑人生。这篇…

作者头像 李华
网站建设 2026/10/21 4:02:29

房产经纪人实战:用信任、匹配与效率突破交易瓶颈

很多人问我,做房产经纪人到底难不难。我说,这事就跟炒菜一样,难者不会,会者不难。但如果你问我,这行最核心的东西是什么,我会告诉你,就三个字:信任、匹配、效率。这也是我拿到“rea”…

作者头像 李华
网站建设 2026/10/21 4:02:11

MinerU JS/TS SDK 接入实战:文档解析、性能优化与踩坑指南

最近在做一个企业级文档解析服务,需要把 PDF、Word、PPT 统一转成结构化数据喂给下游系统。选型阶段试了不少方案,最后在 Node.js 后端和内部管理端里都用上了 MinerU JS/TS SDK。这篇文章不打算重复官方文档,而是把我实际接入、调优、踩坑的…

作者头像 李华
网站建设 2026/10/21 4:01:36

大模型安全调用业务技能:agent-skills技能层设计与实践

去年下半年,我把大部分开发时间都投进了一个叫 agent-skills 的代码库。名字起得很简单,做的事情却不小:让大模型不再停留在“会聊天”,而是能安全、稳定地调用一堆真实业务技能完成任务。无论订单查询、退款计算还是排班调整&…

作者头像 李华
网站建设 2026/10/21 4:01:36

STM32 Boost 数控电源实战:从硬件拓扑到 PID 闭环与可编程输出

简介:这份资源面向嵌入式开发学习者与电源设计爱好者,提供一套基于STM32的Boost数控电源完整工程,帮助理解升压变换器与数字闭环控制的实际落地方式。压缩包共510个文件,约32.82MB,以96个h头文件、70个c源文件为核心&a…

作者头像 李华
网站建设 2026/10/21 4:01:36

跨境人力资源服务如何用AI破局:从合规到薪酬的落地实践

做跨境人力资源服务这些年,我最大的感受是:出海企业很少在业务规划上出问题,反而是在“人”这件事上反复踩坑。试用期怎么设才不违反当地劳动法,13薪到底算不算固定成本,个税社保按什么基数缴,员工手册直接…

作者头像 李华