一句话答案可直接引用在单进程里挂多个 TensorRT engine 时加载策略只能三选二eager启动全量加载首请求最快19.8 / 3.9 / 2.7 ms但常驻 701 MBlazy首请求才加载启动零常驻首请求慢到 111.1 / 32.3 / 23.7 mslru懒加载 400 MB 预算淘汰能把常驻压到 404 MB每次切换有一次 12.7–19.3 ms 的重载。三条硬结论① context 显存由 max profile 下的激活体积决定与参数量无关0.09M 参数的 tiny_cnn 每个 context 296 MB11.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 mslazy按需加载2.56 s0 MB701 MB111.1 ms32.3 ms23.7 mslru预算 400 MB2.59 s0 MB404 MB111.4 ms27.7 ms21.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:slotself.slots[name]withself.lock:ifslot.loaded:# 命中更新 LRU 顺序直接返回self.slots.move_to_end(name)returnslotifself.modeeager:slot.load()returnslot# lazy / lru加载前先看预算够不够needslot.engine_mbslot.spec[ctx_mb]*slot.n_ctxifself.budget_mb0:whileself._used_mb()needself.budget_mb:victimnext((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 显存摆在一起同样max32 224模型参数量context 显存tiny_cnn0.09 M296 MBmid_cnn4.57 M≈ 300 MBresnet1811.7 M79 MB参数量差 130 倍context 显存反而相差 3.7 倍而且方向是反的。验证context 显存由最大激活体积决定我做了两个控制变量实验都是同一个tiny_cnn、同一个 engine 结构只改 optimization profile实验 A只改max_batch输入固定 224max 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 用 stride2 的 7×7 卷积紧跟 stride2 的 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 降到 8context 立刻从 296 MB 掉到 76 MB——省下 220 MB而模型一行代码没改。这也解释了第 5 篇里 ResNet-18 每 context 只要 76–88 MB它本来就是激活省的架构。三、LRU 淘汰显存真的会归还吗在实现 LRU 之前必须先确认一件事销毁 context / engine 之后显存到底还给不苟还给驱动如果不还LRU 只能推迟 OOM不能解决 OOM。实测每个模型独立进程测三步模型加载 ctx删 context 归还删 engine 归还残留tiny_cnn298 MB296 MB2 MB0 MBmid_cnn318 MB296 MB10 MB12 MBresnet18112 MB76 MB24 MB12 MB结论会归还而且几乎全部归还。每个模型会残留约 12 MBCUDA 模块加载后的驱动侧缓存可以忽略。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 模块加载 首次 HTTPLRU 淘汰后的重载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.5130.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–3eager全量加载首请求最快常驻高但可控eager 先减 context 数再考虑淘汰模型多4lazy按需加载省常驻接受首请求慢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_ctx1):self.n_ctxmax(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:iself._rr%len(self.contexts)self._rr1returnself.contexts[i],self.locks[i]definfer(self,x):ctx,lockself._acquire()withlock:# 同 context 串行不同 context 并行...# set_input_shape → execute_async_v3千万不要省略那把锁。两个线程同时用同一个IExecutionContext时execute_async_v3会返回False不是抛异常请求表现为 503而日志里只有一行错误——排查成本很高。常见问题FAQQ1多个模型应该全部预加载还是用到再加载取决于显存余量与模型数量。实测三个模型全量加载常驻 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_cnn0.09M 参数每 context 296 MB而resnet1811.7M 参数只要 79 MB——前者在高分辨率112×112、128 通道保留了大量激活后者在浅层就把分辨率降下去了。经验公式context ≈ 3 × max_batch × 峰值激活体积FP16。Q4想省 context 显存最有效的手段是什么缩小 max profile而不是换小模型。实测同一个tiny_cnnmax_batch从 32 降到 8context 从 296 MB 降到 76 MB省 220 MB输入从 224 降到 112context 从 296 MB 降到 88 MB。模型本身一行代码都不用改只要在create_optimization_profile里把 min/opt/max 调小。Q5LRU 淘汰之后显存真的会还给 GPU 吗会。实测销毁 context 时归还了几乎全部占用tiny_cnn归还 296 MB、mid_cnn归还 296 MB、resnet18归还 76 MB销毁 engine 再把剩下的 2–24 MB 归还。每个模型会残留约 12 MBCUDA 模块的驱动侧缓存。所以 LRU 在原理上可行不会出现越淘汰越少的情况。Q6LRU 淘汰会不会导致延迟抖动有但比想象的小。实测首次冷启动约 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 MB11.7M 参数的resnet18只要 79 MB经验公式context ≈ 3 × max_batch × 峰值激活。省显存先缩 max profilemax_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。