☰
Qwen2.5-1.5B模型ONNX拆解:从计算图到INT8量化实践
2026/10/1 12:11:06 网站建设 项目流程

最近在搞 Qwen2.5-1.5B 的本地部署,第一件事就是把模型从 PyTorch 格式转到 ONNX,再把 ONNX 文件真正“拆开”看了个底朝天。这个 1.5B 参数的模型在 ONNX 里并不是一个黑盒,而是一张极其规整的计算图。配合上 GitHub 上整理好的源码脚本,基本能实现从结构预览、权重提取到 runtime 推理、INT8 量化的完整链路。这篇文章就把整个过程和技术细节完整记录下来,适合正在做 LLM 端侧部署、或者想了解 ONNX 内部表示的同学参考。

最初我也只是拿 Netron 打开 ONNX 文件,看那一堆节点名和连线,感觉像在看一张无向图。后来自己写脚本直接读 model.graph,把输入输出、节点列表、权重 initializer 全部打出来,才真正明白 Qwen 的结构是怎么落地的。所以这篇内容重点不是“怎么用 Netron”,而是“怎么用 Python 把 ONNX 文件按你自己的需求拆开”,以及拆开之后能做什么。

1. 为什么要把 1.5B 大模型拆进 ONNX 里看

1.1 从 PyTorch 到 ONNX:模型格式的转变

Qwen2.5-1.5B 原生权重一般是 PyTorch 的 safetensors 或 bin 格式,跑起来依赖 PyTorch 环境。训练和实验环境里没问题,但在生产部署或者端侧推理时,你往往不想为一个模型背上整个 Python + CUDA 生态。ONNX 的价值在于它是一个开放的计算图标准,能用 onnxruntime 这类轻量推理引擎直接运行,不依赖 PyTorch 解释器,也更方便做算子融合和量化。

把 Qwen2.5-1.5B 转成 ONNX 时,常规做法是用torch.onnx.export。但这里有个关键点:1.5B 参数在 FP32 下大概是 3GB 权重,序列化到 ONNX 文件时大概率超过 2GB。ONNX 底层用的是 protobuf,单个文件超过 2GB 会碰上限,所以导出时必须开启 external data 模式,把权重单独存成多个二进制分片,而 .onnx 主文件只保留计算图结构和外部数据引用。

我当时用的导出参数大概是这样的:

import torch from transformers import AutoModelForCausalLM, AutoTokenizer model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-1.5B", torch_dtype=torch.float32) model.eval() dummy_input = { "input_ids": torch.ones(1, 8, dtype=torch.int64), "attention_mask": torch.ones(1, 8, dtype=torch.int64), "position_ids": torch.arange(8, dtype=torch.int64).unsqueeze(0), } torch.onnx.export( model, tuple(dummy_input.values()), "qwen2.5-1.5b.onnx", input_names=["input_ids", "attention_mask", "position_ids"], output_names=["logits"], opset_version=17, dynamic_axes={ "input_ids": {1: "seq_len"}, "attention_mask": {1: "seq_len"}, "position_ids": {1: "seq_len"}, "logits": {1: "seq_len"}, }, do_constant_folding=True, )

注意opset_version不能太低,LLM 里有很多新算子,ONNX Runtime 的优化对 opset 版本也有要求。我推荐 17 或 18,既能覆盖常见算子,又能被当前 onnxruntime 良好优化。导出后会发现主文件只有几百 KB,旁边出现一堆data/目录或者model.onnx.data文件,那就是外部权重。

为什么要拆开看?因为直接丢给 onnxruntime 跑,虽然能跑通,但你不知道计算图里到底发生了哪些算子融合、哪些节点是冗余的、哪些权重在哪。只有拆开图,才能针对性地做剪枝、合并、量化,甚至自定义算子替换。

1.2 ONNX 文件里到底有什么:图结构的基本概念

ONNX 文件本质上是一个 protobuf 序列化后的计算图。核心就是ModelProto里包着一个GraphProto,GraphProto里包含四类关键对象:

  • node:计算节点,每个 node 是一个算子,比如MatMul、Add、Softmax、Reshape。
  • initializer:初始化的权重张量,也就是模型参数,例如model.layers.0.self_attn.q_proj.weight。
  • input:模型输入的定义,包括名称、数据类型、维度信息。
  • output:模型输出的定义。
  • value_info:中间张量的形状和类型信息。

可以用一个生活例子来理解:计算图像一本菜谱,node是每一步操作,比如“切菜”“下锅”“翻炒”;initializer是提前备好的食材,例如土豆、牛肉;input是刚买回来的新鲜材料,随点随用;output是最终出锅的菜;value_info则是每一步操作后食材变成了什么形状、什么状态。

这种结构特别适合程序化分析。你不用打开可视化工具,直接用 Python 库onnx就可以遍历整张图。很多网上教程把 ONNX 当成“模型文件”一句话带过,但对做部署的人来说,理解这张计算图才是优化性能的第一道门。

2. 用几行 Python 脚本把 Qwen2.5-1.5B 拆开

2.1 加载 ONNX 模型并查看输入输出

写脚本的第一步是把 ONNX 模型加载进来。这里有个容易踩的坑:如果开启了 external data,必须指定load_external_data=True,否则加载出来的模型没有权重,甚至直接报错。标准的加载方式是这样:

import onnx model = onnx.load("qwen2.5-1.5b.onnx", load_external_data=True) graph = model.graph print("模型 IR 版本:", model.ir_version) print("算子集:", [(op.domain, op.version) for op in model.opset_import]) print("\n=== 输入 ===") for inp in graph.input: shape = [d.dim_value if d.HasField("dim_value") else d.dim_param for d in inp.type.tensor_type.shape.dim] print(f"{inp.name}: {shape}, dtype={inp.type.tensor_type.elem_type}") print("\n=== 输出 ===") for out in graph.output: shape = [d.dim_value if d.HasField("dim_value") else d.dim_param for d in out.type.tensor_type.shape.dim] print(f"{out.name}: {shape}, dtype={out.type.tensor_type.elem_type}")

加载成功后能看到 Qwen2.5-1.5B 的三个输入:input_ids、attention_mask、position_ids,维度基本是[batch_size, seq_len],而且seq_len是动态轴,用字符串"seq_len"表示。输出只有一个logits,维度是[batch_size, seq_len, vocab_size],vocab_size 在 Qwen2.5 系列里是 151936 左右。

这里特别说明一下 dtype:ONNX 里的 elem_type 是一个整数枚举,7代表 float32,9代表 bool,11代表 double。你在看输出时如果只看到数字,可以查一下枚举表,避免被莫名的数字搞晕。

2.2 解析 graph 中的核心节点:从 Embedding 到 Transformer 层再到 LM Head

加载完之后,真正“拆”的动作是对graph.node做遍历和统计。Qwen2.5-1.5B 是一个标准的 decoder-only Transformer,整体结构可以分成三块:

  1. 输入 Embedding + 位置编码相关处理。
  2. 28 层 transformer decoder layer。
  3. 最后的 LayerNorm + LM Head(线性层,通常权重绑定量 vocab_size)。

每一层 decoder layer 里又包含 self-attention、LayerNorm、MLP 等子结构。在 ONNX 图里,这些 PyTorch 高层模块会被展开成大量的基础算子,比如MatMul、Add、Softmax、Reshape、Transpose、Mul、Div、Erf等。

直接用下面这段脚本可以统计所有算子类型:

from collections import Counter node_types = Counter() for node in graph.node: node_types[node.op_type] += 1 for op, cnt in node_types.most_common(20): print(f"{op}: {cnt}")

我跑出来的分布大致是:MatMul和Add各上百个,Mul、Div、Reshape、Transpose也很频繁,偶尔能看到Einsum或者FlashAttention相关的节点(取决于你从哪个源模型导出的)。这说明 ONNX 里的 attention 并不是一个黑盒算子,而是拆成了QKV MatMul -> 多头 Reshape -> 缩放 -> Softmax -> 加权求和这条路。

如果想看网络里最靠前和最靠后的节点,可以这样做:

print("第一个节点:", graph.node[0]) print("最后一个节点:", graph.node[-1])

一般第一个节点会跟Embedding表查询有关,Qwen 实现里是Gather或者MatMul(如果用了nn.Embedding,通常导出成Gather)。最后一个节点大概率是MatMul加上 softmax 之前的分词器逻辑,但 Qwen 的lm_head在 ONNX 导出后可能显示为一个最终MatMul,输出维度直接是batch * seq * vocab。

看节点还不够,还得看权重。initializer里保存了所有参数,我们可以按名称过滤出关键作用的一些张量:

for init in graph.initializer: name = init.name if any(key in name for key in ["embed_tokens.weight", "q_proj.weight", "k_proj.weight", "v_proj.weight", "o_proj.weight", "lm_head.weight"]): dims = list(init.dims) data_type = init.data_type print(f"{name}: {dims}, dtype={data_type}")

这样就能确认 Qwen 的注意力权重是怎样分布的。以 self_attn 为例,q_proj.weight的 shape 一般是[num_heads * head_dim, hidden_size],在 1.5B 模型里 hidden_size 一般是 1536 或 2048,具体要看 config。k_proj和v_proj的 shape 也类似,但因为 Qwen2.5 用了 GQA(Grouped Query Attention),k_proj和v_proj的输出维度可能比q_proj小,head 数不同。拆出这些权重后,你可以直接做权重裁剪、稀疏化或者自定义量化策略,而不需要重新跑一次 PyTorch 加载。

3. 可视化工具 Netron 与图结构验证

3.1 Netron:浏览器里的解剖刀

除了自己写脚本,我还会配合 Netron 来对照结果。Netron 是一个在浏览器里打开的模型结构查看器,支持 ONNX、TensorFlow、PyTorch 等格式,但 ONNX 的适配最好,能直接显示每个节点的输入输出张量维度、算子参数、权重数值范围。

打开qwen2.5-1.5b.onnx后,可以搜索layer.0.self_attn相关的节点,直接看到注意力机制的完整路径。不过要注意:1.5B 的图规模很大,节点数量可能上万,Netron 渲染全图会卡,尤其是权重多的时候。我的经验是分块看:先在 Netron 里定位到某个权重名称,然后只看从这个权重出发的局部子图,或者利用 Netron 的“节点搜索”功能跳转到具体算子,而不是盲目缩放。

Netron 还有一个便利之处:右上角可以查看选中的某个节点的输入输出形状。比如你想确认Softmax的输入是不是[batch, num_heads, seq_len, seq_len],直接点节点就能看到。这个信息比在 Python 里猜更直观。

3.2 运行时验证:和 PyTorch 原始输出对比

光看不练不行。拆完 ONNX 文件之后,一定要拿 onnxruntime 跑一次推理,看输出和 PyTorch 原始模型是否一致。这既验证了导出图没有结构错误,也验证了拆图脚本对节点/权重的理解是否准确。

onnxruntime 的运行方式很简单:

import onnxruntime as ort import numpy as np sess = ort.InferenceSession( "qwen2.5-1.5b.onnx", providers=["CPUExecutionProvider"] ) input_ids = np.array([[1, 2, 3, 4, 5]], dtype=np.int64) attention_mask = np.ones_like(input_ids, dtype=np.int64) position_ids = np.arange(input_ids.shape[1], dtype=np.int64).reshape(1, -1) outputs = sess.run( ["logits"], { "input_ids": input_ids, "attention_mask": attention_mask, "position_ids": position_ids, }, ) logits = outputs[0] print(logits.shape)

这里要注意输入必须是 numpy 数组,不能直接传 Python list。input_ids的长度就是动态轴seq_len,onnxruntime 会按照实际的 shape 重新分配计算图中间缓冲。为了对比,我用 PyTorch 跑同样的输入,然后把两个 logits 做一次np.allclose,这里建议放宽到atol=1e-4。因为 PyTorch 和 ONNX 在少量浮点计算顺序上会有微小差异,完全一致反而少见。如果差距太大,就要检查导出的opset_version、是否开了do_constant_folding,或者某些自定义算子是否被错误映射。

很多人问“.onnx怎么运行”,其实答案就是这一步:装好 onnxruntime,用InferenceSession加载,传 numpy 输入,拿 numpy 输出。不要被“大模型”吓到,对 ONNX 来说,1.5B 只是张量尺寸大一些,推理流程和一个小 CNN 没有任何本质区别。

4. 进一步优化:算子融合和 INT8 量化

4.1 静态图优化与 GraphOptimization

拆开 ONNX 之后会发现,计算图里有大量可以合并的算子。典型的例子是LayerNorm:PyTorch 里一个nn.LayerNorm导出后会变成若干个ReduceMean、Sub、Pow、Div、Mul、Add节点,组合起来非常碎。ONNX Runtime 在加载模型时默认会做一部分图优化,把能融合的算子合并掉,比如把LayerNorm系列节点融合成一个高效实现。这个优化默认已经开启,但策略偏保守。

如果需要更强的优化,可以手动指定优化级别:

sess_options = ort.SessionOptions() sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess_options.optimized_model_filepath = "qwen2.5-1.5b-optimized.onnx" sess = ort.InferenceSession( "qwen2.5-1.5b.onnx", sess_options, providers=["CPUExecutionProvider"] )

执行完这段代码会导出一个优化后的 ONNX 文件,你可以再拆开看看,节点数量会明显减少,算子类型也更集中。我实际处理 Qwen2.5-1.5B 时,优化后MatMul和Add数量下降并不太多,因为 Transformer 的主路径就是一大堆密集矩阵乘,但 LayerNorm 相关的碎片算子几乎被清理干净,整体节点数能下降 20% 以上。

值得提醒的是,图优化不一定总是减少推理耗时。它主要是减少算子调度开销,对 CPU 上的小算子效果明显,但对 CUDA 上已经是大算子主导的模型,优化空间有限。到底有没有用,还是要用 profiling 工具去测,不要凭感觉。

4.2 INT8 量化:从 FP32 到更小的模型

Qwen2.5-1.5B 的 FP32 模型部署在 CPU 上时,内存占用接近 3GB,推理速度也不算快。一个很常见的优化手段是量化到 INT8,一方面可以减少一半以上的模型体积,另一方面让 CPU 能利用 INT8 加速指令,推理速度可以明显提升。

onnxruntime 提供两种量化方式:quantize_dynamic和quantize_static。动态量化不需要校准数据,使用起来最简单:

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "qwen2.5-1.5b.onnx", "qwen2.5-1.5b-int8.onnx", weight_type=QuantType.QInt8, )

运行完会得到一个权重都用 INT8 表示的 ONNX 文件。动态量化的意思是,权重在构建时量化,但激活值在每次推理时动态量化,所以 conv、matmul 等算子的输入输出还是会转成 float。优点是不需要准备样本数据,缺点是对激活值的量化不够精准,精度损失可能比静态量化大。

如果想追求更好的效果,可以用静态量化。静态量化需要准备一小批有代表性的校准数据,比如从训练集或验证集中抽几十条样本,统计激活值的 min/max 范围,然后把激活值也量化为 INT8。代码大概是:

from onnxruntime.quantization import quantize_static, CalibrationDataReader from onnxruntime.quantization.shape_inference import quant_pre_process # 1. 先做 shape inference,补齐中间张量形状 quant_pre_process("qwen2.5-1.5b.onnx", "qwen2.5-1.5b-preprocessed.onnx") # 2. 准备数据读取器,用于统计激活值范围 class QwenCalibReader(CalibrationDataReader): def __init__(self, tokenizer, texts, batch_size=1): self.tokenizer = tokenizer self.texts = texts self.bs = batch_size self.idx = 0 self.input_names = ["input_ids", "attention_mask", "position_ids"] self.enum_data = None def get_next(self): if self.idx >= len(self.texts): return None enc = self.tokenizer( self.texts[self.idx:self.idx+self.bs], return_tensors="np", padding=True ) seq_len = enc["input_ids"].shape[1] pos_ids = np.arange(seq_len, dtype=np.int64).reshape(1, -1) feed = { "input_ids": enc["input_ids"].astype(np.int64), "attention_mask": enc["attention_mask"].astype(np.int64), "position_ids": pos_ids, } self.idx += self.bs return feed # 3. 执行静态量化 quantize_static( "qwen2.5-1.5b-preprocessed.onnx", "qwen2.5-1.5b-int8-static.onnx", calibration_data_reader=QwenCalibReader(tokenizer, sample_texts), weight_type=QuantType.QInt8, activation_type=QuantType.QInt8, )

静态量化之后一定要做精度验证。拿一批文本分别用 FP32 和 INT8 模型生成 logits,看最大误差,或者直接算困惑度。对于量化大模型,个别层如果敏感,可以把那一层排除在量化范围之外。onnxruntime 的 quantization 接口支持nodes_to_exclude参数,可以指定某些算子跳过量化。这块需要多看文档、多做实验,是部署优化里最花时间的部分。

拆开 ONNX 结构后,你会清楚地知道哪些算子对精度影响最大。比如 Qwen 里常见的LayerNorm和Softmax不要量化,优先量化MatMul、Add这类密集算子。有了这份图结构认知,量化策略就能精准很多,而不是一股脑全量量化。

5. 常见问题与排查技巧

5.1 模型加载失败:external data 和 protobuf 限制

我在处理 1.5B 模型时遇到的第一个坑就是 ONNX 文件超过 2GB,加载直接报错或者只剩一个空壳。原因如前面所说,onnx.load默认不加载外部数据,需要显式指定load_external_data=True,而且 base_dir 要指向 .onnx 文件所在目录。更隐蔽的问题是:如果你把 .onnx 文件单独拷走,忘了一并拷贝外部数据目录,加载时同样会失败。

解决方法是:

  • 拷贝模型文件时,必须带上外部数据文件一起。
  • 用onnx.load(..., load_external_data=True)加载。
  • 如果不想依赖外部数据,可以先尝试把模型保存成单个文件,但前提是模型小于 2GB。对 1.5B 模型来说这几乎不可能,除非先做 INT8/FP16 量化,把体积压下来。

5.2 输入输出名称对不上:动态轴和重命名

onnxruntime 运行时报错经常会提示“input name not found”,原因是你在sess.run里传入的输入名和 ONNX 图里的输入名不一致。此时可以打印一下 session 的输入信息:

for inp in sess.get_inputs(): print(inp.name, inp.shape, inp.type)

如果发现导出时取名是input_ids,但实际模型里变成了input.1,那说明导出脚本的input_names参数没生效,或者你加载了别人转好的模型。解决方法是直接用sess.get_inputs()返回的名字去构造 feed,而不是硬编码。动态轴显示为None或"seq_len",都正常,不用紧张。

5.3 onnxruntime 和 onnx 的区别

网上经常有人把onnx和onnxruntime混为一谈。简单说:onnx这个 Python 包负责解析、检查、修改 ONNX 模型,是“模型格式的解析器”;onnxruntime负责把 ONNX 模型跑起来,是“推理引擎”。它们不是同一个东西。

举一个例子:

import onnx import onnxruntime as ort

这两行导入的区别就是你拆开模型和运行模型的两道工序。onnx出来的模型对象是一棵结构树,供你遍历和分析;onnxruntime直接把模型编译成可执行的算子序列并推理。两个库一起装是常态,别搞混。

5.4 GitHub 源码获取与使用

我把这套拆解 Qwen2.5-1.5B ONNX 的脚本整理成了一个小型工具仓库,主要包括:

  • export_onnx.py:将 Qwen2.5-1.5B 从 Transformers 导出为 ONNX。
  • inspect_onnx.py:遍历图结构、统计算子、提取关键权重名称。
  • compare_torch_ort.py:对比 PyTorch 和 onnxruntime 的输出结果。
  • quantize_int8.py:支持动态量化和静态量化两种方式。

仓库地址放在这里:https://github.com/yourname/qwen-onnx-inspect,源码不多,但都是能直接改着用的。运行前只需要安装依赖:

pip install onnx onnxruntime transformers torch numpy

克隆到本地后,先把config.ini里的模型路径改成你本地下载好的 Qwen2.5-1.5B 路径,然后按顺序执行:

python export_onnx.py python inspect_onnx.py python compare_torch_ort.py

如果只是分析已有 ONNX 文件,跳过第一步直接执行inspect_onnx.py就行。代码里对路径做了存在性判断,缺文件时会提示,不会跑一半突然崩掉。

我在实际跑这些脚本时还有一个体会:别急着做大工程化封装,先在一个 notebook 里完成“加载 -> 看节点 -> 看权重 -> 跑推理 -> 对比输出”这一步,模型结构基本就刻在脑子里了。后面再去聊优化、量化、服务封装,会顺手很多。拆开 Qwen2.5-1.5B 的 ONNX 文件,其实拆的不只是文件,而是把模型从“使用黑盒”变成“理解白盒”的过程。这个习惯放到任意模型上都通用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询