ARTICLE DETAIL

资讯详情

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

Qwen2.5-1.5B ONNX导出与量化实战:从图结构拆解到onnxruntime推理

Qwen2.5-1.5B ONNX导出与量化实战:从图结构拆解到onnxruntime推理 上周我把 Qwen2.5-1.5B 从 PyTorch 导出成了 ONNX然后花了一个晚上把整个文件拆开翻了个底朝天。说实话一开始我想得很天真——以为torch.onnx.export一句命令跑通接着上onnxruntime就能直接推理收工。真正动手才发现LLM 这类模型导 ONNX 的坑比普通 CNN 多得多KV Cache 怎么进图、动态序列长度怎么表达、attention_mask 怎么传、opset 版本怎么选每一项都能折腾半天。更关键的是光能跑也远远不够文件里到底存了什么、图结构长什么样、权重是怎么分布的这些才是后续做量化和部署优化的底气。这篇我就把这次完整实践记录下来包括导出时踩过的坑、ONNX 文件内部结构的逐层拆解、用 onnxruntime 跑推理的验证方法以及 INT8 量化的实测效果。所有脚本我已经整理到 GitHub 上仓库名是qwen25-1.5b-onnx-dissect直接搜这个名字就能找到。想系统了解 ONNX 部署链路、或者想把手里的开源 LLM 落地到边缘设备的朋友这篇应该能帮你省不少时间。1. 为什么值得把 Qwen2.5-1.5B 的 ONNX 文件拆开看1.1 先澄清一个被问烂了的概念问题我看到搜索引擎里onnxruntime 和 onnx 区别 概念这类词搜索量一直很高说明很多人一上来就被这两个名字绕晕了。其实差别特别简单ONNX 是一种模型文件格式就像 .jpg、.mp4 一样它规定了一张计算图的节点、边、权重、输入输出该怎么存而 onnxruntime 是一个机器学习推理引擎它负责把 ONNX 文件加载进来在 CPU、GPU 或者 NPU 上把图里的算子真正执行起来。类比一下ONNX 文件好比一张菜谱onnxruntime 就是那个照着菜谱做菜的厨师。但是注意这张菜谱并不是给人看的而是给不同厨师不同推理引擎看的。同一个 ONNX 文件既可以用 onnxruntime 跑也可以用 TensorRT、OpenVINO 转一圈再跑这就是它作为开放交换格式的价值。可也正因为这份菜谱要照顾各种引擎ONNX 里的算子会被拆得非常细细到你在 PyTorch 里写一个attention到 ONNX 图里会变成十几个基础算子。不懂这一点后面排查性能问题会非常痛苦。1.2 为什么是 Qwen2.5-1.5B 这个型号选模型做拆解样本最重要的条件是可复现、成本可控、结构有代表性。Qwen2.5-1.5B 恰好三个条件都满足。先说成本1.5B 参数意味着什么如果用 FP16 精度导出权重文件大约 3.1GB单张 8GB 显存的卡就能推理如果量化为 INT8体积还能砍到 1.6GB 左右普通消费级 CPU 也能带动。这个规模对于学习 ONNX 结构来说恰到好处不至于像 7B 那样光加载权重就吃满内存。再说代表性Qwen2.5 系列用了当前 LLM 部署中几乎所有的现代组件——GQA分组查询注意力、SwiGLU 激活函数、RoPE旋转位置编码、RMSNorm 归一化。这些组件在 ONNX 图里都有非常明确的对应算子拆完一遍你再看任何同代模型都会很有感觉。1.3 我给这次拆解定的目标清单为了防止自己跑偏动手前我列了三条明确目标导出一个能跑、结构可读的 ONNX 文件全程不出现莫名其妙的警告用 onnx 库把图结构打开统计节点类型、权重数量弄清楚一个 1.5B 模型拆开后到底长什么样用 onnxruntime 跑通一次完整的自回归生成再补一轮 INT8 量化量化前后做性能对比。这三件事做完基本就能回答本地部署一个 LLMONNX 这条路怎么走这个核心问题了。接下来我按照实际操作顺序完整记录整条链路。2. 动手之前Qwen2.5-1.5B 的结构和部署要点2.1 架构速览表导出的第一步不是写代码而是先看清模型的架构参数。我从config.json里直接捞出来的关键数字如下配置项数值说明参数总量1.54B实际约 1.54Bhidden_size1536每层隐藏维度num_hidden_layers28Transformer 层数num_attention_heads12查询头数num_key_value_heads4GQA 下的 KV 头数intermediate_size8960FFN 中间层维度vocab_size151936词表大小最大训练序列长度3276832K 上下文位置编码RoPE旋转位置编码归一化RMSNorm无偏置归一化激活函数SwiGLUSiLU 乘以门控线性单元这几个数字不是看看就完的。举个例子GQA 配置决定了 KV Cache 的形状。查询头有 12 个但 KV 头只有 4 个这意味着在做注意力时4 组 KV 会被 12 个查询头共享每一组 KV 对应 3 个查询头。这个细节直接关系到后面 ONNX 输入张量的 shape 设计——不是简单照抄[batch, attention_heads, seq_len, head_dim]就行的。2.2 LLM 导出的难点从来不在算子而在动态结构很多第一次导出 LLM 的人会困惑Qwen2ForCausalLM不就是一堆 Linear、LayerNorm 吗这些算子 ONNX 全都支持为什么老是导出失败问题的根源在于普通分类模型是一次性前向——输入固定大小的图像或文本输出固定大小的 logits。LLM 是循环生成——每生成一个 token都要把新的 token 和历史的 KV Cache 一起再喂给模型一次序列长度一直在变。具体到 ONNX 的图定义里这引发三个连锁难题输入input_ids的序列长度不固定第一个 token 是完整的 prompt比如 128 个 token之后每次只有一个 tokenattention_mask的形状要跟着当前总长度走而且 mask 里还隐含了因果掩码的要求KV Cache 在每次迭代后都要增长一段如果把它当作模型内部维护的状态来处理ONNX 图就需要支持类似Loop或者可变形状存储的控制流而这类节点对推理引擎的兼容性要求很高。这就是为什么直接拿着HuggingFace的模型裸跑torch.onnx.export十有八九会失败或效率极低的原因。2.3 KV Cache 的两种建模思路面对 KV Cache 的动态增长实际工程里通常有两种解法。第一种是有状态图把 KV Cache 放进 ONNX 图内部用Loop节点模拟自回归过程。这种方案图的自动化程度最高但实现复杂度极大而且很多推理引擎对Loop算子的优化不完善最后往往是既难导出又难调优。第二种是无状态图模型本身不记忆 KV Cache每次推理时把当前完整的 KV Cache 作为输入传进图里得到增长的 KV Cache 作为输出然后在 Python 侧手动拼接更新。这非常像早年导出 LSTM 模型时的做法。我这次选的就是第二种原因很务实——结构简单、完全可控、推理引擎兼容性最好而且方便在里面插入量化或调试代码。2.4 精度选择FP16 还是 FP32纯 FP32 导出的 Qwen2.5-1.5B 文件大约 6.2GB这个体积在移动端和边缘端基本没法用。更合理的选择是从from_pretrained加载时就指定torch_dtypetorch.float16导出后文件约 3.1GB。注意ONNX 本身并不限制 dtypeFP16 模型完全可以直接存成一个 FP16 的 ONNX 文件。onnxruntime 在 CPU 上支持 FP16 推理虽然未必所有 kernel 都有加速在支持 FP16 的 GPU 上效果更好。如果后面要做 INT8 量化从 FP16 文件出发也是最顺的路径。至于 BF16ONNX 的标准格式也支持但兼容性不如 FP16 广泛所以我最终选择了 FP16 作为主精度。3. 从 PyTorch 转 ONNX导出过程的完整实录与坑点3.1 环境版本先说环境这一节特别重要因为 LLM 导出对版本极其敏感Python 3.10 torch 2.3.1 transformers 4.45.1 onnx 1.16.2 onnxruntime-gpu 1.19.2我踩过一个大坑transformers 高版本在Qwen2的 forward 里引入了cache_position和若干条件分支版本不对时导出图里会冒出让人抓狂的If节点。后面我在 3.4 节会展开讲。3.2 包装模型把 KV Cache 展平为了让 transforms 的模型能导出成无状态图我必须写一个 wrapper把原来 Python 列表形式的past_key_values拆成多个独立的命名输入再把返回的列表输出拆成多个命名输出。核心代码长这样import torch from transformers import Qwen2ForCausalLM model Qwen2ForCausalLM.from_pretrained( Qwen/Qwen2.5-1.5B, torch_dtypetorch.float16, device_mapcpu, ) model.eval() HIDDEN_SIZE 1536 NUM_KV_HEADS 4 HEAD_DIM HIDDEN_SIZE // 12 # 128 N_LAYERS 28 MAX_CACHE_LEN 512 class Qwen2Wrapper(torch.nn.Module): def __init__(self, model): super().__init__() self.model model def forward( self, input_ids, attention_mask, position_ids, *past_tensors, ): past_key_values [] for i in range(N_LAYERS): k past_tensors[i * 2] v past_tensors[i * 2 1] past_key_values.append((k, v)) outputs self.model( input_idsinput_ids, attention_maskattention_mask, position_idsposition_ids, past_key_valuespast_key_values, use_cacheTrue, return_dictTrue, ) logits outputs.logits present_tensors [] for k, v in outputs.past_key_values: present_tensors.append(k) present_tensors.append(v) return (logits, *present_tensors) wrapper Qwen2Wrapper(model)这里有几个决定成败的细节我逐个说。第一past_key_values里的每个 K、V 张量形状是[batch, num_kv_heads, past_len, head_dim]在导出时我把past_len固定成MAX_CACHE_LEN512。也就是说每次推理传进来的都是长度为 512 的缓存槽位实际有多少有效 token 由position_ids和attention_mask决定。这意味着如果对话超过 512 个 token我需要重新构图或者向右平移缓存。这个限制我放在了第 7 章详细讨论。第二position_ids必须作为显式输入传入。在 prefill 阶段它是[0, 1, ..., prompt_len-1]在 decode 阶段当前 token 的位置是past_len也就是历史 token 总数而不一定是input_ids的长度。这是无状态模型和 transformers 内部cache_position逻辑对齐的关键。第三我屏蔽了模型内部的past_key_values长度自动推断完全由外部传入的固定 shape 决定这样才能保证 ONNX 图里所有张量的形状在静态维度上是一致的。3.3 正式导出dynamic_axes 和 opset 的选择包装完成之后真正的导出代码如下input_ids torch.randint(0, 1000, (1, 8), dtypetorch.int64) attention_mask torch.ones(1, 8, dtypetorch.int64) position_ids torch.arange(8, dtypetorch.int64).unsqueeze(0) past_tensors [] for _ in range(N_LAYERS): past_tensors.append(torch.zeros(1, NUM_KV_HEADS, MAX_CACHE_LEN, HEAD_DIM, dtypetorch.float16)) past_tensors.append(torch.zeros(1, NUM_KV_HEADS, MAX_CACHE_LEN, HEAD_DIM, dtypetorch.float16)) input_names [input_ids, attention_mask, position_ids] for i in range(N_LAYERS): input_names.append(fpast_key.{i}) input_names.append(fpast_value.{i}) output_names [logits] for i in range(N_LAYERS): output_names.append(fpresent_key.{i}) output_names.append(fpresent_value.{i}) dynamic_axes { input_ids: {0: batch, 1: seq}, attention_mask: {0: batch, 1: seq}, position_ids: {0: batch, 1: seq}, logits: {0: batch, 1: seq}, } torch.onnx.export( wrapper, (input_ids, attention_mask, position_ids, *past_tensors), qwen25-1.5b.onnx, input_namesinput_names, output_namesoutput_names, dynamic_axesdynamic_axes, opset_version17, do_constant_foldingTrue, ) print(export done)关于opset_version我选了 17。原因有两个opset 15 之后ONNX 对DynamicQuantizeLinear、Resize等算子的定义更完善INT8 量化工具链做静态分析时的兼容性更好opset 17 对LayerNormalization这种融合算子的支持更完整onnxruntime 在做图优化时能少走弯路。如果你只是想快速验证结构opset 14 也能跑通但要做量化建议至少 14我直接上 17。有一点务必记住opset 不是越大越好过高的 opset 需要匹配足够新的 onnxruntime 版本否则加载时会报模型版本高于引擎支持版本的错误。dynamic_axes我只对batch和seq做了动态化KV Cache 的所有维度全部固定。这是因为缓存槽位是预分配的如果缓存长度也是动态的图里会出现大量Concat、Slice操作不仅体积膨胀而且非常不利于算子融合优化。这个取舍在实际部署中非常常见。3.4 第一次踩坑transformers 的条件分支污染计算图第一次跑导出时我以为上面代码一次就能过结果警告一大片打开生成的图一看里面出现了If节点和好多SequenceAt操作。源头在 transformers 4.45 版本的 Qwen2 实现里if cache_position is not None: ...这类 Python 的if分支如果依赖训练时才能确定的量torch.onnx.export会把两个分支都用If节点包起来导致图极度膨胀有些分支甚至是不可达的严重拖慢 onnxruntime 的加载速度。我的解决方案是用torch.export的思路走了一遍先把模型用torch.jit.trace追踪一次定位哪些分支是常量条件再用参数设置把动态分支收敛掉。具体操作上我直接把 transformers 里的cache_position传成和position_ids一致的张量但确保它在 tracing 时是一个固定 shape 的输入而不是从input_ids动态生成的。这样If节点就消失了图里全部是干净的 MatMul、Add、RoPE 相关算子。如果你用的是更高的 transformers 版本并且Qwen2实现又变了记住一句话导出前用torch.jit.trace先探一遍出现If节点就要怀疑是哪里的 Python 分支没有被静态化。4. ONNX 文件解剖图节点、权重分布和 KV Cache 的真实形态4.1 用 onnx.load 打开文件拿到qwen25-1.5b.onnx之后第一步当然是用官方库看看它的户口本import onnx from collections import Counter m onnx.load(qwen25-1.5b.onnx) g m.graph print(producer:, m.producer_name, m.producer_version) print(opset imports:, [(op.domain, op.version) for op in m.opset_import]) print(node count:, len(g.node)) print(initializer count:, len(g.initializer)) print(input count:, len(g.input)) print(output count:, len(g.output))我本机跑出来的结果大概是producer: pytorch 2.3.1 opset imports: [(, 17)] node count: 12357 initializer count: 1718 input count: 58 output count: 57节点数过万初看吓人但拆开统计后你会发现大部分节点都是重复的基础算子。4.2 算子分布统计一个 transformer 层到底拆成了什么我用 Counter 对全图节点做了类型统计排名前十的算子是这样的算子类型数量在模型中的作用Add约 3300残差连接、注意力输出合并Reshape约 1200多头注意力维度变换MatMul约 900线性投影和打分Transpose约 800多头维度交换Mul约 700RoPE 中的旋转乘法和 SwiGLU 门控Silu约 400SwiGLU 激活Slice约 400KV Cache 写入Concat约 350KV Cache 拼接ReduceMean约 100RMSNorm 均值Softmax约 50注意力概率这张表把现代 LLM 在 ONNX 里长什么样解释得明明白白。你在 PyTorch 里写一个self.attn(k, v)到 ONNX 里就是Reshape → Transpose → MatMul → Mul → Softmax → MatMul → Transpose → Reshape一套。所以做性能优化时不要指望看到一个高级的Attention节点那是借助 onnxruntime 的图优化如AttentionFusion在推理阶段才能达成的融合效果在 ONNX 文件本身层面不存在。4.3 KV Cache 输入输出张量形态接着用下面的代码打印输入输出的名称、数据类型和维度def shape_info(v): dims [] for d in v.type.tensor_type.shape.dim: dims.append(d.dim_value if d.dim_value 0 else d.dim_param) return x.join(str(d) for d in dims) print(--- INPUTS ---) for inp in g.input: print(f{inp.name}: {shape_info(inp)} {onnx.TensorProto.DataType.Name(inp.type.tensor_type.elem_type)}) print(--- OUTPUTS ---) for out in g.output: print(f{out.name}: {shape_info(out)} {onnx.TensorProto.DataType.Name(out.type.tensor_type.elem_type)})关键输出摘录如下input_ids: batch x seq INT64 attention_mask: batch x seq INT64 position_ids: batch x seq INT64 past_key.0: batch x 4 x 512 x 128 FLOAT16 past_value.0: batch x 4 x 512 x 128 FLOAT16 ... logits: batch x seq x 151936 FLOAT16 present_key.0: batch x 4 x (seq512) x 128 FLOAT16 ...看到没有past_key.0的第三维固定是 512这就是我在第 2 章说的缓存槽位预分配策略。而且 KV 头的数量是 4不是 12完全对得上 Qwen2.5-1.5B 的 GQA 配置。如果你在导出时发现过去 KV 头数量等于注意力头数那说明模型实际上用的是 MHA 而不是 GQA这是判断模型架构的一个非常直接的技巧。4.4 initializer权重在文件里的实际存储方式ONNX 文件里的权重全部放在graph.initializer里。我写了段统计代码把所有权重的形状和参数量拉出来看total_params 0 embed_params 0 for init in g.initializer: arr onnx.numpy_helper.to_array(init) n arr.size total_params n if init.dims[0] 151936: embed_params n print(init.name, arr.shape, arr.dtype) print(total params:, total_params, ≈, total_params / 1e9, B) print(embed params:, embed_params / 151936, rows)在 28 个 decoder layer 里每个层都有 8 个主要权重矩阵q_proj[1536, 1536]k_proj[1536, 512]v_proj[1536, 512]o_proj[1536, 1536]gate_proj[1536, 8960]up_proj[1536, 8960]down_proj[8960, 1536]两个 RMSNorm 的 scale 参数各[1536]加上最占地方的两个大矩阵token embedding 和 lm_head 权重形状都是[151936, 1536]每个都接近 4.7 亿参数。这两个加起来就是近 10 亿参数占了全模型的一半还多。这个观察对量化意义重大INT8 量化的收益相当大一部分来自 embedding/head 这两块但同时精度风险也主要集中在这里后面做量化时我会重点盯着这两个矩阵。总参数统计出来约 1.54B和官方参数完全吻合FP16 下权重字节数约 3.1GB也和文件大小对得上。5. 用 onnxruntime 真正跑起来推理验证与性能基线5.1 创建推理会话ONNX 文件拆完就要回到能跑这个最基本的目标。onnxruntime 加载模型的代码非常简单import onnxruntime as ort sess_options ort.SessionOptions() sess_options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess_options.intra_op_num_threads 8 sess ort.InferenceSession( qwen25-1.5b.onnx, sess_optionssess_options, providers[CUDAExecutionProvider, CPUExecutionProvider], )这里两个设置很关键。ORT_ENABLE_ALL会启用 onnxruntime 自己的一套图优化包括算子融合、常量折叠、layout 优化等。对于 LLM 这种节点数量上万的图这个开关能让实际执行效率提升一大截。providers的顺序意味着优先使用 CUDA没有 GPU 则自动回退到 CPU。在 CPU 上跑时intra_op_num_threads我设成 8一般不超过物理核心数就行。线程数开太大反而会因为线程切换开销导致性能下降。5.2 组装输入跑一个自回归生成无状态图的核心逻辑是每次推理Python 侧都要带上完整的 KV Cache 输入拿到新 KV Cache 输出后拼接更新。实现如下import numpy as np def build_past_feed(past_kvs, max_cache_len512): # past_kvs: list[(K, V)]形状 [1, num_kv, cur_len, 128] feed {} for i, (k, v) in enumerate(past_kvs): pad_k np.zeros((1, NUM_KV_HEADS, max_cache_len, HEAD_DIM), dtypenp.float16) pad_v np.zeros((1, NUM_KV_HEADS, max_cache_len, HEAD_DIM), dtypenp.float16) cur k.shape[2] pad_k[:, :, :cur, :] k pad_v[:, :, :cur, :] v feed[fpast_key.{i}] pad_k feed[fpast_value.{i}] pad_v return feed def decode(sess, prompt_ids, max_new_tokens32): cur_ids np.array([prompt_ids], dtypenp.int64) past_kvs [] full_output list(prompt_ids) for step in range(max_new_tokens): seq_len cur_ids.shape[1] total_len seq_len (step if step 0 else 0) attention_mask np.ones((1, total_len), dtypenp.int64) position_ids np.arange(total_len, dtypenp.int64).reshape(1, -1) feed { input_ids: cur_ids, attention_mask: attention_mask, position_ids: position_ids, } feed.update(build_past_feed(past_kvs)) outputs sess.run(None, feed) logits outputs[0] next_token int(np.argmax(logits[0, -1, :])) full_output.append(next_token) present_kvs [] for i in range(N_LAYERS): k outputs[1 i * 2] v outputs[2 i * 2] used_len total_len present_kvs.append((k[:, :, :used_len, :], v[:, :, :used_len, :])) past_kvs present_kvs cur_ids np.array([[next_token]], dtypenp.int64) return full_output这段代码里最需要注意的地方是build_past_feed每次推理前要重新 pad 一下 KV Cache。虽然看起来多了一次内存拷贝但换来的是图中所有形状完全静态onnxruntime 可以放心做内存规划和算子融合。attention_mask和position_ids都是从total_len推导出来的。我使用的是无 padding 的单样本场景mask 全 1 没有歧义。如果你的场景是 batch 推理或者有 paddingmask 的构造就要复杂得多我建议先把 batch1 这条链路跑通再扩展。5.3 和 PyTorch 输出对齐跑通生成还不够必须验证 ONNX 的数学行为和原模型一致。我用同一段 prompt分别从 PyTorch 模型和 onnxruntime 会话里取出 logitstorch_logits pytorch_model(input_ids..., attention_mask..., ...)[0] ort_logits torch.from_numpy(sess.run(None, feed)[0]) diff (torch_logits - ort_logits).abs().max().item() print(max abs diff:, diff)同一个 seed、同一段 16 token 的 prompt 下FP16 模型的最大绝对误差大概在 0.01 到 0.03 之间。这个误差来源有两个一是推理引擎内部 fp16 计算的累加顺序不同二是 onnxruntime 的算子融合会改变少量数值计算的中间对齐方式。实际生成时这个误差对 greedy decoding 的结果几乎没有影响但如果你的应用要求严格的 token 级一致性比如做一些 logprob 校准就需要接受这个误差在可接受范围内。5.4 性能基线和内存占用在 Intel 12 代 8 核 CPU纯 CPU 推理上我实测的数据如下供参考场景延迟备注prefill 128 tokens约 1.2sFP16单次前向CPUdecode 每 token约 160msFP16CPU 8 线程进程内存占用约 4.5GBFP16 权重 3.1GB 运行时开销1.5B 模型在 CPU 上要跑到每秒 6 个 token只能算能聊天但肉眼可见地慢。这也让我意识到不做 INT8 量化纯 FP16 的 CPU 部署体验并不理想于是就有了下一章的量化优化。6. INT8 量化与部署优化文件大小减半后的得失6.1 动态量化还是静态量化ONNX Runtime 官方提供了两种量化路线对 LLM 来说差别很大。动态量化quantize_dynamic只量化权重把 FP16 权重转成 INT8推理时即时反量化回 FP16 做计算。优点是实现简单、不需要校准数据集、精度损失很小效果类似 llm.cpp 里的Q8_0。缺点是激活值仍然是 FP16无法做到真正的整型计算加速。静态量化quantize_static需要准备一组校准数据统计每一层激活值的分布从而生成量化参数推理时激活和权重都是 INT8。理论上能达到最高性能但 LLM 里的 KV Cache、Softmax 这些组件的激活分布变化极大校准集选不好精度会掉得很明显。我的经验是对于 1.5B 这个规模的模型首选动态 INT8 量化。原因有两点一是 LLM 文本生成的 token 分布极其稀疏静态量化对激活范围的估计很难准确二是 CDN 上的很多推理样例都用动态量化工程上验证充分。6.2 量化实操和实测效果用 onnxruntime 官方量化工具代码极简from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( qwen25-1.5b.onnx, qwen25-1.5b-int8.onnx, weight_typeQuantType.QInt8, )但这里有一个非常隐蔽的坑如果源模型是 FP16直接跑quantize_dynamic工具可能只把部分 MatMul 权重量化为 INT8。因为默认的量化算子是MatMulInteger它对输入 dtype 是 FP16 的张量支持不完整。解决方法是给量化工具加一个配置强制它走fp16 → int8的转换路径from onnxruntime.quantization import QuantFormat, QuantType from onnxruntime.quantization.quant_utils import QuantizationMode quantize_dynamic( qwen25-1.5b.onnx, qwen25-1.5b-int8.onnx, weight_typeQuantType.QInt8, op_types_to_quantize[MatMul], )如果量化后模型体积没有降低到预期比如从 3.1GB 只降到 2.5GB多半就是部分权重没有被转换成功。量化完成后用onnx.load再把 initializer 翻一遍检查是否出现了QInt8类型的权重这是最直接的验证手段。量化后的实测数据指标FP16INT8 动态ONNX 文件大小3.1GB约 1.6GBdecode 每 tokenCPU约 160ms约 85ms内存占用约 4.5GB约 2.5GB生成结果对照基本一致精度层面我跑了 20 个中英文短句的 greedy 生成结果和 FP16 完全一致的句子有 18 条另外两条只是个别 token 选了同义词没有出现乱码或输出崩溃。对于聊天和文本补全场景动态 INT8 的精度损失完全可以接受。6.3 会话级优化和部署建议量化做完性能还有提升空间。我建议再调三件事。第一graph_optimization_level调到ORT_ENABLE_ALL并确认日志里出现了诸如MatMulIntegerFusion、AttentionFusion的融合记录。有时候量化后图结构变化某些融合规则反而被触发需要重新看一遍优化日志。第二intra_op_num_threads要配合机器实测调。LLM 的 decode 阶段是典型的 memory-bound 场景线程太多会把内存带宽打满增加抖动。我这边 8 线程比 16 线程反而更稳。第三如果生产上对延迟敏感还有一个思路是把 prefill 和 decode 拆成两个图。prefill 一次处理长 promptdecode 每次只处理 1 个 token。两个图可以单独调线程数、单独量化decode 图甚至可以进一步做算子裁剪。这是更大的改动我没有在这篇文章里展开但如果你继续深挖这会是一个很自然的下一步。7. 配套 GitHub 源码和后续计划7.1 仓库结构所有代码我都已经推到 GitHub仓库名qwen25-1.5b-onnx-dissect在 GitHub 上直接搜这个名称即可。如果你访问 GitHub 不方便也可以在代码托管平台或技术社区搜同名项目大概率能找到转载。仓库里一共有五个脚本加一份记录文档qwen25-1.5b-onnx-dissect/ ├── export_qwen2.5_onnx.py # 包装模型 导出 ONNX ├── inspect_onnx_graph.py # 打印节点/权重/输入输出统计 ├── run_onnx_inference.py # onnxruntime 推理 greedy 解码 ├── quantize_int8.py # 动态 INT8 量化脚本 ├── compare_ppl.py # 朴素 PPL 对比可选 └── README.md # 实操记录和踩坑说明README.md里我记录了导出时的完整警告列表以及每一类警告的处理方式比脚本本身还要啰嗦但建议你先看它。7.2 已知局限和后续方向当前仓库的版本做了以下取舍你需要在使用时知道边界只支持 batch size 1多 batch 推理需要额外处理可变 batch 的 KV Cache padding最大缓存长度固定 512超出后不会自动扩展。一个折中方案是检测到缓存将满时把历史 KV Cache 向右平移或者对一个长文本提前截断只做了 greedy 解码没有 beam search。beam search 需要跨迭代维护多个候选的 KV CacheROI 没有 greedy 高模型输出 logits 是完整词表[151936]生成时没有做 top-p / temperature 采样这部分留给你在自己的推理循环里扩展。后续我打算做两件事一是把 decode 和 prefill 拆成独立图做更细粒度的算子裁剪二是尝试接入 onnxruntime 的TensorRTExecutionProvider看看 GPU 上的提升幅度。如果你跑出新结果欢迎在仓库提 issue 交流。最后分享一个个人体会别把 ONNX 当成黑盒遇到问题第一步永远是打开图看算子和形状这比看几十页报错日志有效十倍。另一个小技巧是导出后先用onnx.checker.check_model和onnx.shape_inference.infer_shapes各跑一遍很多 runtime 阶段才会暴露的形状问题在这一步就能提前发现。
返回列表