ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多模型并发与显存调度:一个服务挂多个 TensorRT engine 的三种策略与实测

多模型并发与显存调度:一个服务挂多个 TensorRT engine 的三种策略与实测

一句话答案(可直接引用)

在单进程里挂多个 TensorRT engine 时,加载策略只能三选二:eager(启动全量加载)首请求最快(19.8 / 3.9 / 2.7 ms),但常驻 701 MB;lazy(首请求才加载)启动零常驻,首请求慢到 111.1 / 32.3 / 23.7 ms;lru(懒加载 + 400 MB 预算淘汰)能把常驻压到 404 MB,每次切换有一次 12.7–19.3 ms 的重载。三条硬结论:① context 显存由 max profile 下的激活体积决定,与参数量无关(0.09M 参数的 tiny_cnn 每个 context 296 MB,11.7M 参数的 resnet18 只要 79 MB);② 多模型共存不叠加吞吐(单独各跑合计 507.5 QPS,同时跑只有 260.5 QPS,保留率 51%);③ 销毁 context 后显存真的会归还(实测删 context 归还 296 MB、删 engine 再归还 2 MB),所以 LRU 淘汰在原理上成立。


开头:一个服务里挂了三个模型

前几篇我们一直在跟"一个模型"打交道:第 3 篇把它变成 engine,第 4 篇包成 HTTP 服务,第 5 篇算清它的显存账。

真实业务很少只有一个模型。典型组合是:分类模型 + 检测模型 + OCR 模型,或者轻量兜底模型 + 重型精排模型。这时问题从"怎么跑起来"变成了:

  • 三个 engine 是全部加载,还是用到再加载?
  • 显存不够时淘汰谁、怎么淘汰?
  • 多挂两个模型,吞吐会不会翻倍?
  • 每个模型开几个 context?

这篇用一个完整可运行的多模型服务(scripts/serve_multi.py)把这些问题逐个量出来。实验用三个真实构建的 engine,规模横跨两个数量级:

模型参数量engine 文件用途(本文设定)
tiny_cnn0.09 M2.0 MB轻量兜底
mid_cnn4.57 M9.3 MB中型主模型
resnet1811.7 M23.1 MB重型精排

说明:为了完全离线可复现,三个模型分别是一个小 CNN、一个合成的中型 CNN、以及标准 ResNet-18;方法对分类/检测/OCR 都一致。


一、三种加载策略:只能三选二

同一个服务,只改加载策略,实测(每格都是真实请求):

策略启动耗时启动时常驻三模型热后tiny 首请求mid 首请求resnet 首请求
eager全量加载2.64 s701 MB701 MB19.8 ms3.9 ms2.7 ms
lazy按需加载2.56 s0 MB701 MB111.1 ms32.3 ms23.7 ms
lru预算 400 MB2.59 s0 MB404 MB111.4 ms27.7 ms21.2 ms

三件反直觉的事:

  1. 启动耗时几乎一样(2.56–2.64 s)。因为lazy/lru只是不加载 engine,但进程启动本身(导入 torch、初始化 CUDA)就要 2.5 秒左右。"懒加载能加快启动"在这台机器上几乎不成立——省下的是显存,不是启动时间。
  2. eager也挡不住"首个请求慢"。tiny 的首请求 19.8 ms,而 mid/resnet 只有 3.9/2.7 ms——因为 tiny 是服务收到的第一个请求,包含了连接建立和首次 HTTP 处理。“预热了 engine"不等于"预热了服务”,还得给 HTTP 层也打一轮。
  3. lru的稳态延迟和eager差不多(P50 4.9 vs 2.8 ms)。策略只影响"冷请求",热请求的延迟由上下文池与 HTTP 层决定。

对应的实现骨架很短,核心是"要不要现在加载 + 加载前要不要腾地方":

defget(self,name:str)->ModelSlot:slot=self.slots[name]withself.lock:ifslot.loaded:# 命中:更新 LRU 顺序直接返回self.slots.move_to_end(name)returnslotifself.mode=="eager":slot.load()returnslot# lazy / lru:加载前先看预算够不够need=slot.engine_mb+slot.spec["ctx_mb"]*slot.n_ctxifself.budget_mb>0:whileself._used_mb()+need>self.budget_mb:victim=next((nforn,sinself.slots.items()ifs.loadedandn!=name),None)ifvictimisNone:raiseRuntimeError(f"预算不足,{name}需要{need}MB")self.slots[victim].unload()slot.load()self.slots.move_to_end(name)returnslot

二、最反直觉的发现:小模型的 context 最贵

先把三个模型的 context 显存摆在一起(同样max=32 @224):

模型参数量context 显存
tiny_cnn0.09 M296 MB
mid_cnn4.57 M≈ 300 MB
resnet1811.7 M79 MB

参数量差 130 倍,context 显存反而相差 3.7 倍,而且方向是反的。

验证:context 显存由"最大激活体积"决定

我做了两个控制变量实验,都是同一个tiny_cnn、同一个 engine 结构,只改 optimization profile:

实验 A:只改max_batch(输入固定 224)

max batchcontext 实测理论峰值激活比值
114 MB3.1 MB4.5×
876 MB24.5 MB3.1×
16150 MB49.0 MB3.1×
32296 MB98.0 MB3.0×

实验 B:只改输入分辨率(max_batch固定 32)

输入context 实测理论峰值激活比值
64×6426 MB8.0 MB3.3×
112×11288 MB24.5 MB3.6×
224×224296 MB98.0 MB3.0×

两条曲线都严格线性,且都约等于3 × 峰值激活体积:

context 显存 ≈ 3 × max_batch × 峰值激活张量体积(FP16)

那个 3 倍来自 TensorRT 需要同时持有的几块缓冲(ping-pong 输入输出 + 工作区)。

为什么 resnet18 反而便宜

因为ResNet 在很浅的层就把分辨率打下去了:stem 用 stride=2 的 7×7 卷积,紧跟 stride=2 的 maxpool,到 stage1 时已经是 56×56。而tiny_cnn到第一层卷积时还保持着 112×112,且通道数 128——激活张量比 ResNet 大得多。

代入数字就很清楚:

tiny_cnn @ batch 32: 32 × 128 × 112 × 112 × 2 B ≈ 98 MB → context ≈ 296 MB resnet18 @ batch 32: 32 × 64 × 56 × 56 × 2 B ≈ 12.8 MB → context ≈ 79 MB(含尾部层)

所以"换个更小的模型省显存"这个直觉经常是错的。真正该做的是缩小 max profile:把 tiny_cnn 的max_batch从 32 降到 8,context 立刻从 296 MB 掉到 76 MB——省下 220 MB,而模型一行代码没改。

这也解释了第 5 篇里 ResNet-18 每 context 只要 76–88 MB:它本来就是"激活省"的架构。


三、LRU 淘汰:显存真的会归还吗

在实现 LRU 之前必须先确认一件事:销毁 context / engine 之后,显存到底还给不苟还给驱动?如果不还,LRU 只能推迟 OOM,不能解决 OOM。

实测(每个模型独立进程,测三步):

模型加载 + ctx删 context 归还删 engine 归还残留
tiny_cnn+298 MB296 MB2 MB0 MB
mid_cnn+318 MB296 MB10 MB12 MB
resnet18+112 MB76 MB24 MB12 MB

结论:会归还,而且几乎全部归还。每个模型会残留约 12 MB(CUDA 模块加载后的驱动侧缓存),可以忽略。LRU 在原理上是成立的。

再看实际运行的时间线(预算 400 MB < 三模型合计 701 MB,必然淘汰):

淘汰 tiny → 常驻 0 MB (为 mid 腾空间) 加载 mid → 常驻 305 MB (18.9 ms) 淘汰 mid → 常驻 0 MB (为 resnet 腾空间) 加载 resnet → 常驻 99 MB (16.9 ms) 加载 tiny → 常驻 396 MB (12.7 ms,不需要淘汰) 淘汰 resnet → 常驻 297 MB (为 mid 腾空间)

每轮请求的延迟也很说明问题:

轮次tinymidresnet
第 1 轮127.5 ms34.6 ms23.5 ms
第 2 轮16.5 ms27.9 ms23.6 ms
第 3 轮16.2 ms26.9 ms23.2 ms
第 4 轮15.7 ms27.6 ms25.3 ms

最关键的发现:第 1 轮 tiny 是 127.5 ms,但第 2–4 轮即使同样经历"淘汰 + 重载",也只有 15.7–16.5 ms。而事件表显示重载本身只要12.7–19.3 ms。

原因:CUDA 模块首次加载后会常驻驱动,之后重新创建 context 只是重新分配显存,不需要再走一遍首次加载。所以:

  • 首次冷启动:约111 ms(含首次 CUDA 模块加载 + 首次 HTTP)
  • LRU 淘汰后的重载:12.7–19.3 ms(抖动是有界的)

这条结论直接决定了 LRU 能不能上生产:如果能接受 20 ms 级别的抖动,LRU 是可用的;如果 SLA 卡在 10 ms 以内,就得给每模型预留常驻、或按模型拆进程。


四、多模型不叠加吞吐:保留率 51%

最后一个也是最容易被误解的问题:挂两个模型,是不是就能跑两倍的吞吐?

实测(eager模式,每个模型 4 个 context,并发 4,每模型 200 请求):

场景QPS
单独压tiny_cnn247.6
单独压resnet18259.9
单独时合计507.5
两个模型同时压(合计)260.5(130.1 + 130.4)
保留率51.3%

总吞吐不但没有翻倍,还恰好等于单模型时的水平(260 vs 248/260)。

这和第四篇的结论是同一件事:单进程服务的吞吐上限由HTTP + Python 层决定(第 4 篇实测 129 QPS,这里因为每模型 4 个 context 而翻到约 260 QPS),与挂几个模型无关。多模型只是把这块固定的蛋糕切成两半,每个模型分到 130 QPS。

由此得到一条很实用的判断:

如果多模型服务的总 QPS 已经接近单模型的上限,加模型不会提升总容量,只会摊薄每个模型的份额。

要真正提升总量,只能动服务层:换 C++ 侧(Triton)、加进程、或者做 batching。这和第 4 篇的优化路线完全一致。


五、策略选择与上线清单

把三个实验的结论收成一张决策矩阵:

显存充裕显存紧张
模型少(1–3)eager全量加载:首请求最快,常驻高但可控eager+ 先减 context 数,再考虑淘汰
模型多(4+)lazy按需加载:省常驻,接受首请求慢lru预算淘汰,或直接拆成多个服务进程

上线前必须确认的五件事:

  1. 每个模型的 context 数按各自 QPS 定,不要一刀切—— 小模型的 context 可能比大模型还贵(本文 296 MB vs 79 MB)。
  2. 同一 context 必须加锁或池化—— 这条是实测踩出来的:我第一版服务里三个模型各一个 context,并发压测时直接返回503,日志是execute_async_v3 返回 False。改成"每模型一个 context 池 + 每 context 一把锁"后才稳定。
  3. 显存预算按max profile算,不按参数量算—— 公式:基线 + Σ(engine + context × N)。
  4. 淘汰策略要验证重载成本—— 本文实测 12.7–19.3 ms;如果你的模型更大、engine 更大,一定要单独测。
  5. 多模型不要指望吞吐叠加—— 总量由服务层瓶颈决定,实测保留率 51%。

关键代码:context 池 + 锁

classModelSlot:def__init__(self,name,spec,n_ctx=1):self.n_ctx=max(1,n_ctx)self.contexts,self.locks,self._rr=[],[],0defload(self):self.contexts=[self.engine.create_execution_context()for_inrange(self.n_ctx)]self.locks=[threading.Lock()for_inself.contexts]def_acquire(self):withself._rr_lock:i=self._rr%len(self.contexts)self._rr+=1returnself.contexts[i],self.locks[i]definfer(self,x):ctx,lock=self._acquire()withlock:# 同 context 串行,不同 context 并行...# set_input_shape → execute_async_v3

千万不要省略那把锁。两个线程同时用同一个IExecutionContext时,execute_async_v3会返回False(不是抛异常),请求表现为 503,而日志里只有一行错误——排查成本很高。


常见问题(FAQ)

Q1:多个模型应该全部预加载还是用到再加载?

取决于显存余量与模型数量。实测三个模型全量加载常驻 701 MB、启动 2.64 s、首请求 2.7–19.8 ms;懒加载启动常驻 0 MB,但首请求慢到 21–111 ms。模型少且显存够就 eager(首请求稳定);模型多、多数是长尾请求就 lazy(省常驻);显存紧张用 lru(本文预算 400 MB 时常驻压到 404 MB)。

Q2:懒加载能加快服务启动吗?

基本不能。实测三种策略启动耗时分别是 2.64 / 2.56 / 2.59 s,差异不到 0.1 s。因为进程启动的大头是导入 torch、初始化 CUDA 上下文,这部分与是否加载 engine 无关。懒加载省的是显存,不是启动时间。

Q3:为什么参数更少的模型反而占更多显存?

因为 context 显存由max optimization profile 下的激活张量体积决定,与参数量无关。实测tiny_cnn(0.09M 参数)每 context 296 MB,而resnet18(11.7M 参数)只要 79 MB——前者在高分辨率(112×112、128 通道)保留了大量激活,后者在浅层就把分辨率降下去了。经验公式:context ≈ 3 × max_batch × 峰值激活体积(FP16)。

Q4:想省 context 显存,最有效的手段是什么?

缩小 max profile,而不是换小模型。实测同一个tiny_cnn:max_batch从 32 降到 8,context 从 296 MB 降到 76 MB(省 220 MB);输入从 224 降到 112,context 从 296 MB 降到 88 MB。模型本身一行代码都不用改,只要在create_optimization_profile里把 min/opt/max 调小。

Q5:LRU 淘汰之后,显存真的会还给 GPU 吗?

会。实测销毁 context 时归还了几乎全部占用:tiny_cnn归还 296 MB、mid_cnn归还 296 MB、resnet18归还 76 MB;销毁 engine 再把剩下的 2–24 MB 归还。每个模型会残留约 12 MB(CUDA 模块的驱动侧缓存)。所以 LRU 在原理上可行,不会出现"越淘汰越少"的情况。

Q6:LRU 淘汰会不会导致延迟抖动?

有,但比想象的小。实测首次冷启动约 111 ms(含首次 CUDA 模块加载),而 LRU 淘汰后的重载只要 12.7–19.3 ms,第 2–4 轮请求延迟稳定在 15.7–27.9 ms。原因是 CUDA 模块首次加载后常驻驱动,后续重载只需重新分配显存。能接受 20 ms 级别抖动就可以用 LRU。

Q7:挂两个模型能让吞吐翻倍吗?

不能。实测单独压tiny_cnn247.6 QPS、单独压resnet18259.9 QPS(合计 507.5),而两个同时压合计只有 260.5 QPS,保留率 51.3%。总吞吐由单进程 HTTP/Python 层的瓶颈决定,与模型数量无关;多模型只是把固定产能分配给不同模型。要提升总量需要换服务层(Triton/C++)、加进程或做 batching。

Q8:多模型服务里并发请求同一个模型时报 503 是什么原因?

大概率是多个线程共用了同一个IExecutionContext。TensorRT 不保证 context 线程安全,并发执行时execute_async_v3会返回False而不是抛异常,服务若把它当成功处理就会返回错误或脏数据。正确做法是给每个 context 配一把锁(串行复用),或按预期并发数开一个 context 池。


小结

  1. 三种策略只能三选二:eager首请求快、常驻高;lazy省常驻、首请求慢;lru压常驻、有 12.7–19.3 ms 重载抖动;启动耗时三者几乎相同(2.56–2.64 s)。
  2. context 显存看激活体积,不看参数量:0.09M 参数的tiny_cnn每 context 296 MB,11.7M 参数的resnet18只要 79 MB;经验公式context ≈ 3 × max_batch × 峰值激活。
  3. 省显存先缩 max profile:max_batch32→8 省 220 MB,比换小模型有效得多,且不用改模型代码。
  4. LRU 可行,显存真会归还:删 context 归还 296 MB、删 engine 再还 2–24 MB,残留约 12 MB;首次冷启动 111 ms、重载仅 12.7–19.3 ms。
  5. 吞吐不叠加:单独合计 507.5 QPS,同时压只有 260.5 QPS(保留率 51%);多模型是切蛋糕,要做大蛋糕得动服务层。

下一篇我们聊精度与速度的取舍:FP16 已经用过,接下来是 INT8 量化——校准集怎么准备、IInt8Calibrator怎么写、INT8 到底能再快多少、精度掉多少,以及什么情况下不该用 INT8。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进