本节摘要:intra_op_num_threads 控制单算子内并行(CPU EP/OpenMP);inter_op_num_threads 控制算子间并行。对 CUDA EP 几乎无意义,却常误把 CPU 线程拉满导致 P99 抖动。
复制网上「intra=16, inter=16」到 Jetson——CPU 与 GPU 抢资源,延迟波动 **300%** 级(工业现场常见量级)。线程配置必须 **按 EP 与硬件核数** 单独标定:同一组参数在 8 核 x86 上合理,在 4 核 ARM 边缘盒子上就是灾难。线程是资源维度的调优,脱离硬件谈参数等于在随机数表里找最优解。
两个线程数的分工要钉死:intra_op_num_threads 控制单个算子内部的并行度(一个 MatMul 被切几块),inter_op_num_threads 控制算子之间的并行度(几个无依赖节点同时跑)。对 GPU EP,两者几乎都不起作用——算子内并行由 CUDA 内核负责,算子间并行被单 stream 天然串行——但线程池本身仍会创建,配置过高就让 CPU 在空转中争抢资源。
| 参数 | 影响对象 | GPU EP |
|---|---|---|
| intra_op_num_threads | 单 MatMul/Conv 分块 | 基本忽略 |
| inter_op_num_threads | 多节点并行调度 | 基本忽略 |
| graph_optimization_level | Session 创建 | 通用 |
| enable_profiling | 生成 profile 文件 | 通用 |
| log_severity_level | 日志量 | 调试时降低性能 |
import onnxruntime as ort so = ort.SessionOptions() so.intra_op_num_threads = 8 # CPU EP:物理核数 so.inter_op_num_threads = 1 # 默认起步,Profile 后再加 sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
边缘低延迟策略示例(SOURCE 7.2 思路):
policy = { "execution_mode": "SEQUENTIAL", "graph_optimization_level": "ORT_ENABLE_EXTENDED", "intra_op_num_threads": 2, "inter_op_num_threads": 1, "enable_mem_pattern": True, }
可写入模型 metadata_props 或由 Sidecar 注入,实现 调优即策略——策略随模型走,换环境不换代码,参数不再散落在部署脚本里。
| 部署 | intra | inter | mode |
|---|---|---|---|
| 云 GPU | 1 | 1 | SEQUENTIAL |
| 8 核 CPU 服务器 | 8 | 2 | PARALLEL 试 |
| 树莓派 | 2 | 1 | SEQUENTIAL |
| 4 核边缘盒子 | 3 | 1 | SEQUENTIAL |
原则:GPU 场景 intra/inter 保持 1 或默认,让 GPU 成为唯一算力来源;CPU 场景 intra 设为物理核数(不是逻辑核数,超线程共享执行单元),inter 从 1 起步,Profile 确认有可并行分支再加。
⚠️ 生产开启 verbose 日志 + profiling 忘关——性能掉 10%+ 且磁盘爆。调优结束后务必关闭,把开关交给策略文件而不是常驻代码。
💡 GPU 推理 intra/inter 保持 1 或默认;CPU 推理 intra≈物理核,inter=1 起步。这条直觉覆盖 80% 场景,剩下 20% 用 Profile 修正。
GPU 场景线程配置的意义不在"加速计算",而在别拖后腿:CPU 线程池占满时,host 端的数据搬运、kernel launch 排队、CUDA 同步都会变慢。正确做法是给 CPU 预留核心:比如 16 核机器跑 GPU 推理服务,ORT 线程留 2~4,其余交给预处理与整体服务进程,避免超线程争用导致的 P99 长尾。
线程数不能拍脑袋,测量流程是:固定输入与 warmup,对候选配置各跑 100 次,记录 P50/P99/吞吐,做一张对照表再选。线程参数是离散的,完全值得穷举小范围:
import onnxruntime as ort import numpy as np import time def bench(intra, inter, runs=100): so = ort.SessionOptions() so.intra_op_num_threads = intra so.inter_op_num_threads = inter sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"]) feed = {"input": np.random.randn(1, 3, 224, 224).astype(np.float32)} for _ in range(10): # warmup sess.run(None, feed) times = [] for _ in range(runs): t0 = time.perf_counter() sess.run(None, feed) times.append((time.perf_counter() - t0) * 1000) times.sort() return times[runs // 2], times[int(runs * 0.99)] # P50, P99 for intra in (1, 2, 4, 8): for inter in (1, 2): p50, p99 = bench(intra, inter) print(f"intra={intra} inter={inter} P50={p50:.2f}ms P99={p99:.2f}ms")
选择标准:P99 优先于 P50。吞吐与 P99 冲突时,看业务是延迟敏感还是吞吐敏感——这决定你在表中选哪一列。
| 症状 | 根因 | 处置 |
|---|---|---|
| GPU 场景 CPU 满载 | intra/inter 抄了 CPU 配置 | 归 1 或默认 |
| CPU 推理核没用满 | intra < 物理核 | intra 绑核数 |
| P99 长尾 | 超线程争用 | 预留核心给其他服务 |
| 多模型服务卡顿 | 各 Session 线程叠加 | 全局线程预算 |
| profiling 后性能掉 | 忘了关 | 配置化开关 |
容器化部署时,nproc 看到的 CPU 数可能与宿主机不同,ORT 若按"检测到的核数"自动设线程,会超占容器配额。显式指定线程数不仅是调优,更是资源配额治理:
import os, onnxruntime as ort # 从配额获取线程预算,而不是靠 ort 自动检测 cpu_limit = os.environ.get("CPU_LIMIT", "2") # k8s 注入 so = ort.SessionOptions() so.intra_op_num_threads = int(cpu_limit) so.inter_op_num_threads = 1 sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CPUExecutionProvider"])
k8s/容器场景要特别注意两点:一是线程数受 CPU 配额约束,设大了线程池仍会创建(空转与上下文切换);二是多个 replica 在同一节点时,各实例的线程预算要能加总 ≤ 节点核数,否则节点超卖、P99 集体恶化。线程调优的边界从"单进程"扩展到了"整个调度单元"。
Linux 上 ORT 线程池与 NUMA 亲和性相互作用:跨 NUMA 的内存访问会让延迟翻倍。生产环境可用 taskset 或 cgroup 把进程钉在某个 NUMA 节点,配合 OMP_NUM_THREADS 让 OpenMP 不跨节点调度。Windows 上则是处理器组(processor group)的边界问题。亲和性调优属于"最后 5%"的优化,前提是前 95% 的 EP 与内存策略已就绪。
第5章:量化与 INT8/FP16 压缩。