【Bug已解决】CPU EP mis-loads packedUINT2Constant initializer (treatsUInt2x4as unpacked storage;INT2unaffected) 解决方案
一、现象长什么样
模型里有一个被量化成2-bit的权重常量,用 ONNX 的UINT2打包数据类型(在每个字节里塞 4 个 2-bit 元素,UInt2x4)存成Constant初始化器。在 ONNX Runtime 的 CPU EP 上加载并推理,结果全是垃圾值,和预期差很远:
余弦相似度接近 0,或分类输出全错 —— 但同一个模型在 GPU EP 上结果正确最小触发:
import onnxruntime as ort # 模型含一个 UINT2 打包的 Constant 初始化器 sess = ort.InferenceSession("uint2_model.onnx", providers=["CPUExecutionProvider"]) out = sess.run(None, feed)[0] # 结果错误;换成 providers=["CUDAExecutionProvider"] 结果正确排查发现:CPU EP 把UInt2x4当成了未打包的存储来读——也就是按“每字节一个元素”去解释那块 buffer,而实际上每字节装了 4 个 2-bit 元素。偏偏INT2(2-bit 有符号)是正确的,只有UINT2错。这是典型的“打包数据类型反序列化漏了一种”。
二、背景
ONNX 从 opset 21 起支持 2-bit 和 4-bit 的“打包”数据类型:UINT2/INT2(每字节 4 个)、UINT4/INT4(每字节 2 个)。打包后的常量在 proto 里表现为:外层 tensor 的elem_type是UINT2,dims描述的是打包后的形状(比如原始 1024 个元素打包成 256 个字节,dims=[256]),原始元素个数靠data_type+ 一个packed属性或约定推断。
加载时,运行时需要:
- 识别
elem_type == UINT2; - 按“每字节 4 个元素”做位解包(bit-unpack),还原出 1024 个 0~3 的值;
- 把解包后的张量交给后续算子。
CPU EP 的常量加载器(tensor deserializer)对INT2写了正确的解包路径,但对UINT2走错了分支——直接把dims=[256]的字节 buffer 当 256 个完整uint8元素用,没解包。于是 256 个字节被当成 256 个值(范围 0255),而不是 1024 个 03 的值,形状也对不上,后续算子拿到完全错误的数据。
三、根因
根因是CPU EP 的常量加载器对UINT2和INT2的处理不对称:
- 解包分支漏了
UINT2:加载器里有一张elem_type -> 加载函数的映射,INT2指向了“位解包到 int8”的实现,UINT2却错误地指向了“原样按 uint8 读”的默认实现(或根本没在映射里,fallback 到逐字节读)。 - 形状未还原:因为没解包,
dims停留在打包后的[256],而下游算子期望[1024],读出来的元素个数和语义全错。 - INT2 正常对照:
INT2因为有正确的解包实现,所以无影响——这进一步说明不是“打包机制整体坏了”,而是UINT2这一支被漏掉。
所以这不是模型算错,而是CPU EP 反序列化 2-bit 数据时漏了对无符号UINT2的解包,把它当成了未打包的 uint8。
四、最小可运行复现
下面用 Python + NumPy 模拟“UINT2 打包数据被错误地当 uint8 读取”的偏差,并给出正确的位解包:
import numpy as np def wrong_load(packed: np.ndarray) -> np.ndarray: """CPU EP 当前的错误做法:把每字节当 1 个 uint8 元素。""" return packed.astype(np.uint8) # 256 个值,值域 0~255,且未解包 def correct_unpack_uint2(packed: np.ndarray) -> np.ndarray: """正确做法:每字节拆出 4 个 2-bit 无符号元素,低位在前。""" packed = packed.astype(np.uint8) out = np.zeros(packed.shape[0] * 4, dtype=np.uint8) for i in range(4): out[i::4] = (packed >> (2 * i)) & 0x03 return out if __name__ == "__main__": # 打包前原始 4 个元素 [0,1,2,3] -> 打包成 1 字节 0b11100100? 计算: raw = np.array([0, 1, 2, 3], dtype=np.uint8) packed = np.zeros(1, dtype=np.uint8) for i, v in enumerate(raw): packed[0] |= (v & 0x03) << (2 * i) print("packed byte:", int(packed[0])) # 0b00001111 = 15 wrong = wrong_load(packed) right = correct_unpack_uint2(packed) print("错误加载:", wrong, "形状", wrong.shape) # [15] 错 print("正确解包:", right, "形状", right.shape) # [0 1 2 3] 对 assert np.array_equal(right, raw)跑出来:错误加载得到[15](一个 uint8),正确解包得到[0 1 2 3](四个 2-bit 元素)。这正好复现了 CPU EP 把UInt2x4当未打包存储读错的现象。
五、解决方案(第一层:最小直接修复)
最小修复:在常量加载器里给UINT2补上和INT2对称的解包实现。对使用者来说,临时规避是把模型里的UINT2常量在导出时改成UINT4或UINT8(未打包),绕开 CPU EP 的UINT2解包 bug;或者干脆用 GPU EP 跑(GPU EP 解包正确)。
对 ORT 仓库侧,加载器映射应改成:
// 伪代码:常量加载器的 elem_type 分支 switch (elem_type) { case ONNX_NAMESPACE::TensorProto::UINT2: return LoadPacked<uint8_t, 2, /*signed=*/false>(tensor); // 补这一支 case ONNX_NAMESPACE::TensorProto::INT2: return LoadPacked<int8_t, 2, /*signed=*/true>(tensor); case ONNX_NAMESPACE::TensorProto::UINT4: return LoadPacked<uint8_t, 4, /*signed=*/false>(tensor); case ONNX_NAMESPACE::TensorProto::INT4: return LoadPacked<int8_t, 4, /*signed=*/true>(tensor); default: return LoadPlain(tensor); }LoadPacked负责按 bits=2 做位解包,并把dims从打包形状还原成原始元素个数(dim[0] *= 4)。这一层立刻让UINT2常量被正确解包。
六、解决方案(第二层:结构性改进)
把“哪些打包类型需要解包、如何解包”收口成唯一的配置对象OrtCpuUint2ConstantPolicy,加载器读它,避免再漏类型:
from dataclasses import dataclass, field from typing import Dict, Tuple @dataclass(frozen=True) class OrtCpuUint2ConstantPolicy: """CPU EP 打包常量加载的单一事实来源。""" # 打包类型 -> (每字节元素数, 是否有符号) packed_types: Tuple[str, ...] = ("UINT2", "INT2", "UINT4", "INT4") bits_per_element: Dict[str, int] = field(default_factory=lambda: { "UINT2": 2, "INT2": 2, "UINT4": 4, "INT4": 4, }) signed: Dict[str, bool] = field(default_factory=lambda: { "UINT2": False, "INT2": True, "UINT4": False, "INT4": True, }) # 是否必须解包(True 表示不能当 uint8 原样读) must_unpack: Tuple[str, ...] = ("UINT2", "INT2", "UINT4", "INT4") # 字节内位序:低位在前 lsb_first: bool = True def needs_unpack(self, elem_type: str) -> bool: return elem_type in self.must_unpack def unpack_shape_scale(self, elem_type: str) -> int: return 8 // self.bits_per_element.get(elem_type, 8) def describe(self) -> str: return "UINT2/INT2/UINT4/INT4 全部走对称位解包,无符号与有符号一致" POLICY = OrtCpuUint2ConstantPolicy() def plan_load(elem_type: str, policy: OrtCpuUint2ConstantPolicy = POLICY) -> dict: return { "unpack": policy.needs_unpack(elem_type), "scale": policy.unpack_shape_scale(elem_type), "signed": policy.signed.get(elem_type, False), }所有加载逻辑读同一份POLICY,新增打包类型只要在packed_types里加一项就自动覆盖,不会再出现“INT2 有、UINT2 漏”的不对称。
七、解决方案(第三层:断言 / CI 守护)
把“UINT2 必须解包、结果与 GPU EP 一致”做成断言。下面用 pytest 风格守护(复用第四节解包逻辑):
import numpy as np def test_uint2_must_unpack(policy): assert policy.needs_unpack("UINT2") is True assert policy.needs_unpack("INT2") is True def test_uint2_symmetrical_with_int2(policy): # UINT2 与 INT2 应走相同的解包机制(仅符号不同) assert policy.bits_per_element["UINT2"] == policy.bits_per_element["INT2"] assert policy.signed["UINT2"] is False assert policy.signed["INT2"] is True def test_unpack_recovers_values(): raw = np.array([0, 1, 2, 3], dtype=np.uint8) packed = np.zeros(1, dtype=np.uint8) for i, v in enumerate(raw): packed[0] |= (v & 0x03) << (2 * i) assert np.array_equal(correct_unpack_uint2(packed), raw) def test_shape_restored_after_unpack(policy): scale = policy.unpack_shape_scale("UINT2") assert scale == 4 # 每字节 4 个元素这四组断言锁住:(1)UINT2必须解包;(2)UINT2/INT2对称(仅符号差异);(3) 解包还原正确值;(4) 形状按 4 倍还原。CI 跑通即代表 CPU EP 的 2-bit 加载对称正确。
八、排查清单
遇到 CPU EP 上 2-bit 量化模型结果错、GPU EP 正常:
- 先换 EP 测:GPU EP 结果对、CPU EP 错 → 多半是 CPU 反序列化问题。
- 看常量 elem_type:模型里是不是
UINT2打包常量(INT2正常可对照)。 - 检查加载器映射:
UINT2有没有指向位解包实现,还是 fallback 到逐字节读。 - 临时规避:导出时把
UINT2改UINT4/UINT8,或暂用 GPU EP。 - 根本修复:给
UINT2补对称解包,并还原dims。 - 统一策略对象:用
OrtCpuUint2ConstantPolicy固化,新增打包类型自动覆盖。 - CI 守护:断言
UINT2解包正确、与INT2对称,防止回归。
九、小结
CPU EP mis-loads packed UINT2 Constant initializer的根因是 CPU EP 的常量加载器对 2-bit 数据类型处理不对称:INT2走了正确的位解包,而UINT2漏了分支、被当成未打包的 uint8 逐字节读取,导致值错误、形状未还原,而 GPU EP 解包正确所以无影响。
最小修复是给UINT2补上和INT2对称的解包实现并还原形状;结构性改进是用唯一的OrtCpuUint2ConstantPolicy把打包类型的处理收口;CI 用四组断言守护“UINT2 必须解包、与 INT2 对称、形状还原、值正确”。记住:2-bit 打包数据在 CPU 反序列化时必须位解包,无符号和有符号要走同一套机制、只差符号位。