我最近在做AI模型推理服务的并发改造时,被一次多线程性能测试狠狠上了一课:把同一套ONNX模型的推理任务从单线程改成32个线程并发,QPS不升反降,P99延迟却涨了两倍还多。后来把线程数、批处理、推理引擎内部线程配置、JMeter压测和系统监控数据串起来复盘,才看清问题根本不在“线程开得够不够多”,而在于没搞明白AI推理的工作负载到底吃的是什么资源。这篇文章就把整条链路从头到尾梳理一遍,适合正在做AI应用落地或平台性能测试的工程师参考,哪怕你之前没碰过多线程压测,跟着这套思路也能少走很多弯路。
1. 为什么AI推理在多线程下会翻车:先把瓶颈模型理清
1.1 CPU推理场景:线程、核心与GIL的真实边界
先明确一件事:AI推理任务不是普通Web接口那种以IO为主的轻计算。一次推理会触发大量矩阵乘法、张量拷贝和内存访问,属于典型的CPU密集型或GPU加速计算任务。在这种负载下加线程,收益曲线从第一步就不是线性的。
如果是Python写推理服务,很多人的第一反应是用threading开线程池。这里就踩到Python最著名的坑:GIL让同一个进程内的多个线程无法真正并行执行字节码。虽然像NumPy、PyTorch底层的C/C++运算会释放GIL,但推理接口的调度逻辑、预处理、后处理代码仍然受GIL约束。并发线程一多,锁竞争会直接吃掉收益,甚至让耗时倒退。我见过一个团队用Python ThreadPoolExecutor开16个线程跑一个图像分类模型,结果QPS只比单线程高20%,而CPU使用率却常年跑在500%以上。
C++和Java没有GIL问题,多线程推理可以真实并行,但依然绕不开物理核心数量的限制。设计测试用例之前,要先看清楚测试机的CPU拓扑:是几路物理CPU、每颗物理CPU有几个核心、是否开了超线程。超线程提供的是逻辑执行单元,跑纯计算任务时,1个物理核心上的2个逻辑线程并不能带来双倍吞吐,通常只能多挤出10%到30%的算力。所以线程数、核心数、逻辑线程数的关系,直接决定了压测的梯度设计。
1.2 GPU推理场景:并发推理未必是线程并发
GPU场景下的多线程逻辑又不一样。GPU本身就有并行执行海量运算的能力,你的推理任务只要以batch形式提交,单张卡就能同时处理多份输入。这时候再开多线程,需要理解的是线程到底在并发什么。
以CUDA为例,GPU上的计算是以stream(流)为组织单位的,同一个stream内的操作按顺序执行,不同stream之间才有并发的可能性。如果你在Python或者C++代码里开了多个线程,每个线程把输入数据拷贝到显存、执行推理、拷回结果,这些操作默认可能都塞进了同一个默认stream,结果就是所有线程的计算在GPU侧被串行排队。表面上线程数是上去了,GPU利用率却可能只跳动几个百分点。
真正想让多线程压榨出GPU能力,要么显式使用多个stream并在推理框架里开启并发执行,要么调整推理引擎自身的批处理参数,让单次推理吞下更多数据。测试时如果不区分“线程并发数”和“GPU执行流并发度”,对比数据毫无意义。
1.3 不可忽视的隐性竞争:显存带宽、内存分配与调度开销
第三个容易忽略的瓶颈是内存墙。无论是CPU推理还是GPU推理,模型权重和中间特征图都要在内存和显存之间流动。多线程并行执行时,多个线程会同时抢占内存带宽。尤其是CPU推理,内存带宽常常先于CPU算力被打满。
我用一个背靠背的矩阵乘基准测过:单线程跑一个1.5GB的大模型时,CPU利用率能稳定在80%以上;当线程数超过物理核心数的一半,CPU利用率还在涨,但单位时间完成的浮点运算量已经开始下跌。原因是所有物理核心在同时抢内存控制器和三级缓存,大量的CPU周期浪费在等待数据上。
还有个隐蔽开销是线程调度和上下文切换。Linux默认的CFS调度器会把线程在各核心之间迁移以追求负载均衡,但推理任务的缓存亲和性很强,线程被切走一次,L2/L3缓存里的权重数据就全失效了。多线程测试如果出现“线程数多了但单线程延迟也变差”的现象,第一个要怀疑的就是莫名其妙的上下文切换。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭建线程级推理压测环境:从脚本到监控
2.1 先定义指标:QPS、RT、P99和线程饱和度缺一不可
性能测试最忌讳只盯一个指标。推理服务最常见的错误目标就是“QPS越高越好”,结果把请求打满了,单个请求的响应时间早超过业务容忍线。
我习惯把指标拆成四组来记录:
- 吞吐指标:QPS、每分钟成功推理数,看系统处理能力;
- 延迟指标:平均RT、P50、P95、P99,看用户体验和抖动情况;
- 资源指标:CPU利用率、内存占用、显存占用、GPU利用率,看瓶颈在哪里;
- 饱和指标:活跃线程数、队列堆积长度、线程池拒绝次数,看系统是不是已经濒临崩溃。
压测前还得约定好测试数据。用线上真实请求的录制回放最好,没有的话就准备一份和真实输入大小、特征分布接近的样本集。有些模型对输入长度非常敏感,比如文本模型,输入长度直接决定计算量;如果测试样本全是短文本,压出来的QPS可能比线上高一倍,参考价值就打了折扣。
2.2 JMeter对推理API服务的并发压测:线程组参数怎么设
如果推理服务已经封装成HTTP接口,JMeter是最容易上手的压测入口。它的线程组模型其实就是多线程测试的直观体现:你设定的“线程数”就是并发客户端数,每个线程循环发送请求。
JMeter里几个关键参数要格外注意:
-线程数:模拟的并发请求数,不是CPU线程数。测试时从1、2、4、8、16、32这样爬坡,不要一口气开到100。
- Ramp-Up Period:线程启动的斜坡时间。设成0表示所有线程瞬间并发,容易造成流量毛刺;建议根据线程数按每秒启动几个来设。
- 循环次数:每个线程要执行的请求次数。为了得到稳定统计,建议保证总请求数在1万以上。
- Same user on each iteration:这个选项要不要勾选,取决于接口是否依赖登录态;推理接口多数无所谓,但不勾选更接近线上随机用户场景。
JMeter自带聚合报告和汇总报告,能直接给出Average、Median、90%Line、95%Line、99%Line、Throughput等指标。不过要提醒一句:JMeter所在的压测机本身也吃CPU,如果压测机性能不行,JMeter自己先成了瓶颈,测出来的是“两个系统争抢资源”的结果。压测机配置至少要跟被测服务同级别,或者分布式部署JMeter Agent。
2.3 本地多线程压测驱动:Java和Python两种写法对比
在接口还没封装好、或者想直接测模型推理库本身性能的时候,就需要自己写多线程压测驱动。Java和Python是两种最常见的场景。
Java侧推荐用ExecutorService配合CountDownLatch做任务栅栏,代码如下:
java复制int threadCount = 8;
int totalRequests = 10000;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch ready = new CountDownLatch(threadCount);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(totalRequests);
AtomicInteger successCount = new AtomicInteger();
ConcurrentLinkedQueue<Long> costTimes = new ConcurrentLinkedQueue<>();
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
ready.countDown();
try {
start.await();
for (int j = 0; j < totalRequests / threadCount; j++) {
long begin = System.nanoTime();
// 调用推理接口或直接调用推理库
inferenceEngine.infer(sampleData);
costTimes.add(System.nanoTime() - begin);
successCount.incrementAndGet();
done.countDown();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
ready.await();
long beginTest = System.currentTimeMillis();
start.countDown();
done.await();
long totalTime = System.currentTimeMillis() - beginTest;
executor.shutdown();
这段代码的关键在于用两个CountDownLatch把“准备工作”和“正式压测”隔离开,确保所有线程真正同时开始施压,而不是先后启动造成流量不齐。结果统计用ConcurrentLinkedQueue收集每次耗时,测试完了统一排序算P99,比在线程里直接累加平均数要准确得多。
Python侧则要区分场景。如果测的是PyTorch/ONNX Runtime这类的CPU推理,concurrent.futures.ThreadPoolExecutor能反映GIL下的真实并发表现,但要知道它天花板很低;如果真正需要多核并行,可以考虑ProcessPoolExecutor或者直接用Python的multiprocessing,不过进程间模型复制和通信开销都要纳入测试考量。
2.4 系统侧数据采集:别只看压测工具的报表
压测工具给出的QPS和延迟只是一面,系统侧指标才是判断瓶颈位置的依据。我在每次压测时都会同步记录三类数据:
- CPU维度的持续跟踪:
pidstat -p <pid> 1能看到进程内各线程的用户态和内核态CPU占比;top里的%wa变高说明IO在拖后腿。多线程推理场景里,如果%sy(内核态CPU)占比超过10%,锁竞争和上下文切换大概率已经失控。 - GPU维度:NVIDIA显卡用
nvidia-smi dmon -d 1实时看GPU利用率、显存带宽利用率和温度。nvidia-smi默认视图里那行Volatile GPU-Util是个粗糙的瞬时值,只看它会漏掉很多间歇性瓶颈,dmon这类持续采样工具更适合压测。 - 上下文切换与运行队列:
vmstat 1里的cs列是每秒上下文切换次数,r列是运行队列长度。当r数值持续大于CPU逻辑核心数,说明CPU已经超载,再加线程只会加剧排队。
把这些采集结果和压测工具报表按时间对齐,才能回答“QPS拐点到底是被什么卡住”的因果问题。
3. 实测数据解读:多线程并非线性收益
3.1 一组16核CPU服务器上的纵向对比数据
下面这组数据来自一台双路志强、共16物理核心32逻辑线程的测试服务器,模型是一个参数量约1.4亿的文本匹配模型,采用CPU推理,输入长度固定为128 token,测试持续10分钟,请求总量2万:
| 线程数 | 平均RT(ms) | P99 RT(ms) | QPS | CPU利用率 | 上下文切换/秒 |
|---|---|---|---|---|---|
| 1 | 37 | 62 | 27 | 8% | 420 |
| 2 | 39 | 68 | 50 | 16% | 780 |
| 4 | 44 | 79 | 89 | 31% | 1500 |
| 8 | 56 | 104 | 141 | 57% | 3200 |
| 16 | 82 | 173 | 158 | 68% | 6100 |
| 32 | 135 | 335 | 121 | 71% | 13000 |
这组数据有几个值得品的地方。首先,从1线程到8线程,QPS随着线程数近似线性增长,这符合计算密集型任务在物理核心未打满时的特征。到16线程时,虽然QPS仍在涨,但涨幅已经明显放缓,而P99从104ms跳到173ms,涨了66%。到32线程,性能彻底反转,QPS反而比16线程时低了23%,P99更是破了300ms。
最刺眼的指标是上下文切换。32线程时每秒切换1.3万次,是8线程时的4倍。这些切换消耗了大量CPU周期,还不断把线程从核心上踢来踢去,缓存亲和性被破坏,推理请求的耗时自然飙升。
3.2 线程数与batch大小:谁对吞吐的贡献更大
既然线程并发有拐点,那是不是就该死磕batch?我做了另一组对照实验,同样16核CPU服务器上,用固定8线程,把每次推理的batch从1逐步加大:
| Batch大小 | 平均RT(ms) | QPS | 每样本平均耗时(ms) |
|---|---|---|---|
| 1 | 56 | 141 | 56 |
| 4 | 143 | 225 | 36 |
| 8 | 236 | 272 | 30 |
| 16 | 420 | 310 | 26 |
| 32 | 780 | 320 | 24 |
batch从1加到8,QPS几乎翻倍,而P99延迟(单请求维度)因为总RT变长,看起来上涨了4倍。这里有个关键认知:batch提升的是系统吞吐,代价是单个请求的排队时间。如果你的业务对实时性要求高,batch不能无脑加大,否则用户侧的响应时间会突破SLA。
线程和batch的组合策略通常是:先用batch把单次推理的计算密度提上去,让CPU或GPU的单位算力利用得更充分;再用线程数去兜住并发请求的排队需求。两者不是替代关系,而是先算力、后并发的两级放大关系。
3.3 GPU推理下的并发表现:流的并发才真正影响吞吐
在同一台带RTX 3090的服务器上,我测了同一个视觉模型的GPU推理。测试方式分成三种:单线程单stream、多线程默认stream、多线程多stream。结果差异非常大:
| 测试方式 | 平均RT(ms) | QPS | GPU利用率 |
|---|---|---|---|
| 单线程单stream | 3.1 | 322 | 38% |
| 8线程默认stream | 4.8 | 410 | 42% |
| 8线程多stream | 5.2 | 1120 | 91% |
多线程默认stream的场景非常误导人:线程数上去了,QPS只提升了27%,GPU利用率几乎没动,所有线程的推算都被塞进同一条执行流排队。而切换到多stream之后,8个线程的计算真正在GPU上并行展开,QPS直接翻到1120,GPU利用率也冲到91%。
这组对比说明:GPU推理的多线程测试,线程只是发起方,真正决定并行能力的是推理框架内部到底把计算拆分到了几条可并发的执行流里。测试脚本里如果不显式配置流,那多线程压测GPU等于白测。
4. 多线程推理中容易踩的四个坑和修复思路
4.1 坑一:线程池队列无限堆积,系统进入假死状态
用Java线程池封装推理服务时,很多人知道要限制核心线程数和最大线程数,却忽略了工作队列的长度。我排查过这样一个线上问题:某文本审核推理服务在流量高峰时段,用户请求RT从500ms劣化到8秒,服务没死但跟死了没区别。
翻日志发现ThreadPoolExecutor的队列是一个无界的LinkedBlockingQueue,当最大线程数全部占满、新任务塞不进去,任务就开始在队列里排队。推理请求本身就慢,每个任务还要在队列里再等好几个推理周期,延迟项叠加后自然爆炸。
修复手段是给队列设上限并明确拒绝策略:
java复制ThreadPoolExecutor executor = new ThreadPoolExecutor(
8, 16,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy()
);
CallerRunsPolicy的好处是当队列和线程池都满时,由调用方线程自己执行任务,既不会丢请求,也给调用方起到了天然的背压效果。配合监控队列积压长度,能在系统崩溃前发现问题。
4.2 坑二:不设置CPU亲和性,上下文切换风暴毁掉推理性能
前面那组32线程掉吞吐的数据,根因就是上下文切换。进一步排查还发现,Linux默认调度下线程会在多个核心之间迁移。对推理任务来说,模型权重和中间特征图在缓存里的局部性太重要了,线程一旦被调度器迁移到另一个物理核心,缓存命中率骤降,推理耗时马上跳变。
排查命令是perf sched record加perf sched latency,如果看到大量migration事件,就要考虑用taskset把特定线程绑定到物理核心上。C++代码里也可以用sched_setaffinity,或者用OpenMP的GOMP_CPU_AFFINITY环境变量来控制线程绑核。
我实际测过,在16线程场景下做绑核优化之后,P99从173ms降到121ms,QPS上升了12%。绑核不是万能药,但对计算密集型的推理任务,收益非常可观。
4.3 坑三:GPU推理被隐式串行化,多线程等于排队
前面提到多stream的对比实验,就是排查这类问题的模板。如果GPU推理服务的多线程压测数据显示GPU利用率上不去,先检查推理框架的并发执行配置:
- PyTorch/TorchScript推理时,
torch.cuda.Stream的使用方式是否正确; - ONNX Runtime的
SessionOptions里是否设置了enable_cpu_mem_arena和合适的intra_op_num_threads; - TensorRT是否在多stream上执行了
executeV2调用。
常见错误是在每个线程里都调用一次torch.cuda.synchronize(),这个函数会强制当前线程等待所有CUDA操作完成,效果等于把并发线程在GPU侧硬生生排成串行。正常做法是让异步操作自然提交,只在最终取结果的必要节点做一次同步。
4.4 坑四:模型加载不共享,线程一多显存和内存双爆
还有一个非常隐蔽的坑:多线程推理时,每个线程如果都各自加载一份模型副本,内存和显存消耗会随线程数线性放大。一个500MB的模型,开32个线程就是16GB内存,再叠加推理过程中的中间张量,内存很可能先于CPU被打满。
排查方式很简单:看推理进程的RES内存是不是随着线程数线性增长,或者nvidia-smi里进程显存占用是否翻倍。正确做法是把模型对象设计成只读共享,同一个模型实例被多个线程调用。PyTorch里把模型放到share_memory或者使用torch.jit优化后的模型做线程安全推理;ONNX Runtime的OrtSession本身就是线程安全的,可以在多线程里共享同一个Session实例,不需要每个线程新建。
如果你的服务是无状态的,还要考虑GPU显存碎片问题。多次加载卸载模型会让显存碎片增多,即使总量没超限,大块显存也可能分配失败。上线前最好做一次长时间的并发回归,专门盯着显存峰值和碎片率。
5. 测试后的调优路径和上线容量规划
5.1 从压测数据反推线程池参数的设置方法
压测的终点不是得到一张数据表,而是把数据翻译成可执行参数。我这边总结出一套以P99容忍度为核心的参数推导逻辑:
- 首先定业务SLA,比如P99必须小于200ms;
- 看压测数据里哪个线程数满足P99要求,同时QPS还留有30%以上余量;
- 线程池核心线程数设为这个值的70%到80%,最大线程数设为这个值的1.5到2倍,避免把线程开满;
- 队列长度根据单次推理耗时来定:假设单次推理50ms,要容忍1秒排队,队列长度就是20乘线程数。
核心思路是让线程池在峰值流量下不至于瞬间打满,而是先通过队列做平滑缓冲。
5.2 推理引擎的线程控制参数:别和应用线程互相打架
应用层线程数只是并发模型的一部分。ONNX Runtime、OpenVINO、TensorRT这些推理引擎内部还有自己的线程池配置。最常见的配置错误是应用开了16个请求线程,推理引擎内部每个请求又默认开满所有核心,结果一个请求就把CPU占完,其他请求全部排队。
ONNX Runtime里有两个关键参数:intra_op_num_threads控制单个算子内部并行线程数,inter_op_num_threads控制算子间并行线程数。对多请求并发场景,我建议把intra_op_num_threads设小一点,比如2到4,把inter_op_num_threads设为1,让引擎内部的并行度让位于应用层的请求并行度。OpenVINO对应的是ov::inference_num_threads参数,TensorRT则通过BuilderConfig里的maxThreads控制。
判断内部线程设置是否合理,可以在压测时观察单请求RT和CPU利用率的关系:如果CPU没满但RT很高,大概率是引擎内部等待资源;如果CPU打满但RT在高位波动,通常是线程调度都在抢资源。
5.3 从测试到生产:容量规划的正确姿势
压测数据落到生产环境,要把测试机和线上机器的差异算进去。测试机只有独占16核,线上容器可能只分到4核;测试用的离线推理样本人为控制了输入长度,线上真实请求的长度分布完全是另一回事。
一个保守的做法是:用压测得到的单Pod最大稳定QPS乘0.7作为安全容量基线,再结合线上峰值QPS算副本数。比如压测下来单实例稳定QPS为150,那安全容量基线就是105,线上峰值如果有1000 QPS,至少需要10个实例,还要预留故障转移时1个实例宕机的冗余,实际部署12到13个更稳妥。
容量规划还要考虑突刺流量。推理服务的负载经常是突发性的,配合弹性伸缩策略,以P99延迟为扩缩容触发条件,比单纯看CPU利用率合理得多。因为有时CPU利用率并没那么高,GPU显存或带宽已经触到天花板,直接用延迟指标兜底更接近用户体验。
5.4 上线后持续监控:从性能测试到性能运营
性能测试做完了就万事大吉?真实环境会持续给你“惊喜”。我把上线后的监控拆成三个维度:
- 延迟分布监控:不只盯平均延迟,要把P50、P95、P99、P999放在一张图上,观察尾部延迟是不是随时间在悄悄变差;
- 资源水位监控:CPU、内存、显存、带宽的周同比和月同比,提前发现代码变更或数据分布变化带来的性能偏移;
- 请求特征监控:输入长度、batch大小、并发数的分布变化,有时候不是服务变慢了,而是流量特征变了导致基线右移。
我在实际运维中遇到过一种情况:某接口RT指标在每次发版后都会逐渐劣化,单看每次版本内差异似乎都在合理范围,但拉长到一个月维度,P99从120ms爬到了260ms。原因是输入文本的平均长度在持续增长,模型算力需求变大,而线程池参数还是按旧基线配置的。这种慢变量的性能劣化,只有长期监控才能发现。
性能测试不是一锤子买卖。每次模型迭代、推理引擎升级、甚至机器规格变更,都应该至少重跑一遍本章的基线场景,把旧数据和新数据做对比。CPU推理、GPU推理、多线程参数、batch策略、引擎内部线程配置,这些变量相互耦合,单独调一个参数很容易顾此失彼。完整的回归记录,才是性能问题排查时最有价值的资产。
