并发推理场景分析:多模型并发、多卡并发、动态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,还部署在多卡上。这时候显存池的设计就变得非常复杂。

我个人建议,先从最简单的场景入手——单模型单卡,把显存池调通。然后再逐步增加并发维度。别一上来就想搞个大而全的方案,容易翻车。

总结一下:并发推理的显存优化,核心就是「共享」和「调度」。共享减少浪费,调度避免冲突。你只要把这两个点抓住了,大部分问题都能解决。

并发推理显存管理核心逻辑 推理请求 显存感知调度器 (优先级+水位线) 多模型并发 多卡并发 动态batch并发 统一显存池 (共享+回收) 推理结果 显存使用反馈(碎片率、利用率)

这张图把整个流程串起来了。你看,推理请求先经过显存感知调度器,根据优先级和水位线决定走哪个分支。然后统一到显存池里分配显存。最后推理结果返回,同时显存使用情况反馈给调度器,形成闭环。

好了,这一节的内容就到这。记住,并发场景下的显存优化,没有银弹。你得根据实际业务场景,选择合适的策略组合。多试试,多调参,总能找到最优解。

可可精品资料库