先回答一个我经常被问到的经典问题:本地跑 AI 模型推理的时候,把并发线程从 1 加到 64,服务吞吐是不是就能翻 64 倍?答案是:大概率在第 4 到第 8 个线程之间,曲线就拐头了,甚至可能往下掉。这个现象背后藏着 AI 模型推理多线程性能测试这件事最重要的一层逻辑——先搞清楚你的瓶颈到底在哪,再谈加线程。
这篇文章聊的就是这件事。我会从一个实际可复现的 AI 模型推理多线程压测方案出发,讲清楚测试设计怎么搭、参数怎么定、工具怎么选、结果怎么解读,也把我在这个过程中踩过的坑、发现的反直觉现象一并写出来。无论你是刚接触大模型推理服务的新手,还是已经调过一轮吞吐量的老手,只要你的工作里出现了“模型推理变慢”“并发上不去”“线程加多了反而卡死”这类问题,这篇内容都能给你一条可以直接照着走的排查路径。
1. 内容整体设计与思路拆解
1.1 多线程推理的收益到底从哪里来
很多人对多线程推理的期待,是“同一时刻能多算几个请求”。但真实的 AI 推理链路并不是只有“计算”这一环。一个典型的推理服务,单个请求在服务器内部要经过数据接收、预处理(分词、归一化、padding)、模型前向计算(也就是真正的主干网络或 Transformer 解码)、后处理(softmax、采样、解码结果整理)、响应返回这几个阶段。如果我在服务外面用 10 个并发用户打进来,请求会先排队,然后分别经过这些阶段。
计算密集的矩阵乘法大多集中在模型前向这一步,而其它阶段通常伴随内存拷贝、CPU 算子调度、网络 I/O 等待。多线程能带来的真实收益,恰恰在于它把“CPU 在等内存/网络/算子库内部调度”的时间碎片利用起来了。举个例子,当线程 A 的请求在做显存张量搬运的时候,线程 B 的请求可以利用空闲的 CPU 核做分词和采样。这个思路和单线程下载文件时 CPU 大部分时间在干等、多线程下载能把带宽打满的底层逻辑是一样的。
但也必须坦白说,如果模型的矩阵运算已经把计算核心占满了,比如同一个 GPU 上的张量计算已经达到高利用率,那么外面再起 32 个线程抢同一个 GPU,效果不会线性增长,反而会因为线程切换、显存带宽争抢、上下文切换让端到端延迟变差。这就是为什么性能测试必须分“计算密集阶段”和“整体服务阶段”来看,两者结论可能完全不同。
1.2 测试目标口径要先统一
开始压测前,第一件事不是写脚本,而是定义清楚你测的是哪个指标。我建议至少区分四个口径:
| 指标 | 含义 | 适用场景 |
|---|---|---|
| 时延(Latency) | 单个请求从发出到收到完整结果的耗时 | 交互式应用、实时对话 |
| 吞吐量(Throughput/QPS) | 单位时间内服务能处理的请求数 | 批处理任务、离线推理 |
| 尾部延迟(p99/p999) | 最慢的 1% 或 0.1% 请求的延迟 | 服务稳定性评估 |
| 资源利用率 | CPU、内存、GPU 利用率 | 瓶颈定位与容量规划 |
这四个指标经常互相冲突。比如把服务端批处理大小调大,吞吐量会提升,但单请求的 p99 延迟很可能飙升;把超时时间设短,客户端看到的失败率会上升,但服务端实际吞吐并没有变。所以测试报告里如果不写清楚“本次优化的目标是 QPS 还是 p99”,后面所有结果都可能被误读。
我自己习惯在测试脚本开头把目标声明写成注释,比如“目标是单机在 p99 <= 500ms 时最大化 QPS”。这个约束条件很关键,它决定了后面你调线程数、调 batch size 时的最终判断标准。没有约束地跑一个最大 QPS,只能说明系统在濒临崩溃边缘的极限水位,不能作为容量规划依据。
1.3 测试场景要区分单机推理与在线服务
AI 模型推理多线程测试,实际上有两种非常不同的场景,我强烈建议分开建模。
第一种是纯推理引擎基准测试,比如你用 C++ 加载一个 ONNX 模型,在本地起多个线程循环调用引擎的 Run 接口。这种场景重点测的是“引擎本身在多线程下的行为”,没有网络层,没有并发连接管理,适合评估模型和推理引擎的极限能力。第二种是端到端在线推理服务,比如用 Java 写一个 HTTP 推理服务,外面用 JMeter 打并发请求。这种场景里线程不仅存在于模型引擎内部,还存在 Tomcat 工作线程、连接池线程、网关线程,测的是整个服务链路。
这两种场景的结论经常不一致。一个典型的现象:引擎内部单线程推理时延 10ms,理论上单进程每秒能处理 100 个请求;但当你起 4 个服务线程对外提供 HTTP 接口时,QPS 可能只有 200 出头,因为线程之间要抢锁,网络解析、JSON 序列化这些 CPU 开销被放大了。所以如果你只测了引擎层多线程,就直接推算在线服务容量,基本会跑偏。正确的做法是两层都测,先拿到单机推理引擎的多线程基线,再对服务层做压测,两层数据对比才能定位瓶颈是出在引擎还是服务框架。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理剖析:多线程推理的瓶颈在哪
2.1 CPU 推理场景的线程叠加陷阱
在 CPU 上跑模型推理,比如用 OpenVINO、ONNX Runtime 或者 TFLite,首先得搞清楚两套线程数量:一套是引擎内部的算子并行线程数,另一套是引擎外部同时执行多个请求的线程数。两套线程是叠加关系,不是替代关系。
我见过最典型的配置错误是:ONNX Runtime 的 intra_op_num_threads 设成 4,同时又在服务层起了 32 个线程并发调用引擎。如果机器只有 16 个物理核,系统实际有 128 个线程在抢 16 个核,操作系统调度开销直接吃掉大量 CPU,最后不仅快不起来,反而产生严重的抖动。正确做法是结合硬件异构性统筹考虑——比如 16 核机器上,把服务并发线程控制在 8 到 12 个,同时把引擎内部算子并行线程数设成 2 到 4,确保两者乘积不超过物理核心数,并且留出一定余量给系统调用和内存管理。
还有超线程(Hyper-Threading)这个变量,CPU 在逻辑上显示 32 个核但物理只有 16 个时,压测场景开不开超线程对结果影响很大。我实测过 OpenVINO 的 CPU 推理在关闭超线程时,单核时延更稳定;打开超线程时,总吞吐上限更高,但 p99 会变差。这个没有绝对对错,取决于你是延迟敏感型还是吞吐追求型,但测试报告中必须注明超线程状态,否则数据不可复现。
3.2 GPU 推理场景的主机和设备分工
GPU 推理用多线程的思路和 CPU 略有不同。现代 GPU 推理引擎本身已经是高度并行化的,一个 batch 内的张量计算会被切成几千个 kernel 塞进 GPU 流处理器。这时 CPU 侧开多个线程的真正作用,是让预处理、后处理、请求分发、模型加载这些主机端工作不阻塞在同一个串行循环里。
但 GPU 侧的并发又是另一个故事。如果显存足够,你可以同时跑多个推理流(stream),提高 GPU 利用率;如果模型非常大,显存已经占满,多流并发反而会因为显存不足导致反复换入换出,性能断崖式下跌。所以在 GPU 推理测试里,光看 CPU 线程数是不够的,还要关注两件事:显存峰值利用率和 GPU 计算核心利用率(比如用 nvidia-smi dmon 或 ncu 监控)。
实际调优时,我习惯先固定一个合理 batch size,比如 8 或 16,然后用不同数量的 CPU 线程去喂 GPU,观察 GPU 利用率能否稳定保持在 90% 以上。如果线程数已经加到 16,GPU 利用率还在 60% 徘徊,那瓶颈大概率不在 CPU 并发,而在数据加载、预处理管线的吞吐,或者请求到达速率不够。这时候一味加线程只会增加 CPU 空转,正确方向是加大 batch 或者优化数据管线。
2.3 语言选型对多线程有本质影响
很多 AI 服务用 Python 写,但 Python 的多线程有一个天然限制:全局解释器锁(GIL)。同一时刻只有一个线程能执行 Python 字节码,所以如果你用纯 Python 写的代码去做 CPU 密集型推理,多线程不仅没有收益,反而因为锁竞争更慢。
但注意,如果推理计算是在推理引擎的 C++ 底层执行,比如通过 PyTorch、ONNX Runtime Python 绑定调用,底层计算时会释放 GIL,那么 Python 层的多线程仍能提升整体吞吐,因为多个请求可以在 C++ 层面真正并行执行预处理或推理。这说明一个实操原则:先确认你的推理调用是否会释放 GIL。验证方法很简单,写 4 个线程各自加载模型并推理,对比 4 个独立进程的总吞吐,如果线程版本显著低于进程版本,说明 GIL 卡住了。
Java 和 C++ 没有这个限制,但要注意线程池管理和异步编排的细节。Java 端我通常用 ExecutorService 配合 CompletableFuture,把每个推理请求封装成一个异步任务,主线程等待所有 future 完成,再统一统计耗时。C++ 则用标准线程池或 OpenMP,配合原子计数器统计完成数。选型没有标准答案,关键是你有没有理解语言对并发模型的影响,以及有没有在测试方案里规避掉这种影响。
3. 实操过程与核心环节实现
3.1 压测功能与脚本的实现步骤
先说一个完整可落地的最小测试方案。我以 Python + ONNX Runtime + 一个文本分类模型为例,场景是测“服务端同一进程内起 N 个线程并发推理时的最大吞吐和 p99 延迟”。这个方案足够通用,换成 TensorRT、OpenVINO 或者 vLLM 只是换了引擎初始化方式,测试骨架保持不变。
第一步,准备一份固定的测试数据集,比如 500 条长度不一但分布相对均匀的文本。固定数据非常重要,因为模型推理时延和输入序列长度强相关,如果不固定数据,你测出来的波动根本不是多线程的差异,而是数据长度的差异。
第二步,写一个压力发生器和请求执行器。压力发生器负责把 500 条数据按索引分发给 N 个线程,每个线程循环处理自己负责的那部分数据,记录每条数据的耗时。实际压测时不要用生成器动态造数,一次性加载到内存里,避免把磁盘 I/O 混进测试。
第三步,控制线程数量递增。分别跑 thread=1、2、4、8、16、32 五组测试,每组之间留 30 秒以上冷却时间,避免 CPU 降频或显存温度影响结果。
第四步,统一结果汇总。每组测试记录总耗时、QPS、p50/p95/p99 时延,以及测试时 CPU/GPU 的平均利用率。
3.2 线程并发执行器的三种写法对比
不同语言的并发写法差异很大,我直接把常见的三种都列出来,方便你按自己的技术栈选。
Python 版本用 ThreadPoolExecutor,代码骨架类似这样:
python复制import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import numpy as np
def run_one_inference(idx_text):
idx, text = idx_text
start = time.perf_counter()
# 这里调用 ONNX Runtime / PyTorch 的推理接口
results = model_predict(text)
cost = time.perf_counter() - start
return idx, cost, results
if __name__ == "__main__":
dataset = load_fixed_dataset() # 预先加载 500 条数据
for num_threads in [1, 2, 4, 8, 16, 32]:
latencies = []
# 每个线程分到固定的一批数据,避免用全局下标反复加锁
chunk_size = len(dataset) // num_threads
tasks = []
for t in range(num_threads):
chunk = dataset[t * chunk_size : (t + 1) * chunk_size]
tasks.extend(chunk)
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(run_one_inference, (i, t))
for i, t in enumerate(tasks)]
for f in as_completed(futures):
_, cost, _ = f.result()
latencies.append(cost)
total_cost = time.perf_counter() - start
latencies = np.array(latencies)
qps = len(latencies) / total_cost
print(f"threads={num_threads}, QPS={qps:.2f}, "
f"p50={np.percentile(latencies, 50) * 1000:.2f}ms, "
f"p99={np.percentile(latencies, 99) * 1000:.2f}ms")
这里有一个细节值得展开:为什么每轮测试的 tasks 是按块分配而不是用一个全局自增下标让每个线程去抢?因为当 32 个线程同时改一个共享索引变量时,为了保证线程安全,通常会加重锁或原子操作,这把“取任务”这个动作本身变成了一个锁竞争热点。在线程数少的时候无所谓,线程数一多,这个锁会显著拉高耗时。按块分配虽然理论上可能导致某些线程先跑完空等,但总体上的锁开销远小于动态分配。
Java 版本我更习惯用 ExecutorService 配合 CompletableFuture 来同时等待所有推理结果:
java复制import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class InferenceLoadTest {
public static void main(String[] args) throws Exception {
List<DataItem> dataset = loadDataset(); // 固定 500 条数据
int[] threadCounts = {1, 2, 4, 8, 16, 32};
for (int threads : threadCounts) {
ExecutorService executor = Executors.newFixedThreadPool(threads);
List<CompletableFuture<Long>> futureList = new ArrayList<>();
long start = System.nanoTime();
for (DataItem item : dataset) {
CompletableFuture<Long> future = CompletableFuture.supplyAsync(() -> {
long begin = System.nanoTime();
InferenceResult result = modelPredict(item);
return System.nanoTime() - begin;
}, executor);
futureList.add(future);
}
// 等待所有任务结束,统计结果
CompletableFuture.allOf(futureList.toArray(new CompletableFuture[0])).join();
long totalCostMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
executor.shutdown();
double qps = dataset.size() * 1000.0 / totalCostMs;
System.out.println("threads=" + threads + ", QPS=" + qps);
}
}
}
Java 这个写法里,CompletableFuture.allOf(...).join() 是关键。它会把当前线程阻塞住,直到所有异步任务全部完成。如果你用的是 Future.get() 一个一个拿结果,在主线程里等待时,一旦第一个 future 任务执行特别慢,后面所有结果都会排着队等,统计口径就会失真。allOf 的模式才是“同时等待所有结果”的正确方式。这正好呼应了很多人搜“Java 多线程 CompletableFuture 等待任务结果”时真正想问的问题——不是怎么把任务提交出去,而是怎么让所有的异步任务在同一个时间点被感知完成。
C++ 版本很简单,用标准库的线程和原子计数即可:
cpp复制#include <iostream>
#include <thread>
#include <vector>
#include <atomic>
#include <chrono>
#include <numeric>
void worker(const std::vector<DataItem>& items, std::atomic<long long>* total_cost_us) {
for (const auto& item : items) {
auto start = std::chrono::high_resolution_clock::now();
auto result = modelPredict(item);
auto end = std::chrono::high_resolution_clock::now();
auto cost = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
total_cost_us->fetch_add(cost, std::memory_order_relaxed);
}
}
int main() {
auto dataset = loadDataset();
std::vector<int> threadCounts = {1, 2, 4, 8, 16, 32};
for (int numThreads : threadCounts) {
std::atomic<long long> totalCostUs{0};
std::vector<std::thread> threads;
size_t chunk = dataset.size() / numThreads;
auto start = std::chrono::high_resolution_clock::now();
for (int t = 0; t < numThreads; ++t) {
size_t begin = t * chunk;
size_t end = (t == numThreads - 1) ? dataset.size() : (t + 1) * chunk;
threads.emplace_back(worker, std::vector<DataItem>(dataset.begin() + begin, dataset.begin() + end), &totalCostUs);
}
for (auto& th : threads) th.join();
auto finish = std::chrono::high_resolution_clock::now();
double totalMs = std::chrono::duration<double, std::milli>(finish - start).count();
double qps = dataset.size() * 1000.0 / totalMs;
std::cout << "threads=" << numThreads << ", QPS=" << qps << std::endl;
}
return 0;
}
C++ 这边的经验是,统计单次推理时延用原子变量而不是每个线程维护一个局部数组再汇总,代码会简洁很多。在压力测试场景下,原子操作的开销远小于推理耗时本身,所以不用担心影响被测量的目标。如果是微基准测试,推理耗时只有几百微秒,那么原子操作的影响可能达到几个百分点,就需要考虑用 thread-local 收集后再归并。
3.3 固定负载还是恒定压力——两种压测模式的选择
很多人在压测时搞混两件本质不同的事:一个是“固定一批请求,看处理完要多长时间”,另一个是“持续用每秒 N 个请求打服务,看服务的稳定表现”。第一种适合比较不同线程数配置下的极限吞吐,第二种适合评估线上系统在真实流量下的 QoS 表现。
上面写的脚本属于第一种,它衡量的是系统把所有请求处理完的能力。这种模式的优点是简单、可控、易于对比不同配置;缺点是无法反映真实场景中的排队效应——真实服务里请求是按随机间隔到达的,倾向于在某个时间段拥堵,然后再回归空闲。所以如果你想验证的是“把服务部署上线后,并发用户会不会有明显卡顿”,我建议再加一个恒定压力模式的压测:用固定速率发生器(比如每秒 20 个请求)持续跑 5 分钟,观察服务是否稳定,有没有资源泄漏。
性能测试中常出现一个有趣现象:固定负载下系统吞吐很好,改成恒定压力模式后,p99 时延会出现周期性尖峰。这是因为系统在低负载时会把 CPU 降到较低频率,当突发流量到来,CPU 提升频率和 OS 调度响应需要几百毫秒的过渡时间,已经排队的请求就遭殃了。所以对服务端做多线程测试,最终一定要包含这两个模式,缺一不可。
3.4 参数矩阵设计:不能只测一个点
多线程性能测试最忌讳的是一上来只测一个线程数,得到一个 QPS 数字,然后就开始汇报。因为线程数只是其中一个维度,完整的参数矩阵至少还应该包含模型 batch size、请求并发数、输入序列长度和推理引擎内部线程数。
我设计参数矩阵的经验是,先做一个小范围预实验缩小变量集,再构建正式矩阵。比如模型 batch size 固定 1、4、16 三个值,外部并发线程固定 1、4、16、64 四个值,一共 12 组实验,每组测 3 次取中位数。12 组实验跑完后,你基本能看出“batch size 对吞吐的贡献”和“线程数对吞吐的贡献”分别有多大,以及它们是否存在交互效应。
在测试过程中建议记录 CPU 利用率和 GPU 利用率。如果你发现线程加到 32,QPS 却没有明显提升,同时 CPU 利用率只有 30%,那就说明线程并非核心瓶颈方向,可能瓶颈在内存带宽、锁竞争、显存搬运或数据加载。这种“参数调了但指标不变”的现象,恰恰是定位瓶颈的线索,不要跳过。
下面是一个正式压测的报告摘要模板,我自己一直在用,你可以直接照搬:
| 线程数 | batch size | QPS | p50 (ms) | p99 (ms) | CPU 利用率 | GPU 利用率 | 结论 |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 85 | 11.2 | 28.4 | 12% | 35% | 单线程利用率低 |
| 4 | 1 | 221 | 13.5 | 41.2 | 35% | 78% | 明显提升 |
| 8 | 1 | 289 | 22.1 | 96.0 | 51% | 88% | p99 恶化 |
| 16 | 1 | 295 | 48.7 | 310.4 | 66% | 86% | QPS 到顶,时延恶化 |
这个表格最能说明问题的点在于第 3、4 行:QPS 从 289 只涨到 295,几乎平了,但 p99 从 96ms 飙到 310ms。说明线程从 8 加到 16,系统的边际收益已经趋近于零,资源的增加全变成了排队和调度开销。如果你只看 QPS,会误以为 16 线程更好;如果你带着 p99 约束去看,最优参数应该是 8 线程。这就是为什么我一直强调测试目标要有约束条件。
4. 工具选型与实测对比
4.1 JMeter 做服务层压测的配置要点
如果你的服务已经包装成 HTTP API,那么 JMeter 是一个绕不开的压力工具。不过很多人第一次用 JMeter 压 AI 推理服务时都会遇到一个困惑:设置了 200 个线程,但 QPS 始终上不去,并发数看起来再高都没有意义。原因通常是 AI 推理请求是耗时敏感型的,单个请求处理可能就要几百毫秒,而 JMeter 默认的超时时间设得太短,大量请求在预处理阶段就被判定超时了。
我在 JMeter 里压推理服务时的核心配置经验是:
- HTTP 请求的
Timeout (ms)设为推理服务 p99 时延预估值的 3 倍以上。比如线下实测 p99 是 500ms,线上压测超时至少设 1500ms,否则会人为产生大量超时报错。 - 线程组中的 Ramp-Up Period 不建议设成 0,建议让线程在 5 到 10 秒内逐步启动,给服务端缓冲区一个预热过程,否则突发模式下你会把每秒超时率误判成服务端性能问题。
- 压测时长不要低于 3 分钟。AI 推理服务经常有缓存、预热、动态 batch 调度等机制,30 秒短跑连 JIT 和显存缓存都没有完全稳定,结果不可信。
- 用
Constant Throughput Timer控制发压速率时,target throughput 要设成略低于预估最大 QPS,比如预估峰值 300,可以设 240 左右的恒定速率跑稳定验证,看超时率和 p99 是否保持平稳。 - 汇总报告里不要只盯 Average,要看 90% Line 和 99% Line。AI 服务常出现长尾效应,平均时延被大量快速请求拉低,但最慢的 1% 可能是平均值的 10 倍以上。
4.2 从引擎层直接压测:NVIDIA 工具链实测经验
在线服务压测得到的结果是“服务整体表现”,但如果你想定位瓶颈是不是模型引擎本身,就必须回到引擎层做基准测试。GPU 场景下,NVIDIA 的 trtexec 是一个运行 TensorRT 引擎做推理基准测试的官方工具,支持设置并发流、批大小、迭代次数。我自己常用的一行命令类似:
bash复制trtexec --loadEngine=model_fp16.engine \
--shapes=input:1x3x224x224 \
--streams=4 \
--iterations=200 \
--warmup=200
其中 --streams 对应并发推理流的数量,这个参数和 CPU 线程并发在效果上类似,都会让不同请求交替占用计算资源。--warmup 用来跳过前几轮因显存分配、张量布局切换导致的慢执行。运行结束后,它会输出平均时延、p50/p90/p99 以及 GPU 计算利用率和显存利用率,信息量比你自己写计时脚本高得多。
CPU 场的引擎基准可以用 onnxruntime_perf_test 或者 OpenVINO 的 benchmark_app,两个工具都支持 -nstreams 参数指定并发流数。配合 Linux 的 perf stat 看 paused CPU 周期、缓存未命中率,往往能发现多线程性能瓶颈的真相。比如某次我用 benchmark_app 压一个量化模型,线程从 4 加到 16,QPS 不升反降,一查 perf stat 发现 context switches 从每秒 4 万涨到 80 万,明显是线程数超过物理核心数后系统在做大量空转切换。这类问题只靠看上层 QPS 很难定位,必须借助底层工具把硬件计数器拉出来看。
4.3 本地进程级别的监控组合
发压和工具选好之后,别忘了记录测试期间的系统状态。我习惯在压测脚本执行期间,并行开一组轻量监控命令实时记录数据。Linux 下常用 pidstat -p <pid> -t 1 看进程内每个线程的 CPU 占用,用 free -g 看内存余量,用 iostat -x 1 看磁盘 I/O。GPU 场景用 nvidia-smi dmon -s pucvmet -d 1 每秒记录 GPU 利用率、显存占用量、温度、功耗,避免忽略热降频导致的性能衰减。
监控数据的保存和处理也很重要。跑完一组实验后,不要只在终端里瞄一眼数值就去下一组,建议把这些输出重定向到文件,压测结束后统一汇总。因为多线程压测经常跑 10 分钟以上,中间的温度、功耗波动会对结果产生干扰,如果你只在最终拿到一个平均 QPS,很难判断这个平均值是在什么状态下得到的。有一回我测一个 GPU 推理服务,上午跑出来的 QPS 是 350,下午同一份代码变成 280,排查了半天才发现是机房温度升高导致 GPU 降频。后来我就习惯了把所有压测任务的监控数据落盘,以便追溯任何异常波动的原因。
5. 结果解读与调优策略
5.1 衡量最优线程数的收益拐点方法
拿到一组不同线程数下的 QPS 和 p99 数据之后,怎么才能快速判断最佳线程数?一个非常实用且不依赖复杂统计的方法,是计算相邻线程数之间的边际收益比。公式很简单:
text复制边际收益比 = (QPS(n+1) - QPS(n)) / QPS(n)
假设 n 从 1 到 2 时,QPS 提升了 100%;n 从 2 到 4 时,提升了 50%;n 从 4 到 8 时,提升只有 15%;n 从 8 到 16 时,提升不到 5%。那么从工程角度讲,最优线程数就是 8。因为再往上加,你为多获得 5% 的吞吐,换来的可能是 p99 延迟翻倍,以及系统复杂度和排障难度的大幅提升。
在“吞吐优先”的场景,如果服务对时延不敏感,比如离线批量任务,你可以选择收益拐点之后的那个线程数,也就是 16,因为吞吐还有 5% 的提升,且你不需要担心用户体验。在“延迟敏感”的场景,比如在线 API,我通常把 p99 是否超过目标值作为硬性约束。这种情况下,即使 16 线程的 QPS 比 8 线程高 5%,但如果 p99 从 80ms 涨到 200ms,违背了 SLA,那也必须退回 8 线程。
这种收益拐点的判断同样适用 batch size 调优。一次我调一个 OCR 推理服务,batch size 从 1 增到 4,QPS 提升了差不多 3 倍,因为 GPU 利用率从不足 40% 提到了 92%;继续增到 8,QPS 反而下降,因为单 batch 处理时间过长,导致请求排队和显存分配压力增大。调优做多了就会意识到:多线程只是手段,找到那个“收益不再增长的点”才是最终目标。
5.2 从反直觉现象中定位真正的瓶颈
多线程压测里最反直觉的现象,是线程数增加了,QPS 反而下降。这种时候千万不要直接下结论说“多线程没用”,而是要先检查几个典型诱因。
第一个诱因是锁竞争。如果推理链路里有共享的缓存、字典、tokenizer 之类的可变对象,多线程并发访问时会被锁串行化,推理本身再快,也只能排队拿锁。这个问题在高并发场景下表现为:QPS 线性增长到某个水平后突然掉头,CPU 利用率却很高。定位方法很简单,用 perf top 看内核和用户态热点函数,如果看到大量时间花在 pthread_mutex_lock 或 __lll_lock_wait 上,基本可以确认锁竞争。解决办法是尽量让每个线程持有独立的资源副本,比如为每个线程初始化独立的 tokenizer 实例,而不是共享同一个实例并加锁访问。
第二个诱因是线程数超过物理核心数导致的上下文切换。当线程数从 8 加到时,理论上系统可以通过超线程装下更多执行流,但如果线程内部的工作集很大,缓存被频繁驱逐,每次切换都带来 cache miss 和 TLB miss,性能就会断崖下滑。判断方法是在不同线程数下运行 vmstat 1 观察 cs(上下文切换)列,如果从几千涨到几十万,并且同时在升高,说明调度开销已经主导。
第三个诱因不太常被提到,是内存带宽和缓存层级冲突。多核 CPU 推理时,如果多个线程同时访问同一块模型权重或同一批输入数据,它们会争抢最后一级缓存(LLC)的带宽。尤其对于权重量化模型,本身数据量已经很大,多个核同时去读,内存带宽会成为硬瓶颈。这种现象的标志是:CPU 利用率看着不高,QPS 也不涨,但内存带宽已经几乎打满。排查工具可以用 perf stat -e cache-misses,cache-references,instructions,cycles,观察缓存未命中率是否随线程数显著上升。
5.3 多语言实现中锁与并发控制的细节
如果你是在 Python 或 Java 里自己实现多线程推理,绕不开的一关是显式控制并发对象的访问方式。很多人在这种场景下加了锁,却发现性能反而下降两个数量级,于是得出结论“引擎并发太差”。其实问题往往出在锁粒度和锁策略上。
用 Python 举例:threading.Lock 是重量级的互斥锁,一个简单的 with lock 块在锁冲突时可能触发系统调用,性能远不如原子操作。但如果资源本身不支持并发读写,又不能省锁,那就要考虑缩小锁范围。比如推理引擎内部通常要求每次只有一个线程调用 Run 接口,那么不要粗暴地给整个请求处理流程加锁,而是把预处理、请求排队放在锁外,只在真正调用引擎那一刻加锁。这样,多线程争取的并发空间就是“预处理+排队”这一段,引擎串行执行的部分占整体时间越短,多线程收益越明显。
Java 里的常见坑则是对 CompletableFuture 使用不当。有一种写法是主线程里循环调用 future.get(),这在任务不多时感觉不到问题;一旦任务规模达到几十上百,这种写法就会把“同时提交的并行任务”退化成“串行等待”,因为主线程会阻塞在第一个慢任务上。正确做法是上面给出的 allOf(...).join(),让所有线程在完全结束后统一返回。之后如果你还需要按请求顺序关联结果,可以用 future.thenApply 把每个结果和一个序号绑定,避免事后调 future.get() 按提交顺序取时再次阻塞。
5.4 采样稳定性与数据可信度校验
压测做了五组实验,却没法判断哪组数据可信,这是新手常遇到的尴尬。AI 模型推理的时延分布天生是重尾的,一次 GC(Java)或一次 CPU 频率切换就可能产生一个 2 秒的异常点,直接拉高平均时延。如果这个异常点刚好出现在线程数 16 那一组,你可能就会得出“16 线程非常差”的错误结论。
解决这个问题有三条经验。第一,每组实验至少重复 3 次,最终取中位数或最小值作为该组的代表值,不要用均值。均值容易被极端值拉偏,中位数能反映大多数请求的真实体验。第二,去掉热身数据。AI 推理引擎首次运行时要初始化算子、分配显存、构建算法选择缓存,前 20 到 50 个请求的耗时通常显著偏高。如果你把热身数据也计入统计,线程少时影响大,线程多时影响被稀释,导致组间对比失真。第三,报告离散度。除了中位数,公布每组实验的极差或标准差,如果同一线程数下重复三轮的 QPS 波动超过 10%,说明测试环境不够干净或系统状态不稳定,需要排查后再继续。
6. 常见问题与排查技巧实录
6.1 多线程性能测试问题速查表
| 现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| 线程数增加,QPS 近乎不变 | 引擎内部已到瓶颈,外部线程增加无意义 | 看 CPU/GPU 利用率是否已打满 | 增加 batch size / 横向扩服务实例 |
| 线程数增加,QPS 反而下降 | 锁竞争 / 上下文切换过多 / 内存带宽争抢 | perf top、vmstat、perf stat |
缩小锁范围、线程数压回物理核以内、线程本地化资源 |
| Python 多线程推理不加速 | GIL 在纯 Python 执行阶段未被释放 | 对比线程版本与进程版本吞吐 | 改用多进程,或确认底层推理库释放 GIL |
| JMeter 压测大量超时,但服务端无明显错误 | 超时时间设置过短 | 查看 JMeter 响应断言与超时日志 | 拉长 HTTP 超时时间 |
| 服务空闲时延正常,压测时 p99 剧烈抖动 | CPU 频率切换 / 垃圾回收 / 缓存竞争 | 看 GC 日志、系统温度、CPU 频率 | 绑定 CPU 核心、扩大堆、锁定频率策略 |
| GPU 利用率偏低,CPU 线程已很高 | 预处理/数据加载成为瓶颈 | 监控 CPU 各线程占用、数据加载耗时 | 用 CPU 线程做预处理与推理的流水线并行 |
| 同一配置多次测试结果波动大 | 数据未固定 / 热身不充分 / 环境被干扰 | 检查输入数据是否相同、压测间隙冷却 | 固定数据集、增加热身轮次、隔离测试环境 |
6.2 我遇到过的三个“坑”与复盘
第一个坑是某次用 JMeter 压一个基于 Java 的推理服务,压测结果发现 p99 异常高,检查服务端日志没有任何超时。最后追到 JMeter 所在机器的网络连接数限制——客户端没开 keep-alive,每次请求都新建 TCP 连接,握手和 TLS 协商占了大半时延。这个案例说明,服务端性能测试要特别确认压力机本身没有成为瓶颈。我后来的做法是把压力机和被测服务部署在同一台高性能机器上,网卡走本地回环地址,并且明确启用 keep-alive 连接复用,才能把网络开销降到最低限度。
第二个坑是在调一个 C++ 实现的实时语音推理服务时,发现加锁保护共享的音频特征缓存后,多线程版本比单进程版本还慢。排查后确认问题出在锁的粒度过大——整段推理代码都包在 std::lock_guard 里,等于把多线程退化成了串行执行。改法是把模型预测调用从锁内移到锁外,用两个独立缓冲区轮流承载输入输出,锁只保护缓冲区交换那一瞬间。改造之后,多线程版本终于跑出了接近预期的性能。这个教训是:多线程代码不是“加了线程就并行”,而是“只有锁外的部分才可能是并行的”。
第三个坑和显存有关。测试一个超大模型时,线程数从 4 加到 8,显存爆了,服务直接 OOM 崩溃。虽然加了线程本身不直接增加显存消耗,但每个并发线程执行推理时,引擎会为每个执行流申请独立的临时缓冲区,线程越多,显存峰值越高。所以做多线程压测前,一定要先估算峰值显存需求,尤其是 TensorRT 引擎和 vLLM 这类会动态申请 KV cache 的服务。我给 GPU 推理服务预留显存时的经验是:按单线程显存占用 × 最大并发数 × 1.5 的系数预留,避免压测把服务压崩。
6.3 服务化场景中的线程池与队列配置建议
如果你的模型推理服务是像 FastAPI 或 Spring Boot 这种 Web 服务,那么性能和最外层的线程池配置直接相关。线程池太小,请求在 HTTP 接入层就开始排队;线程池太大,线程之间上下文切换开销会让真正执行推理的线程饿死。我一般建议从核心业务耗时出发反推线程池大小,最常用的经验公式是:
text复制线程池大小 ≈ 目标 QPS × 单请求平均耗时
比如目标 QPS 是 200,单请求平均处理耗时是 450ms,那么理论需要的并发处理能力是 90,考虑部分线程可能在等待 I/O 或执行异步回调,线程池建议设成 128 到 160。这个公式只作为起点,真正的最优值还是要通过压测曲线的拐点去确认。
另一个容易忽略的配置是请求队列深度。当线程池已被占满,新请求会进入队列等待。队列如果设得过大,相当于允许无限制的排队,会让 p99 时延无限膨胀;队列如果过小,一有尖峰流量就立刻拒绝服务。一个让我觉得比较稳妥的配置是:队列长度设为线程池大小乘 2,超过队列长度就快速返回 503 或 429 错误。这样虽然会牺牲一部分极端流量下的请求成功率,但能保证系统在重压下不会全面崩溃,也避免了一个慢请求堵死后端导致全链路雪崩。
6.4 面试场景中性能测试项目的回答要点
经常有人问性能测试岗位相关的面试题,面试官特别爱问“你做过的最能体现性能测试能力的项目是什么”。如果你借用了 AI 模型推理多线程测试这个项目,回答时要避免只描述“我跑了几个脚本、看了几个数字”,而要突出三个层面的东西。
第一是“目标定义与策略设计”,比如为什么选择固定负载加恒定压力两种模式,为什么给 p99 设了硬性阈值,为什么先测引擎再测服务。第二是“定位瓶颈的方法论”,比如如何通过收益拐点判断最优线程数,如何通过 CPU 利用率不上升来排除线程瓶颈方向。第三是“优化结果的可量化表达”,比如最终把 p99 从 500ms 降到 200ms、同时 QPS 保持 300,这种量化的前后对比最能说明问题。
回答中如果有机会,可以把上面表格里那种“线程到 8 后 QPS 不再增长但 p99 恶化”的例子讲出来。面试官听到这种描述,基本能判断你是真的踩过现场,而不是背了一套教科书流程。相比把工具用得很熟,能解释现象背后的原因才是更稀缺的能力。
7. 几个能直接上手的工具组合技巧
7.1 不同推理引擎的线程设置接口怎么查
不同推理引擎对多线程的设置接口千差万别,一个很容易掉的坑是你找到了某个配置项,但实际没生效。我在这几个主流引擎上调多线程时,习惯性的配置路径是这样的。
ONNX Runtime 在 CPU 上有两个关键参数:intra_op_num_threads 控制每个算子内部的线程数,inter_op_num_threads 控制算子之间的并行线程数。对于单模型推理,intra_op 影响通常更大;对于多模型流水线,inter_op 才重要。Python 端可以通过 SessionOptions 设置,C++ 端通过 OrtSessionOptionsAppendExecutionOption 或直接设置环境变量 OMP_NUM_THREADS 也能影响底层 OpenMP 线程数。我实测的经验是:如果模型是单张量的大卷积或大矩阵乘,intra_op 对时延影响明显;如果是小算子特别多的模型,比如很多 NLP 模型,线程数超过 8 后收益非常有限。
OpenVINO 则稍微抽象。它的核心配置不在 API 里直接叫 thread 数,而是通过 ov::inference_num_threads 设置,运行时还可以配合 CPU_THREADS_NUM 属性控制 CPU 线程。如果你用 benchmark_app 这类工具,可以直接用 -nthreads 参数。特别提醒,benchmark_app 的 -nstreams 是流水并行数,-nthreads 才是每个流的内部线程数,两者不要搞混。
TensorRT 的早期版本里,一个 Engine 实例本身不是线程安全的,多个线程需要各自创建独立的 execution context。新版(8.x 以后)支持同一个 engine 在不同 stream 上并发执行,但每个线程必须绑定自己的 context 和 CUDA stream。很多人在 TensorRT 多线程推理报错,原因就是多个线程共享了同一个 IExecutionContext。跨线程共享引擎没问题,但 context 必须线程隔离。
7.2 测试环境隔离与机器校准
性能测试最难保证的是“同一套代码、不同时间跑出来的结果一致”。环境层面有几件事必须先做。一是关闭 CPU 频率动态调节,Linux 下用 cpupower frequency-set -g performance 把调速器固定到性能模式,避免频率波动污染测试结果。二是关闭 NUMA 失衡的影响,如果一个多路服务器上有两个 CPU,测试线程最好用 numactl --cpunodebind=0 绑定到同一个 NUMA 节点,否则线程跨节点访问内存的延迟差异会大到让结果完全不可比。三是检查是否有定时任务、监控 Agent 之类在测试期间抢 CPU,用 top 观察系统整体负载是否为 0。
GPU 场景还需要单独校核三件事:显存时钟和核心时钟是否已经锁定到最高档、GPU 温度是否已降到怠速基准、显存是否有其它进程占用。最简单的校准方式是在正式压测前先跑一轮完整测试,观察 QPS 和功耗曲线,如果波动超过预期,先解决环境问题,再开始采集正式数据。
7.3 负载生成器的速率控制技巧
固定负载模式下“同时提交所有请求”是容易导致 CPU 竞争加剧的,因为前几十毫秒系统会同时收到几百个请求,瞬间压力远超实际服务能力。更符合实际流量的做法是让请求按指数间隔或均匀间隔逐步释放。Python 里可以用 time.sleep(interval) 的控制循环实现间隔发送,Java 里用 ScheduledExecutorService 实现定时提交,JMeter 里用 Constant Throughput Timer 控制每分钟发送的请求总数。
控制发送速率还有一个特殊用途:测量服务在不同请求到达速率下的延迟变化曲线。把速率从 QPS 100 逐步升到 600,每档跑 60 秒,记录 p50 和 p99 的变化。你会明显看到:在某个速率值之前,p99 基本平稳;一旦超过它,p99 开始线性恶化。这个拐点值就是系统实际的可持续容量。相比直接跑到服务崩溃去测最大 QPS,这种方式得到的数字对容量规划更有参考价值,因为容量规划从来不是问“极限能扛多少”,而是问“保证时延可接受的前提下能扛多少”。
8. 最后分享几点真实体会
写到这里,关于 AI 模型推理多线程性能测试的方法论和实操细节已经梳理得比较完整了。最后聊几句我个人在实际操作中的体会。
测多线程性能最怕一开始就盯着最大 QPS 数字,因为最大 QPS 只是一个瞬时状态下的峰值,真正的服务稳定性要看时延分布和资源利用率。我在前面提到的收益拐点判定、p99 约束下的最优线程数选型,以及固压与恒压双模式的测试组合,这些方法我在多个推理服务项目里验证过,方向都是靠谱的。如果你按这套流程跑下来,至少不会再出现“线程调大反而更慢,但不知道去哪排查”的尴尬局面。
还有一个我踩了多次才记住的教训:任何多线程性能结论都必须伴随完整的运行环境描述,包括物理机 CPU 型号、超线程开关、推理引擎版本、GPU 型号与显存、测试数据内容、压测工具版本。这些环境变量任何一个改变,结论都可能失效。我自己写压测报告时,会把环境信息作为一张固定表格放在开头,即使只跑一组内部对比实验也会写上。数据可以复现,结论才值得讨论。
最后建议你在完成一轮测试后,把脚本、监控日志、参数矩阵、结果汇总保存成一个独立目录,并写一段不超过五行的注释,说明这轮测试的背景和最终结论。等三个月后你回看时,会发现这些记录比想象中有价值得多。性能测试本身不是目的,它是帮助你把推理服务稳定地部署到生产环境、清晰预测容量边界的一个工具,把这件事做扎实,后续很多线上问题都能提前规避。
