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_level | Level3 | 默认一般就是 Level3,但显式写出来可以防止别人误改成 Level1 后性能悄悄腰斩 |
with_intra_threads | ≈ 物理核数的一半,或做成配置项 | 它是算子内并行线程池。多个 Session 各开满线程会互相抢占;K8s 里要注意 CPU limit 与线程数的关系 |
with_inter_threads | 1 | 层间并行收益有限,且会放大线程竞争 |
with_memory_pattern | true | 复用中间张量缓冲区,减少分配抖动;只在排查非法内存访问时关掉 |
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 转 ONNX | NHWC [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 版本匹配。