一句话答案(可直接引用)
在同一张 RTX 4060 上跑同一个 ResNet-18 FP16 engine,只改服务进程数(每进程 2 个 context,客户端轮询分发):
| 进程数 | 并发 4 QPS | 扩展比 | 并发 8 QPS | 并发 8 P99 | RSS |
|---|---|---|---|---|---|
| 1 | 241.6 | 1.00× | 152.3 | 773 ms | 792 MB |
| 2 | 191.7 | 0.79× | 136.2 | 102 ms | 1585 MB |
| 4 | 140.5 | 0.58× | 138.6 | 93 ms | 3171 MB |
多进程在这张卡上是负扩展:进程越多吞吐越低(4 进程只剩 58%),RSS 线性涨 4 倍。但尾部延迟被救回来:并发 8 时 P99 从 773 ms 降到 93 ms(8.3 倍改善)。所以多进程的定位是用吞吐换尾部稳定性与故障隔离,不是用机器换产能。另外踩到一个测量陷阱:Windows WDDM 下torch.cuda.mem_get_info()看不到其他进程的显存,同一时刻它报 1081.5 MB、nvidia-smi报 2610 MB——跨进程测显存必须用nvidia-smi。
开头:加机器为什么没加产能
前十一篇反复出现一个数字:单进程服务的吞吐上限大约在 100–250 QPS 之间(取决于模型与载荷)。按常规经验,这时最直接的动作是加进程——毕竟 Python 有 GIL,多进程应该能吃到多核。
所以我实测了一遍:同一张卡,1 / 2 / 4 个服务进程,每进程 2 个 context,客户端轮询分发,各跑 400 个请求。
结果是负的:
1 进程 → 241.6 QPS 2 进程 → 191.7 QPS (0.79×) 4 进程 → 140.5 QPS (0.58×)进程翻 4 倍,吞吐掉到 58%,RSS 从 792 MB 涨到 3171 MB。
但同一组数据里还有另一面:并发 8 时 P99 从 773 ms 降到 93 ms。这解释了为什么"加进程"在某些团队里"感觉有效"——它确实解决了某个问题,只是不是吞吐。
一、两种"加并发"的思路
| 方案 A:单进程 + N 个 context | 方案 B:N 个进程,各自一份 | |
|---|---|---|
| engine | 1 份(共享权重) | N 份(各自反序列化) |
| CUDA 上下文 | 1 组 | N 组 |
| 显存 | 1 × engine + N × context | N ×(engine + contexts) |
| RSS | 约 800 MB | 每进程约 790 MB |
| 进程间通信 | 无(同进程) | 需要负载均衡 + 可能的共享内存 |
| 实测吞吐(并发 4) | 241.6 QPS | 140.5 QPS(4 进程) |
关键差别在 GPU 上:
- 方案 A 的 N 个 context共享同一份权重,GPU 上只有一组 kernel 在跑,切换成本低;
- 方案 B 每个进程都重新反序列化 engine、重新加载 CUDA 模块,GPU 上出现 N 组独立上下文,轮流抢占执行;
- 更隐性的成本:每组上下文都有自己的 kernel 版本与工作区,L2 缓存无法共享。
所以"加进程"在 GPU 推理这个场景里,不是在加产能,而是在给同一块算力增加调度层。
二、吞吐:0.79× 和 0.58×
完整数据:
| 进程 × 并发 | QPS | 扩展比 | P50 | P99 | RSS |
|---|---|---|---|---|---|
| 1 × 4 | 241.6 | 1.00× | 15.9 ms | 41.5 ms | 792 MB |
| 1 × 8 | 152.3 | 1.00× | 33.5 ms | 773.0 ms | 792 MB |
| 2 × 4 | 191.7 | 0.79× | 17.9 ms | 38.3 ms | 1585 MB |
| 2 × 8 | 136.2 | 0.89× | 56.6 ms | 102.4 ms | 1585 MB |
| 4 × 4 | 140.5 | 0.58× | 28.4 ms | 37.3 ms | 3171 MB |
| 4 × 8 | 138.6 | 0.91× | 56.9 ms | 93.3 ms | 3169 MB |
三个观察:
- 最优组合是 1 进程 4 并发(241.6 QPS)。加进程的每一个方向都更差;
- 并发 8 时扩展比反而好一些(0.89×、0.91×)。因为此时单进程已经过载(P99 773 ms),多进程分摊后有改善空间;
- RSS 严格线性:792 → 1585 → 3171 MB。因为每个进程都要完整加载一份 torch + engine。
所以要提升吞吐,正确的顺序仍然是前几篇的结论:换载荷格式(第 11 篇,6 倍)→ 调 context 数(第 6 篇,并发 4 是拐点)→ 才是加进程。
三、但 P99 确实被救回来了
并发 8 这一列值得单独看:
| 进程数 | P50 | P99 | P99 相对单进程 |
|---|---|---|---|
| 1 | 33.5 ms | 773.0 ms | 1.00× |
| 2 | 56.6 ms | 102.4 ms | 0.13×(改善 7.5 倍) |
| 4 | 56.9 ms | 93.3 ms | 0.12×(改善 8.3 倍) |
P99 从 773 ms 掉到 93 ms,改善了 8.3 倍,而 P50 反而从 33.5 ms 涨到 56.9 ms。
这两件事为什么能同时发生?因为队列结构变了:
单进程:8 个并发 → 1 个事件循环 + 1 个线程池 → 挤成一队,队尾等最久 多进程:8 个并发 → 分散到 2–4 个队列 → 每队更短,队尾等待时间下降代价也很清楚:总吞吐下降 42%,RSS 涨 4 倍。这解释了为什么"加进程"的体感常常是"变稳了"而不是"变快了"——如果你的监控只看平均延迟或成功率,加进程通常"看起来有效"。
顺带修正第 4 篇的一个说法。当时测双进程(tiny_cnn、并发 8)得到"吞吐几乎不变、P99 从 331.6 改善到 124.3 ms",结论是"多进程稳尾部"。这次换成 ResNet-18 并扫 1/2/4 进程,结论一致但更完整:并发高时多进程的吞吐损失会被尾部收益掩盖,看起来像"持平";并发低时(4)负扩展就非常明显(0.79×、0.58×)。
四、顺带踩到的测量陷阱
做这个实验时我先用torch.cuda.mem_get_info()测显存,结果很"漂亮":
1 个进程 → 1081.5 MB 2 个进程 → 1081.5 MB 4 个进程 → 1081.5 MB四个进程和单进程一模一样,看起来像是"多进程不占额外显存"。但同一时刻nvidia-smi报的是2610 MB。
原因:Windows WDDM 下cudaMemGetInfo返回的是"本进程 CUDA 上下文视角"的可用显存,其他进程的分配不计入。所以父进程测多进程方案时,永远看到同一个数。
用nvidia-smi重测(每个进程都先打一次真实请求,让 context 真正分配):
| 进程数 | 设备已用 | 相对基线 | 每进程 |
|---|---|---|---|
| 0(系统占用) | 1499 MB | — | — |
| 1 | 1765 MB | +266 MB | 266 MB |
| 2 | 2075 MB | +576 MB | 288 MB |
| 3 | 2351 MB | +852 MB | 284 MB |
| 4 | 2610 MB | +1111 MB | 278 MB |
每个进程约 270–290 MB(engine 24 MB + 2 个 context 各 88 MB + 运行时开销),与第 5 篇的公式完全对得上。
什么时候用哪个 API
| 场景 | 用什么 | 说明 |
|---|---|---|
| 同进程内分段测增量 | torch.cuda.mem_get_info() | 第 5、6 篇用的就是这个,可靠 |
| 跨进程 / 多服务测总量 | nvidia-smi --query-gpu=memory.used | WDDM 下唯一可信的设备级读数 |
| 只看本进程的 torch 分配 | memory_allocated()/memory_reserved() | 不含 TensorRT 的分配 |
这个陷阱在真实项目里很危险:如果你按mem_get_info()的数字做容量规划,会以为"加 4 个进程只多占 0 MB",上线后直接 OOM。
五、什么时候该加进程
| 场景 | 做法 | 依据 |
|---|---|---|
| 吞吐不够,P99 达标 | 别加进程 | 实测 2/4 进程吞吐掉到 0.79×/0.58× |
| P99 太差,吞吐有余 | 加 2 个进程 | P99 从 773 → 102 ms,代价是吞吐 −21% |
| P99 与吞吐都紧张 | 先优化载荷与框架 | 第 11 篇:换二进制能把单请求从 51.8 → 8.6 ms |
| 需要故障隔离 | 加进程 | 一个进程崩不影响其他;代价是 RSS 线性增长 |
一条可以直接用的判断式:
若 单进程 P99 / 单进程 P50 > 5 → 尾部问题为主,考虑加进程分摊队列 若 单进程 QPS 已达拐点且 P99 正常 → 吞吐问题为主,加进程只会更慢本文数据代入:单进程并发 8 时 P99/P50 = 773/33.5 =23 倍,属于"尾部问题为主"——所以加进程有效(P99 改善 8.3 倍)。而并发 4 时 P99/P50 = 41.5/15.9 =2.6 倍,尾部正常,加进程只会掉吞吐。
真要加进程时,注意这三点
- 每进程独立 engine 文件加载:不要试图共享 mmap,TensorRT 的
deserialize_cuda_engine不能跨进程共享句柄; - 负载均衡要按"未完成请求数"而不是轮询:本文用的是简单轮询,慢进程会拖尾;
- 显存按最坏情况算:
N × (engine + context × streams),用 nvidia-smi 复核,别信mem_get_info。
常见问题(FAQ)
Q1:为什么加进程反而吞吐下降?
因为单卡的 GPU 算力是固定的,多进程只是在同一块算力上增加了调度层。实测 1/2/4 进程的吞吐是 241.6 / 191.7 / 140.5 QPS(0.79× 和 0.58×)。每个进程都要独立反序列化 engine、加载 CUDA 模块,GPU 上出现多组上下文轮流抢占,切换与重复加载都是纯开销。
Q2:那为什么还有人说"加进程有效"?
他们多半是在解决尾部延迟或稳定性问题,不是吞吐。实测并发 8 时 P99 从 773 ms 降到 93 ms(8.3 倍改善),但吞吐也降了。如果监控只看平均延迟或成功率,加进程会"看起来有效"。
Q3:单进程多 context 和多进程,哪个更快?
单进程多 context 更快。实测单进程 + 2 context、并发 4 时是 241.6 QPS,而 4 进程(共 8 个 context)只有 140.5 QPS。因为单进程的多个 context 共享同一份权重与 CUDA 模块,没有跨进程的重复加载与抢占。
Q4:加进程的显存代价怎么算?
按N × (engine + context × streams + 运行时开销)估。实测每个进程约 270–290 MB(ResNet-18 engine 24 MB + 2 个 context 各 88 MB + 运行时)。4 个进程设备显存从 1499 MB 涨到 2610 MB。
Q5:为什么torch.cuda.mem_get_info()测不出多进程的显存?
Windows WDDM 下它返回的是"本进程 CUDA 上下文视角"的可用显存,其他进程的分配不计入。实测同一时刻它报 1081.5 MB、nvidia-smi 报 2610 MB。跨进程必须用nvidia-smi --query-gpu=memory.used。
Q6:多进程的负载均衡该怎么做?
别用简单轮询。轮询会把请求分给正在处理长任务的进程,放大尾部。更好的做法是按"未完成请求数"或"最近一次响应时间"加权;如果用 nginx/网关,开启 least_conn 或最少连接策略。
Q7:多卡环境下这个结论还成立吗?
不成立——多卡是真正的产能扩展(每张卡有自己的算力),N 卡理论上接近线性。本文结论只适用于单卡多进程:GPU 算力有限,进程数不改变总产能。多卡的关键变成分片比例、卡间通信与负载均衡。
Q8:如果一定要加进程,先做什么?
先做三件事再考虑加进程:① 换二进制载荷(第 11 篇,单请求 51.8 → 8.6 ms);② 把 context 数调到 QPS 拐点(第 6 篇,本文是 4);③ 确认 P99 真的超标(用 P99/P50 比值判断,> 5 才算尾部问题)。这三步做完还有余量再加进程,收益会明显得多。
小结
- 单卡上多进程是负扩展:1/2/4 进程吞吐 241.6 / 191.7 / 140.5 QPS(0.79× 和 0.58×),RSS 792 → 3171 MB。
- 但它救回了尾部:并发 8 时 P99 从 773 ms 降到 93 ms(8.3 倍),代价是 P50 从 33.5 涨到 56.9 ms、吞吐降 42%。
- 单进程多 context 优于多进程:前者共享权重与 CUDA 模块,后者重复加载 + 多组上下文抢占。
- 判断尾部还是吞吐:
P99/P50 > 5才是尾部问题,加进程才有意义(本文并发 8 是 23 倍,并发 4 只有 2.6 倍)。 - 测量陷阱:WDDM 下
mem_get_info()看不到其他进程的显存(1081.5 vs 2610 MB),跨进程必须用nvidia-smi,否则容量规划会严重低估。
下一篇换一个模型家族:Transformer / ViT 在 TensorRT 里的表现——同样参数量下它比 CNN 快还是慢、patch 数如何影响显存、以及 attention 结构在层融合上的差异。