ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HuggingFace英译中模型迁移ONNX:量化压缩与推理部署实战

HuggingFace英译中模型迁移ONNX:量化压缩与推理部署实战 1. 为什么要把英译中模型从 HuggingFace 搬到 ONNX1.1 一个真实的需求场景去年帮一个做跨境电商的朋友处理商品详情页的本地化流程他们每天要翻译几千条英文商品描述到中文。最开始用的是在线翻译接口按字符计费量一上来成本就压不住了。后来改成自己部署模型从 HuggingFace 上拉了一个英译中的预训练模型用 PyTorch 直接推理。单条翻译效果确实不错但问题很快就暴露了服务器上跑 PyTorch 推理内存占用高单条延迟在 800ms 到 1.2s 之间波动并发一上来就排队。更麻烦的是他们想把这套东西塞进一个边缘设备做离线翻译PyTorch 那套依赖在 ARM 环境下装起来简直是噩梦。这时候 ONNX 就进入了视野。简单说ONNX 是一个开放的模型交换格式它把模型的计算图用一种与框架无关的方式描述出来。PyTorch 训练出来的模型可以导出成 ONNX然后用 ONNX Runtime 来推理。ONNX Runtime 的好处是轻量、跨平台、启动快而且支持各种硬件加速后端。对于我朋友这种既要降成本又要上边缘设备的场景几乎是量身定做的。这篇文章就是把这套迁移流程完整拆一遍。从环境准备、模型导出、精度校验到量化压缩、推理部署每一步我都会把踩过的坑和关键参数讲清楚。适合已经会用 HuggingFace 跑模型、但想把推理环节做得更轻更快的朋友。如果你只是想在本地跑个 demo 看看效果那其实没必要折腾 ONNXPyTorch 直接推理就够了。但如果你面临的是部署成本、延迟、跨平台这些工程问题那这篇内容应该能帮你省不少时间。1.2 ONNX 到底解决了什么问题要理解迁移的价值得先搞清楚 PyTorch 推理在生产环境里的几个痛点。第一个是依赖体积。PyTorch 的运行时库加上 CUDA 相关依赖动辄几个 GB。而 ONNX Runtime 的基础包只有几十 MB即使加上 GPU 支持也就几百 MB。对于容器化部署来说镜像体积直接影响到拉取速度和冷启动时间。第二个是推理性能。PyTorch 的 eager 模式虽然灵活但每次前向传播都要经过 Python 解释器有额外的开销。ONNX Runtime 用的是静态计算图可以做图层面的优化比如算子融合、常量折叠、内存复用。实测下来同样的模型在 ONNX Runtime 上推理CPU 场景通常能快 1.5 到 3 倍GPU 场景也能有 20% 到 50% 的提升。第三个是跨平台能力。ONNX Runtime 支持 Windows、Linux、macOS、Android、iOS甚至可以在浏览器里通过 WebAssembly 跑。这意味着同一份模型文件可以在服务器、手机、嵌入式设备上通用。而 PyTorch 的移动端部署虽然也有方案但成熟度和易用性还是差一截。第四个是量化支持。ONNX 生态里有成熟的 INT8 量化工具链可以把 FP32 模型压缩到原来的四分之一推理速度还能再提升一截。对于英译中这种序列到序列的任务量化后的精度损失通常在可接受范围内。注意ONNX 不是万能的。如果你的模型里有大量动态控制流比如 if-else 分支依赖输入数据导出过程可能会很痛苦甚至失败。英译中模型一般是标准的 Transformer 结构这方面问题不大。2. 环境搭建与模型选型的关键决策2.1 工具链版本选择与安装环境这块我踩过的最大坑就是版本兼容性。PyTorch、Transformers、ONNX、ONNX Runtime 这四个库之间的版本匹配非常讲究版本不对就会出现导出失败、算子不支持、推理结果对不上等各种问题。我目前验证过比较稳定的一套组合是Python 3.10、PyTorch 2.1.x、Transformers 4.36.x、ONNX 1.15.x、ONNX Runtime 1.17.x。这个组合在 x86 Linux 和 Windows 上都跑通了。安装命令如下pip install torch2.1.2 --index-url https://download.pytorch.org/whl/cpu pip install transformers4.36.2 pip install onnx1.15.0 pip install onnxruntime1.17.0如果你需要 GPU 推理把 onnxruntime 换成 onnxruntime-gpupip install onnxruntime-gpu1.17.0提示PyTorch 从 2.0 开始对 ONNX 导出的 API 做了调整旧的 torch.onnx.export 虽然还能用但官方推荐用新的 dynamo 导出路径。不过实测下来对于 Transformer 类模型传统导出路径反而更稳定dynamo 路径在某些算子上的支持还不完善。所以我下面还是用传统路径。另外如果你在国内访问 HuggingFace 比较慢可以设置镜像源环境变量export HF_ENDPOINThttps://hf-mirror.com这个镜像同步了 HuggingFace 上的大部分模型下载速度会快很多。模型选型方面英译中任务常用的有 Helsinki-NLP 的 opus-mt-en-zh、facebook 的 m2m100、以及各种基于 Transformer 的翻译模型。我这次用的是 Helsinki-NLP/opus-mt-en-zh因为它体积适中约 300MB翻译质量在通用场景下够用而且结构标准导出 ONNX 比较顺利。2.2 模型结构对导出难度的影响不是所有 HuggingFace 模型都同样容易导出。这里有个经验判断基于标准 Transformer 编码器-解码器结构的模型导出成功率最高。因为这类模型的计算图是静态的没有复杂的分支逻辑。具体来说影响导出难度的因素有几个位置编码方式正弦位置编码比可学习位置编码更容易导出因为前者是纯计算后者涉及查表操作。注意力实现标准的 scaled dot-product attention 没问题但如果用了自定义的注意力 kernel比如 FlashAttention导出时可能需要额外处理。解码策略贪心解码和 beam search 的导出方式不同。贪心解码可以直接导出整个模型beam search 需要在外部用 Python 循环控制每次只调用模型的前向传播。词表大小词表越大输出层的矩阵越大导出的模型文件也越大。opus-mt-en-zh 的词表约 65000属于中等水平。我建议在正式导出前先用一个小脚本检查模型的结构from transformers import AutoModelForSeq2SeqLM, AutoTokenizer model_name Helsinki-NLP/opus-mt-en-zh model AutoModelForSeq2SeqLM.from_pretrained(model_name) tokenizer AutoTokenizer.from_pretrained(model_name) print(model.config) print(f词表大小: {model.config.vocab_size}) print(f编码器层数: {model.config.encoder_layers}) print(f解码器层数: {model.config.decoder_layers}) print(f隐藏维度: {model.config.d_model})输出确认结构后再决定导出策略。如果模型有自定义层可能需要在导出前做一些适配。3. 从 PyTorch 到 ONNX 的完整导出流程3.1 导出脚本的核心逻辑导出英译中模型和导出普通分类模型最大的区别在于翻译模型是自回归生成的输出长度不固定。这就带来一个关键问题——ONNX 的计算图需要固定或半固定的输入输出形状。解决思路有两种。第一种是把编码器和解码器分开导出编码器处理源语言输入解码器每次接收一个 token 生成下一个 token循环由外部控制。第二种是导出整个模型但用固定长度的输入输出配合 padding 和 mask 处理变长序列。我推荐第一种方案因为它更灵活也更容易调试。下面是我实际用的导出脚本import torch from transformers import AutoModelForSeq2SeqLM, AutoTokenizer model_name Helsinki-NLP/opus-mt-en-zh model AutoModelForSeq2SeqLM.from_pretrained(model_name) tokenizer AutoTokenizer.from_pretrained(model_name) model.eval() # 构造示例输入 sample_text Hello, how are you today? inputs tokenizer(sample_text, return_tensorspt, paddingTrue, truncationTrue, max_length128) # 导出编码器 encoder model.get_encoder() torch.onnx.export( encoder, (inputs[input_ids], inputs[attention_mask]), encoder.onnx, input_names[input_ids, attention_mask], output_names[encoder_output], dynamic_axes{ input_ids: {0: batch, 1: seq_len}, attention_mask: {0: batch, 1: seq_len}, encoder_output: {0: batch, 1: seq_len} }, opset_version14, do_constant_foldingTrue )这里有几个关键参数需要解释。opset_version14是我实测下来对 Transformer 支持最好的版本太低会缺算子太高某些 Runtime 还不支持。dynamic_axes指定了哪些维度是动态的batch 和 seq_len 都设为动态这样同一个模型可以处理不同长度的输入。do_constant_foldingTrue让 ONNX 在导出时做常量折叠优化能减小模型体积。解码器的导出稍微复杂一点因为它有 KV Cache 的机制。为了简化我第一版先导出了不带 Cache 的解码器decoder model.get_decoder() # 构造解码器输入 decoder_input_ids torch.tensor([[tokenizer.pad_token_id]]) encoder_hidden_states torch.randn(1, 128, model.config.d_model) torch.onnx.export( decoder, (decoder_input_ids, encoder_hidden_states), decoder.onnx, input_names[decoder_input_ids, encoder_hidden_states], output_names[decoder_output], dynamic_axes{ decoder_input_ids: {0: batch, 1: dec_seq_len}, encoder_hidden_states: {0: batch, 1: enc_seq_len}, decoder_output: {0: batch, 1: dec_seq_len} }, opset_version14 )3.2 导出后的精度校验方法导出完成不代表万事大吉必须做精度校验。我见过太多次导出成功但结果完全不对的情况原因可能是算子实现差异、数值精度损失、或者导出时的图优化改变了计算逻辑。校验方法很简单用同一组输入分别跑 PyTorch 和 ONNX Runtime对比输出。import numpy as np import onnxruntime as ort # PyTorch 推理 with torch.no_grad(): pt_output encoder(**inputs).last_hidden_state.numpy() # ONNX Runtime 推理 sess ort.InferenceSession(encoder.onnx) onnx_output sess.run( None, { input_ids: inputs[input_ids].numpy(), attention_mask: inputs[attention_mask].numpy() } )[0] # 对比 diff np.abs(pt_output - onnx_output) print(f最大绝对误差: {diff.max()}) print(f平均绝对误差: {diff.mean()})判定标准最大绝对误差在 1e-4 以内平均误差在 1e-5 以内基本可以认为精度无损。如果误差在 1e-3 量级对于翻译任务通常也能接受因为最终输出是离散的 token小的数值波动不一定会改变 token 选择。但如果误差超过 1e-2那就要排查原因了。我遇到过一次误差特别大的情况最后发现是导出时没有设置model.eval()导致 dropout 层还在起作用。这个坑很隐蔽因为 PyTorch 推理时如果忘了 eval结果本身就是随机的对比就失去了意义。实操心得校验时不要只用一条输入至少准备 10 条不同长度的句子覆盖短句、长句、含特殊符号的句子。我遇到过某些特定输入下才触发的精度问题单条测试根本发现不了。4. 量化压缩与推理性能优化4.1 INT8 量化的实操步骤模型导出后FP32 的 encoder.onnx 大约 150MBdecoder.onnx 约 200MB。对于边缘设备来说还是偏大。这时候就需要量化。ONNX 的量化分两种动态量化和静态量化。动态量化不需要校准数据直接对权重做量化推理时激活值动态计算量化参数。静态量化需要一批校准数据预先计算激活值的量化范围精度通常更好。对于翻译模型我推荐先用动态量化试水因为简单from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_inputencoder.onnx, model_outputencoder_int8.onnx, weight_typeQuantType.QInt8 ) quantize_dynamic( model_inputdecoder.onnx, model_outputdecoder_int8.onnx, weight_typeQuantType.QInt8 )量化后模型体积能降到原来的四分之一左右encoder 约 40MBdecoder 约 50MB。推理速度在 CPU 上通常能提升 1.5 到 2 倍。但动态量化有个问题它只量化权重激活值还是 FP32 计算所以加速效果有限。如果想要更好的性能得用静态量化from onnxruntime.quantization import quantize_static, CalibrationDataReader class TranslationCalibrationReader(CalibrationDataReader): def __init__(self, calibration_texts, tokenizer, max_length128): self.data [] for text in calibration_texts: inputs tokenizer(text, return_tensorsnp, paddingmax_length, truncationTrue, max_lengthmax_length) self.data.append({ input_ids: inputs[input_ids].astype(np.int64), attention_mask: inputs[attention_mask].astype(np.int64) }) self.index 0 def get_next(self): if self.index len(self.data): return None item self.data[self.index] self.index 1 return item calibration_texts [ The quick brown fox jumps over the lazy dog., Machine translation has improved significantly in recent years., # ... 至少准备 100 条覆盖不同领域的句子 ] reader TranslationCalibrationReader(calibration_texts, tokenizer) quantize_static( model_inputencoder.onnx, model_outputencoder_int8_static.onnx, calibration_data_readerreader, quant_formatQuantFormat.QDQ )校准数据的选择很关键。我建议从实际业务数据里采样覆盖不同的句子长度和领域。如果校准数据太单一量化后的模型在遇到分布外的输入时精度会掉得很厉害。4.2 量化后的精度损失评估量化一定会带来精度损失关键是要控制在可接受范围内。评估方法和前面的精度校验类似但这次要对比的是量化前后的翻译结果而不仅仅是数值误差。我的做法是准备一个测试集比如 200 条英文句子分别用 FP32 模型和 INT8 模型翻译然后计算 BLEU 分数或者人工评估。from nltk.translate.bleu_score import sentence_bleu def translate_with_onnx(text, encoder_path, decoder_path, tokenizer, max_len128): # 编码 inputs tokenizer(text, return_tensorsnp, paddingTrue, truncationTrue, max_lengthmax_len) enc_sess ort.InferenceSession(encoder_path) enc_output enc_sess.run(None, { input_ids: inputs[input_ids].astype(np.int64), attention_mask: inputs[attention_mask].astype(np.int64) })[0] # 解码贪心 dec_sess ort.InferenceSession(decoder_path) decoder_input np.array([[tokenizer.pad_token_id]], dtypenp.int64) generated [] for _ in range(max_len): dec_output dec_sess.run(None, { decoder_input_ids: decoder_input, encoder_hidden_states: enc_output })[0] next_token dec_output[0, -1, :].argmax() if next_token tokenizer.eos_token_id: break generated.append(next_token) decoder_input np.concatenate([ decoder_input, np.array([[next_token]], dtypenp.int64) ], axis1) return tokenizer.decode(generated, skip_special_tokensTrue)实测下来动态量化后的 BLEU 分数相比 FP32 通常下降 0.5 到 1.5 个点。静态量化如果校准数据选得好下降可以控制在 0.3 到 0.8 个点。对于大部分应用场景这个损失是可以接受的。注意如果你的翻译内容涉及专业领域比如医疗、法律量化带来的精度损失可能会被放大。这种情况下建议先在小批量真实数据上验证确认没问题再全量上线。5. 部署落地与常见问题排查5.1 推理服务的封装方式模型导出和量化完成后下一步是封装成可调用的服务。最简单的做法是用 FastAPI 包一层from fastapi import FastAPI from pydantic import BaseModel import onnxruntime as ort import numpy as np from transformers import AutoTokenizer app FastAPI() tokenizer AutoTokenizer.from_pretrained(Helsinki-NLP/opus-mt-en-zh) enc_sess ort.InferenceSession(encoder_int8.onnx) dec_sess ort.InferenceSession(decoder_int8.onnx) class TranslateRequest(BaseModel): text: str app.post(/translate) def translate(req: TranslateRequest): inputs tokenizer(req.text, return_tensorsnp, paddingTrue, truncationTrue, max_length128) enc_output enc_sess.run(None, { input_ids: inputs[input_ids].astype(np.int64), attention_mask: inputs[attention_mask].astype(np.int64) })[0] decoder_input np.array([[tokenizer.pad_token_id]], dtypenp.int64) generated [] for _ in range(128): dec_output dec_sess.run(None, { decoder_input_ids: decoder_input, encoder_hidden_states: enc_output })[0] next_token int(dec_output[0, -1, :].argmax()) if next_token tokenizer.eos_token_id: break generated.append(next_token) decoder_input np.concatenate([ decoder_input, np.array([[next_token]], dtypenp.int64) ], axis1) result tokenizer.decode(generated, skip_special_tokensTrue) return {translation: result}这个服务启动后单条翻译的延迟在 CPU 上大约 200 到 400ms比 PyTorch 版本快了将近一倍。如果换成 GPU 版的 ONNX Runtime延迟可以压到 50ms 以内。5.2 常见问题速查表迁移过程中遇到的问题不少我整理了一个速查表方便对照排查问题现象可能原因解决方法导出时报算子不支持opset 版本过低提升 opset_version 到 14 或更高导出成功但推理结果乱码输入 dtype 不匹配确保输入是 int64 而非 int32精度误差超过 1e-2模型未设为 eval 模式导出前调用 model.eval()动态维度不生效dynamic_axes 配置错误检查维度名称和索引是否对应量化后精度暴跌校准数据分布单一增加校准数据多样性覆盖实际场景推理速度没提升用了动态量化但瓶颈在激活计算改用静态量化或检查是否启用了优化长句翻译截断max_length 设置过小根据业务需求调整建议 256 以上内存占用持续增长每次请求都创建新 SessionSession 全局初始化一次复用其中最容易踩的是 dtype 问题。HuggingFace 的 tokenizer 返回的 input_ids 默认是 int64但如果你手动构造 numpy 数组时用了默认的 int32ONNX Runtime 会报类型错误或者静默产生错误结果。这个坑我踩过两次排查起来很费时间。另一个常见问题是 Session 的创建开销。ONNX Runtime 的 InferenceSession 初始化需要加载模型、优化计算图耗时可能几百毫秒。如果在每次请求里都创建 Session延迟会非常高。正确做法是在服务启动时创建全局 Session请求处理时复用。5.3 性能调优的几个实用技巧除了量化还有几个调优手段可以进一步压榨性能。第一是启用 ONNX Runtime 的图优化。默认情况下Runtime 会做基础优化但你可以通过 SessionOptions 开启更激进的优化opts ort.SessionOptions() opts.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL opts.intra_op_num_threads 4 # 根据 CPU 核心数调整 sess ort.InferenceSession(encoder_int8.onnx, opts)第二是批处理。如果业务场景允许攒一批请求一起翻译吞吐量能提升好几倍。ONNX 模型支持动态 batch只需要把多条输入 padding 到同一长度即可。第三是 KV Cache 的引入。前面为了简化解码器没有用 Cache每次都要重新计算所有已生成 token 的注意力。如果生成序列较长这部分计算量很大。引入 Cache 后每次只需要计算新 token 的注意力速度能提升 2 到 3 倍。不过 Cache 的导出比较复杂需要对模型做改造这里先不展开。第四是选择合适的执行提供器。ONNX Runtime 支持 CPU、CUDA、TensorRT、OpenVINO 等多种后端。在 Intel CPU 上OpenVINO 后端通常比默认 CPU 后端快 20% 到 40%。在 NVIDIA GPU 上TensorRT 后端能带来显著加速但首次编译耗时较长。# 使用 OpenVINO 后端 providers [OpenVINOExecutionProvider, CPUExecutionProvider] sess ort.InferenceSession(encoder_int8.onnx, providersproviders)实操心得调优不要一次改多个变量否则出了问题不知道是哪个改动导致的。我的习惯是先固定一个基线配置然后每次只改一个参数记录性能变化逐步找到最优组合。6. 一些关于迁移决策的个人体会整套流程走下来从 PyTorch 到 ONNX 的迁移大概需要两到三天的工作量其中导出和调试占一半时间量化和精度校验占另一半。如果模型结构标准、业务场景对精度要求不是极端苛刻这个投入是值得的。但有几个情况我建议慎重考虑。一是模型更新频繁的场景每次模型迭代都要重新导出、校验、量化维护成本不低。二是对翻译质量要求极高的场景量化带来的精度损失可能无法接受那就只能用 FP32 的 ONNX 模型性能提升有限。三是团队里没有人熟悉 ONNX 生态出了问题排查起来会比较吃力。我个人的经验是ONNX 最适合的是那种模型结构稳定、部署环境多样、对延迟和成本敏感的场景。英译中翻译恰好符合这些特征所以迁移的收益比较明显。如果你也在做类似的模型部署优化希望这篇内容能帮你少走一些弯路。
返回列表