HuggingFace英译中模型转ONNX部署与量化实战
1. 为什么要把英译中模型从 HuggingFace 搬到 ONNX英译中模型在 HuggingFace 上跑得好好的为什么还要折腾一层 ONNX这个问题我在过去一年里被问过不下二十次。答案其实很朴素训练用 PyTorch部署用 ONNX这是目前工业界最省心的组合拳。HuggingFace 的transformers库把模型封装得极其优雅但它的运行时依赖太重——一个torch包动辄两三个 G推理时还要拖着 Python 解释器和一堆动态图调度开销。如果你只是本地跑个 demo这无所谓可一旦要把模型塞进 C 服务、移动端 App、边缘设备或者想用 TensorRT、OpenVINO 这类推理引擎加速PyTorch 就成了累赘。ONNXOpen Neural Network Exchange本质上是一份计算图的中间表示。它把模型的结构和权重固化成一个.onnx文件任何支持 ONNX 的运行时都能加载执行不再需要原始的训练框架。对于英译中这种典型的 Encoder-Decoder 结构比如 MarianMT、mBART、T5 系列导出 ONNX 之后你可以用onnxruntime在 CPU 上跑出比原生 PyTorch 快 2 到 5 倍的推理速度显存占用也能压下来一大截。这篇文章适合三类人看一是手里已经有 HuggingFace 英译中模型、想把它部署到生产环境的工程师二是做端侧翻译、需要在手机或嵌入式设备上跑模型的开发者三是单纯想搞明白 PyTorch 转 ONNX 这套流程到底有哪些坑的技术爱好者。我会从模型选型、导出脚本、动态轴处理、量化压缩一路讲到实际部署时的性能调优把每一步的“为什么”都掰开揉碎讲清楚。先说结论整个流程的核心难点不在导出本身而在动态序列长度的处理和解码循环的迁移。Encoder 部分相对简单Decoder 因为涉及自回归生成需要把 KV Cache 和循环逻辑单独处理。下面我按实际操作的顺序一层层拆开讲。2. 动手前的环境准备与模型选型2.1 选哪个英译中模型最合适HuggingFace 上的英译中模型多如牛毛但不是每一个都适合导出 ONNX。我踩过的第一个坑就是选了个基于T5的模型结果导出时发现它的相对位置编码在 ONNX 里支持得很别扭折腾了两天才绕过去。所以选型这一步直接决定了后面顺不顺。我的建议是优先考虑MarianMT系列。Helsinki-NLP 开源的opus-mt-en-zh是英译中场景里最经典的选择模型体积小约 300MB 的 PyTorch 权重结构是标准的 Transformer Encoder-Decoder没有花里胡哨的相对位置编码导出 ONNX 的成功率极高。如果你对翻译质量要求更高可以考虑facebook/mbart-large-50-many-to-many-mmt但它体积大、导出慢而且需要额外处理语言 ID token。模型参数量权重体积ONNX 导出难度适用场景opus-mt-en-zh约 77M~300MB低通用英译中、端侧部署mbart-large-50约 610M~2.4GB中高质量多语言翻译m2m100约 418M~1.6GB中多语言互译t5-small约 60M~240MB高实验性质不推荐生产选opus-mt-en-zh的另一个好处是它的 tokenizer 是 SentencePiece导出后可以很方便地用tokenizers库在非 Python 环境里复现不需要拖一个完整的transformers进来。2.2 依赖安装与版本锁定版本兼容性是 ONNX 导出最容易翻车的地方。我见过太多次因为torch和onnx版本对不上导出时报一堆莫名其妙的算子不支持错误。下面这套组合是我实测下来最稳的pip install torch2.1.0 pip install transformers4.35.0 pip install onnx1.15.0 pip install onnxruntime1.16.3 pip install sentencepiece0.1.99注意torch2.1 和onnx1.15 是经过大量验证的稳定搭配。如果你用的是更新的 torch 2.2建议把 onnx 升到 1.16 以上否则torch.onnx.export里的dynamo参数会报错。另外强烈建议用虚拟环境隔离因为transformers对tokenizers的版本有硬性要求跟系统里其他包的依赖很容易打架。我一般用conda create -n onnx-export python3.10起一个干净环境Python 3.10 是目前兼容性最好的版本3.11 和 3.12 在某些 onnx 算子上还有坑。2.3 模型下载与国内访问优化HuggingFace 的模型仓库在国内访问经常不稳定下载一个 300MB 的模型可能卡半天。这里有两个实用方案一是设置镜像端点二是提前把模型缓存到本地。设置镜像端点的方法是在代码里指定HF_ENDPOINT环境变量或者直接用huggingface-cli的镜像参数。我通常会在脚本开头加这么一段import os os.environ[HF_ENDPOINT] https://hf-mirror.com这样from_pretrained就会自动走镜像源。如果你已经手动下载了模型文件也可以直接用本地路径加载from transformers import MarianMTModel, MarianTokenizer model_path ./local_models/opus-mt-en-zh tokenizer MarianTokenizer.from_pretrained(model_path) model MarianMTModel.from_pretrained(model_path)提示下载模型时记得把config.json、pytorch_model.bin、tokenizer_config.json、source.spm、target.spm、vocab.json这几个文件都拉全缺一个都会导致加载失败。3. 核心导出流程与动态轴处理3.1 Encoder 和 Decoder 为什么要分开导出很多人第一次导出 seq2seq 模型时会想当然地整个模型一把梭结果发现导出的 ONNX 根本没法用。原因在于Encoder 是一次性前向计算Decoder 是自回归循环。如果强行把整个模型导出成一个计算图那这个图里就包含了 Python 的 for 循环逻辑ONNX 是表达不了的。正确的做法是把模型拆成三部分分别导出Encoder输入是源语言 token ids 和 attention mask输出是 encoder hidden states。Decoder带 KV Cache输入是当前步的 token、encoder hidden states、以及上一步的 KV Cache输出是 logits 和新的 KV Cache。Decoder不带 Cache用于首次前向输入是 decoder 的起始 token输出初始的 KV Cache。这样拆开之后解码循环由外部的推理代码控制ONNX 图里只有纯粹的张量运算任何运行时都能跑。3.2 Encoder 导出实操先看 Encoder 的导出代码。核心是构造 dummy input然后调用torch.onnx.exportimport torch from transformers import MarianMTModel, MarianTokenizer model_name Helsinki-NLP/opus-mt-en-zh tokenizer MarianTokenizer.from_pretrained(model_name) model MarianMTModel.from_pretrained(model_name) model.eval() # 构造 dummy input dummy_text Hello, how are you today? inputs tokenizer(dummy_text, return_tensorspt) input_ids inputs[input_ids] attention_mask inputs[attention_mask] # 导出 Encoder encoder model.get_encoder() torch.onnx.export( encoder, (input_ids, attention_mask), encoder.onnx, input_names[input_ids, attention_mask], output_names[encoder_hidden_states], dynamic_axes{ input_ids: {0: batch, 1: src_len}, attention_mask: {0: batch, 1: src_len}, encoder_hidden_states: {0: batch, 1: src_len}, }, opset_version14, do_constant_foldingTrue, )这里有几个关键点必须解释清楚。dynamic_axes是整段代码的灵魂它告诉 ONNX 哪些维度是动态的。如果不设置导出的模型就固定死了输入长度换个句子长度就报错。batch维度动态是为了支持批量翻译src_len动态是因为每个句子长度不一样。opset_version14是我推荐的最低版本因为 MarianMT 里用到的某些 attention 算子在 opset 12 以下支持不完整。do_constant_foldingTrue会把能提前算的常量折叠掉减小模型体积、加快推理。3.3 Decoder 与 KV Cache 的处理Decoder 的导出是整个流程里最绕的部分。MarianMT 的 Decoder 在推理时会缓存每一层的 key 和 value避免重复计算。导出时我们需要把这个缓存机制显式地暴露成输入输出。先看首次前向没有历史 cache的导出decoder model.get_decoder() # 首次前向的 dummy input decoder_input_ids torch.tensor([[tokenizer.pad_token_id]], dtypetorch.long) encoder_hidden_states encoder(input_ids, attention_mask)[0] torch.onnx.export( decoder, (decoder_input_ids, encoder_hidden_states), decoder_init.onnx, input_names[decoder_input_ids, encoder_hidden_states], output_names[logits, past_key_values], dynamic_axes{ decoder_input_ids: {0: batch, 1: dec_len}, encoder_hidden_states: {0: batch, 1: src_len}, logits: {0: batch, 1: dec_len}, }, opset_version14, )带 cache 的增量前向稍微复杂一点需要把 past_key_values 作为输入传进去。MarianMT 的 cache 结构是每层两个张量key 和 value共 6 层所以是 12 个输入张量。实际写的时候可以用循环动态构造past_key_values tuple( torch.zeros(1, 8, 0, 64) for _ in range(12) ) torch.onnx.export( decoder, (decoder_input_ids, encoder_hidden_states, *past_key_values), decoder_with_cache.onnx, input_names[decoder_input_ids, encoder_hidden_states] [fpast_key_{i} for i in range(6)] [fpast_value_{i} for i in range(6)], output_names[logits] [fpresent_key_{i} for i in range(6)] [fpresent_value_{i} for i in range(6)], dynamic_axes{...}, opset_version14, )注意torch.zeros(1, 8, 0, 64)里的0表示序列长度为 0这是首次前向时 cache 的初始状态。8 是 attention head 数64 是每个 head 的维度这两个值要跟模型 config 里的num_attention_heads和d_model / num_attention_heads对上。3.4 导出后的验证导出完别急着部署先用onnxruntime跑一遍验证输出是否跟 PyTorch 一致import onnxruntime as ort import numpy as np sess ort.InferenceSession(encoder.onnx) ort_inputs { input_ids: input_ids.numpy(), attention_mask: attention_mask.numpy(), } ort_outputs sess.run(None, ort_inputs) # 跟 PyTorch 输出对比 with torch.no_grad(): pt_output encoder(input_ids, attention_mask)[0].numpy() diff np.abs(ort_outputs[0] - pt_output).max() print(f最大误差: {diff})正常情况下误差应该在1e-5量级。如果误差超过1e-3说明某个算子导出有问题需要检查 opset 版本或者换用torch.onnx.export的dynamoTrue模式。4. 量化压缩与推理性能调优4.1 INT8 动态量化实操导出的 FP32 ONNX 模型体积还是偏大opus-mt-en-zh大概 300MB。如果部署到端侧这个体积很难接受。ONNX Runtime 提供了动态量化工具可以把权重从 FP32 压到 INT8体积直接砍到四分之一推理速度还能再快一截。from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_inputencoder.onnx, model_outputencoder_int8.onnx, weight_typeQuantType.QInt8, )动态量化的原理是权重在导出时就转成 INT8激活值在推理时动态计算量化参数。它不需要校准数据集用起来最省事。代价是精度会掉一点实测下来 BLEU 分数大概降 0.3 到 0.5对于大多数场景可以接受。如果你对精度要求极高可以用静态量化但需要准备一批校准数据from onnxruntime.quantization import quantize_static, CalibrationDataReader class MyCalibrationReader(CalibrationDataReader): def __init__(self, data): self.data data self.iter iter(data) def get_next(self): return next(self.iter, None) quantize_static( model_inputencoder.onnx, model_outputencoder_int8_static.onnx, calibration_data_readerMyCalibrationReader(calib_data), )4.2 量化前后的性能对比我在一台 8 核 CPU 的机器上做了实测输入是一段 50 词的英文输出中文翻译。结果如下模型版本体积单句推理耗时BLEU 变化PyTorch FP32300MB420ms基准ONNX FP32300MB180ms0ONNX INT8 动态78MB95ms-0.4ONNX INT8 静态78MB88ms-0.2可以看到光是转 ONNX 就能带来 2.3 倍的加速再叠加 INT8 量化整体加速接近 4.5 倍。这个提升在批量翻译场景下非常可观。4.3 推理会话的配置优化onnxruntime的InferenceSession有一堆参数可以调调好了还能再榨出 20% 到 30% 的性能options ort.SessionOptions() options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL options.intra_op_num_threads 4 options.inter_op_num_threads 2 options.execution_mode ort.ExecutionMode.ORT_SEQUENTIAL sess ort.InferenceSession( encoder_int8.onnx, sess_optionsoptions, providers[CPUExecutionProvider], )intra_op_num_threads控制单个算子内部并行度inter_op_num_threads控制算子之间的并行度。对于 Transformer 这种算子粒度较大的模型intra_op设成物理核心数、inter_op设成 2 就够了。设太多反而会因为线程切换开销导致性能下降。提示如果你有 GPU把providers换成[CUDAExecutionProvider]就能走 GPU 推理。但要注意 INT8 量化模型在 GPU 上的加速效果不如 CPU 明显因为 GPU 本身对 FP16 的支持就很好量化收益有限。5. 解码循环的完整实现与踩坑记录5.1 贪心解码的完整代码模型导出只是第一步真正跑起来还需要自己实现解码循环。下面是一个完整的贪心解码实现def translate(text, encoder_sess, decoder_init_sess, decoder_cache_sess, tokenizer, max_len128): inputs tokenizer(text, return_tensorsnp) input_ids inputs[input_ids].astype(np.int64) attention_mask inputs[attention_mask].astype(np.int64) # Encoder 前向 encoder_hidden encoder_sess.run( [encoder_hidden_states], {input_ids: input_ids, attention_mask: attention_mask} )[0] # Decoder 首次前向 decoder_input np.array([[tokenizer.pad_token_id]], dtypenp.int64) outputs decoder_init_sess.run( None, {decoder_input_ids: decoder_input, encoder_hidden_states: encoder_hidden} ) logits outputs[0] past_kv outputs[1:] generated [] for step in range(max_len): next_token int(np.argmax(logits[0, -1, :])) if next_token tokenizer.eos_token_id: break generated.append(next_token) decoder_input np.array([[next_token]], dtypenp.int64) feed {decoder_input_ids: decoder_input, encoder_hidden_states: encoder_hidden} for i, kv in enumerate(past_kv): feed[fpast_{key if i % 2 0 else value}_{i // 2}] kv outputs decoder_cache_sess.run(None, feed) logits outputs[0] past_kv outputs[1:] return tokenizer.decode(generated, skip_special_tokensTrue)这段代码里有几个容易出错的地方。第一decoder_input的形状必须是(1, 1)不能是(1,)否则 ONNX 会报维度不匹配。第二past_kv的顺序必须跟导出时的input_names严格对应key 和 value 交替排列。第三np.argmax取的是最后一个位置的 logits因为增量解码时每次只输入一个 token。5.2 常见报错与排查表导出和部署过程中遇到的报错五花八门我整理了一份速查表报错信息原因解决方法Unsupported operator: aten::xxxopset 版本太低升级 opset 到 14 以上Input shape mismatchdynamic_axes 没设对检查所有输入输出的动态维度CUDA out of memorybatch 太大减小 batch 或改用 CPUOutput all zerosattention mask 传错确认 mask 的 0/1 语义Slow first inference图优化未生效设置 ORT_ENABLE_ALLINT8 精度暴跌量化范围溢出改用静态量化加校准注意Output all zeros这个坑我踩过两次。MarianMT 的 attention mask 里 1 表示有效 token、0 表示 padding如果你传反了模型会把所有 token 都 mask 掉输出自然全是零。这个错误不会报异常只会静默地给你错误结果非常隐蔽。5.3 批量翻译的优化技巧单句翻译跑通之后下一步通常是批量处理。批量翻译的关键是padding 对齐同一个 batch 里的句子要 pad 到相同长度同时用 attention mask 标记哪些是真实 token。def batch_translate(texts, ...): inputs tokenizer(texts, return_tensorsnp, paddingTrue, truncationTrue) # 后续流程跟单句一样只是 batch 维度变成 len(texts)批量翻译能显著提升吞吐量因为 Encoder 的前向计算可以并行。但 Decoder 部分因为是自回归的batch 内不同句子可能在不同步数结束需要动态剔除已完成的句子。我的做法是维护一个活跃索引列表每步只对未完成的句子做前向完成的就移出 batch。这样能避免为了等最长句子而浪费计算。实测下来batch size 设为 8 时吞吐量最高再大就会因为 padding 浪费和内存压力导致收益递减。这个值跟你的 CPU 核心数和内存带宽有关建议自己压测一下找最优点。6. 部署到生产环境的几点经验6.1 模型文件的组织方式生产环境里我建议把三个 ONNX 文件encoder、decoder_init、decoder_cache和 tokenizer 相关文件放在同一个目录下用一个配置文件描述它们的路径和参数{ encoder: encoder_int8.onnx, decoder_init: decoder_init_int8.onnx, decoder_cache: decoder_cache_int8.onnx, tokenizer: tokenizer/, max_length: 128, num_threads: 4 }这样部署脚本只需要读一个配置换模型时改配置就行不用动代码。我在实际项目里还加了一个warmup步骤启动时先用几句固定文本跑一遍推理把 ONNX Runtime 的图优化和内存分配都触发一遍避免第一个真实请求响应特别慢。6.2 内存与并发控制ONNX Runtime 的InferenceSession本身是线程安全的多个线程可以共享同一个 session 并发调用。但要注意每个调用都会分配自己的中间张量并发数太高会导致内存暴涨。我的经验是并发数不要超过物理核心数超出的请求排队等待。如果你用的是 Python 的ThreadPoolExecutor记得把max_workers设成核心数。如果是 C 部署可以用Ort::Session配合线程池。实测在 8 核机器上并发 8 路翻译的吞吐量是单路的 6 倍左右再往上加收益就很小了。6.3 精度与速度的取舍最后聊聊精度和速度怎么平衡。如果你的场景是离线批量翻译对延迟不敏感那就用 FP32 模型精度最高。如果是在线服务延迟要求高那就上 INT8 动态量化速度提升明显、精度损失可接受。如果是端侧部署内存和存储都紧张那 INT8 静态量化是唯一选择但一定要用真实数据做校准否则精度可能掉得很难看。我个人在实际操作中的体会是先跑通 FP32再逐步量化。不要一上来就追求极致压缩那样出了问题很难定位是导出环节还是量化环节的锅。分阶段验证每一步都跟 PyTorch 原始输出对比才能保证最终结果可靠。这套流程我前后在三个项目里用过从 300MB 的 FP32 到 78MB 的 INT8翻译质量肉眼几乎看不出差别但推理速度和资源占用完全是两个量级。