模型速度的瓶颈往往不在算法本身。几毫秒的优化累积起来就能让用户感受到明显的性能提升。下面这些技术都是在生产环境跑出来的经验,不需要重构代码实施起来也相对简单并且效果显著。

动态形状用起来方便但对性能不友好。TensorRT 和 ONNX Runtime 在处理固定形状时能做更激进的优化。
TensorRT 这边,构建引擎时最好围绕实际使用的 min/opt/max 设置 optimization profile,生产环境尽量让所有请求都落在 opt 范围。ONNX Runtime 可以直接导出固定维度的模型,比如 1×3×224×224。确实需要动态性的话,也要把范围控制得足够紧。
# TensorRT: build with a tight optimization profile profile = builder.create_optimization_profile() profile.set_shape("input", (1,3,224,224), (1,3,224,224), (1,3,224,224)) config.add_optimization_profile(profile)
这样kernel 选择、内存规划、算子融合在形状确定的情况下都能做得更彻底。
启动前把该热的都热一遍冷启动的开销藏在各个角落:驱动初始化、page fault、lazy allocation。服务启动和重启后跑几轮 warmup,把这些一次性成本提前消化掉。
# ONNX Runtime warmup + pinned buffers import onnxruntime as ort, numpy as np so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CUDAExecutionProvider"]) x = np.random.randn(1,3,224,224).astype(np.float32) for _ in range(8): # small loop to populate caches/contexts sess.run(None, {"input": x})
warmup 的形状一定要和线上一模一样。如果服务多种 batch size,每个都得过一遍。
I/O binding 配合 pinned memory 减少拷贝Host 和 device 之间来回搬数据是 tail latency 的大敌。buffer 绑定一次,后面反复用就行了。
# ONNX Runtime I/O binding example io = sess.io_binding() x = np.random.randn(1,3,224,224).astype(np.float32) # Upload once & bind io.bind_cpu_input("input", x) # or bind to CUDA device via OrtValue io.bind_output("logits", device_type="cuda") sess.run_with_iobinding(io) out = io.copy_outputs_to_cpu()[0] # pull back only when you must
GPU 流量大的场景,host 端内存用 page-locked(pinned)能让 H2D/D2H 传输快不少。本质上是把多次小开销合并成一次前置成本,allocator 也不用频繁介入。
精度降低不一定掉点现在的 GPU 对 FP16 支持很好,服务器 CPU 和 NPU 上 INT8 的收益也越来越明显。只要精度守得住,延迟的改善非常直接。
TensorRT 开 FP16 就是一个 flag 的设置:config.set_flag(trt.BuilderFlag.FP16)。但是INT8 需要校准,要拿代表性数据跑一遍生成 per-channel scale。ONNX Runtime 可以用 TensorRT EP 或者直接加载量化后的模型。
# TensorRT FP16 (and INT8 if you have a calibrator) config.set_flag(trt.BuilderFlag.FP16) # config.set_flag(trt.BuilderFlag.INT8) # config.int8_calibrator = calibrator
这里可以先量化最慢的几个子图,比如 embedding 层或者 attention block,不用一上来就全模型量化。
图优化可以开到最高档,但要验证数值让运行时自己去融合算子、选更优的 kernel,这个收益基本是白来的。
# ONNX Runtime optimizations + EPs so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL providers = [ ("TensorrtExecutionProvider", {"trt_fp16_enable": True}), "CUDAExecutionProvider", "CPUExecutionProvider" ] sess = ort.InferenceSession("model.onnx", sess_options=so, providers=providers)
但是需要注意的是融合会改变计算顺序,数值可能有细微漂移。开启前后要跑个 tolerance check,确保输出没问题。
micro-batch 在 GPU 上效果明显单条请求跑推理简单,但硬件利用率往往上不去。打包成 4-8 个请求一起跑,能在保持低延迟的同时提升吞吐。
关键是 batching window 要够小,比如 2-5ms,不然 p95 会飙。micro-batch 的大小最好和前面 optimization profile 设置的尺寸对齐。
不过如果 SLA 本身就很紧(p50 要求 5ms 以内),micro-batch 带来的收益可能不如下面要说的 CUDA Graph。
CUDA Graph 消除 kernel launch 开销小模型或者调用频繁的 graph,kernel launch 的开销会很明显。CUDA Graph 能把整个推理过程录制下来,replay 时几乎没有 CPU 开销。
TensorRT 在形状固定的情况下可以直接启用,只需要warmup 一次,后面就一直跑 captured graph。
这里可以理解成在 GPU driver 层面把推理编译成一个可重放的宏。
ONNX Runtime 线程设置有讲究ONNX Runtime 暴露了几个线程相关的参数,对 CPU 和混合负载的 tail latency 影响挺大。
so = ort.SessionOptions() so.intra_op_num_threads = 1 # one thread per operator often stabilizes latency so.inter_op_num_threads = 1 # avoid oversubscription; raise carefully if parallel graphs
Execution Provider 的选择也很重要:
GPU 场景优先级是 TensorRT EP → CUDA EP → CPU EP fallback。纯 CPU 跑的话 OpenMP 或者 DNNL/MKL build 配合合理的线程池设置效果最好。边缘设备上 Intel 的盒子可以试试 OpenVINO EP。
把预处理后处理从 GIL 里挪出去Python 的胶水代码经常成为隐藏的性能杀手。能用 NumPy 向量化就别写循环,能用 Numba 或者推到 CUDA/CuPy 上更好。热路径里的 transform 最好提前编译好。如果要并发处理请求,worker pool 的规模要和运行时的线程数配合好。
# Example: pre-allocate and reuse buffers to dodge Python overheads import numpy as np class Preprocessor: def __init__(self, shape=(1,3,224,224)): self.buf = np.empty(shape, dtype=np.float32) def __call__(self, img): # write into self.buf in-place; no fresh allocations np.copyto(self.buf, img) self.buf /= 255.0 return self.buf
这里的判断标准很简单,每个请求都会跑的代码,问问能不能预分配、向量化或者缓存起来。
Session、Engine、Buffer 都只建一次每个请求新建一个 trt.ICudaEngine 或 onnxruntime.InferenceSession 基本等于自杀。output array 每次重新分配也一样。
正确做法是进程启动时就加载好 singleton session/engine,每个 worker 维护一两个 CUDA stream,buffer pool 按 shape 和 dtype 索引。
# Simple singleton-ish loader class Model: _sess = None _io = None @classmethod def get(cls): if cls._sess is None: so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL cls._sess = ort.InferenceSession("model.onnx", sess_options=so, providers=["CUDAExecutionProvider"]) cls._io = cls._sess.io_binding() return cls._sess, cls._io
这样做的好处是稳定,p95 不会因为 allocator 和 initializer 出现在热路径而突然炸掉。
一个完整的 GPU 推理骨架下面的代码是把前面几个关键技术串起来:
import onnxruntime as ort, numpy as np def make_session(path): so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL providers = [("TensorrtExecutionProvider", {"trt_fp16_enable": True}), "CUDAExecutionProvider", "CPUExecutionProvider"] return ort.InferenceSession(path, sess_options=so, providers=providers) class Runner: def __init__(self, model_path, shape=(1,3,224,224)): self.sess = make_session(model_path) self.shape = shape self.io = self.sess.io_binding() self._warmup() def _warmup(self, iters=8): x = np.random.randn(*self.shape).astype(np.float32) self.io.bind_cpu_input("input", x) self.io.bind_output("logits", device_type="cuda") for _ in range(iters): self.sess.run_with_iobinding(self.io) self.io.clear_binding_inputs() # ready for real runs def run(self, x_np: np.ndarray): # assumes x_np matches self.shape; in production, validate or clamp self.io.bind_cpu_input("input", x_np) self.io.bind_output("logits", device_type="cuda") self.sess.run_with_iobinding(self.io) return self.io.copy_outputs_to_cpu()[0] # usage runner = Runner("model.onnx") batch = np.random.randn(1,3,224,224).astype(np.float32) probs = runner.run(batch)
这个代码已经包含了图优化、I/O binding 和 warmup。后面再加上 CUDA Graph、micro-batch 和固定 shape,能把延迟压到很低,基本上拿来就可以用了
几个容易踩的坑延迟指标一定要看 p50/p90/p95,别只盯平均值。真正的问题都藏在 tail 里。API 层面最好把 shape 和 dtype 固定下来,或者至少让调用方知道优化过的范围。这样生产请求才能稳定落在最优路径上。
开了融合或量化之后,精度的自动化回归测试必不可少。
总结低延迟不靠黑科技就是一堆小优化叠起来:形状固定、减少拷贝、更好的 kernel、graph capture、运行时零意外。每个单拎出来可能只省几毫秒,但加起来用户就能感受到"快"。
https://avoid.overfit.cn/post/494ca93b9c184407936ef7b6bd16e15e
作者:Syntal