楼层: 首页/ 软件技术/ Rust + AI 全栈/ ONNX Runtime 推理:ort crate 从加载到上线
11

ONNX Runtime 推理:ort crate 从加载到上线

ONNX Runtime in Rust · the ort crate

第 2 章里 ort 只占了三行代码,但真拿它上生产,你要面对的问题是:模型怎么从 PyTorch 导出来、输入张量的名字和维度怎么对齐、为什么报"找不到 onnxruntime 动态库"、多线程下 Session 到底能不能共享、GPU 怎么开。这一章按"模型从哪来 → 怎么加载 → 怎么喂数据 → 怎么取结果 → 怎么加速 → 怎么踩坑"的顺序走一遍,每个环节都给可运行代码和验证手段。技术基线:ort 2.x(1.x 的 API 形态相近,细节以对应版本文档为准)。

为什么是 ONNX:跨语言的"模型交换格式"

ONNX(Open Neural Network Exchange)不是框架,而是一张标准化的计算图。.onnx 文件里存的是算子图 + 权重,不含训练代码、不含 Python。

方案训练侧部署侧代价
Python + PyTorch 直跑 原生,最舒服 解释器 + 全套依赖,镜像 3GB 起 GIL、启动慢、依赖地狱,不适合边缘与高并发
libtorch(tch-rs) 直接加载 TorchScript Rust 侧能用 要背 2GB 的 libtorch 动态库,端侧/容器里都太重
ONNX + ort 训练后导出一次 C++/Rust/Go/C#/Java/JS 全支持,原生代码 导出可能失败(不支持的算子),动态 shape 要显式声明
candle(纯 Rust) Pytorch 权重直接加载(safetensors) 零外部依赖,交叉编译最省心 算子覆盖不如 ORT 全,性能优化要自己调

论ONNX Runtime 真正的价值不只是"跨语言"

① 它是"独立演进的推理引擎"。模型的训练和推理被解耦了:训练侧用 PyTorch 继续迭代,部署侧用 ORT 对图做算子融合、常量折叠、布局转换、量化。同一份 .onnx 在 ORT 上通常比"PyTorch 直跑推理"快 1.5~3 倍,且 CPU 上的差距更明显。

② 它把"硬件适配"变成了配置。ORT 有统一的 Execution Provider(EP)抽象:CPU、CUDA、CoreML、DirectML、OpenVINO、TensorRT……换一行配置就能换硬件,业务代码一行不用改。这是自研推理引擎最难追上的地方。

③ 代价是"导出边界"。任何"PyTorch 里有、ONNX 里没有"的算子(早期是很多动态控制流、自定义算子)都会在导出时报错或行为不一致。所以正确的心智是:ONNX 适合"结构规整的推理模型"(CV、BERT 类 NLP、推荐模型),不适合"模型里带复杂 Python 逻辑"的场景。选型前先花半天试导出,比上线前一周发现导不出来便宜得多。

导出模型为 .onnx:一次做对,后面省事

导出的核心是把动态的东西固定下来:输入的名字、形状的哪些维度是变的、opset 版本。

PyTorch 侧导出(这一段是 Python,但决定了 Rust 侧能不能跑)

# export.py —— 在训练环境里跑一次 import torch model = MyModel().eval() # 必须 eval():关掉 dropout / BN 的训练行为 # 输入名字要与 Rust 侧喂数据的名字一致,建议起可读的名字 input_ids = torch.zeros((1, 128), dtype=torch.long) attention_mask = torch.ones((1, 128), dtype=torch.long) # 动态轴声明:不写这些,导出来后 batch/seq 会被写死成 1 和 128 # 这是"明明 Python 里能跑,Rust 里换了个长度就报错"的根因 dynamic_axes = { "input_ids": {0: "batch", 1: "seq_len"}, "attention_mask": {0: "batch", 1: "seq_len"}, "logits": {0: "batch", 1: "seq_len"}, } torch.onnx.export( model, (input_ids, attention_mask), "model.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes=dynamic_axes, opset_version=17, # 选一个 ORT 支持的 opset,不要一味追新 do_constant_folding=True, ) # 导完立刻自检:用 onnx 库跑一遍形状推断,比等到 Rust 里报错强 import onnx m = onnx.load("model.onnx") onnx.checker.check_model(m) for i in m.graph.input: print("input:", i.name, [d.dim_param or d.dim_value for d in i.type.tensor_type.shape.dim]) for o in m.graph.output: print("output:", o.name, [d.dim_param or d.dim_value for d in o.type.tensor_type.shape.dim])

HuggingFace 模型用 optimum 一键导出(省去手写 dynamic_axes)

# 文本分类 / 特征提取模型的标准导出方式 pip install optimum[exporters] optimum-cli export onnx --model bert-base-chinese --task text-classification onnx/bert/ # 导出目录里会有:model.onnx、tokenizer.json、config.json # Rust 侧只需要前两个文件 # 想让模型更小更快:导出后再做量化(PyTorch 侧动态量化,不需要校准数据) from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic("model.onnx", "model.int8.onnx", weight_type=QuantType.QInt8) # 体积降到 1/4,CPU 上通常快 2 倍左右,精度损失一般 < 1%
坑:不声明 dynamic_axes,模型被"写死"成固定形状

这是导出环节最经典的坑。你的 Python 里 model(input_ids) 对任意长度都能跑,但导出的 .onnx 里 shape = [1, 128] 是常量。到 Rust 侧喂一个长度 30 的序列,ORT 直接报维度不匹配。

更隐蔽的版本是只声明了 batch 是动态的,忘了 seq_len:单条推理没问题(batch=1),一上批处理就炸。

还有两个容易漏的点:① 忘了 model.eval(),导出的模型带着 dropout 和 BatchNorm 的训练行为,推理结果随机跳变;② opset 版本追得太新,ORT 的动态库版本跟不上,报 "Unsupported opset version"。导完一定要用 onnx.checker.check_model + 打印每个 input/output 的维度,把"我以为的形状"确认成"实际的形状"。

加载 Session:SessionBuilder 的每个参数都值钱

Cargo.toml:动态库怎么来,先决定清楚

[dependencies] # download-binaries:编译期自动下载对应平台的 onnxruntime 预编译包,最省事 # 生产环境若要用系统里已有的 ORT(共用一份、便于统一升级),改用 load-dynamic ort = { version = "2", features = ["download-binaries", "ndarray"] } # ort = { version = "2", features = ["load-dynamic", "ndarray"] } ndarray = "0.16" # 三个 feature 的作用: # download-binaries —— 构建时下载 onnxruntime 静态/动态库并链接 # load-dynamic —— 不链接,运行时通过 dlopen 加载,路径由 ORT_DYLIB_PATH 指定 # ndarray —— 提供 TensorRef::from_array_view 这类与 ndarray 互转的便利方法

Session 构建:图优化级别、线程数、内存模式

use ort::session::builder::{GraphOptimizationLevel, SessionBuilder}; use ort::session::Session; pub fn build_session(model_path: &str, intra_threads: usize) -> ort::Result<Session> { // 进程级初始化:可以设置日志级别,排查 ORT 内部问题时会用到 // ort::init().with_name("order-api").commit()?; let mut builder = Session::builder()? // 1) 图优化级别:Level3 = 全部优化(算子融合、常量折叠、布局改写) // 用 Level1 会让推理明显变慢,除非在排查"优化后结果不对"的问题 .with_optimization_level(GraphOptimizationLevel::Level3)? // 2) 算子内部并行线程数:一次推理内部用几个核 // 注意它是【线程池】,不是"每请求一线程";设太大反而抢占 .with_intra_threads(intra_threads)? // 3) 同时跑几个算子(层间并行):通常保持默认 .with_inter_threads(1)? // 4) 内存模式:开启后复用缓冲区,减少分配;排查内存问题时可以关掉对比 .with_memory_pattern(true)? // 5) 执行提供者(GPU 等),见后面章节 // .with_execution_providers([...])? // 6) 允许把优化后的模型缓存到磁盘,跳过重复优化 .with_optimized_model_path(&format!("{model_path}.opt"))?; let session = builder.commit_from_file(model_path)?; Ok(session) } // 加载完先把输入输出的名字与形状打出来 —— 这是排查一切"喂不进去"的第一步 pub fn dump_io(session: &Session) { for input in session.inputs() { println!("input name={} dtype={:?}", input.name(), input.dtype()); } for output in session.outputs() { println!("output name={} dtype={:?}", output.name(), output.dtype()); } // 还有一类信息容易被忽略:模型自带的元数据(训练时的超参、类别标签) if let Ok(meta) = session.metadata() { // 很多团队会把 id2label 塞在 metadata 里,部署时直接读,不用另传文件 println!("producer={:?}", meta.producer()); } }
构造项推荐取值为什么
with_optimization_levelLevel3默认一般就是 Level3,但显式写出来可以防止别人误改成 Level1 后性能悄悄腰斩
with_intra_threads≈ 物理核数的一半,或做成配置项它是算子内并行线程池。多个 Session 各开满线程会互相抢占;K8s 里要注意 CPU limit 与线程数的关系
with_inter_threads1层间并行收益有限,且会放大线程竞争
with_memory_patterntrue复用中间张量缓冲区,减少分配抖动;只在排查非法内存访问时关掉
with_optimized_model_path生产建议开图优化要花几百毫秒到几秒;缓存成文件后二次启动几乎无成本

喂数据:Tensor 构造与维度对齐

喂数据只有一件事要对:形状(shape)必须和模型声明的完全一致,顺序也不能错。ONNX 里的形状是"逻辑顺序",跟内存布局是两件事——这正是 NCHW/NHWC 混乱的根源。

用 ndarray 构造输入(推荐写法:借用现有数组,零拷贝)

use ndarray::{Array2, Array3, Array4}; use ort::value::TensorRef; // 文本模型:两个 long 类型的输入,形状 [batch, seq_len] let input_ids: Array2<i64> = Array2::from_shape_vec((1, 5), vec![101, 872, 1962, 102, 0])?; let attention_mask: Array2<i64> = Array2::from_shape_vec((1, 5), vec![1, 1, 1, 1, 0])?; // TensorRef::from_array_view 是【借用】—— 不拷贝数据、不拥有内存 // 注意生命周期:底层 ndarray 必须在 run 期间一直存活 let outputs = session.run(ort::inputs![ // 键名必须与模型里的 input 名字完全一致(区分大小写) "input_ids" => TensorRef::from_array_view(&input_ids)?, "attention_mask" => TensorRef::from_array_view(&attention_mask)?, ])?; // 图像模型:NCHW 还是 NHWC,取决于导出时模型的约定 // ResNet 等 torchvision 模型是 NCHW:[batch, channels, height, width] let image_nchw: Array4<f32> = Array4::zeros((1, 3, 224, 224)); // 部分 TF / TFLite 转换来的模型是 NHWC:[batch, height, width, channels] let image_nhwc: Array4<f32> = Array4::zeros((1, 224, 224, 3)); // 需要有拥有权的 Tensor(数据要活过 ndarray 的作用域)时用 from_array(会拷贝) // let owned = Tensor::from_array(([1usize, 3, 224, 224], vec![0f32; 3 * 224 * 224]))?;
模型来源输入布局怎么确认
PyTorch 原生导出NCHW [N,C,H,W]PyTorch 约定通道在前,torch.onnx.export 不会帮你转
TF / Keras 转 ONNXNHWC [N,H,W,C]Keras 约定通道在后,转换工具(tf2onnx)默认保留 NHWC
HuggingFace NLP 模型[batch, seq_len] 的 i64用 dump_io 打印 input 名与 dtype;缺 attention_mask 常导致结果异常
动态 shape 模型维度显示为字符串(如 batch/seq_len)动态维度可以任意长度;静态维度必须精确匹配

输出解析:拿到 tensor 之后的三件事

use ndarray::ArrayViewD; // 1) 按名字取,比按索引取可读得多(模型输出顺序变了也不会错) let logits: ArrayViewD<f32> = outputs["logits"].try_extract_array::<f32>()?; // 形状一般是 [batch, num_class](分类)或 [batch, seq_len, vocab](语言模型) // 先断言形状,把"我以为的"变成"编译期/运行期会检查的" let shape = logits.shape(); assert_eq!(shape.len(), 2, "期望二维 logits,实际 {:?}", shape); // 2) Softmax:把 logit 变成概率。记得减去最大值防止 exp 溢出 fn softmax(logits: &[f32]) -> Vec<f32> { let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max); let exps: Vec<f32> = logits.iter().map(|x| (x - max).exp()).collect(); let sum: f32 = exps.iter().sum(); exps.into_iter().map(|e| e / sum).collect() } // 3) argmax:取最大概率的类别 fn argmax(scores: &[f32]) -> usize { scores.iter().enumerate() .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(i, _)| i).unwrap_or(0) } // 4) top-k:推荐/召回场景需要前 k 个而不是只要最大那个 fn top_k(scores: &[f32], k: usize) -> Vec<(usize, f32)> { let mut idx: Vec<usize> = (0..scores.len()).collect(); // 部分排序:K 远小于 N 时比全排序快得多 idx.select_nth_unstable_by(k.min(scores.len().saturating_sub(1)), |a, b| { scores[*b].partial_cmp(&scores[*a]).unwrap_or(std::cmp::Ordering::Equal) }); idx[..k.min(scores.len())].iter().map(|&i| (i, scores[i])).collect() } // 用起来:取 batch 里第一条样本 let row = logits.index_axis(ndarray::Axis(0), 0); // 注意:标准布局下 as_slice() 一定成功;不标准时再 to_vec() 兜底 let row_vec: Vec<f32> = row.iter().copied().collect(); let probs = softmax(&row_vec); let best = argmax(&probs); println!("预测类别 = {}, 置信度 = {:.4}", best, probs[best]);

批处理与动态 shape:吞吐和延迟的取舍

单条推理跑 8ms 不代表你能扛 125 QPS——因为单条推理根本没吃满 CPU 的并行度。批处理是提升吞吐最直接的手段,但会拉高单请求延迟。

动态批处理:攒够 N 条或等 M 毫秒就发一批

use tokio::sync::{mpsc, oneshot}; struct Job { input_ids: Vec<i64>, // 每条请求自带一个"回信地址",批处理完成后逐个应答 reply: oneshot::Sender<anyhow::Result<usize>>, } /// 批处理 worker:单线程持有 session(因为 run 需要 &mut self) async fn batch_worker( mut rx: mpsc::Receiver<Job>, mut session: Session, max_batch: usize, max_wait: std::time::Duration, ) { loop { // 1) 阻塞等第一条,等不到就退出(通道关闭) let Some(first) = rx.recv().await else { break }; let mut batch = vec![first]; // 2) 在 max_wait 内尽量多攒几条,攒到 max_batch 就立刻发 // 这两个参数直接决定"吞吐 vs 延迟"的平衡点 let deadline = tokio::time::Instant::now() + max_wait; while batch.len() < max_batch { match tokio::time::timeout_at(deadline, rx.recv()).await { Ok(Some(job)) => batch.push(job), // 超时或通道关闭,立即发车 _ => break, } } // 3) padding 到同一长度:动态 shape 模型可以不做,静态模型必须做 let seq_len = batch.iter().map(|j| j.input_ids.len()).max().unwrap_or(1); let rows = batch.len(); let mut flat = vec![0i64; rows * seq_len]; // 0 作为 pad token let mut mask = vec![0i64; rows * seq_len]; for (r, job) in batch.iter().enumerate() { for (c, &id) in job.input_ids.iter().enumerate() { flat[r * seq_len + c] = id; mask[r * seq_len + c] = 1; // 真实 token 标 1,pad 保持 0 } } // 4) 一次 run 处理整批 let ids: ndarray::Array2<i64> = ndarray::Array2::from_shape_vec((rows, seq_len), flat).unwrap(); let m: ndarray::Array2<i64> = ndarray::Array2::from_shape_vec((rows, seq_len), mask).unwrap(); let out = session.run(ort::inputs![ "input_ids" => ort::value::TensorRef::from_array_view(&ids).unwrap(), "attention_mask" => ort::value::TensorRef::from_array_view(&m).unwrap(), ]); // 5) 把结果按行拆开,逐条回信(每条的 argmax 独立算) match out { Ok(outputs) => { let logits = outputs["logits"].try_extract_array::<f32>().unwrap().to_owned(); for (r, job) in batch.into_iter().enumerate() { let row = logits.index_axis(ndarray::Axis(0), r); let row_vec: Vec<f32> = row.iter().copied().collect(); let _ = job.reply.send(Ok(argmax(&row_vec))); } } Err(e) => { // 单批失败要逐条报错,不能让调用方永久挂住 let msg = e.to_string(); for job in batch { let _ = job.reply.send(Err(anyhow::anyhow!(msg.clone()))); } } } } }

论批处理为什么能同时提升吞吐和降低平均成本

① 单条推理的主要成本不是算力,而是内存带宽与内核启动。矩阵乘法在 batch=1 时,权重张量的读取时间占比极高(每个权重只乘一个数);batch 变大后,同一份权重被复用 N 次,单位样本的计算成本下降。这是"批处理更快"的本质。

② 代价是延迟。批处理必须等(等 batch 满或者等超时),所以 P50 延迟会上升,P99 反而可能变好(因为排队更稳定)。典型参数是 max_batch=32、max_wait=5~20ms,实际值必须靠压测决定,不能拍脑袋。

③ 别忘了 padding 的副作用。一个 batch 里如果最短 10 个 token、最长 500 个,padding 会让 300 个样本的场景浪费大量算力。优化手段是"按长度分桶"(length bucketing):把长度接近的请求放进同一批,能显著降低 padding 浪费——这是真实生产里最有效的推理优化手段之一,比换更快的硬件便宜得多。

GPU 执行提供者:CoreML 与 CUDA

ORT 的 EP 机制让"换硬件"变成配置。但要注意:不是所有 EP 都更快,模型太小的时候数据搬运开销可能盖过计算收益。

按平台挂载执行提供者

use ort::session::builder::SessionBuilder; // macOS:CoreML(走 Apple Neural Engine / GPU) #[cfg(target_os = "macos")] fn with_accel(b: SessionBuilder) -> ort::Result<SessionBuilder> { use ort::ep::CoreML; // 顺序很重要:先列的优先使用,失败会自动回退到后面的 // 所以常规写法是 [GPU_EP, CPU EP],ORT 自带 CPU 兜底 b.with_execution_providers([ CoreML::default() // 把整个图交给 ANE 通常会失败回退,按算子子图划分反而更稳 .with_model_format(ort::ep::coreml::ModelFormat::MLProgram) .build(), ]) } // Linux + NVIDIA:CUDA #[cfg(target_os = "linux")] fn with_accel(b: SessionBuilder) -> ort::Result<SessionBuilder> { use ort::ep::CUDA; b.with_execution_providers([ CUDA::default() .with_device_id(0) // fp16 推理:显存减半、速度提升,精度损失通常可接受 // 但必须在精度敏感的模型上验证,别默认打开 // .with_fp16(true) .build(), ]) } // Windows 上可以用 DirectML 走任意显卡;Intel CPU 服务器用 OpenVINO // 注意 CUDA/CoreML 的 EP 需要 onnxruntime 构建时带了对应支持, // 官方预编译包的 GPU 版本要单独下载(CPU 版不含 CUDA) // 怎么确认 EP 真的生效了?打开 ORT 日志看它选了哪个 EP,别只看代码 // ort::init().with_log_level(ort::logging::LogLevel::Info).commit()?;
坑:动态库找不到 —— ort 最高频的启动失败

典型报错:Failed to load ONNX Runtime: libonnxruntime.so: cannot open shared object file(Linux)、libonnxruntime.dylib not found(macOS)、DLL load failed(Windows)。三种解法按场景选:

① 用 download-binaries feature(最省事):构建时自动下载官方预编译包并链接。代价是镜像/产物里会带上 ORT 动态库,且下载依赖网络。

② 用 load-dynamic + ORT_DYLIB_PATH(生产推荐):不链接,运行时加载,路径由环境变量指定。好处是可以换 ORT 版本而不重新编译,也方便共用系统里的一份库。注意:只能在第一次创建 Session 之前设置,程序跑起来后再改环境变量无效。

③ 系统包管理器装的 ORT:把库路径写进 LD_LIBRARY_PATH / DYLD_LIBRARY_PATH,或写进 rpath。容器里可以用 ldconfig。

排查三连(照顺序执行,基本能定位)

# 1) 动态库到底在哪?链接到了谁? ldd target/release/order-api | grep onnx # Linux otool -L target/release/order-api | grep onnx # macOS # 2) 动态加载模式:确认路径生效(程序启动时打印一行日志最省事) ORT_DYLIB_PATH=/usr/local/lib/libonnxruntime.dylib ./order-api # 在代码里验证:println!("{:?}", ort::info()); # 3) 版本匹配:ORT 动态库版本与 ort crate 期望的 ABI 版本不一致也会加载失败 # 动态库与 crate 的版本对应关系写在 ort 的 README / MINOR_VERSION 常量里 python3 -c "import onnxruntime; print(onnxruntime.__version__)" # 容器里别忘了把这个库一起 COPY 进最终镜像 # FROM scratch 的极简镜像尤其容易漏 —— 它连 glibc 都没有

常见坑清单

现象原因修法
报 "找不到 onnxruntime 动态库" 链接方式与部署环境不一致;库没进镜像 用 download-binaries,或 load-dynamic + ORT_DYLIB_PATH;用 ldd/otool 验证
报 "input 'input_ids' is not a valid input name" 喂数据的键名与模型里的 input 名不一致(或大小写不同) 先用 dump_io(session) 打印真实输入名,再对齐;不要靠猜
报维度不匹配(expected [1,128]) 导出时没声明 dynamic_axes,形状被写死 重新导出并声明动态轴;或把输入 padding/截断到模型固定长度
结果全一样 / 置信度都很低 NCHW 与 NHWC 搞反;attention_mask 没传或全 0;忘了 model.eval() 打印输入 shape 与模型声明比对;用 Python 侧同一输入做对照验证
编译不过:cannot borrow session as mutable run 需要 &mut self(ORT 内部有非线程安全的分配器/统计器) 用 Mutex<Session> 包起来,或"每线程一个 Session",或改用 run_async 配合 intra_threads
多线程压测时偶发崩溃或结果错乱 共享了同一个 Session 并绕过 &mut 约束 老老实实加锁或每线程独立 Session;不要为了性能用 unsafe 绕过
GPU 开了但没变快 模型太小,数据搬运开销盖过计算收益;或 EP 根本没生效静默回退到 CPU 开 ORT 日志确认实际使用的 EP;用真实模型压测对比 CPU/GPU 的 P99
启动要好几秒 每次启动都重新做图优化 打开 with_optimized_model_path 缓存优化结果;或启动时预热一次推理
记
本章小结

① ONNX 的价值 = 跨语言 + 独立演进的推理引擎 + 统一的硬件抽象(EP);代价是"导出边界",选型前先试导出。

② 导出三件必做:model.eval()、声明 dynamic_axes、onnx.checker 自检并打印输入输出形状。

③ Session 构造里最该调的三个参数:图优化级别(Level3)、intra 线程数(按核数与并发量)、优化模型缓存路径。

④ 加载完先 dump_io 打印输入名与形状,这是排查一切"喂不进去"的第一动作。

⑤ 维度对齐要盯两件事:名字(区分大小写)与布局(NCHW / NHWC);TensorRef::from_array_view 零拷贝但要求底层数组活过 run。

⑥ 批处理提升吞吐但抬高延迟:max_batch 与 max_wait 必须压测确定;长度分桶能显著减少 padding 浪费。

⑦ run 需要 &mut self(ORT 内部非线程安全),并发方案是"加锁 / 每线程一个 Session / 用 run_async"。

⑧ 动态库问题三招:download-binaries、load-dynamic + ORT_DYLIB_PATH、ldd/otool 验证。

小练习 · 五道 ort 自测题(点开看答案)

1.(排错题)Python 里同一个模型对任意长度输入都能跑,导成 ONNX 后 Rust 侧喂 30 长度就报维度错误,为什么?

查看答案

导出时没声明 dynamic_axes,形状被固化成了导出示例的形状(如 [1,128])。ONNX 里的形状是图的一部分,静态维度必须精确匹配。修法:重新导出,为 batch 与 seq_len 维度命名(dynamic_axes={"input_ids": {0:"batch", 1:"seq_len"}}),然后用 onnx.checker 确认维度变成了 dim_param。

2.(概念题)Session::run 为什么需要 &mut self?该怎么并发?

查看答案

不是因为它修改模型状态,而是因为 ORT 内部的 EP 分配器、统计器等组件不是线程安全的(早期允许并发推理的 ort 版本出现过崩溃与内存损坏)。官方建议:① 每线程一个 Session;② 用批处理把多个请求攒成一次 run;③ 用 run_async 配合 with_intra_threads,让推理落在 Session 自己的线程池里。不要用 unsafe 绕开这个约束。

3.(工程题)想提升吞吐,批处理的两个关键参数怎么定?

查看答案

max_batch 与 max_wait。前者决定"一批最多几条",后者决定"最多等多久攒批"。取值必须压测:观察吞吐曲线在哪个 batch 之后趋于平坦(继续加只增延迟不增吞吐),以及延迟预算允许多少排队时间。经验起点是 max_batch 取 8~32、max_wait 取 5~20ms,再按 P99 延迟要求调整。另外别忘了按长度分桶,否则 padding 会吃掉大部分收益。

4.(调试题)接口返回的置信度全部在 0.2~0.3 之间、类别几乎不变,怎么查?

查看答案

先怀疑输入而不是模型。三个常见原因:① 图像模型 NCHW/NHWC 搞反(内存布局错位,对模型来说是"噪声图");② attention_mask 没传或全为 0(全部 token 被 mask 掉);③ 导出时忘了 model.eval(),dropout 在推理时随机丢弃。验证方法:拿 Python 侧对同一条输入的输出与 Rust 侧逐值对比,能立刻判断是"输入不对"还是"后处理不对"。

5.(部署题)用 load-dynamic 模式部署,容器启动报找不到动态库,怎么系统性排查?

查看答案

三步走:① ldd target/release/app | grep onnx(Linux)或 otool -L(macOS)确认链接状态;② 确认 ORT_DYLIB_PATH 在创建第一个 Session 之前就已设置(运行时晚设无效),并用 ort::info() 打印实际加载到的版本;③ 确认动态库真的被 COPY 进最终镜像(scratch 镜像尤其容易漏),且与 ort crate 期望的 ABI 版本匹配。