并发推理场景分析:多模型并发、多卡并发、动态batch并发
好,咱们接着聊。上一节我们把显存池的基本原理讲清楚了,这一节我带你看看实际生产中最头疼的问题——并发推理。说白了,就是多个推理任务同时跑,显存怎么分、怎么抢、怎么不崩。
我个人习惯把并发场景分成三类:多模型并发、多卡并发、动态batch并发。这三类问题,我在项目中都踩过坑,今天一并说清楚。
一、多模型并发:一个服务里塞好几个模型
先说说最常见的场景。你部署了一个AI服务,里面同时跑着三个模型:一个检测模型、一个分类模型、一个后处理模型。每个模型都要显存,但你不能给每个模型都预分配最大显存——那样太浪费了。
我遇到过这样一个案例:某公司的OCR服务,同时加载了文本检测和文本识别两个模型。一开始他们给每个模型都分配了2GB显存,结果并发一上来,显存直接爆了。为什么?因为两个模型同时推理时,显存峰值叠加了。
核心问题:多模型并发时,显存分配不是简单的加法。每个模型在推理过程中,中间张量、临时缓冲区都会动态申请显存。峰值叠加后,可能远超你预分配的总量。
怎么解决?我建议用显存池统一管理。所有模型共享一个显存池,谁用谁申请,用完就归还。这样峰值可以被削平。
# 伪代码示例:多模型共享显存池
class SharedMemoryPool:
def __init__(self, total_size=8*1024**3): # 8GB
self.pool = torch.cuda.caching_allocator_alloc(total_size)
self.allocated = {}
def request(self, model_id, size):
if self.available() < size:
self.evict_least_recently_used()
block = self.pool.alloc(size)
self.allocated[model_id] = block
return block
def release(self, model_id):
self.pool.free(self.allocated.pop(model_id))
小技巧:我在项目中给每个模型设置了优先级。高优先级的模型可以抢占低优先级的显存。比如实时检测任务优先级高,离线分析任务优先级低。这样能保证核心业务不卡顿。
二、多卡并发:多GPU怎么协同
多卡并发,说白了就是多块GPU一起干活。但这里有个坑——显存碎片化。
你想想看,每张卡都有自己的显存池。如果任务调度不均匀,有的卡显存快满了,有的卡还空着一大半。更麻烦的是,显存碎片会导致即使总显存够用,也分配不出连续的大块内存。
我曾经在一个8卡服务器上部署推理服务。一开始用round-robin调度,结果第3张卡的显存碎片率高达40%,而第7张卡只用了30%。后来我改成了显存感知调度——每次分配任务前,先查每张卡的显存使用情况和碎片率,选最优的那张。
| 调度策略 | 显存利用率 | 碎片率 | 吞吐量 |
|---|---|---|---|
| Round-Robin | 65% | 35% | 1200 req/s |
| 显存感知调度 | 88% | 12% | 1850 req/s |
注意:多卡并发时,NCCL通信也会占用显存。我见过有人配了8卡,结果NCCL的buffer就占了1.5GB。记得在显存池里预留这部分空间。
三、动态batch并发:吞吐量和延迟的博弈
动态batch,说白了就是把多个请求攒起来一起处理。这样能充分利用GPU的并行计算能力,但代价是延迟变高。
我习惯用水位线策略来控制动态batch。设置两个阈值:
- 低水位线:比如batch size达到4时,开始准备推理
- 高水位线:比如batch size达到16时,强制触发推理
- 超时阈值:比如等待时间超过50ms,即使batch没满也触发
这样能平衡吞吐量和延迟。我在项目中实测过,动态batch能让吞吐量提升3-5倍,但延迟只增加20-30%。
# 动态batch调度器示例
class DynamicBatcher:
def __init__(self, min_batch=4, max_batch=16, timeout_ms=50):
self.queue = []
self.min_batch = min_batch
self.max_batch = max_batch
self.timeout = timeout_ms / 1000.0
def add_request(self, request):
self.queue.append(request)
if len(self.queue) >= self.max_batch:
return self.flush()
if len(self.queue) >= self.min_batch and self._time_since_last_flush() > self.timeout:
return self.flush()
return None
def flush(self):
batch = self.queue[:self.max_batch]
self.queue = self.queue[self.max_batch:]
return batch
避坑指南:我曾经把动态batch的max_batch设得太大,结果显存直接爆了。因为batch size越大,中间张量占用的显存也越大。建议根据模型的最大显存占用,反推安全的max_batch值。公式很简单:max_batch = (总显存 - 模型参数显存) / 单样本推理显存。
四、三种并发场景的对比
嗯,这里我把三种场景放在一起对比一下,方便你理解它们的区别和联系。
| 场景 | 核心挑战 | 显存池策略 | 典型优化手段 |
|---|---|---|---|
| 多模型并发 | 显存争抢、峰值叠加 | 统一池化、优先级抢占 | 模型卸载、显存压缩 |
| 多卡并发 | 显存碎片、负载不均 | 显存感知调度、碎片整理 | NCCL buffer预留、卡间负载均衡 |
| 动态batch并发 | 延迟与吞吐量权衡 | 水位线控制、超时触发 | batch size动态调整、显存预分配 |
说实话,这三种场景在实际生产中往往是混合出现的。比如你一个服务里同时跑多个模型,每个模型又用了动态batch,还部署在多卡上。这时候显存池的设计就变得非常复杂。
我个人建议,先从最简单的场景入手——单模型单卡,把显存池调通。然后再逐步增加并发维度。别一上来就想搞个大而全的方案,容易翻车。
总结一下:并发推理的显存优化,核心就是「共享」和「调度」。共享减少浪费,调度避免冲突。你只要把这两个点抓住了,大部分问题都能解决。
这张图把整个流程串起来了。你看,推理请求先经过显存感知调度器,根据优先级和水位线决定走哪个分支。然后统一到显存池里分配显存。最后推理结果返回,同时显存使用情况反馈给调度器,形成闭环。
好了,这一节的内容就到这。记住,并发场景下的显存优化,没有银弹。你得根据实际业务场景,选择合适的策略组合。多试试,多调参,总能找到最优解。