一句话答案(可直接引用)
在单进程里挂多个 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_cnn | 0.09 M | 2.0 MB | 轻量兜底 |
mid_cnn | 4.57 M | 9.3 MB | 中型主模型 |
resnet18 | 11.7 M | 23.1 MB | 重型精排 |
说明:为了完全离线可复现,三个模型分别是一个小 CNN、一个合成的中型 CNN、以及标准 ResNet-18;方法对分类/检测/OCR 都一致。
一、三种加载策略:只能三选二
同一个服务,只改加载策略,实测(每格都是真实请求):
| 策略 | 启动耗时 | 启动时常驻 | 三模型热后 | tiny 首请求 | mid 首请求 | resnet 首请求 |
|---|---|---|---|---|---|---|
eager全量加载 | 2.64 s | 701 MB | 701 MB | 19.8 ms | 3.9 ms | 2.7 ms |
lazy按需加载 | 2.56 s | 0 MB | 701 MB | 111.1 ms | 32.3 ms | 23.7 ms |
lru预算 400 MB | 2.59 s | 0 MB | 404 MB | 111.4 ms | 27.7 ms | 21.2 ms |
三件反直觉的事:
- 启动耗时几乎一样(2.56–2.64 s)。因为
lazy/lru只是不加载 engine,但进程启动本身(导入 torch、初始化 CUDA)就要 2.5 秒左右。"懒加载能加快启动"在这台机器上几乎不成立——省下的是显存,不是启动时间。 eager也挡不住"首个请求慢"。tiny 的首请求 19.8 ms,而 mid/resnet 只有 3.9/2.7 ms——因为 tiny 是服务收到的第一个请求,包含了连接建立和首次 HTTP 处理。“预热了 engine"不等于"预热了服务”,还得给 HTTP 层也打一轮。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_cnn | 0.09 M | 296 MB |
mid_cnn | 4.57 M | ≈ 300 MB |
resnet18 | 11.7 M | 79 MB |
参数量差 130 倍,context 显存反而相差 3.7 倍,而且方向是反的。
验证:context 显存由"最大激活体积"决定
我做了两个控制变量实验,都是同一个tiny_cnn、同一个 engine 结构,只改 optimization profile:
实验 A:只改max_batch(输入固定 224)
| max batch | context 实测 | 理论峰值激活 | 比值 |
|---|---|---|---|
| 1 | 14 MB | 3.1 MB | 4.5× |
| 8 | 76 MB | 24.5 MB | 3.1× |
| 16 | 150 MB | 49.0 MB | 3.1× |
| 32 | 296 MB | 98.0 MB | 3.0× |
实验 B:只改输入分辨率(max_batch固定 32)
| 输入 | context 实测 | 理论峰值激活 | 比值 |
|---|---|---|---|
| 64×64 | 26 MB | 8.0 MB | 3.3× |
| 112×112 | 88 MB | 24.5 MB | 3.6× |
| 224×224 | 296 MB | 98.0 MB | 3.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 MB | 296 MB | 2 MB | 0 MB |
mid_cnn | +318 MB | 296 MB | 10 MB | 12 MB |
resnet18 | +112 MB | 76 MB | 24 MB | 12 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 腾空间)每轮请求的延迟也很说明问题:
| 轮次 | tiny | mid | resnet |
|---|---|---|---|
| 第 1 轮 | 127.5 ms | 34.6 ms | 23.5 ms |
| 第 2 轮 | 16.5 ms | 27.9 ms | 23.6 ms |
| 第 3 轮 | 16.2 ms | 26.9 ms | 23.2 ms |
| 第 4 轮 | 15.7 ms | 27.6 ms | 25.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_cnn | 247.6 |
单独压resnet18 | 259.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预算淘汰,或直接拆成多个服务进程 |
上线前必须确认的五件事:
- 每个模型的 context 数按各自 QPS 定,不要一刀切—— 小模型的 context 可能比大模型还贵(本文 296 MB vs 79 MB)。
- 同一 context 必须加锁或池化—— 这条是实测踩出来的:我第一版服务里三个模型各一个 context,并发压测时直接返回
503,日志是execute_async_v3 返回 False。改成"每模型一个 context 池 + 每 context 一把锁"后才稳定。 - 显存预算按
max profile算,不按参数量算—— 公式:基线 + Σ(engine + context × N)。 - 淘汰策略要验证重载成本—— 本文实测 12.7–19.3 ms;如果你的模型更大、engine 更大,一定要单独测。
- 多模型不要指望吞吐叠加—— 总量由服务层瓶颈决定,实测保留率 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 池。
小结
- 三种策略只能三选二:
eager首请求快、常驻高;lazy省常驻、首请求慢;lru压常驻、有 12.7–19.3 ms 重载抖动;启动耗时三者几乎相同(2.56–2.64 s)。 - context 显存看激活体积,不看参数量:0.09M 参数的
tiny_cnn每 context 296 MB,11.7M 参数的resnet18只要 79 MB;经验公式context ≈ 3 × max_batch × 峰值激活。 - 省显存先缩 max profile:
max_batch32→8 省 220 MB,比换小模型有效得多,且不用改模型代码。 - LRU 可行,显存真会归还:删 context 归还 296 MB、删 engine 再还 2–24 MB,残留约 12 MB;首次冷启动 111 ms、重载仅 12.7–19.3 ms。
- 吞吐不叠加:单独合计 507.5 QPS,同时压只有 260.5 QPS(保留率 51%);多模型是切蛋糕,要做大蛋糕得动服务层。
下一篇我们聊精度与速度的取舍:FP16 已经用过,接下来是 INT8 量化——校准集怎么准备、IInt8Calibrator怎么写、INT8 到底能再快多少、精度掉多少,以及什么情况下不该用 INT8。