已合并
feat: graph-based PTQ/QAT 支持 A8W4 量化 #269
feat: graph-based PTQ/QAT 支持 A8W4 量化 #269
已合并
QQR创建于 26 天前
46 个文件变更+2002-550
@@ -9,8 +9,8 @@ QUANTIZABLE_TYPES, list,Conv2d,Conv3d,Linear,ConvTranspose2d,AvgPool2d,Conv1d,Co
9QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,MatMul,ConvTranspose,AveragePool9QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,MatMul,ConvTranspose,AveragePool
10INT16_QUANTIZABLE_TYPES, list,Conv2d,Linear,ConvTranspose2d,Conv1d,ConvTranspose1d,GRU,LSTM10INT16_QUANTIZABLE_TYPES, list,Conv2d,Linear,ConvTranspose2d,Conv1d,ConvTranspose1d,GRU,LSTM
11INT16_QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,ConvTranspose,LSTM,GRU11INT16_QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,ConvTranspose,LSTM,GRU
12-CHANNEL_WISE_TYPES, list,Conv2d,Conv3d,ConvTranspose2d,Conv1d,LSTM,GRU,ConvTranspose1d12+CHANNEL_WISE_TYPES, list,Conv2d,Conv3d,ConvTranspose2d,Conv1d,LSTM,GRU,ConvTranspose1d,Linear
13-CHANNEL_WISE_ONNX_TYPES,list,Conv,ConvTranspose,LSTM,GRU13+CHANNEL_WISE_ONNX_TYPES,list,Conv,ConvTranspose,LSTM,GRU,Gemm,MatMul
14FUSE_TYPES, list,Conv2d,Conv3d,Conv1d14FUSE_TYPES, list,Conv2d,Conv3d,Conv1d
15FUSE_ONNX_TYPES,list,Conv15FUSE_ONNX_TYPES,list,Conv
16AMCT_OPERATIONS, list,IFMR,HFMG,Recorder,AscendQuant,AscendDeQuant,RetrainQuant,RNNRetrainQuant,MarkedQuantizableModule,QuantIdentity,SelectiveMaskGen,DMQBalancer,Conv2dQAT,LinearQAT,LSTMQAT,GRUQAT,OFMRQuant,ConvTranspose1dQAT16AMCT_OPERATIONS, list,IFMR,HFMG,Recorder,AscendQuant,AscendDeQuant,RetrainQuant,RNNRetrainQuant,MarkedQuantizableModule,QuantIdentity,SelectiveMaskGen,DMQBalancer,Conv2dQAT,LinearQAT,LSTMQAT,GRUQAT,OFMRQuant,ConvTranspose1dQAT
@@ -325,30 +325,28 @@ class GraphQuerier:
325 return layers325 return layers
326 326 
327 @staticmethod327 @staticmethod
328- def check_int4_cin_pack_supported(graph, layer_name):328+ def is_int4_weight_pack_axis_even(graph, layer_name):
329+ """Check the final Deploy pack axis of weights for RETRAIN_ONNX_TYPES.
330+ 
331+ Check both main and recurrence weights. The pack axis is the last
332+ dimension, except Gemm with transB=1, whose final layout uses axis 0.
333+ Empty shapes, invalid axes and odd axis sizes cannot be packed.
334+ Operators outside the weight-check capacity do not need this check.
329 """335 """
330- Whether the layer's weight can be INT4-packed along the Cin axis.
331- INT4 packs two values along Cin, so it is NOT supported when:
332- - the conv is grouped (groups > 1): the onnx weight Cin dim is Cin/groups
333- (1 for depthwise), which cannot be nibble-packed along Cin;
334- - the Cin axis length is odd (e.g. first conv with Cin=3).
335- RNN also checks its recurrence_weight Cin.
336- Returns True when packable, False otherwise.
337- """
338- # layer_name 预期能取到 node,取不到属于异常,交由 get_node_by_name 抛出
339 node = graph.get_node_by_name(layer_name)336 node = graph.get_node_by_name(layer_name)
340- cin_axis = QuantOpInfo.get_cin_axis(node)337+ if node.type not in RETRAIN_ONNX_TYPES:
341- if cin_axis is None:
342- # 非量化算子类型,不涉及 INT4 pack,视为无需拦截(可放行)
343 return True338 return True
344- # group/depthwise conv: onnx weight Cin dim is Cin/groups, cannot pack339+ 
345- if node.type in ('Conv', 'ConvTranspose'):340+ pack_axis = -1
341+ min_rank = 2 if node.type in ('Gemm', 'MatMul') else 3
342+ if node.type == 'Gemm':
346 attr_helper = AttributeProtoHelper(node.proto)343 attr_helper = AttributeProtoHelper(node.proto)
347 if (344 if (
348- attr_helper.has_attr('group')345+ attr_helper.has_attr('transB')
349- and attr_helper.get_attr_value('group') > 1346+ and attr_helper.get_attr_value('transB') == 1
350 ):347 ):
351- return False348+ pack_axis = 0
349+ 
352 for wnode in (350 for wnode in (
353 QuantOpInfo.get_weight_node(node),351 QuantOpInfo.get_weight_node(node),
354 QuantOpInfo.get_recurrence_weight_node(node),352 QuantOpInfo.get_recurrence_weight_node(node),
@@ -356,7 +354,27 @@ class GraphQuerier:
356 if wnode is None:354 if wnode is None:
357 continue355 continue
358 dims = QuantOpInfo.get_node_tensor(wnode).dims356 dims = QuantOpInfo.get_node_tensor(wnode).dims
359- if cin_axis < len(dims) and dims[cin_axis] % 2 == 1:357+ if (
358+ not dims
359+ or len(dims) < min_rank
360+ or not -len(dims) <= pack_axis < len(dims)
361+ ):
362+ LOGGER.logw(
363+ "Cannot pack INT4 weights for layer '{}': weight shape {} "
364+ "is invalid for {} Deploy pack axis {} (minimum rank {}).".format(
365+ layer_name, dims, node.type, pack_axis, min_rank
366+ ),
367+ module_name='Configuration',
368+ )
369+ return False
370+ if dims[pack_axis] % 2 == 1:
371+ LOGGER.logw(
372+ "Skip INT4 weights for layer '{}': ONNX weight shape {} has odd Deploy "
373+ "pack axis {} (size {}).".format(
374+ layer_name, list(dims), pack_axis, dims[pack_axis]
375+ ),
376+ module_name='Configuration',
377+ )
360 return False378 return False
361 return True379 return True
362 380 
@@ -25,10 +25,15 @@ from ....amct_pytorch.custom_op import arq_retrain_backward_pytorch
25from ....amct_pytorch.custom_op.utils import check_quant_data25from ....amct_pytorch.custom_op.utils import check_quant_data
26from ....amct_pytorch.custom_op.utils import check_group_param26from ....amct_pytorch.custom_op.utils import check_group_param
27from ....amct_pytorch.custom_op.utils import process_tensor_shape27from ....amct_pytorch.custom_op.utils import process_tensor_shape
28-from ....amct_pytorch.utils.vars import QUANTIZE_LINEAR
29-from ....amct_pytorch.utils.vars import DEQUANTIZE_LINEAR
30from ....amct_pytorch.utils.vars import TRANSPOSE28from ....amct_pytorch.utils.vars import TRANSPOSE
31from ....amct_pytorch.utils.weight_quant_api import adjust_axis_for_group_wise29from ....amct_pytorch.utils.weight_quant_api import adjust_axis_for_group_wise
30+from ....amct_pytorch.custom_op.qdq_symbolic import (
31+ add_qdq,
32+ add_weight_qdq_dynamo,
33+ check_int4_export,
34+ check_int4_dynamo_export,
35+ is_dynamo_export,
36+)
32 37 
33 38 
34class ArqRetrainFunction(Function):39class ArqRetrainFunction(Function):
@@ -49,8 +54,25 @@ class ArqRetrainFunction(Function):
49 axis=0,54 axis=0,
50 ):55 ):
51 """56 """
52- Function: ArqRetrain foward funtion.57+ Function: ArqRetrain forward function.
53 """58 """
59+ if is_dynamo_export():
Y
Yyaoguangxiu15 天前

AMCT内部没有效用dynamo=True的选项,也没有暴露接口给用户,这里的处理是不会被触发的?

likedislike
QQR
11 天前 评论:
60+ if wts_param.get('num_bits', 8) == 4:
61+ check_int4_dynamo_export(wts_param)
62+ zero_point = offset_deploy if offset_deploy is not None else offset
63+ return (
64+ add_weight_qdq_dynamo(
65+ weight_tensor,
66+ scale,
67+ zero_point,
68+ wts_param.get('num_bits', 8),
69+ wts_param.get('module_type'),
70+ wts_param.get('channel_wise', False),
71+ wts_param.get('module'),
72+ ),
73+ scale,
74+ offset,
75+ )
54 # check weight tensor76 # check weight tensor
55 check_quant_data(weight_tensor, 'weight')77 check_quant_data(weight_tensor, 'weight')
56 weight_tensor_processed = process_tensor_shape(78 weight_tensor_processed = process_tensor_shape(
@@ -86,7 +108,7 @@ class ArqRetrainFunction(Function):
86 @staticmethod108 @staticmethod
87 def backward(ctx, grad_outputs, grad_scale, grad_offset):109 def backward(ctx, grad_outputs, grad_scale, grad_offset):
88 """110 """
89- Function: ArqRetrain backward funtion required by torch torch.autograd.111+ Function: ArqRetrain backward function required by torch torch.autograd.
90 """112 """
91 grad_input = arq_retrain_backward_pytorch(grad_outputs)113 grad_input = arq_retrain_backward_pytorch(grad_outputs)
92 ret = (grad_input, None, None, None, None)114 ret = (grad_input, None, None, None, None)
@@ -101,28 +123,46 @@ class ArqRetrainFuncQAT(ArqRetrainFunction):
101 Args:123 Args:
102 g (Graph): graph to write the ONNX representation into.124 g (Graph): graph to write the ONNX representation into.
103 """125 """
104- module_type = inputs[3].get('module_type')126+ wts_param = inputs[3]
127+ module_type = wts_param.get('module_type')
128+ num_bits = wts_param.get('num_bits', 8)
129+ if num_bits == 4:
130+ check_int4_export(wts_param)
131+ channel_axis = 1 if wts_param.get('channel_wise', False) else None
105 if module_type in ["ConvTranspose1d", "ConvTranspose2d"]:132 if module_type in ["ConvTranspose1d", "ConvTranspose2d"]:
106- quant = g.op(QUANTIZE_LINEAR, inputs[0], inputs[1], inputs[4])133+ out_node = add_qdq(
107- out_node = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[4])134+ g, inputs[0], inputs[1], inputs[4], num_bits, channel_axis
135+ )
108 elif module_type == 'Conv1d':136 elif module_type == 'Conv1d':
109 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2]))137 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2]))
110- quant = g.op(QUANTIZE_LINEAR, transpose, inputs[1], inputs[4])138+ dequant = add_qdq(
111- dequant = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[4])139+ g, transpose, inputs[1], inputs[4], num_bits, channel_axis
140+ )
112 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2]))141 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2]))
113 elif module_type == 'Conv2d':142 elif module_type == 'Conv2d':
114 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3]))143 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3]))
115- quant = g.op(QUANTIZE_LINEAR, transpose, inputs[1], inputs[4])144+ dequant = add_qdq(
116- dequant = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[4])145+ g, transpose, inputs[1], inputs[4], num_bits, channel_axis
146+ )
117 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3]))147 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3]))
118 elif module_type == 'Conv3d':148 elif module_type == 'Conv3d':
119 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3, 4]))149 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3, 4]))
120- quant = g.op(QUANTIZE_LINEAR, transpose, inputs[1], inputs[4])150+ dequant = add_qdq(
121- dequant = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[4])151+ g, transpose, inputs[1], inputs[4], num_bits, channel_axis
152+ )
122 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3, 4]))153 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3, 4]))
123 elif module_type == 'Linear':154 elif module_type == 'Linear':
124- quant = g.op(QUANTIZE_LINEAR, inputs[0], inputs[1], inputs[4])155+ if channel_axis is None:
125- out_node = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[4])156+ out_node = add_qdq(g, inputs[0], inputs[1], inputs[4], num_bits)
157+ else:
158+ weight_dim = wts_param.get('module').weight.dim()
159+ transpose_axes = list(range(weight_dim))
160+ transpose_axes[0], transpose_axes[1] = 1, 0
161+ transpose = g.op(TRANSPOSE, inputs[0], perm_i=transpose_axes)
162+ dequant = add_qdq(
163+ g, transpose, inputs[1], inputs[4], num_bits, channel_axis
164+ )
165+ out_node = g.op(TRANSPOSE, dequant, perm_i=transpose_axes)
126 elif module_type in RNN_TENSOR_NUM:166 elif module_type in RNN_TENSOR_NUM:
127 shape = g.op(167 shape = g.op(
128 "Constant",168 "Constant",
@@ -137,8 +177,7 @@ class ArqRetrainFuncQAT(ArqRetrainFunction):
137 ),177 ),
138 )178 )
139 reshape = g.op('Reshape', inputs[0], shape)179 reshape = g.op('Reshape', inputs[0], shape)
140- quant = g.op(QUANTIZE_LINEAR, reshape, inputs[1], inputs[4])180+ out_node = add_qdq(g, reshape, inputs[1], inputs[4], num_bits, channel_axis)
141- out_node = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[4])
142 LOGGER.logi(181 LOGGER.logi(
143 "Convert ARQ op to onnx QuantizeLinear and DequantizeLinear op successfully."182 "Convert ARQ op to onnx QuantizeLinear and DequantizeLinear op successfully."
144 )183 )
@@ -29,7 +29,10 @@ class CompModuleLinear(CompModuleBase): # pylint: disable=R0903
29 29 
30 def __init__(self, *args, **kwargs):30 def __init__(self, *args, **kwargs):
31 super(CompModuleLinear, self).__init__(*args, **kwargs)31 super(CompModuleLinear, self).__init__(*args, **kwargs)
32- self.num_scales = 132+ if not self.wts_config.get('channel_wise'):
33+ self.num_scales = 1
34+ else:
35+ self.num_scales = self.replaced_module.weight.size(0)
33 self._init_output()36 self._init_output()
34 37 
35 def forward(self, inputs):38 def forward(self, inputs):
@@ -0,0 +1,182 @@
1+#!/usr/bin/env python3
2+# -*- coding: UTF-8 -*-
3+# ----------------------------------------------------------------------------
4+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# http://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+# ----------------------------------------------------------------------------
18+from onnx import TensorProto
19+import torch
20+ 
21+from ...amct_pytorch.common.utils.vars_util import RNN_TENSOR_NUM
22+from ...amct_pytorch.utils.vars import DEQUANTIZE_LINEAR, QUANTIZE_LINEAR
23+ 
24+ 
25+def is_dynamo_export():
26+ """Return whether the current call is being traced by the Dynamo exporter."""
27+ compiler = getattr(torch, 'compiler', None)
28+ is_compiling = getattr(compiler, 'is_compiling', None)
29+ onnx_export = getattr(torch.onnx, 'is_in_onnx_export', None)
30+ return bool(
31+ is_compiling
32+ and is_compiling()
33+ and onnx_export
34+ and onnx_export()
35+ and hasattr(torch.onnx, 'ops')
36+ )
37+ 
38+ 
39+def check_int4_export(wts_param):
40+ """Validate prerequisites for native INT4 QAT Q/DQ export."""
41+ if not hasattr(TensorProto, 'INT4'):
F
Ffujun199 天前

【严重】【Major】此处 INT4 QuantizeLinear 使用 output_dtype=INT4,该属性仅在 ONNX opset 21+ 的 schema 中定义,但 check_int4_export 没有校验 torch.onnx 实际导出版本。用户按默认或旧 opset 导出时会生成与目标 opset 不匹配的模型并在校验/部署阶段失败;请恢复 opset >= 21 的显式校验,或在导出入口强制/升级 opset。

likedislike
QQR
9 天前 评论:
42+ raise RuntimeError('INT4 export requires ONNX native TensorProto.INT4.')
43+ if not wts_param.get('channel_wise', False):
44+ return
45+ module = wts_param.get('module')
46+ scale_count = int(module.wts_scales.numel())
47+ out_channels = int(module.out_channels)
F
Ffujun1915 天前

【致命】【Fatal】当 channel_wise=True 的 LinearQAT 使用 INT4 导出时,这里无条件访问 module.out_channelstorch.nn.Linear/LinearQAT 只有 out_features,没有 out_channels,因此实际导出会在校验阶段抛出 AttributeError,无法生成声称支持的 Linear A8W4 模型。请按模块类型使用 out_features,并同步修复下方 Dynamo 校验中的同样访问。

likedislike
QQR
11 天前 评论:
48+ if scale_count != out_channels:
49+ raise ValueError(
50+ 'INT4 per-channel scale count {} must equal out_channels {}.'.format(
51+ scale_count, out_channels
52+ )
53+ )
54+ 
55+ 
56+def check_int4_dynamo_export(wts_param):
57+ """Validate INT4 prerequisites available while tracing with Dynamo."""
58+ if not hasattr(TensorProto, 'INT4'):
59+ raise RuntimeError('INT4 export requires ONNX native TensorProto.INT4.')
60+ if not wts_param.get('channel_wise', False):
61+ return
62+ module = wts_param.get('module')
63+ scale_count = int(module.wts_scales.numel())
64+ out_channels = int(module.out_channels)
65+ if scale_count != out_channels:
66+ raise ValueError(
67+ 'INT4 per-channel scale count {} must equal out_channels {}.'.format(
68+ scale_count, out_channels
69+ )
70+ )
71+ 
72+ 
73+def _restore_input_device(output, input_tensor):
74+ """Keep Dynamo fake outputs on the same device as the traced input."""
75+ if isinstance(output, torch.Tensor) and output.device != input_tensor.device:
76+ return output.to(device=input_tensor.device)
77+ return output
78+ 
79+ 
80+def _prepare_zero_point(zero_point, num_bits):
81+ """Convert Q/DQ zero points to the integer dtype required by ONNX."""
82+ if not isinstance(zero_point, torch.Tensor):
83+ return zero_point
84+ target_dtype = torch.int16 if num_bits == 16 else torch.int8
85+ if zero_point.dtype == target_dtype:
86+ return zero_point
87+ return zero_point.round().to(dtype=target_dtype)
88+ 
89+ 
90+def add_qdq(g, tensor, scale, zero_point, num_bits, axis=None):
91+ """Add standard ONNX Q/DQ nodes for an AMCT QAT weight tensor."""
92+ attributes = {}
93+ if axis is not None:
94+ attributes['axis_i'] = axis
95+ 
96+ if num_bits == 4:
97+ quant = g.op(
98+ QUANTIZE_LINEAR,
99+ tensor,
100+ scale,
101+ output_dtype_i=TensorProto.INT4,
102+ **attributes,
103+ )
104+ return g.op(DEQUANTIZE_LINEAR, quant, scale, **attributes)
105+ 
106+ quant = g.op(QUANTIZE_LINEAR, tensor, scale, zero_point, **attributes)
107+ return g.op(DEQUANTIZE_LINEAR, quant, scale, zero_point, **attributes)
108+ 
109+ 
110+def add_qdq_dynamo(tensor, scale, zero_point, num_bits, axis=None, shape=None):
111+ """Build Q/DQ nodes through the Dynamo ONNX symbolic-op API."""
112+ onnx_ops = getattr(torch.onnx, 'ops', None)
113+ if onnx_ops is None or not hasattr(onnx_ops, 'symbolic'):
114+ raise RuntimeError('Dynamo Q/DQ export requires PyTorch 2.10 or newer.')
115+ if num_bits == 4 and not hasattr(TensorProto, 'INT4'):
116+ raise RuntimeError('INT4 export requires ONNX native TensorProto.INT4.')
117+ 
118+ if shape is None:
119+ shape = tensor.shape
120+ zero_point = _prepare_zero_point(zero_point, num_bits)
121+ quant_attrs = {'output_dtype': TensorProto.INT4} if num_bits == 4 else {}
122+ if axis is not None:
123+ quant_attrs['axis'] = axis
124+ quant_inputs = (tensor, scale) if num_bits == 4 else (tensor, scale, zero_point)
125+ quant_dtype = {
126+ 4: TensorProto.INT4,
127+ 8: torch.int8,
128+ 16: torch.int16,
129+ }.get(num_bits)
130+ if quant_dtype is None:
131+ raise ValueError('Unsupported quantization bit width: {}'.format(num_bits))
132+ 
133+ quant = onnx_ops.symbolic(
134+ QUANTIZE_LINEAR,
135+ quant_inputs,
136+ attrs=quant_attrs,
137+ dtype=quant_dtype,
138+ shape=shape,
139+ version=21,
140+ )
141+ 
142+ dequant_attrs = {'axis': axis} if axis is not None else {}
143+ dequant_inputs = (quant, scale) if num_bits == 4 else (quant, scale, zero_point)
144+ dequant = onnx_ops.symbolic(
145+ DEQUANTIZE_LINEAR,
146+ dequant_inputs,
147+ attrs=dequant_attrs,
148+ dtype=tensor.dtype,
149+ shape=shape,
150+ version=21,
151+ )
152+ return _restore_input_device(dequant, tensor)
153+ 
154+ 
155+def add_weight_qdq_dynamo(
156+ tensor, scale, zero_point, num_bits, module_type, channel_wise, module
157+):
158+ """Build a weight Q/DQ graph using the layout expected by ONNX operators."""
159+ axis = 1 if channel_wise else None
160+ if module_type in ('ConvTranspose1d', 'ConvTranspose2d', 'ConvTranspose3d'):
161+ return add_qdq_dynamo(tensor, scale, zero_point, num_bits, axis)
162+ 
163+ if module_type in ('Conv1d', 'Conv2d', 'Conv3d'):
164+ rank = tensor.dim()
165+ perm = [1, 0] + list(range(2, rank))
166+ transposed = tensor.permute(perm)
167+ dequant = add_qdq_dynamo(transposed, scale, zero_point, num_bits, axis)
168+ return dequant.permute(perm)
169+ 
170+ if module_type == 'Linear' and channel_wise:
171+ rank = module.weight.dim()
172+ perm = [1, 0] + list(range(2, rank))
173+ transposed = tensor.permute(perm)
174+ dequant = add_qdq_dynamo(transposed, scale, zero_point, num_bits, axis)
175+ return dequant.permute(perm)
176+ 
177+ if module_type in ('Linear',) or module_type in RNN_TENSOR_NUM:
178+ return add_qdq_dynamo(tensor, scale, zero_point, num_bits, axis)
179+ 
180+ raise RuntimeError(
181+ 'Unsupported QAT module type for Dynamo export: {}'.format(module_type)
182+ )
@@ -23,6 +23,7 @@ from ....amct_pytorch.utils.log import LOGGER
23from ....amct_pytorch.custom_op import ulq_retrain_forward_pytorch23from ....amct_pytorch.custom_op import ulq_retrain_forward_pytorch
24from ....amct_pytorch.custom_op import ulq_retrain_backward_pytorch24from ....amct_pytorch.custom_op import ulq_retrain_backward_pytorch
25from ....amct_pytorch.custom_op.utils import check_quant_data25from ....amct_pytorch.custom_op.utils import check_quant_data
26+from ....amct_pytorch.custom_op.qdq_symbolic import add_qdq_dynamo, is_dynamo_export
26 27 
27 28 
28class UlqRetrainFunction(Function):29class UlqRetrainFunction(Function):
@@ -48,6 +49,20 @@ class UlqRetrainFunction(Function):
48 """49 """
49 Function: UlqRetrain foward funtion.50 Function: UlqRetrain foward funtion.
50 """51 """
52+ if is_dynamo_export():
53+ output = add_qdq_dynamo(
54+ inputs,
55+ act_qat_param.get('acts_scale'),
56+ act_qat_param.get('acts_offset'),
57+ act_qat_param.get('num_bits', 8),
58+ )
59+ return (
60+ output,
61+ act_qat_param.get('acts_scale'),
62+ act_qat_param.get('acts_offset'),
63+ clip_max,
64+ clip_min,
65+ )
51 check_quant_data(inputs, 'activation')66 check_quant_data(inputs, 'activation')
52 outputs, scale, offset, clip_max, clip_min = ulq_retrain_forward_pytorch(67 outputs, scale, offset, clip_max, clip_min = ulq_retrain_forward_pytorch(
53 inputs,68 inputs,
@@ -25,10 +25,15 @@ from ....amct_pytorch.custom_op import ulq_scale_retrain_backward_pytorch
25from ....amct_pytorch.custom_op.utils import check_quant_data25from ....amct_pytorch.custom_op.utils import check_quant_data
26from ....amct_pytorch.custom_op.utils import check_group_param26from ....amct_pytorch.custom_op.utils import check_group_param
27from ....amct_pytorch.custom_op.utils import process_tensor_shape27from ....amct_pytorch.custom_op.utils import process_tensor_shape
28-from ....amct_pytorch.utils.vars import QUANTIZE_LINEAR
29-from ....amct_pytorch.utils.vars import DEQUANTIZE_LINEAR
30from ....amct_pytorch.utils.vars import TRANSPOSE28from ....amct_pytorch.utils.vars import TRANSPOSE
31from ....amct_pytorch.utils.weight_quant_api import adjust_axis_for_group_wise29from ....amct_pytorch.utils.weight_quant_api import adjust_axis_for_group_wise
30+from ....amct_pytorch.custom_op.qdq_symbolic import (
31+ add_qdq,
32+ add_weight_qdq_dynamo,
33+ check_int4_export,
34+ check_int4_dynamo_export,
35+ is_dynamo_export,
36+)
32 37 
33 38 
34MODULE_TYPE = 'module_type'39MODULE_TYPE = 'module_type'
@@ -53,8 +58,25 @@ class UlqScaleRetrainFunction(Function):
53 axis=0,58 axis=0,
54 ):59 ):
55 """60 """
56- Function: UlqRetrain foward funtion.61+ Function: UlqRetrain forward function.
57 """62 """
63+ if is_dynamo_export():
64+ if wts_qat_param.get('num_bits', 8) == 4:
65+ check_int4_dynamo_export(wts_qat_param)
66+ zero_point = offset_deploy if offset_deploy is not None else offset
67+ return (
68+ add_weight_qdq_dynamo(
69+ inputs,
70+ scale,
71+ zero_point,
72+ wts_qat_param.get('num_bits', 8),
73+ wts_qat_param.get(MODULE_TYPE),
74+ wts_qat_param.get('channel_wise', False),
75+ wts_qat_param.get('module'),
76+ ),
77+ scale,
78+ offset,
79+ )
58 # check input data80 # check input data
59 check_quant_data(inputs, 'weights')81 check_quant_data(inputs, 'weights')
60 82 
@@ -105,7 +127,7 @@ class UlqScaleRetrainFunction(Function):
105 @staticmethod127 @staticmethod
106 def backward(ctx, grad_outputs, grad_scale, grad_offset):128 def backward(ctx, grad_outputs, grad_scale, grad_offset):
107 """129 """
108- Function: UlqRetrain backward funtion required by torch130+ Function: UlqRetrain backward function required by torch
109 torch.autograd.131 torch.autograd.
110 """132 """
111 res = ulq_scale_retrain_backward_pytorch(133 res = ulq_scale_retrain_backward_pytorch(
@@ -134,28 +156,43 @@ class UlqScaleRetrainFuncQAT(UlqScaleRetrainFunction):
134 Args:156 Args:
135 g (Graph): graph to write the ONNX representation into.157 g (Graph): graph to write the ONNX representation into.
136 """158 """
137- module_type = inputs[3].get(MODULE_TYPE)159+ wts_param = inputs[3]
160+ module_type = wts_param.get(MODULE_TYPE)
161+ num_bits = wts_param.get('num_bits', 8)
162+ if num_bits == 4:
163+ check_int4_export(wts_param)
164+ channel_axis = 1 if wts_param.get('channel_wise', False) else None
138 if module_type in ["ConvTranspose1d", "ConvTranspose2d"]:165 if module_type in ["ConvTranspose1d", "ConvTranspose2d"]:
139- quant = g.op(QUANTIZE_LINEAR, inputs[0], inputs[1], inputs[5])166+ out_node = add_qdq(
140- out_node = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[5])167+ g, inputs[0], inputs[1], inputs[5], num_bits, channel_axis
168+ )
141 elif module_type == 'Conv1d':169 elif module_type == 'Conv1d':
142 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2]))170 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2]))
143- quant = g.op(QUANTIZE_LINEAR, transpose, inputs[1], inputs[5])171+ dequant = add_qdq(
144- dequant = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[5])172+ g, transpose, inputs[1], inputs[5], num_bits, channel_axis
173+ )
145 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2]))174 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2]))
146 elif module_type == 'Conv2d':175 elif module_type == 'Conv2d':
147 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3]))176 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3]))
148- quant = g.op(QUANTIZE_LINEAR, transpose, inputs[1], inputs[5])177+ dequant = add_qdq(
149- dequant = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[5])178+ g, transpose, inputs[1], inputs[5], num_bits, channel_axis
179+ )
150 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3]))180 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3]))
151 elif module_type == 'Conv3d':181 elif module_type == 'Conv3d':
152 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3, 4]))182 transpose = g.op(TRANSPOSE, inputs[0], perm_i=list([1, 0, 2, 3, 4]))
153- quant = g.op(QUANTIZE_LINEAR, transpose, inputs[1], inputs[5])183+ dequant = add_qdq(
154- dequant = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[5])184+ g, transpose, inputs[1], inputs[5], num_bits, channel_axis
185+ )
155 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3, 4]))186 out_node = g.op(TRANSPOSE, dequant, perm_i=list([1, 0, 2, 3, 4]))
156 elif module_type == 'Linear':187 elif module_type == 'Linear':
157- quant = g.op(QUANTIZE_LINEAR, inputs[0], inputs[1], inputs[5])188+ if channel_axis is None:
158- out_node = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[5])189+ out_node = add_qdq(g, inputs[0], inputs[1], inputs[5], num_bits)
190+ else:
191+ transpose = g.op(TRANSPOSE, inputs[0], perm_i=[1, 0])
192+ dequant = add_qdq(
193+ g, transpose, inputs[1], inputs[5], num_bits, channel_axis
194+ )
195+ out_node = g.op(TRANSPOSE, dequant, perm_i=[1, 0])
159 elif module_type in RNN_TENSOR_NUM:196 elif module_type in RNN_TENSOR_NUM:
160 shape = g.op(197 shape = g.op(
161 "Constant",198 "Constant",
@@ -170,8 +207,7 @@ class UlqScaleRetrainFuncQAT(UlqScaleRetrainFunction):
170 ),207 ),
171 )208 )
172 reshape = g.op('Reshape', inputs[0], shape)209 reshape = g.op('Reshape', inputs[0], shape)
173- quant = g.op(QUANTIZE_LINEAR, reshape, inputs[1], inputs[5])210+ out_node = add_qdq(g, reshape, inputs[1], inputs[5], num_bits, channel_axis)
174- out_node = g.op(DEQUANTIZE_LINEAR, quant, inputs[1], inputs[5])
175 LOGGER.logi(211 LOGGER.logi(
176 "Convert ULQ scale op to onnx QuantizeLinear and DequantizeLinear op successfully."212 "Convert ULQ scale op to onnx QuantizeLinear and DequantizeLinear op successfully."
177 )213 )
@@ -19,12 +19,15 @@ import torch.nn as nn
19from torch.nn import functional as F19from torch.nn import functional as F
20 20 
21from .....amct_pytorch.nn.module.quantization.qat_base import QATBase21from .....amct_pytorch.nn.module.quantization.qat_base import QATBase
22+from .....amct_pytorch.common.utils.vars_util import INT4, INT8
23+from .....amct_pytorch.utils.vars import DST_TYPE
22 24 
23SUPPORTED_DATA_DIMS = 425SUPPORTED_DATA_DIMS = 4
24 26 
25 27 
26class Conv2dQAT(nn.Conv2d, QATBase):28class Conv2dQAT(nn.Conv2d, QATBase):
27 _float_module = nn.Conv2d29 _float_module = nn.Conv2d
30+ _supported_weight_dst_types = (INT8, INT4)
28 _required_params = (31 _required_params = (
29 "in_channels",32 "in_channels",
30 "out_channels",33 "out_channels",
@@ -72,6 +75,15 @@ class Conv2dQAT(nn.Conv2d, QATBase):
72 raise ValueError(75 raise ValueError(
73 f'Do not support Conv2d with padding mode {self.padding_mode}'76 f'Do not support Conv2d with padding mode {self.padding_mode}'
74 )77 )
78+ if (
79+ self.retrain_weight_config.get(DST_TYPE, INT8) == INT4
80+ and self.weight.shape[-1] % 2 == 1
81+ ):
82+ raise ValueError(
83+ 'Conv2d INT4 weight shape {} has pack axis W (size {}) that is odd.'.format(
84+ list(self.weight.shape), self.weight.shape[-1]
85+ )
86+ )
75 87 
76 def forward(self, inputs):88 def forward(self, inputs):
77 if inputs.dim() != SUPPORTED_DATA_DIMS:89 if inputs.dim() != SUPPORTED_DATA_DIMS:
@@ -22,7 +22,8 @@ import torch.nn.functional as F
22 22 
23from .....amct_pytorch.utils.log import LOGGER23from .....amct_pytorch.utils.log import LOGGER
24from .....amct_pytorch.nn.module.quantization.qat_base import QATBase24from .....amct_pytorch.nn.module.quantization.qat_base import QATBase
25-from .....amct_pytorch.utils.vars import CHANNEL_WISE25+from .....amct_pytorch.common.utils.vars_util import INT4, INT8
26+from .....amct_pytorch.utils.vars import CHANNEL_WISE, DST_TYPE
26 27 
27RETRAIN_WEIGHT_CONFIG = 'retrain_weight_config'28RETRAIN_WEIGHT_CONFIG = 'retrain_weight_config'
28 29 
@@ -35,6 +36,7 @@ class LinearQAT(nn.Linear, QATBase):
35 36 
36 _float_module = nn.Linear37 _float_module = nn.Linear
37 _required_params = ("in_features", "out_features", "bias")38 _required_params = ("in_features", "out_features", "bias")
39+ _supported_weight_dst_types = (INT8, INT4)
38 40 
39 def __init__(41 def __init__(
40 self, in_features, out_features, bias=True, device=None, dtype=None, config=None42 self, in_features, out_features, bias=True, device=None, dtype=None, config=None
@@ -79,8 +81,16 @@ class LinearQAT(nn.Linear, QATBase):
79 81 
80 def check_quantifiable(self):82 def check_quantifiable(self):
81 """check qat config for LinearQat"""83 """check qat config for LinearQat"""
82- if self.retrain_weight_config.get(CHANNEL_WISE, True):84+ if (
83- raise RuntimeError('Do not support Linear with channel_wise.')85+ self.retrain_weight_config.get(DST_TYPE, INT8) == INT4
86+ and self.weight.shape[0] % 2 == 1
87+ ):
88+ raise ValueError(
89+ 'Linear INT4 weight shape {} has out_features pack axis '
90+ '(size {}) that is odd.'.format(
91+ list(self.weight.shape), self.weight.shape[0]
92+ )
93+ )
84 return True94 return True
85 95 
86 def forward(self, input):96 def forward(self, input):
@@ -25,7 +25,7 @@ from torch.nn.parameter import Parameter
25 25 
26from .....amct_pytorch.utils.log import LOGGER26from .....amct_pytorch.utils.log import LOGGER
27from .....amct_pytorch.common.utils.check_params import check_params27from .....amct_pytorch.common.utils.check_params import check_params
28-from .....amct_pytorch.common.utils.vars_util import INT8, INT1628+from .....amct_pytorch.common.utils.vars_util import INT4, INT8, INT16
29from .....amct_pytorch.common.utils.vars_util import RNN_TENSOR_NUM29from .....amct_pytorch.common.utils.vars_util import RNN_TENSOR_NUM
30from .....amct_pytorch.custom_op.ifmr.ifmr import IFMR30from .....amct_pytorch.custom_op.ifmr.ifmr import IFMR
31from .....amct_pytorch.custom_op.utils import copy_tensor31from .....amct_pytorch.custom_op.utils import copy_tensor
@@ -35,6 +35,12 @@ from .....amct_pytorch.custom_op.arq_retrain.arq_retrain import ArqRetrainFuncQA
35from .....amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain import (35from .....amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain import (
36 UlqScaleRetrainFuncQAT,36 UlqScaleRetrainFuncQAT,
37)37)
38+from .....amct_pytorch.custom_op.qdq_symbolic import (
39+ add_qdq_dynamo,
40+ add_weight_qdq_dynamo,
41+ check_int4_dynamo_export,
42+ is_dynamo_export,
43+)
38from .....amct_pytorch.utils.vars import (44from .....amct_pytorch.utils.vars import (
39 CLIP_MAX,45 CLIP_MAX,
40 CLIP_MIN,46 CLIP_MIN,
@@ -66,6 +72,7 @@ class QATBase(metaclass=ABCMeta):
66 72 
67 _float_module = None73 _float_module = None
68 _required_params = list()74 _required_params = list()
75+ _supported_weight_dst_types = (INT8,)
69 76 
70 @check_params(layer_type=str, device=(str, type(None)), config=(dict, type(None)))77 @check_params(layer_type=str, device=(str, type(None)), config=(dict, type(None)))
71 def __init__(self, layer_type, device, config=None):78 def __init__(self, layer_type, device, config=None):
@@ -324,6 +331,8 @@ class QATBase(metaclass=ABCMeta):
324 inputs.dtype331 inputs.dtype
325 )332 )
326 )333 )
334+ if is_dynamo_export() and self.retrain_enable:
335+ return self._forward_qat_export(inputs)
327 if self.retrain_enable:336 if self.retrain_enable:
328 if self.do_init:337 if self.do_init:
329 self.acts_quant_init(inputs)338 self.acts_quant_init(inputs)
@@ -338,6 +347,42 @@ class QATBase(metaclass=ABCMeta):
338 quantized_acts, quantized_weights = inputs, self.weight347 quantized_acts, quantized_weights = inputs, self.weight
339 return quantized_acts, quantized_weights348 return quantized_acts, quantized_weights
340 349 
350+ def _forward_qat_export(self, inputs):
351+ """Build Q/DQ nodes without retraining-time state updates."""
352+ if self.do_init:
353+ raise RuntimeError(
354+ 'QAT model must be initialized before Dynamo ONNX export.'
355+ )
356+ 
357+ quantized_acts = add_qdq_dynamo(
358+ inputs,
359+ self.acts_scale,
360+ self.acts_offset_deploy,
361+ self.act_num_bits,
362+ )
363+ 
364+ wts_config = self.retrain_weight_config
365+ if self.wts_num_bits == 4:
366+ check_int4_dynamo_export(
367+ {
368+ 'channel_wise': wts_config.get('channel_wise', True),
369+ 'module': self,
370+ }
371+ )
372+ algo = wts_config.get('weights_retrain_algo', 'arq_retrain')
373+ if algo not in ('arq_retrain', 'ulq_retrain'):
374+ raise RuntimeError('Unsupported weights retrain algorithm: {}'.format(algo))
375+ quantized_weights = add_weight_qdq_dynamo(
376+ self.weight,
377+ self.wts_scales,
378+ self.wts_offsets_deploy,
379+ self.wts_num_bits,
380+ self.layer_type,
381+ wts_config.get('channel_wise', True),
382+ self,
383+ )
384+ return quantized_acts, quantized_weights
385+ 
341 def acts_quant_init(self, inputs):386 def acts_quant_init(self, inputs):
342 """do activations quant in the first batch"""387 """do activations quant in the first batch"""
343 is_init, ulq_retrain_params = self.do_ifmr(388 is_init, ulq_retrain_params = self.do_ifmr(
@@ -466,6 +511,9 @@ class QATBase(metaclass=ABCMeta):
466 "but your input is {}".format(self.retrain_data_config.get(DST_TYPE))511 "but your input is {}".format(self.retrain_data_config.get(DST_TYPE))
467 )512 )
468 513 
514+ if self.retrain_data_config.get('channel_wise', False):
515+ raise ValueError('Activation quantization only supports per-tensor.')
516+ 
469 batch_num = self.retrain_data_config.get(BATCH_NUM, 1)517 batch_num = self.retrain_data_config.get(BATCH_NUM, 1)
470 if not isinstance(batch_num, int) or batch_num <= 0:518 if not isinstance(batch_num, int) or batch_num <= 0:
471 raise ValueError(519 raise ValueError(
@@ -498,11 +546,21 @@ class QATBase(metaclass=ABCMeta):
498 )546 )
499 547 
500 # check params for weights548 # check params for weights
501- if self.retrain_weight_config.get(DST_TYPE, INT8) not in [INT8]:549+ if (
550+ self.retrain_weight_config.get(DST_TYPE, INT8)
551+ not in self._supported_weight_dst_types
552+ ):
502 raise ValueError(553 raise ValueError(
503- "dst_type for weight should be in range ['INT8'], "554+ "dst_type for weight should be in range {}, but your input is {}".format(
504- "but your input is {}".format(self.retrain_weight_config.get(DST_TYPE))555+ list(self._supported_weight_dst_types),
556+ self.retrain_weight_config.get(DST_TYPE),
557+ )
505 )558 )
559+ if (
560+ self.retrain_weight_config.get(DST_TYPE, INT8) == INT4
561+ and self.retrain_data_config.get(DST_TYPE, INT8) != INT8
562+ ):
563+ raise ValueError('INT4 weight quantization requires INT8 activation.')
506 564 
507 if self.retrain_weight_config.get('weight_retrain_algo', 'arq_retrain') not in [565 if self.retrain_weight_config.get('weight_retrain_algo', 'arq_retrain') not in [
508 'arq_retrain',566 'arq_retrain',
@@ -51,7 +51,6 @@ __all__ = [
51 'ReplaceBiasQuantPass',51 'ReplaceBiasQuantPass',
52 'RepalceSyncBNPass',52 'RepalceSyncBNPass',
53 'ReplaceRNNPass',53 'ReplaceRNNPass',
54- 'PackInt4WeightPass',
55 'SetRecorderPass',54 'SetRecorderPass',
56 'ShareActCompPass',55 'ShareActCompPass',
57 'WeightsCalibrationPass',56 'WeightsCalibrationPass',
@@ -146,7 +145,6 @@ from .replace_weight_quant_pass import ReplaceWeightQuantPass
146from .replace_bias_quant_pass import ReplaceBiasQuantPass145from .replace_bias_quant_pass import ReplaceBiasQuantPass
147from .replace_sync_bn_pass import RepalceSyncBNPass146from .replace_sync_bn_pass import RepalceSyncBNPass
148from .replace_rnn_pass import ReplaceRNNPass147from .replace_rnn_pass import ReplaceRNNPass
149-from .pack_int4_weight_pass import PackInt4WeightPass
150from .set_recorder_pass import SetRecorderPass148from .set_recorder_pass import SetRecorderPass
151from .share_act_comp_pass import ShareActCompPass149from .share_act_comp_pass import ShareActCompPass
152from .weight_calibration import WeightsCalibrationPass150from .weight_calibration import WeightsCalibrationPass
@@ -66,7 +66,7 @@ class InsertFakequantLinearPass(BaseModuleFusionPass):
66 model_helper = ModuleHelper(model)66 model_helper = ModuleHelper(model)
67 parent_module = model_helper.get_parent_module(object_name)67 parent_module = model_helper.get_parent_module(object_name)
68 quant_params = self.records[object_name]68 quant_params = self.records[object_name]
69- quant_params['channel_wise'] = False69+ quant_params['channel_wise'] = len(quant_params['weight_scale'].flatten()) > 1
70 70 
71 # Step2: fake quant71 # Step2: fake quant
72 fakequant_linear_module = FakeQuantizedLinear(72 fakequant_linear_module = FakeQuantizedLinear(
@@ -19,6 +19,7 @@ import numpy as np
19 19 
20from ...amct_pytorch.optimizer.base_fusion_pass import BaseFusionPass20from ...amct_pytorch.optimizer.base_fusion_pass import BaseFusionPass
21from ...amct_pytorch.custom_op.arq.arq import weight_quant_np21from ...amct_pytorch.custom_op.arq.arq import weight_quant_np
22+from ...amct_pytorch.common.utils.onnx_node_util import AttributeProtoHelper
22from ...amct_pytorch.utils.onnx_initializer_util import TensorProtoHelper23from ...amct_pytorch.utils.onnx_initializer_util import TensorProtoHelper
23from ...amct_pytorch.utils.quant_node import QuantOpInfo24from ...amct_pytorch.utils.quant_node import QuantOpInfo
24from ...amct_pytorch.utils.log import LOGGER25from ...amct_pytorch.utils.log import LOGGER
@@ -79,9 +80,26 @@ class InsertWeightQuantPass(BaseFusionPass):
79 if object_node.type == 'ConvTranspose':80 if object_node.type == 'ConvTranspose':
80 group = get_deconv_group(object_node)81 group = get_deconv_group(object_node)
81 weight = adjust_deconv_weight_shape(group, weight)82 weight = adjust_deconv_weight_shape(group, weight)
83+ weight_quant_axis = 0
84+ if object_node.type == 'MatMul' and not (
85+ object_node.has_attr('with_weights_trans')
86+ and object_node.get_attr('with_weights_trans')
87+ ):
88+ weight_quant_axis = -1
89+ if object_node.type == 'Gemm':
90+ attr_helper = AttributeProtoHelper(object_node.proto)
91+ if (
92+ not attr_helper.has_attr('transB')
93+ or attr_helper.get_attr_value('transB') == 0
94+ ):
95+ weight_quant_axis = 1
96+ if weight_quant_axis != 0:
97+ weight = np.moveaxis(weight, weight_quant_axis, 0)
82 scale_w = self.records.get(object_node.name).get('weight_scale')98 scale_w = self.records.get(object_node.name).get('weight_scale')
83 offset_w = self.records.get(object_node.name).get('weight_offset')99 offset_w = self.records.get(object_node.name).get('weight_offset')
84 quant_weight = weight_quant_np(weight, scale_w, offset_w, num_bits)100 quant_weight = weight_quant_np(weight, scale_w, offset_w, num_bits)
101+ if weight_quant_axis != 0:
102+ quant_weight = np.moveaxis(quant_weight, 0, weight_quant_axis)
85 if object_node.type == 'ConvTranspose':103 if object_node.type == 'ConvTranspose':
86 group = get_deconv_group(object_node)104 group = get_deconv_group(object_node)
87 quant_weight = adjust_deconv_weight_shape(group, quant_weight)105 quant_weight = adjust_deconv_weight_shape(group, quant_weight)
@@ -1,105 +0,0 @@
1-#!/usr/bin/env python3
2-# -*- coding: UTF-8 -*-
3-# ----------------------------------------------------------------------------
4-# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
5-#
6-# Licensed under the Apache License, Version 2.0 (the "License");
7-# you may not use this file except in compliance with the License.
8-# You may obtain a copy of the License at
9-#
10-# http://www.apache.org/licenses/LICENSE-2.0
11- 
12-# Unless required by applicable law or agreed to in writing, software
13-# distributed under the License is distributed on an "AS IS" BASIS,
14-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15-# See the License for the specific language governing permissions and
16-# limitations under the License.
17-# ----------------------------------------------------------------------------
18-import numpy as np
19- 
20-from ...amct_pytorch.optimizer.base_fusion_pass import BaseFusionPass
21-from ...amct_pytorch.utils.onnx_initializer_util import TensorProtoHelper
22-from ...amct_pytorch.utils.quant_node import QuantOpInfo
23-from ...amct_pytorch.utils.log import LOGGER
24- 
25- 
26-def pack_along_axis(int4_vals, dims, axis):
27- """
28- Pack every two INT4 into one INT8 along `axis`, leaving other axes
29- unchanged (axis -> axis/2). The quant axis is guaranteed even here: odd
30- quant-axis INT4 layers are already rejected at config stage
31- (check_int4_weight_quant_axis). Returns (flat_packed_int8, new_dims).
32- """
33- arr = np.asarray(int4_vals).reshape(dims).astype(np.int8)
34- # 把量化轴移到最后,沿它两两配对,再移回原位
35- moved = np.moveaxis(arr, axis, -1)
36- last = moved.shape[-1]
37- if last % 2 == 1:
38- # 配置阶段已拦截奇数量化轴,走到这里说明校验有漏,暴露而非静默补零
39- raise RuntimeError(
40- 'INT4 quant-axis length {} is odd; should have been rejected '
41- 'at config stage'.format(last)
42- )
43- low = moved[..., 0::2] & 0x0F
44- high = moved[..., 1::2] & 0x0F
45- packed_moved = (low | (high << 4)).astype(np.uint8).astype(np.int8)
46- packed = np.moveaxis(packed_moved, -1, axis)
47- return packed.reshape(-1), list(packed.shape)
48- 
49- 
50-def pack_int4_weight_node(weight_node, cin_axis):
51- """
52- Read INT4 values from a weight tensor node and write them back packed
53- two-per-byte along the Cin axis. No-op when weight_node is None (e.g.
54- non-RNN ops have no recurrence_weight).
55- """
56- if weight_node is None:
57- return
58- helper = TensorProtoHelper(weight_node.proto, weight_node.model_path)
59- int4_vals = np.asarray(helper.get_data())
60- orig_dims = list(helper.tensor.dims)
61- # 沿 Cin 维 nibble-pack:该轴每两个 INT4 合成一个 INT8,其余轴不变
62- # (Cin -> Cin/2)。deploy 为 AMCT 专有格式,实际字节由 op_data_type
63- # 标记为 INT4-packed。Cin 奇数已在配置阶段拦截,不会走到这里。
64- packed, new_dims = pack_along_axis(int4_vals, orig_dims, cin_axis)
65- helper.clear_data()
66- helper.set_data(packed, 'INT8', dims=new_dims)
67- 
68- 
69-class PackInt4WeightPass(BaseFusionPass):
70- """
71- Function: Pack INT4 weight storage for the deploy model: two INT4 nibbles
72- into one INT8 byte. Applies to every layer whose weight is configured
73- as INT4 (Conv/ConvTranspose/Linear/LSTM/GRU). The main weight is packed
74- for all such layers; recurrence_weight is packed additionally for RNN
75- ops only (non-RNN ops have no recurrence_weight and are skipped).
76- APIs: match_pattern, do_pass
77- 
78- For RNN layers this pass must run while the original LSTM/GRU nodes are
79- still in the graph (i.e. BEFORE ReplaceRNNPass on the deploy path), so that
80- QuantOpInfo can locate the recurrence_weight tensor via the original node
81- type.
82- """
83- 
84- def __init__(self, records):
85- BaseFusionPass.__init__(self)
86- self.records = records
87- 
88- def match_pattern(self, node):
89- """Match layers configured with INT4 weight."""
90- if node.name not in self.records:
91- return False
92- return self.records[node.name].get("wts_type") == "INT4"
93- 
94- def do_pass(self, graph, object_node, model=None):
95- """Pack the INT4 weight (and recurrence_weight for RNN) of object_node."""
96- axis = QuantOpInfo.get_cin_axis(object_node)
97- weight_node = QuantOpInfo.get_weight_node(object_node)
98- pack_int4_weight_node(weight_node, axis)
99- # recurrence_weight 仅 RNN 有,其 Cin(hidden) 同样在 onnx 张量的轴 2
100- recurrence_weight_node = QuantOpInfo.get_recurrence_weight_node(object_node)
101- pack_int4_weight_node(recurrence_weight_node, axis)
102- LOGGER.logd(
103- "Pack INT4 weight (deploy) for layer '{}'".format(object_node.name),
104- "PackInt4WeightPass",
105- )
@@ -25,6 +25,7 @@ from ...amct_pytorch.custom_op.arq.arq import weight_quant_np
25from ...amct_pytorch.custom_op.fake_quant import FAKE_MODULES25from ...amct_pytorch.custom_op.fake_quant import FAKE_MODULES
26from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV_TRANSPOSE26from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV_TRANSPOSE
27from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV27from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV
28+from ...amct_pytorch.custom_op.fake_quant import FAKE_LINEAR
28from ...amct_pytorch.utils.log import LOGGER29from ...amct_pytorch.utils.log import LOGGER
29from ...amct_pytorch.utils.weight_quant_api import adjust_deconv_weight_shape30from ...amct_pytorch.utils.weight_quant_api import adjust_deconv_weight_shape
30 31 
@@ -94,6 +95,12 @@ class WeightFakequantModulePass(BaseModuleFusionPass):
94 weight_offset = weight_offset.astype(np.float32).reshape(95 weight_offset = weight_offset.astype(np.float32).reshape(
95 reshaped_weight_shape96 reshaped_weight_shape
96 )97 )
98+ elif type(object_module).__name__ == FAKE_LINEAR:
99+ reshaped_weight_shape = [1] * len(object_module.sub_module.weight.shape)
100+ reshaped_weight_shape[0] = -1
101+ weight_offset = weight_offset.astype(np.float32).reshape(
102+ reshaped_weight_shape
103+ )
97 104 
98 int9_weight = int8_weight.astype(np.float32) - weight_offset105 int9_weight = int8_weight.astype(np.float32) - weight_offset
99 if type(object_module).__name__ == FAKE_CONV_TRANSPOSE:106 if type(object_module).__name__ == FAKE_CONV_TRANSPOSE:
@@ -96,7 +96,14 @@ class WeightFakequantPass(BaseFusionPass):
96 weight_offset = self.records.get(object_node.name).get('weight_offset')96 weight_offset = self.records.get(object_node.name).get('weight_offset')
97 fp_weight = quant_weight.astype(np.float32)97 fp_weight = quant_weight.astype(np.float32)
98 if not np.all(weight_offset == 0):98 if not np.all(weight_offset == 0):
99- fp_weight = fp_weight - weight_offset.astype(np.float32)99+ weight_offset = weight_offset.astype(np.float32)
100+ if (
101+ object_node.type == 'MatMul'
102+ and object_node.has_attr('with_weights_trans')
103+ and object_node.get_attr('with_weights_trans')
104+ ):
105+ weight_offset = weight_offset.reshape([-1, 1])
106+ fp_weight = fp_weight - weight_offset
100 107 
101 if object_node.type == 'ConvTranspose' and get_deconv_group(object_node) > 1:108 if object_node.type == 'ConvTranspose' and get_deconv_group(object_node) > 1:
102 group = get_deconv_group(object_node)109 group = get_deconv_group(object_node)
@@ -17,6 +17,7 @@
17# ----------------------------------------------------------------------------17# ----------------------------------------------------------------------------
18 18 
19import os19import os
20+import inspect
20from shutil import copyfileobj21from shutil import copyfileobj
21from io import BytesIO22from io import BytesIO
22import pathlib23import pathlib
@@ -114,6 +115,18 @@ class Parser:
114 export_setting = {}115 export_setting = {}
115 else:116 else:
116 Parser.validate_export_setting(export_setting)117 Parser.validate_export_setting(export_setting)
118+ # QuantIdentity relies on the legacy exporter symbolic path to keep
119+ # marker nodes that are consumed by the internal graph parser. Torch
120+ # 2.10 defaults torch.onnx.export to Dynamo, so preserve the legacy
121+ # behavior unless the caller explicitly selects another exporter.
122+ if 'dynamo' in inspect.signature(torch.onnx.export).parameters:
123+ export_setting.setdefault('dynamo', False)
124+ elif export_setting.get('dynamo'):
125+ raise ValueError(
126+ 'This PyTorch version does not support dynamo ONNX export.'
127+ )
128+ else:
129+ export_setting.pop('dynamo', None)
117 if torch.__version__ == '2.1.0':130 if torch.__version__ == '2.1.0':
118 export_setting['opset_version'] = 16131 export_setting['opset_version'] = 16
119 else:132 else:
@@ -63,7 +63,7 @@ def create_quant_config(
63 Function: Create quantize configuration json file for amct_pytorch tool63 Function: Create quantize configuration json file for amct_pytorch tool
64 Parameter: config_file: file path of quantize configuration json file64 Parameter: config_file: file path of quantize configuration json file
65 model: user mode instance of Torch.nn.Module65 model: user mode instance of Torch.nn.Module
66- input_data: used to compile model, can be ramdom data66+ input_data: used to compile model, can be random data
67 skip_layers: list of layers that not do quantize, default empty67 skip_layers: list of layers that not do quantize, default empty
68 batch_num: number of batch that used for calibration68 batch_num: number of batch that used for calibration
69 activation_offset: whether activation quantize with offset69 activation_offset: whether activation quantize with offset
@@ -104,7 +104,7 @@ def quantize_preprocess(config_file, record_file, model, input_data):
104 path information).104 path information).
105 record_file: a string, the name of file recording quantization factor.105 record_file: a string, the name of file recording quantization factor.
106 graph: a torch.nn.Module.106 graph: a torch.nn.Module.
107- input_data: used to compile model, can be ramdom data107+ input_data: used to compile model, can be random data
108 Returns:108 Returns:
109 None109 None
110 """110 """
@@ -168,7 +168,7 @@ def quantize_model(
168 fusion.168 fusion.
169 record_file: temporary file to store scale and offset169 record_file: temporary file to store scale and offset
170 model: user pytorch model's model file170 model: user pytorch model's model file
171- input_data: used to compile model, can be ramdom data171+ input_data: used to compile model, can be random data
172 input_names: list of strings, names to assign to the172 input_names: list of strings, names to assign to the
173 input nodes of the graph, in order173 input nodes of the graph, in order
174 output_names: names to assign to the174 output_names: names to assign to the
@@ -211,7 +211,7 @@ def inner_quantize_model(
211 fusion.211 fusion.
212 record_file: temporary file to store scale and offset212 record_file: temporary file to store scale and offset
213 model: user pytorch model's model file213 model: user pytorch model's model file
214- input_data: used to compile model, can be ramdom data214+ input_data: used to compile model, can be random data
215 input_names: list of strings, names to assign to the215 input_names: list of strings, names to assign to the
216 input nodes of the graph, in order216 input nodes of the graph, in order
217 output_names: names to assign to the217 output_names: names to assign to the
@@ -362,7 +362,7 @@ def create_quant_retrain_config(config_file, model, input_data, config_definatio
362 tool362 tool
363 Parameter: config_file: file path of quantize configuration json file363 Parameter: config_file: file path of quantize configuration json file
364 model: user mode instance of Torch.nn.Module364 model: user mode instance of Torch.nn.Module
365- input_data: used to compile model, can be ramdom data365+ input_data: used to compile model, can be random data
366 config_defination: simply config file from user to set366 config_defination: simply config file from user to set
367 Return: None367 Return: None
368 """368 """
@@ -400,7 +400,7 @@ def create_quant_retrain_model(config_file, model, record_file, input_data):
400 Parameter: config_file: retrain quantize configuration json file400 Parameter: config_file: retrain quantize configuration json file
401 model: user pytorch model's model file401 model: user pytorch model's model file
402 record_file: temporary file to store scale and offset402 record_file: temporary file to store scale and offset
403- input_data: used to compile model, can be ramdom data403+ input_data: used to compile model, can be random data
404 Return: model: modified pytorch model for retrain.404 Return: model: modified pytorch model for retrain.
405 """405 """
406 config_file = os.path.realpath(config_file)406 config_file = os.path.realpath(config_file)
@@ -440,7 +440,7 @@ def restore_quant_retrain_model(
440 Parameter: config_file: retrain quantize configuration json file440 Parameter: config_file: retrain quantize configuration json file
441 model: user pytorch model's model file441 model: user pytorch model's model file
442 record_file: temporary file to store scale and offset442 record_file: temporary file to store scale and offset
443- input_data: used to compile model, can be ramdom data443+ input_data: used to compile model, can be random data
444 pth_file: user quant aware training checkpoint file path444 pth_file: user quant aware training checkpoint file path
445 state_dict_name: key value of weight parameter in pth_file445 state_dict_name: key value of weight parameter in pth_file
446 Return: model: modified pytorch model for retrain.446 Return: model: modified pytorch model for retrain.
@@ -489,7 +489,7 @@ def save_quant_retrain_model(
489 model: retrain model489 model: retrain model
490 record_file: temporary file to store scale and offset490 record_file: temporary file to store scale and offset
491 save_path: a string, the path where to store model and model's name.491 save_path: a string, the path where to store model and model's name.
492- input_data: used to compile model, can be ramdom data492+ input_data: used to compile model, can be random data
493 input_names: list of strings, names to assign to the input nodes of493 input_names: list of strings, names to assign to the input nodes of
494 the graph, in order494 the graph, in order
495 output_names: names to assign to the output nodes of the graph495 output_names: names to assign to the output nodes of the graph
@@ -585,7 +585,7 @@ def _modify_original_model(model, input_data, config_file, record_file):
585 Function: Modify the original model to quantify retraining.585 Function: Modify the original model to quantify retraining.
586 Inputs:586 Inputs:
587 model: original model587 model: original model
588- input_data: used to compile model, can be ramdom data588+ input_data: used to compile model, can be random data
589 config_file: retrain quantize configuration json file589 config_file: retrain quantize configuration json file
590 record_file: temporary file to store scale and offset590 record_file: temporary file to store scale and offset
591 Returns:591 Returns:
@@ -639,7 +639,7 @@ def _preprocess_retrain_model(model, input_data, config_file=None):
639 2. fuse bn639 2. fuse bn
640 Inputs:640 Inputs:
641 model: retrain model641 model: retrain model
642- input_data: used to compile model, can be ramdom data642+ input_data: used to compile model, can be random data
643 config_file: retrain quantize configuration json file643 config_file: retrain quantize configuration json file
644 Returns:644 Returns:
645 model_copy: a model processed645 model_copy: a model processed
@@ -722,9 +722,7 @@ def _generate_model(graph, records, save_path):
722 graph_copy = graph.deep_copy()722 graph_copy = graph.deep_copy()
723 723 
724 # generate and save deploy model724 # generate and save deploy model
725- # PackInt4WeightPass must run BEFORE ReplaceRNNPass to pack INT4 while RNN nodes are still present.
726 deploy_optimizer = opt.GraphOptimizer()725 deploy_optimizer = opt.GraphOptimizer()
727- deploy_optimizer.add_pass(opt.PackInt4WeightPass(records))
728 deploy_optimizer.add_pass(opt.ReplaceRNNPass(records))726 deploy_optimizer.add_pass(opt.ReplaceRNNPass(records))
729 deploy_optimizer.do_optimizer(graph)727 deploy_optimizer.do_optimizer(graph)
730 deploy_file = generate_onnx_file_name(save_dir, save_prefix, 'Deploy')728 deploy_file = generate_onnx_file_name(save_dir, save_prefix, 'Deploy')
@@ -33,7 +33,7 @@ from ...amct_pytorch.utils.weight_quant_api import get_deconv_group
33 33 
34class QuantOpInfo:34class QuantOpInfo:
35 '''35 '''
36- Find infomation of quant_op.36+ Find information of quant_op.
37 '''37 '''
38 38 
39 @staticmethod39 @staticmethod
@@ -67,6 +67,9 @@ class QuantOpInfo:
67 length = weight_param.dims[1]67 length = weight_param.dims[1]
68 shape = [1] * len(weight_param.dims)68 shape = [1] * len(weight_param.dims)
69 shape[1] = length69 shape[1] = length
70+ elif node.type in ['Gemm', 'MatMul']:
71+ length = QuantOpInfo.get_cout_length(node)
72+ shape = [length]
70 else:73 else:
71 # conv2D or conv3D74 # conv2D or conv3D
72 length = weight_param.dims[0]75 length = weight_param.dims[0]
@@ -107,7 +110,7 @@ class QuantOpInfo:
107 def get_dequant_shape(node):110 def get_dequant_shape(node):
108 """111 """
109 Function: Get the dequant scale's shape from node112 Function: Get the dequant scale's shape from node
110- Inputs: node: the node te be quantized113+ Inputs: node: the node to be quantized
111 Returns: the shape of dequant scale114 Returns: the shape of dequant scale
112 """115 """
113 if node.type in ["Conv", "AscendDequant", "ConvTranspose"]:116 if node.type in ["Conv", "AscendDequant", "ConvTranspose"]:
@@ -232,9 +235,16 @@ class QuantOpInfo:
232 tensor = QuantOpInfo.get_weight_tensor(node)235 tensor = QuantOpInfo.get_weight_tensor(node)
233 if node.type == 'Conv':236 if node.type == 'Conv':
234 cout_length = tensor.dims[0]237 cout_length = tensor.dims[0]
235- elif node.type in ['ConvTranspose', 'MatMul']:238+ elif node.type == 'ConvTranspose':
236 # group conv case239 # group conv case
237 cout_length = tensor.dims[1]240 cout_length = tensor.dims[1]
241+ elif node.type == 'MatMul':
242+ if node.has_attr('with_weights_trans') and node.get_attr(
243+ 'with_weights_trans'
244+ ):
245+ cout_length = tensor.dims[0]
246+ else:
247+ cout_length = tensor.dims[-1]
238 elif node.type == 'Gemm':248 elif node.type == 'Gemm':
239 attr_helper = AttributeProtoHelper(node.proto)249 attr_helper = AttributeProtoHelper(node.proto)
240 if (250 if (
@@ -251,7 +251,10 @@ class ConfigBase:
251 self.check_int16_quantize_layers(graph, config, supported_layers)251 self.check_int16_quantize_layers(graph, config, supported_layers)
252 252 
253 self.check_and_down_grade_winograd_num_bits(graph, config, supported_layers)253 self.check_and_down_grade_winograd_num_bits(graph, config, supported_layers)
254- self.check_int4_weight_quant_axis(graph, config, supported_layers)254+ quant_layers = self.check_int4_weight_quant_axis(
255+ graph, config, list(PARAM_POOL.get_quant_layers())
256+ )
257+ PARAM_POOL.set_quant_layers(quant_layers)
255 act_common_config = self.get_common_activation_quant_config(common_config)258 act_common_config = self.get_common_activation_quant_config(common_config)
256 self._fill_default_activation_asymmetric(259 self._fill_default_activation_asymmetric(
257 graph, config, supported_layers, act_common_config260 graph, config, supported_layers, act_common_config
@@ -311,14 +314,10 @@ class ConfigBase:
311 )314 )
312 )315 )
313 316 
314- def check_int4_weight_quant_axis(self, graph, config, supported_layers):317+ def check_int4_weight_quant_axis(self, graph, config, quant_layers):
315- '''318+ '''Skip INT4 layers whose final Deploy pack axis is odd.'''
316- INT4 沿 Cin 轴两两 pack。group/depthwise conv 或 Cin 为奇数的层319+ skipped_layers = []
317- 无法沿 Cin pack,配置阶段自动将其权重降级为 INT8(A8W8)。320+ for layer in quant_layers:
318- 同时兼容 PTQ(weight_quant_params/num_bits) 与 retrain
319- (retrain_weight_config/dst_type) 两种配置结构。
320- '''
321- for layer in supported_layers:
322 layer_cfg = config.get(layer)321 layer_cfg = config.get(layer)
323 wts_ptq = layer_cfg.get(WEIGHT_QUANT_PARAMS)322 wts_ptq = layer_cfg.get(WEIGHT_QUANT_PARAMS)
324 wts_retrain = layer_cfg.get('retrain_weight_config')323 wts_retrain = layer_cfg.get('retrain_weight_config')
@@ -327,17 +326,22 @@ class ConfigBase:
327 )326 )
328 if not is_int4:327 if not is_int4:
329 continue328 continue
330- if self.graph_querier.check_int4_cin_pack_supported(graph, layer):329+ if self.graph_querier.is_int4_weight_pack_axis_even(graph, layer):
331 continue330 continue
332- if wts_ptq is not None and wts_ptq.get('num_bits') == 4:331+ layer_cfg['quant_enable'] = False
333- wts_ptq['num_bits'] = 8332+ if wts_retrain is not None:
334- if wts_retrain is not None and wts_retrain.get('dst_type') == 'INT4':333+ layer_cfg['retrain_enable'] = False
335- wts_retrain['dst_type'] = 'INT8'334+ skipped_layers.append(layer)
336 LOGGER.logw(335 LOGGER.logw(
337- "Layer {} weight cannot be INT4-packed along Cin (grouped "336+ "Cannot quantize layer '{}' with INT4 weights: its final Deploy "
338- "conv or odd Cin), downgraded to INT8 (A8W8).".format(layer)337+ "weight pack axis is invalid or odd; the layer remains in floating point.".format(
338+ layer
339+ ),
340+ module_name=_MODULE_NAME,
339 )341 )
340 342 
343+ return [layer for layer in quant_layers if layer not in skipped_layers]
344+ 
341 def check_and_down_grade_winograd_num_bits(self, graph, config, supported_layers):345 def check_and_down_grade_winograd_num_bits(self, graph, config, supported_layers):
342 '''check quant num bits and turn it into 8 if it not support int6 int7 quant'''346 '''check quant num bits and turn it into 8 if it not support int6 int7 quant'''
343 for layer in supported_layers:347 for layer in supported_layers:
@@ -585,6 +589,9 @@ class ConfigBase:
585 # check quant layer's validation by the graph589 # check quant layer's validation by the graph
586 if quant_config.get('tensor_quantize'):590 if quant_config.get('tensor_quantize'):
587 self.check_quant_tensor_valid(graph, quant_config.get('tensor_quantize'))591 self.check_quant_tensor_valid(graph, quant_config.get('tensor_quantize'))
592+ quant_layers = self.check_int4_weight_quant_axis(
593+ graph, quant_config, quant_layers
594+ )
588 self.set_param_pool(quant_layers, graph)595 self.set_param_pool(quant_layers, graph)
589 self.root.check(None, quant_config)596 self.root.check(None, quant_config)
590 self.root.fill_default(quant_config)597 self.root.fill_default(quant_config)
@@ -661,7 +668,7 @@ class ConfigBase:
661 LOGGER.logi("Check quant tensor success!")668 LOGGER.logi("Check quant tensor success!")
662 # step2: check layers in QUANTIZABLE_TYPES669 # step2: check layers in QUANTIZABLE_TYPES
663 supported_layers = self.get_supported_layers(graph, tensor_quant_valid)670 supported_layers = self.get_supported_layers(graph, tensor_quant_valid)
664- # step3: remove skiped type and layer671+ # step3: remove skipped type and layer
665 layer_type = self.graph_querier.get_name_type_dict(graph)672 layer_type = self.graph_querier.get_name_type_dict(graph)
666 for item in supported_layers:673 for item in supported_layers:
667 if layer_type.get(item) in skip_types:674 if layer_type.get(item) in skip_types:
@@ -760,6 +760,8 @@ class ChannelWiseField(LeafField):
760 layer_name = PARAM_POOL.get_layer_name()760 layer_name = PARAM_POOL.get_layer_name()
761 layer_type = PARAM_POOL.get_layer_type()761 layer_type = PARAM_POOL.get_layer_type()
762 channel_wise_types = self.capacity.get_value('CHANNEL_WISE_TYPES')762 channel_wise_types = self.capacity.get_value('CHANNEL_WISE_TYPES')
763+ if layer_type[layer_name] == 'Linear':
764+ return False
763 return layer_type[layer_name] in channel_wise_types765 return layer_type[layer_name] in channel_wise_types
764 766 
765 def check(self, name, value):767 def check(self, name, value):
@@ -185,9 +185,9 @@ class ProtoConfig: # pylint: disable=too-many-instance-attributes, too-few-publ
185 common_config = self._get_conv_calibration_config()185 common_config = self._get_conv_calibration_config()
186 if hasattr(self.proto_config, FC_CALIBRATION_CONFIG):186 if hasattr(self.proto_config, FC_CALIBRATION_CONFIG):
187 fc_config = self._get_fc_calibration_config()187 fc_config = self._get_fc_calibration_config()
188- for item in set(self.quantizable_type) - set(188+ fc_types = set(self.quantizable_type) - set(self.channel_wise_types)
189- self.channel_wise_types189+ fc_types.add('Linear')
190- ):190+ for item in fc_types:
191 type_config[item] = copy.deepcopy(fc_config)191 type_config[item] = copy.deepcopy(fc_config)
192 else:192 else:
193 if hasattr(self.proto_config, CONV_CALIBRATION_CONFIG):193 if hasattr(self.proto_config, CONV_CALIBRATION_CONFIG):
@@ -218,7 +218,7 @@ class RetrainConfigBase:
218 self._del_reduant_config(218 self._del_reduant_config(
219 ordered_config, retrain_supported_layers, prune_supported_layers219 ordered_config, retrain_supported_layers, prune_supported_layers
220 )220 )
221- self.downgrade_int4_unpackable_layers(graph, ordered_config)221+ self.skip_int4_unpackable_layers(graph, ordered_config)
222 if hasattr(self.graph_checker, 'check_weights_shared'):222 if hasattr(self.graph_checker, 'check_weights_shared'):
223 layer_names = get_layers_from_config(223 layer_names = get_layers_from_config(
224 ordered_config, self.config_tree.get_global_keys()224 ordered_config, self.config_tree.get_global_keys()
@@ -233,25 +233,22 @@ class RetrainConfigBase:
233 with open(config_file, 'w') as fid:233 with open(config_file, 'w') as fid:
234 json.dump(ordered_config, fid, indent=4, separators=(',', ':'))234 json.dump(ordered_config, fid, indent=4, separators=(',', ':'))
235 235 
236- def downgrade_int4_unpackable_layers(self, graph, ordered_config):236+ def skip_int4_unpackable_layers(self, graph, ordered_config):
237- '''237+ '''Keep INT4 layers with an odd final Deploy pack axis in floating point.'''
238- group/depthwise conv 或 Cin 为奇数的层无法沿 Cin pack INT4,
239- 配置阶段将其权重从 INT4 降级为 INT8(A8W8)。遍历所有配置了
240- retrain_weight_config 的层。
241- '''
242- if not hasattr(self.graph_querier, 'check_int4_cin_pack_supported'):
243- return
244 for layer, layer_cfg in ordered_config.items():238 for layer, layer_cfg in ordered_config.items():
245 if not isinstance(layer_cfg, dict):239 if not isinstance(layer_cfg, dict):
246 continue240 continue
247 wts = layer_cfg.get('retrain_weight_config')241 wts = layer_cfg.get('retrain_weight_config')
248 if wts is None or wts.get('dst_type') != 'INT4':242 if wts is None or wts.get('dst_type') != 'INT4':
249 continue243 continue
250- if not self.graph_querier.check_int4_cin_pack_supported(graph, layer):244+ if not self.graph_querier.is_int4_weight_pack_axis_even(graph, layer):
251- wts['dst_type'] = 'INT8'245+ layer_cfg[RETRAIN_ENABLE] = False
252 LOGGER.logw(246 LOGGER.logw(
253- "Layer {} weight cannot be INT4-packed along Cin (grouped "247+ "Cannot quantize layer '{}' with INT4 weights: its final Deploy "
254- "conv or odd Cin), downgraded to INT8 (A8W8).".format(layer)248+ "weight pack axis is invalid or odd; the layer remains in floating point.".format(
249+ layer
250+ ),
251+ module_name=_MODULE_NAME,
255 )252 )
256 253 
257 def get_support_layers(self, graph):254 def get_support_layers(self, graph):
@@ -317,6 +314,7 @@ class RetrainConfigBase:
317 quant_config = self.config_tree.dump()314 quant_config = self.config_tree.dump()
318 valid_layers_refilled = self.get_supported_layers(graph)315 valid_layers_refilled = self.get_supported_layers(graph)
319 self._del_reduant_config(quant_config, valid_layers_refilled, dict())316 self._del_reduant_config(quant_config, valid_layers_refilled, dict())
317+ self.skip_int4_unpackable_layers(graph, quant_config)
320 318 
321 if hasattr(self.graph_checker, 'check_weights_shared'):319 if hasattr(self.graph_checker, 'check_weights_shared'):
322 layer_names = get_layers_from_config(320 layer_names = get_layers_from_config(
@@ -469,7 +467,7 @@ class RetrainConfigBase:
469 proto.check_field(self.enable_retrain, self.enable_prune)467 proto.check_field(self.enable_retrain, self.enable_prune)
470 468 
471 # Step2: check not support layers and not support types469 # Step2: check not support layers and not support types
472- # check gloabl470+ # check global
473 self._check_proto_global(proto, retrain_layers.get('support_layers'))471 self._check_proto_global(proto, retrain_layers.get('support_layers'))
474 # check retrain472 # check retrain
475 if self.enable_retrain:473 if self.enable_retrain:
@@ -522,13 +520,13 @@ def check_dst_type_legal(layer_data_config, layer_weight_config):
522 if not layer_data_config.get(DST_TYPE):520 if not layer_data_config.get(DST_TYPE):
523 LOGGER.logw(521 LOGGER.logw(
524 "dst_type of RetrainDataQuantConfig was not given in config, "522 "dst_type of RetrainDataQuantConfig was not given in config, "
525- "and was set to 'INT8' by defualt!",523+ "and was set to 'INT8' by default!",
526 module_name=_MODULE_NAME,524 module_name=_MODULE_NAME,
527 )525 )
528 if not layer_weight_config.get(DST_TYPE):526 if not layer_weight_config.get(DST_TYPE):
529 LOGGER.logw(527 LOGGER.logw(
530 "dst_type of RetrainWeightQuantConfig was not given in config, "528 "dst_type of RetrainWeightQuantConfig was not given in config, "
531- "and was set to 'INT8' by defualt!",529+ "and was set to 'INT8' by default!",
532 module_name=_MODULE_NAME,530 module_name=_MODULE_NAME,
533 )531 )
534 error_info = (532 error_info = (
@@ -289,10 +289,7 @@ class ChannelWise(ConfigItem):
289 def build(self, val, extra):289 def build(self, val, extra):
290 '''inner method'''290 '''inner method'''
291 self.check_type('ChannelWise', val, bool, extra[0])291 self.check_type('ChannelWise', val, bool, extra[0])
292- if (292+ if extra[1] in ['MatMul', 'InnerProduct', 'Pooling', 'AvgPool'] and val is True:
293- extra[1] in ['Linear', 'MatMul', 'InnerProduct', 'Pooling', 'AvgPool']
294- and val is True
295- ):
296 raise ValueError(' %s layer can not be channewised' % extra[0])293 raise ValueError(' %s layer can not be channewised' % extra[0])
297 self.value = val294 self.value = val
298 295 
@@ -435,7 +435,7 @@ class TestCheckModel(unittest.TestCase):
435 435 
436 def test_get_support_dmq_balancer_types(self):436 def test_get_support_dmq_balancer_types(self):
437 ret = GraphQuerier.get_support_dmq_balancer_types()437 ret = GraphQuerier.get_support_dmq_balancer_types()
438- ans = set(438+ expected = set(
439 [439 [
440 CONV2D,440 CONV2D,
441 CONV3D,441 CONV3D,
@@ -449,12 +449,12 @@ class TestCheckModel(unittest.TestCase):
449 'ConvTranspose1d',449 'ConvTranspose1d',
450 ]450 ]
451 )451 )
452- self.assertEqual(set(ret), ans)452+ self.assertEqual(set(ret), expected)
453 453 
454 def test_get_support_dmq_balancer_layers(self):454 def test_get_support_dmq_balancer_layers(self):
455 self.graph.add_model(self.model_001)455 self.graph.add_model(self.model_001)
456 layer_names = GraphQuerier.get_support_dmq_balancer_layers(self.graph)456 layer_names = GraphQuerier.get_support_dmq_balancer_layers(self.graph)
457- ans = [457+ expected = [
458 'layer1.0',458 'layer1.0',
459 'layer2.0',459 'layer2.0',
460 'layer3.0',460 'layer3.0',
@@ -465,7 +465,7 @@ class TestCheckModel(unittest.TestCase):
465 'fc.2',465 'fc.2',
466 'fc.5',466 'fc.5',
467 ]467 ]
468- self.assertEqual(layer_names, ans)468+ self.assertEqual(layer_names, expected)
469 469 
470 470 
471class TestCheckGraph(unittest.TestCase):471class TestCheckGraph(unittest.TestCase):
@@ -826,7 +826,7 @@ class TestCheckGraph(unittest.TestCase):
826 826 
827 def test_get_support_dmq_balancer_layers(self):827 def test_get_support_dmq_balancer_layers(self):
828 layer_names = GraphQuerier.get_support_dmq_balancer_layers(self.graph)828 layer_names = GraphQuerier.get_support_dmq_balancer_layers(self.graph)
829- ans = [829+ expected = [
830 'layer1.0',830 'layer1.0',
831 'layer2.0',831 'layer2.0',
832 'layer3.0',832 'layer3.0',
@@ -837,7 +837,7 @@ class TestCheckGraph(unittest.TestCase):
837 'fc.2',837 'fc.2',
838 'fc.5',838 'fc.5',
839 ]839 ]
840- self.assertEqual(layer_names, ans)840+ self.assertEqual(layer_names, expected)
841 841 
842 def test_check_distill_type_conv2d(self):842 def test_check_distill_type_conv2d(self):
843 mod_name = CONV1843 mod_name = CONV1
@@ -903,16 +903,12 @@ class TestCheckGraph(unittest.TestCase):
903 self.assertFalse(GraphChecker.check_rnn_limit(mod_type, mod_name, mod))903 self.assertFalse(GraphChecker.check_rnn_limit(mod_type, mod_name, mod))
904 904 
905 905 
906-class TestCheckInt4CinPackSupported(unittest.TestCase):906+class TestIsInt4WeightPackAxisEven(unittest.TestCase):
907- """GraphQuerier.check_int4_cin_pack_supported 各分支,不依赖 onnx 原生 INT4。"""907+ """INT4 support is determined by the final Deploy ONNX pack axis."""
908 908 
909 _QOI = (909 _QOI = (
910 'amct_pytorch.classic.graph_based.amct_pytorch.configuration.check.QuantOpInfo'910 'amct_pytorch.classic.graph_based.amct_pytorch.configuration.check.QuantOpInfo'
911 )911 )
912- _ATTR = (
913- 'amct_pytorch.classic.graph_based.amct_pytorch.configuration.check.'
914- 'AttributeProtoHelper'
915- )
916 912 
917 @staticmethod913 @staticmethod
918 def _graph_with(node):914 def _graph_with(node):
@@ -921,54 +917,132 @@ class TestCheckInt4CinPackSupported(unittest.TestCase):
921 return graph917 return graph
922 918 
923 def test_unknown_type_supported(self):919 def test_unknown_type_supported(self):
924- # get_cin_axis 对未知类型返回 None -> 非量化算子,放行(supported=True)
925 node = mock.MagicMock()920 node = mock.MagicMock()
926 node.type = 'Relu'921 node.type = 'Relu'
927 with mock.patch(self._QOI) as m_qoi:922 with mock.patch(self._QOI) as m_qoi:
928- m_qoi.get_cin_axis.return_value = None923+ m_qoi.get_weight_node.return_value = None
929- ret = GraphQuerier.check_int4_cin_pack_supported(924+ ret = GraphQuerier.is_int4_weight_pack_axis_even(
930 self._graph_with(node), 'relu'925 self._graph_with(node), 'relu'
931 )926 )
932 self.assertTrue(ret)927 self.assertTrue(ret)
933 928 
934- def test_group_conv_not_supported(self):929+ def test_missing_weight_dimensions_rejected(self):
935 node = mock.MagicMock()930 node = mock.MagicMock()
936 node.type = 'Conv'931 node.type = 'Conv'
937- with mock.patch(self._QOI) as m_qoi, mock.patch(self._ATTR) as m_attr:932+ with mock.patch(self._QOI) as m_qoi:
938- m_qoi.get_cin_axis.return_value = 1933+ m_qoi.get_recurrence_weight_node.return_value = None
939- m_attr.return_value.has_attr.return_value = True934+ for dims in (None, [], [2], [2, 2]):
940- m_attr.return_value.get_attr_value.return_value = 16 # groups>1935+ with self.subTest(dims=dims):
941- ret = GraphQuerier.check_int4_cin_pack_supported(936+ m_qoi.get_node_tensor.return_value.dims = dims
942- self._graph_with(node), 'conv'937+ self.assertFalse(
943- )938+ GraphQuerier.is_int4_weight_pack_axis_even(
944- self.assertFalse(ret)939+ self._graph_with(node), 'conv'
940+ )
941+ )
945 942 
946- def test_odd_cin_not_supported(self):943+ def test_group_conv_with_even_last_axis_supported(self):
947 node = mock.MagicMock()944 node = mock.MagicMock()
948 node.type = 'Conv'945 node.type = 'Conv'
949 wnode = mock.MagicMock()946 wnode = mock.MagicMock()
950- with mock.patch(self._QOI) as m_qoi, mock.patch(self._ATTR) as m_attr:947+ with mock.patch(self._QOI) as m_qoi:
951- m_qoi.get_cin_axis.return_value = 1
952- m_attr.return_value.has_attr.return_value = False # 非 group
953 m_qoi.get_weight_node.return_value = wnode948 m_qoi.get_weight_node.return_value = wnode
954 m_qoi.get_recurrence_weight_node.return_value = None949 m_qoi.get_recurrence_weight_node.return_value = None
955- m_qoi.get_node_tensor.return_value.dims = [8, 3, 3, 3] # cin=3 奇950+ m_qoi.get_node_tensor.return_value.dims = [8, 1, 3, 2]
956- ret = GraphQuerier.check_int4_cin_pack_supported(951+ ret = GraphQuerier.is_int4_weight_pack_axis_even(
957- self._graph_with(node), 'conv'
958- )
959- self.assertFalse(ret)
960- 
961- def test_even_cin_supported(self):
962- node = mock.MagicMock()
963- node.type = 'Conv'
964- wnode = mock.MagicMock()
965- with mock.patch(self._QOI) as m_qoi, mock.patch(self._ATTR) as m_attr:
966- m_qoi.get_cin_axis.return_value = 1
967- m_attr.return_value.has_attr.return_value = False
968- m_qoi.get_weight_node.return_value = wnode
969- m_qoi.get_recurrence_weight_node.return_value = None
970- m_qoi.get_node_tensor.return_value.dims = [8, 4, 3, 3] # cin=4 偶
971- ret = GraphQuerier.check_int4_cin_pack_supported(
972 self._graph_with(node), 'conv'952 self._graph_with(node), 'conv'
973 )953 )
974 self.assertTrue(ret)954 self.assertTrue(ret)
955+ 
956+ def test_conv_with_odd_last_axis_not_supported(self):
957+ node = mock.MagicMock()
958+ node.type = 'Conv'
959+ wnode = mock.MagicMock()
960+ with (
961+ mock.patch(self._QOI) as m_qoi,
962+ mock.patch(
963+ 'amct_pytorch.classic.graph_based.amct_pytorch.configuration.'
964+ 'check.LOGGER'
965+ ) as logger,
966+ ):
967+ m_qoi.get_weight_node.return_value = wnode
968+ m_qoi.get_recurrence_weight_node.return_value = None
969+ m_qoi.get_node_tensor.return_value.dims = [8, 4, 3, 3]
970+ ret = GraphQuerier.is_int4_weight_pack_axis_even(
971+ self._graph_with(node), 'conv'
972+ )
973+ self.assertFalse(ret)
974+ self.assertIn('axis -1', logger.logw.call_args.args[0])
975+ 
976+ def test_conv_with_odd_cin_and_even_last_axis_supported(self):
977+ node = mock.MagicMock()
978+ node.type = 'Conv'
979+ wnode = mock.MagicMock()
980+ with mock.patch(self._QOI) as m_qoi:
981+ m_qoi.get_weight_node.return_value = wnode
982+ m_qoi.get_recurrence_weight_node.return_value = None
983+ m_qoi.get_node_tensor.return_value.dims = [8, 3, 3, 2]
984+ ret = GraphQuerier.is_int4_weight_pack_axis_even(
985+ self._graph_with(node), 'conv'
986+ )
987+ self.assertTrue(ret)
988+ 
989+ def test_gemm_transb_one_checks_axis_zero(self):
990+ node = mock.MagicMock()
991+ node.type = 'Gemm'
992+ wnode = mock.MagicMock()
993+ attr = mock.MagicMock()
994+ attr.has_attr.return_value = True
995+ attr.get_attr_value.return_value = 1
996+ with (
997+ mock.patch(self._QOI) as m_qoi,
998+ mock.patch(
999+ 'amct_pytorch.classic.graph_based.amct_pytorch.configuration.'
1000+ 'check.AttributeProtoHelper',
1001+ return_value=attr,
1002+ ),
1003+ ):
1004+ m_qoi.get_weight_node.return_value = wnode
1005+ m_qoi.get_recurrence_weight_node.return_value = None
1006+ m_qoi.get_node_tensor.return_value.dims = [6, 3]
1007+ self.assertTrue(
1008+ GraphQuerier.is_int4_weight_pack_axis_even(
1009+ self._graph_with(node), 'gemm'
1010+ )
1011+ )
1012+ m_qoi.get_node_tensor.return_value.dims = [5, 4]
1013+ self.assertFalse(
1014+ GraphQuerier.is_int4_weight_pack_axis_even(
1015+ self._graph_with(node), 'gemm'
1016+ )
1017+ )
1018+ 
1019+ def test_gemm_transb_zero_and_matmul_check_last_axis(self):
1020+ wnode = mock.MagicMock()
1021+ attr = mock.MagicMock()
1022+ attr.has_attr.return_value = True
1023+ attr.get_attr_value.return_value = 0
1024+ with (
1025+ mock.patch(self._QOI) as m_qoi,
1026+ mock.patch(
1027+ 'amct_pytorch.classic.graph_based.amct_pytorch.configuration.'
1028+ 'check.AttributeProtoHelper',
1029+ return_value=attr,
1030+ ),
1031+ ):
1032+ m_qoi.get_weight_node.return_value = wnode
1033+ m_qoi.get_recurrence_weight_node.return_value = None
1034+ for node_type in ('Gemm', 'MatMul'):
1035+ node = mock.MagicMock()
1036+ node.type = node_type
1037+ m_qoi.get_node_tensor.return_value.dims = [3, 6]
1038+ self.assertTrue(
1039+ GraphQuerier.is_int4_weight_pack_axis_even(
1040+ self._graph_with(node), node_type.lower()
1041+ )
1042+ )
1043+ m_qoi.get_node_tensor.return_value.dims = [4, 5]
1044+ self.assertFalse(
1045+ GraphQuerier.is_int4_weight_pack_axis_even(
1046+ self._graph_with(node), node_type.lower()
1047+ )
1048+ )
@@ -16,7 +16,10 @@
16# limitations under the License.16# limitations under the License.
17# ----------------------------------------------------------------------------17# ----------------------------------------------------------------------------
18import logging18import logging
19+import json
20+import tempfile
19import unittest21import unittest
22+from pathlib import Path
20from unittest.mock import MagicMock23from unittest.mock import MagicMock
21 24 
22from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY25from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY
@@ -117,6 +120,55 @@ class TestConfigBaseChecks(unittest.TestCase):
117 obj.check_and_down_grade_winograd_num_bits(GRAPH, config, ['l1'])120 obj.check_and_down_grade_winograd_num_bits(GRAPH, config, ['l1'])
118 self.assertEqual(config['l1'][WGT]['num_bits'], 8)121 self.assertEqual(config['l1'][WGT]['num_bits'], 8)
119 122 
123+ def test_int4_odd_pack_axis_skips_layer_without_int8_downgrade(self):
124+ obj, q = _cfg()
125+ q.get_support_quant_layers.return_value = ['l1', 'l2']
126+ q.get_name_type_dict.return_value = {'l1': 'Conv2d', 'l2': 'Conv2d'}
127+ q.is_int4_weight_pack_axis_even.side_effect = lambda graph, layer: layer == 'l2'
128+ obj.set_param_pool(['l1', 'l2'], GRAPH)
129+ supported_layers = ['l1', 'l2']
130+ config = {
131+ 'l1': {'quant_enable': True, WGT: {'num_bits': 4}},
132+ 'l2': {'quant_enable': True, WGT: {'num_bits': 4}},
133+ }
134+ 
135+ quant_layers = obj.check_int4_weight_quant_axis(GRAPH, config, supported_layers)
136+ 
137+ self.assertFalse(config['l1']['quant_enable'])
138+ self.assertEqual(config['l1'][WGT]['num_bits'], 4)
139+ self.assertEqual(supported_layers, ['l1', 'l2'])
140+ self.assertEqual(quant_layers, ['l2'])
141+ self.assertTrue(config['l2']['quant_enable'])
142+ 
143+ def test_non_int4_layer_is_not_checked_for_pack_support(self):
144+ obj, q = _cfg()
145+ config = {'l1': {'quant_enable': True, WGT: {'num_bits': 8}}}
146+ 
147+ obj.check_int4_weight_quant_axis(GRAPH, config, ['l1'])
148+ 
149+ q.is_int4_weight_pack_axis_even.assert_not_called()
150+ self.assertTrue(config['l1']['quant_enable'])
151+ 
152+ def test_parse_config_file_applies_int4_pack_axis_filter(self):
153+ obj, q = _cfg()
154+ obj.root = MagicMock()
155+ obj.root.get_keys.return_value = []
156+ q.get_name_type_dict.return_value = {'l1': 'Conv2d'}
157+ obj.check_int4_weight_quant_axis = MagicMock()
158+ config = {
159+ 'l1': {
160+ 'quant_enable': True,
161+ ACT: {'num_bits': 8},
162+ WGT: {'num_bits': 4},
163+ }
164+ }
165+ with tempfile.TemporaryDirectory() as temp_dir:
166+ config_file = Path(temp_dir) / 'config.json'
167+ config_file.write_text(json.dumps(config), encoding='utf-8')
168+ parsed = obj.parse_config_file(str(config_file), GRAPH)
169+ 
170+ obj.check_int4_weight_quant_axis.assert_called_once_with(GRAPH, parsed, ['l1'])
171+ 
120 def test_check_activation_symmetric_valid_raises(self):172 def test_check_activation_symmetric_valid_raises(self):
121 obj, q = _cfg()173 obj, q = _cfg()
122 q.get_act_symmetric_limit_layers.return_value = ['l1']174 q.get_act_symmetric_limit_layers.return_value = ['l1']
@@ -17,6 +17,7 @@
17# ----------------------------------------------------------------------------17# ----------------------------------------------------------------------------
18import logging18import logging
19import unittest19import unittest
20+from types import SimpleNamespace
20from unittest.mock import MagicMock21from unittest.mock import MagicMock
21 22 
22from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY23from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY
@@ -26,6 +27,9 @@ from amct_pytorch.classic.graph_based.amct_pytorch.common.config.config_base imp
26 check_config_quant_enable,27 check_config_quant_enable,
27 check_config_dmq_balancer,28 check_config_dmq_balancer,
28)29)
30+from amct_pytorch.classic.graph_based.amct_pytorch.common.config.proto_config import (
31+ ProtoConfig,
32+)
29 33 
30logger = logging.getLogger(__name__)34logger = logging.getLogger(__name__)
31 35 
@@ -99,6 +103,28 @@ class TestConfigBaseStatic(unittest.TestCase):
99 self.assertEqual(wgt.get('num_bits'), 8)103 self.assertEqual(wgt.get('num_bits'), 8)
100 self.assertEqual(wgt.get('wts_algo'), 'arq_quantize')104 self.assertEqual(wgt.get('wts_algo'), 'arq_quantize')
101 105 
106+ def test_legacy_fc_config_still_applies_to_linear(self):
107+ proto_config = ProtoConfig.__new__(ProtoConfig)
108+ proto_config.proto_config = SimpleNamespace(
109+ conv_calibration_config=object(), fc_calibration_config=object()
110+ )
111+ proto_config.quantizable_type = ['Conv2d', 'Linear']
112+ proto_config.channel_wise_types = ['Conv2d', 'Linear']
113+ proto_config._get_global_config = MagicMock(return_value={})
114+ proto_config._get_override_layer_configs = MagicMock(return_value={})
115+ proto_config._get_override_layer_types = MagicMock(return_value={})
116+ proto_config._get_common_config = MagicMock(return_value={})
117+ proto_config._get_conv_calibration_config = MagicMock(
118+ return_value={'config': 'conv'}
119+ )
120+ proto_config._get_fc_calibration_config = MagicMock(
121+ return_value={'config': 'fc'}
122+ )
123+ 
124+ config = proto_config.get_proto_config()
125+ 
126+ self.assertEqual(config.type_config['Linear'], {'config': 'fc'})
127+ 
102 128 
103class TestConfigBaseModuleFuncs(unittest.TestCase):129class TestConfigBaseModuleFuncs(unittest.TestCase):
104 def test_check_config_quant_enable_ok(self):130 def test_check_config_quant_enable_ok(self):
@@ -198,6 +198,16 @@ class TestParamPool(unittest.TestCase):
198 198 
199 199 
200class TestContainerFields(unittest.TestCase):200class TestContainerFields(unittest.TestCase):
201+ def test_linear_channel_wise_is_allowed_but_disabled_by_default(self):
202+ F.PARAM_POOL.clear()
203+ F.PARAM_POOL.set_layer_type({'linear': 'Linear'})
204+ F.PARAM_POOL.set_layer_name('linear')
205+ field = _f(F.ChannelWiseField)
206+ 
207+ field.check('channel_wise', True)
208+ self.assertFalse(field.default_value())
209+ F.PARAM_POOL.clear()
210+ 
201 def test_skip_fusion_layers_field(self):211 def test_skip_fusion_layers_field(self):
202 F.PARAM_POOL.clear()212 F.PARAM_POOL.clear()
203 F.PARAM_POOL.set_layer_type({'conv1': 'Conv2d', 'relu1': 'ReLU'})213 F.PARAM_POOL.set_layer_type({'conv1': 'Conv2d', 'relu1': 'ReLU'})
@@ -365,14 +365,18 @@ class TestRetrainConfigForPrune(unittest.TestCase):
365 )365 )
366 RetrainConfig.init(self.graph, config_defination, True, True)366 RetrainConfig.init(self.graph, config_defination, True, True)
367 367 
368- def test_compressed_only_quant_only_data_weight_cfg(self):368+ def test_compressed_only_quant_only_data_weight_channelwise_cfg(self):
369 config_defination = os.path.join(369 config_defination = os.path.join(
370 CUR_DIR,370 CUR_DIR,
371 "./utils/compressed_cfg/net_001_compressed_quant_only_data_weight.cfg",371 "./utils/compressed_cfg/net_001_compressed_quant_only_data_weight.cfg",
372 )372 )
373- self.assertRaises(373+ RetrainConfig.init(self.graph, config_defination, True, True)
374- ValueError, RetrainConfig.init, self.graph, config_defination, True, True374+ 
375- )375+ for layer_name in ('fc.0', 'fc.2', 'fc.5'):
376+ weight_config = RetrainConfig.retrain_config[layer_name][
377+ 'retrain_weight_config'
378+ ]
379+ self.assertTrue(weight_config['channel_wise'])
376 380 
377 def test_compressed_only_quant_only_data_weight_no_channelwise_cfg(self):381 def test_compressed_only_quant_only_data_weight_no_channelwise_cfg(self):
378 config_defination = os.path.join(382 config_defination = os.path.join(
@@ -16,7 +16,10 @@
16# limitations under the License.16# limitations under the License.
17# ----------------------------------------------------------------------------17# ----------------------------------------------------------------------------
18import logging18import logging
19+import json
20+import tempfile
19import unittest21import unittest
22+from pathlib import Path
20from unittest.mock import MagicMock23from unittest.mock import MagicMock
21 24 
22from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY25from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY
@@ -119,37 +122,66 @@ class TestRetrainConfigBase(unittest.TestCase):
119 obj.set_config_by_graph_construct({}, GRAPH)122 obj.set_config_by_graph_construct({}, GRAPH)
120 123 
121 124 
122-class TestDowngradeInt4UnpackableLayers(unittest.TestCase):125+class TestSkipInt4UnpackableLayers(unittest.TestCase):
123- def test_downgrade_unpackable_int4_layer_to_int8(self):126+ def test_skip_unpackable_int4_layer_without_int8_downgrade(self):
124 obj, querier, _ = _make()127 obj, querier, _ = _make()
125- # conv1 不支持沿 Cin pack -> 降级;conv2 支持 -> 保持 INT4128+ querier.is_int4_weight_pack_axis_even.side_effect = lambda graph, layer: (
126- querier.check_int4_cin_pack_supported.side_effect = lambda graph, layer: (
127 layer != 'conv1'129 layer != 'conv1'
128 )130 )
129 config = {131 config = {
130- 'conv1': {'retrain_weight_config': {'dst_type': 'INT4'}},132+ 'conv1': {
131- 'conv2': {'retrain_weight_config': {'dst_type': 'INT4'}},133+ 'retrain_enable': True,
132- 'version': 'v1', # 非 dict 之外的全局项也应被安全跳过134+ 'retrain_weight_config': {'dst_type': 'INT4'},
135+ },
136+ 'conv2': {
137+ 'retrain_enable': True,
138+ 'retrain_weight_config': {'dst_type': 'INT4'},
139+ },
140+ 'version': 'v1',
133 }141 }
134- obj.downgrade_int4_unpackable_layers(None, config)142+ 
135- self.assertEqual(config['conv1']['retrain_weight_config']['dst_type'], 'INT8')143+ obj.skip_int4_unpackable_layers(None, config)
144+ 
145+ self.assertFalse(config['conv1']['retrain_enable'])
146+ self.assertEqual(config['conv1']['retrain_weight_config']['dst_type'], 'INT4')
147+ self.assertTrue(config['conv2']['retrain_enable'])
136 self.assertEqual(config['conv2']['retrain_weight_config']['dst_type'], 'INT4')148 self.assertEqual(config['conv2']['retrain_weight_config']['dst_type'], 'INT4')
137 149 
138 def test_non_int4_layer_untouched(self):150 def test_non_int4_layer_untouched(self):
139 obj, querier, _ = _make()151 obj, querier, _ = _make()
140- querier.check_int4_cin_pack_supported.return_value = False152+ querier.is_int4_weight_pack_axis_even.return_value = False
141- config = {'fc': {'retrain_weight_config': {'dst_type': 'INT8'}}}153+ config = {
142- obj.downgrade_int4_unpackable_layers(None, config)154+ 'fc': {
143- # 非 INT4 层即便 not supported 也不动155+ 'retrain_enable': True,
156+ 'retrain_weight_config': {'dst_type': 'INT8'},
157+ }
158+ }
159+ obj.skip_int4_unpackable_layers(None, config)
144 self.assertEqual(config['fc']['retrain_weight_config']['dst_type'], 'INT8')160 self.assertEqual(config['fc']['retrain_weight_config']['dst_type'], 'INT8')
161+ self.assertTrue(config['fc']['retrain_enable'])
145 162 
146- def test_no_querier_method_is_noop(self):163+ def test_parse_config_file_applies_int4_pack_axis_filter(self):
147- obj, _, _ = _make()164+ obj, _, checker = _make()
148- # graph_querier check_int4_cin_pack_supported 时直接返回,不报错165+ obj.get_supported_layers = MagicMock(return_value={'conv': 'Conv2d'})
149- obj.graph_querier = object()166+ obj._check_reduant_config = MagicMock()
150- config = {'conv': {'retrain_weight_config': {'dst_type': 'INT4'}}}167+ obj._del_reduant_config = MagicMock()
151- obj.downgrade_int4_unpackable_layers(None, config)168+ obj.config_tree = MagicMock()
152- self.assertEqual(config['conv']['retrain_weight_config']['dst_type'], 'INT4')169+ quant_config = {
170+ 'conv': {
171+ 'retrain_enable': True,
172+ 'retrain_weight_config': {'dst_type': 'INT4'},
173+ }
174+ }
175+ obj.config_tree.dump.return_value = quant_config
176+ obj.config_tree.get_global_keys.return_value = []
177+ obj.skip_int4_unpackable_layers = MagicMock()
178+ checker.check_weights_shared = MagicMock()
179+ with tempfile.TemporaryDirectory() as temp_dir:
180+ config_file = Path(temp_dir) / 'config.json'
181+ config_file.write_text(json.dumps(quant_config), encoding='utf-8')
182+ obj.parse_config_file(str(config_file), GRAPH)
183+ 
184+ obj.skip_int4_unpackable_layers.assert_called_once_with(GRAPH, quant_config)
153 185 
154 186 
155if __name__ == '__main__':187if __name__ == '__main__':
@@ -116,7 +116,9 @@ class TestSimpleFields(unittest.TestCase):
116 116 
117 def test_channel_wise(self):117 def test_channel_wise(self):
118 item = _item(rf.ChannelWise)118 item = _item(rf.ChannelWise)
119- self.assertRaises(ValueError, item.build, True, [LAYER, 'Linear'])119+ item.build(True, [LAYER, 'Linear'])
120+ self.assertTrue(item.value)
121+ self.assertRaises(ValueError, item.build, True, [LAYER, 'MatMul'])
120 item.build(True, [LAYER, 'Conv2d'])122 item.build(True, [LAYER, 'Conv2d'])
121 self.assertTrue(item.value)123 self.assertTrue(item.value)
122 item.build_default([LAYER, 'Linear'])124 item.build_default([LAYER, 'Linear'])
@@ -21,35 +21,89 @@ can be exercised directly with a mocked graph instead of a full ONNX export
21"""21"""
22 22 
23import unittest23import unittest
24-from unittest.mock import MagicMock24+from types import SimpleNamespace
25+from unittest import mock
25 26 
26import torch27import torch
28+from onnx import TensorProto
27 29 
28from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.arq_retrain.arq_retrain import (30from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.arq_retrain.arq_retrain import (
31+ ArqRetrainFunction,
29 ArqRetrainFuncQAT,32 ArqRetrainFuncQAT,
30)33)
31 34 
32 35 
33def _make_graph():36def _make_graph():
34 """A fake ONNX graph whose op() returns a fresh sentinel node each call."""37 """A fake ONNX graph whose op() returns a fresh sentinel node each call."""
35- g = MagicMock()38+ g = mock.MagicMock()
36- g.op.side_effect = lambda *a, **k: MagicMock(name="node")39+ g.op.side_effect = lambda *a, **k: mock.MagicMock(name="node")
37 return g40 return g
38 41 
39 42 
40-def _make_inputs(module_type, hidden_size=8):43+def _export_opset(version):
41- module = MagicMock()44+ return mock.patch(
45+ 'torch.onnx._globals.GLOBALS',
46+ SimpleNamespace(export_onnx_opset_version=version),
47+ )
48+ 
49+ 
50+def _make_inputs(
51+ module_type,
52+ hidden_size=8,
53+ num_bits=8,
54+ channel_wise=False,
55+ out_channels=4,
56+ scale_count=None,
57+ weight_shape=(4, 4),
58+):
59+ module = mock.MagicMock()
42 module.hidden_size = hidden_size60 module.hidden_size = hidden_size
61+ module.out_channels = out_channels
62+ module.wts_scales = torch.ones(out_channels if scale_count is None else scale_count)
63+ module.weight = torch.randn(*weight_shape)
43 # symbolic reads positional args tensor, scale, offset, wts_param, zero_point64 # symbolic reads positional args tensor, scale, offset, wts_param, zero_point
44- wts_param = {"module_type": module_type, "module": module}65+ wts_param = {
45- tensor = torch.randn(4, 4)66+ "module_type": module_type,
46- scale = torch.ones(1)67+ "module": module,
68+ "num_bits": num_bits,
69+ "channel_wise": channel_wise,
70+ }
71+ tensor = module.weight
72+ scale = module.wts_scales
47 offset = torch.zeros(1)73 offset = torch.zeros(1)
48 zero_point = torch.zeros(1)74 zero_point = torch.zeros(1)
49 return (tensor, scale, offset, wts_param, zero_point)75 return (tensor, scale, offset, wts_param, zero_point)
50 76 
51 77 
52class TestArqRetrainSymbolic(unittest.TestCase):78class TestArqRetrainSymbolic(unittest.TestCase):
79+ def test_forward_dynamo_bypasses_eager_quantization(self):
80+ wts_param = _make_inputs('Linear', num_bits=4)[3]
81+ offset_deploy = torch.tensor([0], dtype=torch.int8)
82+ with (
83+ mock.patch(
84+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.arq_retrain.arq_retrain.is_dynamo_export',
85+ return_value=True,
86+ ),
87+ mock.patch(
88+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.arq_retrain.arq_retrain.check_int4_dynamo_export'
89+ ),
90+ mock.patch(
91+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.arq_retrain.arq_retrain.add_weight_qdq_dynamo',
92+ return_value=torch.randn(4, 4),
93+ ) as add_qdq,
94+ ):
95+ result = ArqRetrainFunction.forward(
96+ None,
97+ torch.randn(4, 4),
98+ torch.ones(4),
99+ torch.zeros(4),
100+ wts_param,
101+ offset_deploy,
102+ )
103+ self.assertEqual(len(result), 3)
104+ add_qdq.assert_called_once()
105+ self.assertIs(add_qdq.call_args.args[2], offset_deploy)
106+ 
53 def test_symbolic_conv_transpose(self):107 def test_symbolic_conv_transpose(self):
54 self._run("ConvTranspose2d")108 self._run("ConvTranspose2d")
55 109 
@@ -71,6 +125,102 @@ class TestArqRetrainSymbolic(unittest.TestCase):
71 def test_symbolic_gru(self):125 def test_symbolic_gru(self):
72 self._run("GRU")126 self._run("GRU")
73 127 
128+ def test_int4_conv2d_per_channel_qdq_contract(self):
129+ g = _make_graph()
130+ inputs = _make_inputs('Conv2d', num_bits=4, channel_wise=True)
131+ with _export_opset(21):
132+ ArqRetrainFuncQAT.symbolic(g, *inputs)
133+ 
134+ calls = g.op.call_args_list
135+ self.assertEqual(
136+ [call.args[0] for call in calls],
137+ ['Transpose', 'QuantizeLinear', 'DequantizeLinear', 'Transpose'],
138+ )
139+ quant_call = calls[1]
140+ dequant_call = calls[2]
141+ self.assertEqual(len(quant_call.args), 3)
142+ self.assertEqual(len(dequant_call.args), 3)
143+ self.assertEqual(quant_call.kwargs['output_dtype_i'], TensorProto.INT4)
144+ self.assertEqual(quant_call.kwargs['axis_i'], 1)
145+ self.assertEqual(dequant_call.kwargs['axis_i'], 1)
146+ 
147+ def test_int4_linear_per_channel_uses_inverse_transposes(self):
148+ g = _make_graph()
149+ inputs = _make_inputs('Linear', num_bits=4, channel_wise=True)
150+ with _export_opset(21):
151+ ArqRetrainFuncQAT.symbolic(g, *inputs)
152+ 
153+ calls = g.op.call_args_list
154+ self.assertEqual(
155+ [call.args[0] for call in calls],
156+ ['Transpose', 'QuantizeLinear', 'DequantizeLinear', 'Transpose'],
157+ )
158+ self.assertEqual(calls[0].kwargs['perm_i'], [1, 0])
159+ self.assertEqual(calls[3].kwargs['perm_i'], [1, 0])
160+ self.assertEqual(calls[1].kwargs['axis_i'], 1)
161+ self.assertEqual(calls[1].kwargs['output_dtype_i'], TensorProto.INT4)
162+ self.assertEqual(len(calls[1].args), 3)
163+ 
164+ def test_int4_linear_multidimensional_weight_swaps_first_two_axes(self):
165+ g = _make_graph()
166+ inputs = _make_inputs(
167+ 'Linear',
168+ num_bits=4,
169+ channel_wise=True,
170+ weight_shape=(4, 2, 3),
171+ )
172+ with _export_opset(21):
173+ ArqRetrainFuncQAT.symbolic(g, *inputs)
174+ 
175+ calls = g.op.call_args_list
176+ self.assertEqual(calls[0].kwargs['perm_i'], [1, 0, 2])
177+ self.assertEqual(calls[3].kwargs['perm_i'], [1, 0, 2])
178+ self.assertEqual(calls[1].kwargs['axis_i'], 1)
179+ 
180+ def test_int4_linear_per_tensor_has_no_transpose_or_zero_point(self):
181+ g = _make_graph()
182+ inputs = _make_inputs('Linear', num_bits=4, channel_wise=False, scale_count=1)
183+ with _export_opset(21):
184+ ArqRetrainFuncQAT.symbolic(g, *inputs)
185+ 
186+ calls = g.op.call_args_list
187+ self.assertEqual(
188+ [call.args[0] for call in calls],
189+ ['QuantizeLinear', 'DequantizeLinear'],
190+ )
191+ self.assertNotIn('axis_i', calls[0].kwargs)
192+ self.assertEqual(len(calls[0].args), 3)
193+ 
194+ def test_missing_native_int4_only_rejects_int4_export(self):
195+ tensor_proto_without_int4 = SimpleNamespace()
196+ with mock.patch(
197+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.'
198+ 'qdq_symbolic.TensorProto',
199+ tensor_proto_without_int4,
200+ ):
201+ with _export_opset(21):
202+ with self.assertRaisesRegex(RuntimeError, r'native TensorProto.INT4'):
203+ ArqRetrainFuncQAT.symbolic(
204+ _make_graph(),
205+ *_make_inputs('Linear', num_bits=4, scale_count=1),
206+ )
207+ 
208+ result = ArqRetrainFuncQAT.symbolic(
209+ _make_graph(),
210+ *_make_inputs('Linear', num_bits=8, scale_count=1),
211+ )
212+ self.assertEqual(len(result), 3)
213+ 
214+ def test_int4_per_channel_scale_count_matches_output_channels(self):
215+ with _export_opset(21):
216+ with self.assertRaisesRegex(ValueError, r'scale count.*out_channels'):
217+ ArqRetrainFuncQAT.symbolic(
218+ _make_graph(),
219+ *_make_inputs(
220+ 'Linear', num_bits=4, channel_wise=True, scale_count=3
221+ ),
222+ )
223+ 
74 def _run(self, module_type):224 def _run(self, module_type):
75 g = _make_graph()225 g = _make_graph()
76 inputs = _make_inputs(module_type)226 inputs = _make_inputs(module_type)
@@ -91,6 +91,20 @@ class TestFakeConvModule(unittest.TestCase):
91 out = fake_linear(torch.tensor(inputs))91 out = fake_linear(torch.tensor(inputs))
92 self.assertIsNotNone(out)92 self.assertIsNotNone(out)
93 93 
94+ def test_fake_linear_module_per_channel(self):
95+ weight_scale = np.array([0.5, 0.25, 0.125, 0.0625])
96+ quant_params = {
97+ "data_scale": 1,
98+ "data_offset": 0,
99+ "weight_scale": weight_scale,
100+ }
101+ sub_module = torch.nn.Linear(2, 4, bias=False)
102+ fake_linear = FakeQuantizedLinear(sub_module, quant_params, "linear_channel")
103+ 
104+ output = fake_linear(torch.ones(3, 2))
105+ 
106+ self.assertEqual(tuple(output.shape), (3, 4))
107+ 
94 def test_fake_conv3d_module(self):108 def test_fake_conv3d_module(self):
95 weight_scale = np.array([0.5, 0.5, 0.5])109 weight_scale = np.array([0.5, 0.5, 0.5])
96 quant_params = {110 quant_params = {
@@ -0,0 +1,276 @@
1+#!/usr/bin/env python3
2+# -*- coding: UTF-8 -*-
3+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
4+#
5+# Licensed under the Apache License, Version 2.0 (the "License");
6+# you may not use this file except in compliance with the License.
7+# You may obtain a copy of the License at
8+#
9+# http://www.apache.org/licenses/LICENSE-2.0
10+#
11+# Unless required by applicable law or agreed to in writing, software
12+# distributed under the License is distributed on an "AS IS" BASIS,
13+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+# See the License for the specific language governing permissions and
15+# limitations under the License.
16+"""Unit tests for the shared ONNX Q/DQ symbolic builder."""
17+ 
18+import unittest
19+from unittest import mock
20+ 
21+import torch
22+from onnx import TensorProto
23+ 
24+from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.qdq_symbolic import (
25+ add_qdq,
26+)
27+from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.qdq_symbolic import (
28+ add_qdq_dynamo,
29+ add_weight_qdq_dynamo,
30+ check_int4_dynamo_export,
31+ is_dynamo_export,
32+)
33+ 
34+ 
35+class TestQdqSymbolic(unittest.TestCase):
36+ def _patch_dynamo_ops(self):
37+ ops = mock.MagicMock()
38+ ops.symbolic.side_effect = lambda name, inputs, **kwargs: mock.Mock(
39+ name=name, dtype=kwargs.get('dtype'), shape=kwargs.get('shape')
40+ )
41+ return mock.patch('torch.onnx.ops', ops, create=True), ops
42+ 
43+ def test_add_weight_qdq_dynamo_conv_transpose(self):
44+ patcher, ops = self._patch_dynamo_ops()
45+ with patcher:
46+ add_weight_qdq_dynamo(
47+ torch.randn(2, 4, 3),
48+ torch.ones(4),
49+ torch.zeros(1),
50+ 4,
51+ 'ConvTranspose1d',
52+ True,
53+ mock.Mock(),
54+ )
55+ self.assertEqual(ops.symbolic.call_count, 2)
56+ 
57+ def test_add_weight_qdq_dynamo_conv_transposes_layout(self):
58+ patcher, ops = self._patch_dynamo_ops()
59+ with patcher:
60+ add_weight_qdq_dynamo(
61+ torch.randn(4, 2, 3, 3),
62+ torch.ones(4),
63+ torch.zeros(1),
64+ 4,
65+ 'Conv2d',
66+ True,
67+ mock.Mock(),
68+ )
69+ quant_inputs = ops.symbolic.call_args_list[0].args[1]
70+ self.assertEqual(tuple(quant_inputs[0].shape), (2, 4, 3, 3))
71+ 
72+ def test_add_weight_qdq_dynamo_linear_per_channel_transposes_layout(self):
73+ patcher, ops = self._patch_dynamo_ops()
74+ module = mock.Mock(weight=torch.randn(4, 2, 3))
75+ with patcher:
76+ add_weight_qdq_dynamo(
77+ module.weight, torch.ones(4), torch.zeros(1), 4, 'Linear', True, module
78+ )
79+ quant_inputs = ops.symbolic.call_args_list[0].args[1]
80+ self.assertEqual(tuple(quant_inputs[0].shape), (2, 4, 3))
81+ 
82+ def test_add_weight_qdq_dynamo_linear_per_tensor(self):
83+ patcher, ops = self._patch_dynamo_ops()
84+ with patcher:
85+ add_weight_qdq_dynamo(
86+ torch.randn(4, 2),
87+ torch.ones(1),
88+ torch.zeros(1),
89+ 8,
90+ 'Linear',
91+ False,
92+ mock.Mock(),
93+ )
94+ self.assertEqual(ops.symbolic.call_count, 2)
95+ 
96+ def test_add_weight_qdq_dynamo_rejects_unknown_module(self):
97+ with self.assertRaisesRegex(RuntimeError, 'Unsupported QAT module'):
98+ add_weight_qdq_dynamo(
99+ torch.randn(2, 2),
100+ torch.ones(1),
101+ torch.zeros(1),
102+ 8,
103+ 'Unknown',
104+ False,
105+ mock.Mock(),
106+ )
107+ 
108+ def test_is_dynamo_export_in_onnx_compilation_context(self):
109+ with (
110+ mock.patch('torch.compiler.is_compiling', return_value=True),
111+ mock.patch('torch.onnx.is_in_onnx_export', return_value=True),
112+ mock.patch('torch.onnx.ops', mock.Mock(symbolic=mock.Mock()), create=True),
113+ ):
114+ self.assertTrue(is_dynamo_export())
115+ 
116+ def test_is_dynamo_export_requires_onnx_export_context(self):
117+ with (
118+ mock.patch('torch.compiler.is_compiling', return_value=True),
119+ mock.patch('torch.onnx.is_in_onnx_export', return_value=False),
120+ mock.patch('torch.onnx.ops', mock.Mock(symbolic=mock.Mock()), create=True),
121+ ):
122+ self.assertFalse(is_dynamo_export())
123+ 
124+ def test_add_qdq_dynamo_uses_native_int4_dtype_without_zero_point(self):
125+ ops = mock.MagicMock()
126+ ops.symbolic.side_effect = [mock.sentinel.quant, mock.sentinel.dequant]
127+ tensor = mock.Mock(dtype='float32')
128+ with mock.patch('torch.onnx.ops', ops, create=True):
129+ output = add_qdq_dynamo(
130+ tensor,
131+ mock.sentinel.scale,
132+ mock.sentinel.zero_point,
133+ num_bits=4,
134+ axis=1,
135+ shape=(2, 4),
136+ )
137+ 
138+ self.assertIs(output, mock.sentinel.dequant)
139+ quant_call, dequant_call = ops.symbolic.call_args_list
140+ self.assertEqual(
141+ quant_call.args[:2], ('QuantizeLinear', (tensor, mock.sentinel.scale))
142+ )
143+ self.assertEqual(
144+ quant_call.kwargs['attrs'], {'output_dtype': TensorProto.INT4, 'axis': 1}
145+ )
146+ self.assertEqual(quant_call.kwargs['dtype'], TensorProto.INT4)
147+ self.assertEqual(quant_call.kwargs['version'], 21)
148+ self.assertEqual(
149+ dequant_call.args[:2],
150+ ('DequantizeLinear', (mock.sentinel.quant, mock.sentinel.scale)),
151+ )
152+ self.assertEqual(dequant_call.kwargs['attrs'], {'axis': 1})
153+ 
154+ def test_add_qdq_dynamo_keeps_zero_point_for_int8(self):
155+ ops = mock.MagicMock()
156+ ops.symbolic.side_effect = [mock.sentinel.quant, mock.sentinel.dequant]
157+ tensor = mock.Mock(dtype='float32')
158+ with mock.patch('torch.onnx.ops', ops, create=True):
159+ add_qdq_dynamo(
160+ tensor,
161+ mock.sentinel.scale,
162+ mock.sentinel.zero_point,
163+ num_bits=8,
164+ shape=(2, 4),
165+ )
166+ 
167+ quant_call, dequant_call = ops.symbolic.call_args_list
168+ self.assertEqual(
169+ quant_call.args[1],
170+ (tensor, mock.sentinel.scale, mock.sentinel.zero_point),
171+ )
172+ self.assertEqual(
173+ dequant_call.args[1],
174+ (mock.sentinel.quant, mock.sentinel.scale, mock.sentinel.zero_point),
175+ )
176+ 
177+ def test_add_qdq_dynamo_casts_float_zero_point_to_integer(self):
178+ ops = mock.MagicMock()
179+ ops.symbolic.side_effect = [mock.sentinel.quant, mock.sentinel.dequant]
180+ tensor = mock.Mock(dtype='float32')
181+ float_zero_point = torch.tensor([-3.0, 0.0, 7.0])
182+ with mock.patch('torch.onnx.ops', ops, create=True):
183+ add_qdq_dynamo(tensor, mock.sentinel.scale, float_zero_point, 8)
184+ 
185+ quant_inputs = ops.symbolic.call_args_list[0].args[1]
186+ self.assertEqual(quant_inputs[2].dtype, torch.int8)
187+ self.assertTrue(
188+ torch.equal(quant_inputs[2], torch.tensor([-3, 0, 7], dtype=torch.int8))
189+ )
190+ 
191+ def test_add_qdq_dynamo_uses_int16_for_int16_quantization(self):
192+ ops = mock.MagicMock()
193+ ops.symbolic.side_effect = [mock.sentinel.quant, mock.sentinel.dequant]
194+ tensor = mock.Mock(dtype=torch.float32)
195+ with mock.patch('torch.onnx.ops', ops, create=True):
196+ add_qdq_dynamo(tensor, mock.sentinel.scale, mock.sentinel.zero_point, 16)
197+ self.assertEqual(ops.symbolic.call_args_list[0].kwargs['dtype'], torch.int16)
198+ 
199+ def test_add_qdq_dynamo_restores_input_device(self):
200+ ops = mock.MagicMock()
201+ ops.symbolic.side_effect = lambda *args, **kwargs: torch.zeros(
202+ kwargs['shape'], dtype=torch.float32
203+ )
204+ tensor = torch.empty((2, 4), device='meta')
205+ scale = torch.empty(1, device='meta')
206+ with mock.patch('torch.onnx.ops', ops, create=True):
207+ output = add_qdq_dynamo(tensor, scale, None, 8)
208+ self.assertEqual(output.device, tensor.device)
209+ 
210+ def test_add_qdq_dynamo_requires_native_export_api(self):
211+ with mock.patch('torch.onnx.ops', None, create=True):
212+ with self.assertRaisesRegex(RuntimeError, 'PyTorch 2.10'):
213+ add_qdq_dynamo(mock.sentinel.tensor, mock.sentinel.scale, None, 4)
214+ 
215+ def test_check_int4_dynamo_export_validates_per_channel_scales(self):
216+ module = mock.Mock(
217+ out_channels=2, wts_scales=mock.Mock(numel=mock.Mock(return_value=1))
218+ )
219+ with self.assertRaisesRegex(ValueError, 'scale count.*out_channels'):
220+ check_int4_dynamo_export({'channel_wise': True, 'module': module})
221+ 
222+ def test_add_int4_qdq_accepts_explicit_num_bits(self):
223+ graph = mock.MagicMock()
224+ graph.op.side_effect = lambda *args, **kwargs: mock.MagicMock(name='node')
225+ 
226+ add_qdq(
227+ graph,
228+ mock.sentinel.tensor,
229+ mock.sentinel.scale,
230+ mock.sentinel.zero_point,
231+ num_bits=4,
232+ axis=1,
233+ )
234+ 
235+ quant_call, dequant_call = graph.op.call_args_list
236+ self.assertEqual(quant_call.args[0], 'QuantizeLinear')
237+ self.assertEqual(dequant_call.args[0], 'DequantizeLinear')
238+ self.assertEqual(quant_call.kwargs['output_dtype_i'], TensorProto.INT4)
239+ self.assertEqual(quant_call.kwargs['axis_i'], 1)
240+ self.assertEqual(dequant_call.kwargs['axis_i'], 1)
241+ 
242+ def test_add_int8_qdq_keeps_zero_point_inputs(self):
243+ graph = mock.MagicMock()
244+ graph.op.side_effect = [mock.sentinel.quant_node, mock.sentinel.dequant_node]
245+ 
246+ add_qdq(
247+ graph,
248+ mock.sentinel.tensor,
249+ mock.sentinel.scale,
250+ mock.sentinel.zero_point,
251+ num_bits=8,
252+ )
253+ 
254+ quant_call, dequant_call = graph.op.call_args_list
255+ self.assertEqual(
256+ quant_call.args,
257+ (
258+ 'QuantizeLinear',
259+ mock.sentinel.tensor,
260+ mock.sentinel.scale,
261+ mock.sentinel.zero_point,
262+ ),
263+ )
264+ self.assertEqual(
265+ dequant_call.args,
266+ (
267+ 'DequantizeLinear',
268+ mock.sentinel.quant_node,
269+ mock.sentinel.scale,
270+ mock.sentinel.zero_point,
271+ ),
272+ )
273+ 
274+ 
275+if __name__ == '__main__':
276+ unittest.main()
@@ -0,0 +1,46 @@
1+import unittest
2+from unittest import mock
3+ 
4+import torch
5+ 
6+from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_retrain.ulq_retrain import (
7+ UlqRetrainFunction,
8+)
9+ 
10+ 
11+class TestUlqRetrainDynamo(unittest.TestCase):
12+ def test_forward_dynamo_bypasses_eager_quantization(self):
13+ params = {
14+ 'acts_scale': torch.ones(1),
15+ 'acts_offset': torch.zeros(1),
16+ 'num_bits': 8,
17+ }
18+ with (
19+ mock.patch(
20+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_retrain.ulq_retrain.is_dynamo_export',
21+ return_value=True,
22+ ),
23+ mock.patch(
24+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_retrain.ulq_retrain.add_qdq_dynamo',
25+ return_value=torch.randn(2, 2),
26+ ) as add_qdq,
27+ ):
28+ result = UlqRetrainFunction.forward(
29+ None,
30+ torch.randn(2, 2),
31+ torch.ones(1),
32+ torch.ones(1),
33+ torch.ones(1),
34+ torch.ones(1),
35+ params,
36+ None,
37+ False,
38+ None,
39+ 1,
40+ )
41+ self.assertEqual(len(result), 5)
42+ add_qdq.assert_called_once()
43+ 
44+ 
45+if __name__ == '__main__':
46+ unittest.main()
@@ -21,33 +21,86 @@ CPU CI environment due to opset incompatibility).
21"""21"""
22 22 
23import unittest23import unittest
24-from unittest.mock import MagicMock24+from types import SimpleNamespace
25+from unittest import mock
25 26 
26import torch27import torch
28+from onnx import TensorProto
27 29 
28from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain import (30from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain import (
31+ UlqScaleRetrainFunction,
29 UlqScaleRetrainFuncQAT,32 UlqScaleRetrainFuncQAT,
30)33)
31 34 
32 35 
33def _make_graph():36def _make_graph():
34- g = MagicMock()37+ g = mock.MagicMock()
35- g.op.side_effect = lambda *a, **k: MagicMock(name="node")38+ g.op.side_effect = lambda *a, **k: mock.MagicMock(name="node")
36 return g39 return g
37 40 
38 41 
39-def _make_inputs(module_type, hidden_size=8):42+def _export_opset(version):
40- module = MagicMock()43+ return mock.patch(
44+ 'torch.onnx._globals.GLOBALS',
45+ SimpleNamespace(export_onnx_opset_version=version),
46+ )
47+ 
48+ 
49+def _make_inputs(
50+ module_type,
51+ hidden_size=8,
52+ num_bits=8,
53+ channel_wise=False,
54+ out_channels=4,
55+ scale_count=None,
56+):
57+ module = mock.MagicMock()
41 module.hidden_size = hidden_size58 module.hidden_size = hidden_size
42- wts_param = {"module_type": module_type, "module": module}59+ module.out_channels = out_channels
60+ module.wts_scales = torch.ones(out_channels if scale_count is None else scale_count)
61+ wts_param = {
62+ "module_type": module_type,
63+ "module": module,
64+ "num_bits": num_bits,
65+ "channel_wise": channel_wise,
66+ }
43 tensor = torch.randn(4, 4)67 tensor = torch.randn(4, 4)
44- scale = torch.ones(1)68+ scale = module.wts_scales
45 zero = torch.zeros(1)69 zero = torch.zeros(1)
46 # symbolic reads positional args at index 0, 1, 3 and 570 # symbolic reads positional args at index 0, 1, 3 and 5
47 return [tensor, scale, zero, wts_param, zero, zero]71 return [tensor, scale, zero, wts_param, zero, zero]
48 72 
49 73 
50class TestUlqScaleRetrainSymbolic(unittest.TestCase):74class TestUlqScaleRetrainSymbolic(unittest.TestCase):
75+ def test_forward_dynamo_bypasses_eager_quantization(self):
76+ wts_param = _make_inputs('Linear', num_bits=4)[3]
77+ offset_deploy = torch.tensor([0], dtype=torch.int8)
78+ with (
79+ mock.patch(
80+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain.is_dynamo_export',
81+ return_value=True,
82+ ),
83+ mock.patch(
84+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain.check_int4_dynamo_export'
85+ ),
86+ mock.patch(
87+ 'amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain.add_weight_qdq_dynamo',
88+ return_value=torch.randn(4, 4),
89+ ) as add_qdq,
90+ ):
91+ result = UlqScaleRetrainFunction.forward(
92+ None,
93+ torch.randn(4, 4),
94+ torch.ones(4),
95+ torch.zeros(4),
96+ wts_param,
97+ 0,
98+ offset_deploy,
99+ )
100+ self.assertEqual(len(result), 3)
101+ add_qdq.assert_called_once()
102+ self.assertIs(add_qdq.call_args.args[2], offset_deploy)
103+ 
51 def test_symbolic_conv_transpose(self):104 def test_symbolic_conv_transpose(self):
52 self._run("ConvTranspose1d")105 self._run("ConvTranspose1d")
53 106 
@@ -69,6 +122,64 @@ class TestUlqScaleRetrainSymbolic(unittest.TestCase):
69 def test_symbolic_gru(self):122 def test_symbolic_gru(self):
70 self._run("GRU")123 self._run("GRU")
71 124 
125+ def test_int4_conv2d_per_channel_qdq_contract(self):
126+ g = _make_graph()
127+ inputs = _make_inputs('Conv2d', num_bits=4, channel_wise=True)
128+ with _export_opset(21):
129+ UlqScaleRetrainFuncQAT.symbolic(g, *inputs)
130+ 
131+ calls = g.op.call_args_list
132+ self.assertEqual(
133+ [call.args[0] for call in calls],
134+ ['Transpose', 'QuantizeLinear', 'DequantizeLinear', 'Transpose'],
135+ )
136+ self.assertEqual(len(calls[1].args), 3)
137+ self.assertEqual(len(calls[2].args), 3)
138+ self.assertEqual(calls[1].kwargs['output_dtype_i'], TensorProto.INT4)
139+ self.assertEqual(calls[1].kwargs['axis_i'], 1)
140+ self.assertEqual(calls[2].kwargs['axis_i'], 1)
141+ 
142+ def test_int4_linear_per_channel_uses_inverse_transposes(self):
143+ g = _make_graph()
144+ inputs = _make_inputs('Linear', num_bits=4, channel_wise=True)
145+ with _export_opset(21):
146+ UlqScaleRetrainFuncQAT.symbolic(g, *inputs)
147+ 
148+ calls = g.op.call_args_list
149+ self.assertEqual(
150+ [call.args[0] for call in calls],
151+ ['Transpose', 'QuantizeLinear', 'DequantizeLinear', 'Transpose'],
152+ )
153+ self.assertEqual(calls[0].kwargs['perm_i'], [1, 0])
154+ self.assertEqual(calls[3].kwargs['perm_i'], [1, 0])
155+ self.assertEqual(calls[1].kwargs['axis_i'], 1)
156+ self.assertEqual(calls[1].kwargs['output_dtype_i'], TensorProto.INT4)
157+ self.assertEqual(len(calls[1].args), 3)
158+ 
159+ def test_int4_linear_per_tensor_has_no_transpose_or_zero_point(self):
160+ g = _make_graph()
161+ inputs = _make_inputs('Linear', num_bits=4, channel_wise=False, scale_count=1)
162+ with _export_opset(21):
163+ UlqScaleRetrainFuncQAT.symbolic(g, *inputs)
164+ 
165+ calls = g.op.call_args_list
166+ self.assertEqual(
167+ [call.args[0] for call in calls],
168+ ['QuantizeLinear', 'DequantizeLinear'],
169+ )
170+ self.assertNotIn('axis_i', calls[0].kwargs)
171+ self.assertEqual(len(calls[0].args), 3)
172+ 
173+ def test_int4_per_channel_scale_count_matches_output_channels(self):
174+ with _export_opset(21):
175+ with self.assertRaisesRegex(ValueError, r'scale count.*out_channels'):
176+ UlqScaleRetrainFuncQAT.symbolic(
177+ _make_graph(),
178+ *_make_inputs(
179+ 'Linear', num_bits=4, channel_wise=True, scale_count=3
180+ ),
181+ )
182+ 
72 def _run(self, module_type):183 def _run(self, module_type):
73 g = _make_graph()184 g = _make_graph()
74 inputs = _make_inputs(module_type)185 inputs = _make_inputs(module_type)
@@ -0,0 +1,65 @@
1+import unittest
2+from unittest import mock
3+ 
4+import torch
5+ 
6+from amct_pytorch.classic.graph_based.amct_pytorch.nn.module.quantization.linear import (
7+ LinearQAT,
8+)
9+ 
10+ 
11+class TestQatExport(unittest.TestCase):
12+ def _make_module(self, channel_wise=False):
13+ return LinearQAT(
14+ 2,
15+ 4,
16+ config={
17+ 'retrain_data_config': {'clip_min': -1.0, 'clip_max': 1.0},
18+ 'retrain_weight_config': {
19+ 'dst_type': 'INT4',
20+ 'channel_wise': channel_wise,
21+ },
22+ },
23+ )
24+ 
25+ def test_forward_qat_dynamo_builds_qdq_without_mutation(self):
26+ module = self._make_module()
27+ module.do_init = False
28+ with (
29+ mock.patch(
30+ 'amct_pytorch.classic.graph_based.amct_pytorch.nn.module.quantization.qat_base.is_dynamo_export',
31+ return_value=True,
32+ ),
33+ mock.patch(
34+ 'amct_pytorch.classic.graph_based.amct_pytorch.nn.module.quantization.qat_base.add_qdq_dynamo',
35+ return_value=torch.randn(1, 2),
36+ ) as add_act,
37+ mock.patch(
38+ 'amct_pytorch.classic.graph_based.amct_pytorch.nn.module.quantization.qat_base.add_weight_qdq_dynamo',
39+ return_value=torch.randn(4, 2),
40+ ) as add_weight,
41+ ):
42+ result = module.forward_qat(torch.randn(1, 2))
43+ self.assertEqual(len(result), 2)
44+ add_act.assert_called_once()
45+ add_weight.assert_called_once()
46+ self.assertIs(add_weight.call_args.args[2], module.wts_offsets_deploy)
47+ 
48+ def test_forward_qat_dynamo_requires_initialized_model(self):
49+ module = self._make_module()
50+ module.do_init = True
51+ with (
52+ mock.patch(
53+ 'amct_pytorch.classic.graph_based.amct_pytorch.nn.module.quantization.qat_base.is_dynamo_export',
54+ return_value=True,
55+ ),
56+ mock.patch(
57+ 'amct_pytorch.classic.graph_based.amct_pytorch.nn.module.quantization.qat_base.add_qdq_dynamo'
58+ ),
59+ ):
60+ with self.assertRaisesRegex(RuntimeError, 'initialized'):
61+ module.forward_qat(torch.randn(1, 2))
62+ 
63+ 
64+if __name__ == '__main__':
65+ unittest.main()
@@ -301,6 +301,50 @@ class TestQatOp(unittest.TestCase):
301 self.assertEqual(mod.act_num_bits, 16)301 self.assertEqual(mod.act_num_bits, 16)
302 self.assertEqual(mod.wts_num_bits, 8)302 self.assertEqual(mod.wts_num_bits, 8)
303 303 
304+ def test_int4_weight_rejects_int16_activation(self):
305+ quant_conf = {
306+ RETRAIN_DATA_CONFIG: {'dst_type': 'INT16'},
307+ 'retrain_weight_config': {
308+ 'dst_type': 'INT4',
309+ 'channel_wise': False,
310+ },
311+ }
312+ 
313+ with self.assertRaisesRegex(ValueError, r'INT4 weight.*INT8 activation'):
314+ LinearQAT(4, 4, config=quant_conf)
315+ 
316+ def test_non_target_qat_ops_reject_int4_weights(self):
317+ config = {
318+ RETRAIN_DATA_CONFIG: {'dst_type': 'INT8'},
319+ 'retrain_weight_config': {
320+ 'dst_type': 'INT4',
321+ 'channel_wise': False,
322+ },
323+ }
324+ constructors = (
325+ lambda: Conv1dQAT(2, 4, 2, config=config),
326+ lambda: Conv3dQAT(2, 4, 2, config=config),
327+ lambda: ConvTranspose1dQAT(2, 4, 2, config=config),
328+ lambda: ConvTranspose2dQAT(2, 4, 2, config=config),
329+ )
330+ for construct in constructors:
331+ with (
332+ self.subTest(construct=construct),
333+ self.assertRaisesRegex(ValueError, r"dst_type for weight.*INT8"),
334+ ):
335+ construct()
336+ 
337+ def test_activation_channel_wise_config_raises(self):
338+ config = {
339+ RETRAIN_DATA_CONFIG: {
340+ 'dst_type': 'INT8',
341+ 'channel_wise': True,
342+ },
343+ 'retrain_weight_config': {'dst_type': 'INT8'},
344+ }
345+ with self.assertRaisesRegex(ValueError, r'(?i)activation.*per-tensor'):
346+ LinearQAT(4, 4, config=config)
347+ 
304 348 
305class TestConv2dQAT(unittest.TestCase):349class TestConv2dQAT(unittest.TestCase):
306 @classmethod350 @classmethod
@@ -376,6 +420,40 @@ class TestConv2dQAT(unittest.TestCase):
376 with self.assertRaises(RuntimeError):420 with self.assertRaises(RuntimeError):
377 mod.forward(torch.randn((3, 224, 224)))421 mod.forward(torch.randn((3, 224, 224)))
378 422 
423+ def test_conv2d_qat_accepts_int4_per_tensor_and_per_channel(self):
424+ for channel_wise, expected_scales in ((False, 1), (True, 4)):
425+ config = {
426+ RETRAIN_DATA_CONFIG: {'dst_type': 'INT8'},
427+ 'retrain_weight_config': {
428+ 'dst_type': 'INT4',
429+ 'channel_wise': channel_wise,
430+ },
431+ }
432+ mod = Conv2dQAT(2, 4, 2, config=config)
433+ self.assertEqual(mod.wts_num_bits, 4)
434+ self.assertEqual(mod.wts_scales.numel(), expected_scales)
435+ 
436+ def test_conv2d_qat_int4_odd_kernel_width_raises(self):
437+ config = {
438+ RETRAIN_DATA_CONFIG: {'dst_type': 'INT8'},
439+ 'retrain_weight_config': {
440+ 'dst_type': 'INT4',
441+ 'channel_wise': False,
442+ },
443+ }
444+ with self.assertRaisesRegex(ValueError, r'Conv2d.*weight shape.*axis W.*odd'):
445+ Conv2dQAT(2, 4, (2, 3), config=config)
446+ 
447+ def test_grouped_conv2d_qat_int4_even_width_is_supported(self):
448+ config = {
449+ 'retrain_weight_config': {
450+ 'dst_type': 'INT4',
451+ 'channel_wise': True,
452+ }
453+ }
454+ mod = Conv2dQAT(4, 4, 2, groups=4, config=config)
455+ self.assertEqual(mod.weight.shape, torch.Size([4, 1, 2, 2]))
456+ 
379 457 
380class TestConvTranspose2dQAT(unittest.TestCase):458class TestConvTranspose2dQAT(unittest.TestCase):
381 @classmethod459 @classmethod
@@ -519,9 +597,68 @@ class TestLinearQAT(unittest.TestCase):
519 def test_down(self):597 def test_down(self):
520 pass598 pass
521 599 
522- def test_lineard_qat_limit_check_01(self):600+ def test_linear_qat_a8w8_channel_wise(self):
523- with self.assertRaises(RuntimeError):601+ mod = LinearQAT(
524- LinearQAT(1, 1, config={'retrain_weight_config': {'channel_wise': True}})602+ 3,
603+ 4,
604+ config={
605+ RETRAIN_DATA_CONFIG: {'dst_type': 'INT8'},
606+ 'retrain_weight_config': {
607+ 'dst_type': 'INT8',
608+ 'channel_wise': True,
609+ },
610+ },
611+ )
612+ self.assertEqual(mod.wts_scales.numel(), 4)
613+ 
614+ def test_linear_qat_accepts_int4_per_tensor_and_per_channel(self):
615+ for channel_wise, expected_scales in ((False, 1), (True, 4)):
616+ config = {
617+ RETRAIN_DATA_CONFIG: {'dst_type': 'INT8'},
618+ 'retrain_weight_config': {
619+ 'dst_type': 'INT4',
620+ 'channel_wise': channel_wise,
621+ },
622+ }
623+ mod = LinearQAT(3, 4, config=config)
624+ self.assertEqual(mod.wts_num_bits, 4)
625+ self.assertEqual(mod.wts_scales.numel(), expected_scales)
626+ 
627+ def test_linear_qat_int4_odd_out_features_raises(self):
628+ config = {
629+ 'retrain_weight_config': {
630+ 'dst_type': 'INT4',
631+ 'channel_wise': False,
632+ }
633+ }
634+ with self.assertRaisesRegex(
635+ ValueError, r'Linear.*weight shape.*out_features.*odd'
636+ ):
637+ LinearQAT(4, 3, config=config)
638+ 
639+ def test_linear_qat_int4_multidimensional_weight_checks_out_features(self):
640+ config = {
641+ RETRAIN_DATA_CONFIG: {'dst_type': 'INT8'},
642+ 'retrain_weight_config': {'dst_type': 'INT8', 'channel_wise': True},
643+ }
644+ mod = LinearQAT(4, 4, config=config)
645+ mod.retrain_weight_config['dst_type'] = 'INT4'
646+ mod.weight = torch.nn.Parameter(torch.randn(4, 2, 3))
647+ self.assertTrue(mod.check_quantifiable())
648+ 
649+ mod.weight = torch.nn.Parameter(torch.randn(3, 2, 4))
650+ with self.assertRaisesRegex(ValueError, r'out_features.*odd'):
651+ mod.check_quantifiable()
652+ 
653+ def test_linear_qat_int4_odd_in_features_is_supported(self):
654+ config = {
655+ 'retrain_weight_config': {
656+ 'dst_type': 'INT4',
657+ 'channel_wise': False,
658+ }
659+ }
660+ mod = LinearQAT(3, 4, config=config)
661+ self.assertEqual(mod.weight.shape, torch.Size([4, 3]))
525 662 
526 def test_lineard_qat_forward(self):663 def test_lineard_qat_forward(self):
527 qat_op = LinearQAT(664 qat_op = LinearQAT(
@@ -109,6 +109,29 @@ class TestInsertRetrainPass(unittest.TestCase):
109 self.assertIsInstance(named_module_dict['linear'], CompModuleLinear)109 self.assertIsInstance(named_module_dict['linear'], CompModuleLinear)
110 110 
111 111 
112+class TestCompModuleLinearChannelWise(unittest.TestCase):
113+ def test_channel_wise_weight_quantization_matches_linear_output_channels(self):
114+ module = torch.nn.Linear(4, 4)
115+ comp_module = CompModuleLinear(
116+ module=module,
117+ act_config={},
118+ wts_config={
119+ 'algo': 'arq_retrain',
120+ 'num_bits': 8,
121+ 'channel_wise': True,
122+ },
123+ common_config={'device': 'cpu'},
124+ )
125+ comp_module.comp_algs.append('quant')
126+ 
127+ quantized_weight = comp_module.wts_comp(
128+ module.weight, comp_module.wts_config, comp_module.common_config
129+ )
130+ 
131+ self.assertEqual(comp_module.wts_scales.shape, (4,))
132+ self.assertEqual(quantized_weight.shape, module.weight.shape)
133+ 
134+ 
112class TestInsertRetrainConv3dPass(unittest.TestCase):135class TestInsertRetrainConv3dPass(unittest.TestCase):
113 @classmethod136 @classmethod
114 def setUpClass(cls):137 def setUpClass(cls):
@@ -1,132 +0,0 @@
1-#!/usr/bin/env python3
2-# -*- coding: UTF-8 -*-
3-# ----------------------------------------------------------------------------
4-# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
5-#
6-# Licensed under the Apache License, Version 2.0 (the "License");
7-# you may not use this file except in compliance with the License.
8-# You may obtain a copy of the License at
9-#
10-# http://www.apache.org/licenses/LICENSE-2.0
11-#
12-# Unless required by applicable law or agreed to in writing, software
13-# distributed under the License is distributed on an "AS IS" BASIS,
14-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15-# See the License for the specific language governing permissions and
16-# limitations under the License.
17-# ----------------------------------------------------------------------------
18-import unittest
19-from unittest import mock
20- 
21-import numpy as np
22- 
23-from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.pack_int4_weight_pass import (
24- PackInt4WeightPass,
25- pack_along_axis,
26-)
27- 
28- 
29-class TestPackAlongAxis(unittest.TestCase):
30- def test_pack_along_last_axis_even(self):
31- # [2, 4] pack along axis 1 -> [2, 2]
32- vals = np.arange(8, dtype=np.int8).reshape(2, 4)
33- packed, new_dims = pack_along_axis(vals, [2, 4], 1)
34- self.assertEqual(new_dims, [2, 2])
35- self.assertEqual(packed.size, 4)
36- 
37- def test_pack_along_axis0(self):
38- # Conv-like: [16, 4, 3, 3] pack along Cin axis 1 -> [16, 2, 3, 3]
39- vals = np.arange(16 * 4 * 3 * 3, dtype=np.int8) % 15 - 7
40- packed, new_dims = pack_along_axis(vals, [16, 4, 3, 3], 1)
41- self.assertEqual(new_dims, [16, 2, 3, 3])
42- self.assertEqual(packed.size, 16 * 2 * 3 * 3)
43- 
44- def test_pack_nibble_layout(self):
45- # low nibble = first value, high nibble = second value
46- vals = np.array([1, 2], dtype=np.int8)
47- packed, _ = pack_along_axis(vals, [2], 0)
48- self.assertEqual(int(packed[0]) & 0x0F, 1)
49- self.assertEqual((int(packed[0]) >> 4) & 0x0F, 2)
50- 
51- def test_pack_odd_axis_raises(self):
52- # odd pack axis should be rejected at config stage; defensive raise here
53- vals = np.arange(3, dtype=np.int8)
54- with self.assertRaises(RuntimeError):
55- pack_along_axis(vals, [3], 0)
56- 
57- 
58-class TestPackInt4Weight(unittest.TestCase):
59- def setUp(self):
60- self.passer = PackInt4WeightPass({})
61- 
62- def test_match_pattern(self):
63- passer = PackInt4WeightPass(
64- {'conv': {'wts_type': 'INT4'}, 'fc': {'wts_type': 'INT8'}}
65- )
66- node_int4 = mock.MagicMock()
67- node_int4.name = 'conv'
68- node_int8 = mock.MagicMock()
69- node_int8.name = 'fc'
70- node_absent = mock.MagicMock()
71- node_absent.name = 'other'
72- self.assertTrue(passer.match_pattern(node_int4)) # INT4 -> match
73- self.assertFalse(passer.match_pattern(node_int8)) # INT8 -> no
74- self.assertFalse(passer.match_pattern(node_absent)) # not in records -> no
75- 
76- _QOI = (
77- 'amct_pytorch.classic.graph_based.amct_pytorch.optimizer.'
78- 'pack_int4_weight_pass.QuantOpInfo'
79- )
80- _HELPER = (
81- 'amct_pytorch.classic.graph_based.amct_pytorch.optimizer.'
82- 'pack_int4_weight_pass.TensorProtoHelper'
83- )
84- 
85- def test_do_pass_conv_packs_weight_only(self):
86- # 通过公有入口 do_pass 覆盖 pack_int4_weight_node:Conv 无 recurrence
87- # (recurrence_weight_node=None 走 no-op 分支),主权重写回 INT8。
88- node = mock.MagicMock()
89- node.type = 'Conv'
90- node.name = 'conv'
91- weight_node = mock.MagicMock()
92- weight_node.model_path = ''
93- helper = mock.MagicMock()
94- helper.get_data.return_value = np.arange(8, dtype=np.int8).reshape(2, 4)
95- helper.tensor.dims = [2, 4]
96- with (
97- mock.patch(self._QOI) as m_qoi,
98- mock.patch(self._HELPER, return_value=helper),
99- ):
100- m_qoi.get_cin_axis.return_value = 1
101- m_qoi.get_weight_node.return_value = weight_node
102- m_qoi.get_recurrence_weight_node.return_value = None
103- self.passer.do_pass(None, node)
104- helper.clear_data.assert_called_once()
105- args, kwargs = helper.set_data.call_args
106- self.assertEqual(args[1], 'INT8')
107- self.assertEqual(kwargs.get('dims'), [2, 2])
108- 
109- def test_do_pass_rnn_packs_weight_and_recurrence(self):
110- # RNN:主权重 + recurrence_weight 都被打包(两次 set_data)
111- node = mock.MagicMock()
112- node.type = 'LSTM'
113- node.name = 'lstm'
114- wnode = mock.MagicMock()
115- wnode.model_path = ''
116- helper = mock.MagicMock()
117- helper.get_data.return_value = np.arange(8, dtype=np.int8).reshape(1, 4, 2)
118- helper.tensor.dims = [1, 4, 2]
119- with (
120- mock.patch(self._QOI) as m_qoi,
121- mock.patch(self._HELPER, return_value=helper),
122- ):
123- m_qoi.get_cin_axis.return_value = 2
124- m_qoi.get_weight_node.return_value = wnode
125- m_qoi.get_recurrence_weight_node.return_value = wnode
126- self.passer.do_pass(None, node)
127- # 主 + recurrence 各一次 set_data
128- self.assertEqual(helper.set_data.call_count, 2)
129- 
130- 
131-if __name__ == '__main__':
132- unittest.main()
@@ -0,0 +1,92 @@
1+#!/usr/bin/env python3
2+# -*- coding: UTF-8 -*-
3+# ----------------------------------------------------------------------------
4+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# http://www.apache.org/licenses/LICENSE-2.0
11+ 
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+# ----------------------------------------------------------------------------
18+import unittest
19+ 
20+import numpy as np
21+import torch
22+ 
23+from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.fake_quant import (
24+ FakeQuantizedLinear,
25+)
26+from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.insert_fakequant_linear_pass import (
27+ InsertFakequantLinearPass,
28+)
29+from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.weight_fakequant_module_pass import (
30+ WeightFakequantModulePass,
31+)
32+ 
33+ 
34+class LinearModel(torch.nn.Module):
35+ def __init__(self):
36+ super().__init__()
37+ self.linear = torch.nn.Linear(2, 4, bias=False)
38+ self.linear.weight.data = torch.tensor(
39+ [[1, 2], [3, 4], [5, 6], [7, 8]], dtype=torch.float32
40+ )
41+ 
42+ def forward(self, inputs):
43+ return self.linear(inputs)
44+ 
45+ 
46+class TestWeightFakequantModulePass(unittest.TestCase):
47+ @staticmethod
48+ def _records():
49+ return {
50+ 'linear': {
51+ 'data_scale': np.array([1], dtype=np.float32),
52+ 'data_offset': np.array([0], dtype=np.int8),
53+ 'weight_scale': np.ones(4, dtype=np.float32),
54+ 'weight_offset': np.array([1, 2, 3, 4], dtype=np.int8),
55+ }
56+ }
57+ 
58+ def test_insert_linear_preserves_per_channel_setting(self):
59+ model = LinearModel()
60+ records = self._records()
61+ 
62+ InsertFakequantLinearPass(records, 8).do_pass(model, model.linear, 'linear')
63+ 
64+ self.assertIsInstance(model.linear, FakeQuantizedLinear)
65+ self.assertTrue(records['linear']['channel_wise'])
66+ 
67+ def test_linear_offset_broadcasts_on_output_channel(self):
68+ model = LinearModel()
69+ records = self._records()
70+ original_weight = model.linear.weight.detach().clone()
71+ fake_linear = FakeQuantizedLinear(model.linear, records['linear'], 'linear', 8)
72+ 
73+ WeightFakequantModulePass(records, 8).do_pass(model, fake_linear, 'linear')
74+ 
75+ torch.testing.assert_close(fake_linear.sub_module.weight, original_weight)
76+ 
77+ def test_linear_offset_broadcasts_for_multidimensional_weight(self):
78+ model = LinearModel()
79+ model.linear.weight = torch.nn.Parameter(
80+ torch.arange(24, dtype=torch.float32).reshape(4, 2, 3)
81+ )
82+ records = self._records()
83+ original_weight = model.linear.weight.detach().clone()
84+ fake_linear = FakeQuantizedLinear(model.linear, records['linear'], 'linear', 8)
85+ 
86+ WeightFakequantModulePass(records, 8).do_pass(model, fake_linear, 'linear')
87+ 
88+ torch.testing.assert_close(fake_linear.sub_module.weight, original_weight)
89+ 
90+ 
91+if __name__ == '__main__':
92+ unittest.main()
@@ -22,7 +22,7 @@ from io import BytesIO
22 22 
23import numpy as np23import numpy as np
24import torch24import torch
25-from onnx import onnx_pb25+from onnx import TensorProto, helper, numpy_helper, onnx_pb
26 26 
27from amct_pytorch.classic.graph_based.amct_pytorch.graph.graph import Graph27from amct_pytorch.classic.graph_based.amct_pytorch.graph.graph import Graph
28from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.insert_quant_pass import (28from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.insert_quant_pass import (
@@ -426,3 +426,38 @@ class TestReplaceWeightQuantPass(unittest.TestCase):
426 node_name = 'conv1'426 node_name = 'conv1'
427 node = graph.get_node_by_name(node_name)427 node = graph.get_node_by_name(node_name)
428 passer.do_pass(graph, node)428 passer.do_pass(graph, node)
429+ 
430+ def test_matmul_transposed_weight_offset_broadcasts_on_output_channel(self):
431+ weight = np.array([[2, 3], [4, 5], [6, 7], [8, 9]], dtype=np.int8)
432+ model = helper.make_model(
433+ helper.make_graph(
434+ [
435+ helper.make_node(
436+ 'Transpose', ['weight'], ['weight_t'], name='weight_trans'
437+ ),
438+ helper.make_node(
439+ 'MatMul', ['inputs', 'weight_t'], ['output'], name='linear'
440+ ),
441+ ],
442+ 'linear_graph',
443+ [helper.make_tensor_value_info('inputs', TensorProto.FLOAT, [1, 2])],
444+ [helper.make_tensor_value_info('output', TensorProto.FLOAT, [1, 4])],
445+ [numpy_helper.from_array(weight, name='weight')],
446+ )
447+ )
448+ graph = Graph(model)
449+ node = graph.get_node_by_name('linear')
450+ records = {
451+ 'linear': {
452+ WEIGHT_SCALE: np.ones(4, dtype=np.float32),
453+ WEIGHT_OFFSET: np.array([1, 2, 3, 4], dtype=np.int8),
454+ }
455+ }
456+ 
457+ WeightFakequantPass(records).do_pass(graph, node)
458+ 
459+ quantized = numpy_helper.to_array(graph.get_node_by_name('weight').proto)
460+ np.testing.assert_array_equal(
461+ quantized,
462+ np.array([[1, 2], [2, 3], [3, 4], [4, 5]], dtype=np.float32),
463+ )
@@ -22,7 +22,9 @@ from unittest.mock import patch
22 22 
23import numpy as np23import numpy as np
24import torch24import torch
25+from onnx import TensorProto, helper, numpy_helper
25 26 
27+from amct_pytorch.classic.graph_based.amct_pytorch.graph.graph import Graph
26from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.graph_optimizer import (28from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.graph_optimizer import (
27 GraphOptimizer,29 GraphOptimizer,
28)30)
@@ -33,6 +35,7 @@ from amct_pytorch.classic.graph_based.amct_pytorch.parser.parser import Parser
33from amct_pytorch.classic.graph_based.amct_pytorch.utils.onnx_initializer_util import (35from amct_pytorch.classic.graph_based.amct_pytorch.utils.onnx_initializer_util import (
34 TensorProtoHelper,36 TensorProtoHelper,
35)37)
38+from amct_pytorch.classic.graph_based.amct_pytorch.utils.quant_node import QuantOpInfo
36from amct_pytorch.classic.graph_based.amct_pytorch.utils.vars import (39from amct_pytorch.classic.graph_based.amct_pytorch.utils.vars import (
37 QUANTIZABLE_TYPES,40 QUANTIZABLE_TYPES,
38)41)
@@ -95,6 +98,11 @@ class TestWeightQuantPass(unittest.TestCase):
95 98 
96 @unittest.skipUnless(_INT4_SUPPORTED, _SKIP_INT4_MSG)99 @unittest.skipUnless(_INT4_SUPPORTED, _SKIP_INT4_MSG)
97 def test_quant_weight_int4(self):100 def test_quant_weight_int4(self):
101+ target_node = self.graph.get_node_by_name('fc.2')
102+ weight_node = QuantOpInfo.get_weight_node(target_node)
103+ original_dims = list(weight_node.proto.dims)
104+ element_count = int(np.prod(original_dims))
105+ 
98 with patch(106 with patch(
99 'amct_pytorch.classic.graph_based.amct_pytorch.utils.quant_node.'107 'amct_pytorch.classic.graph_based.amct_pytorch.utils.quant_node.'
100 'QuantOpInfo.get_dst_num_bits',108 'QuantOpInfo.get_dst_num_bits',
@@ -108,6 +116,128 @@ class TestWeightQuantPass(unittest.TestCase):
108 after_nodes = len(self.graph.nodes)116 after_nodes = len(self.graph.nodes)
109 self.assertEqual(after_nodes - before_nodes, 0)117 self.assertEqual(after_nodes - before_nodes, 0)
110 118 
119+ self.assertEqual(
120+ weight_node.proto.data_type,
121+ TensorProtoHelper.data_type_maps['INT4'][0],
122+ )
123+ self.assertEqual(list(weight_node.proto.dims), original_dims)
124+ self.assertEqual(len(weight_node.proto.raw_data), (element_count + 1) // 2)
125+ 
126+ def test_matmul_weight_quantizes_per_output_channel(self):
127+ class LinearModel(torch.nn.Module):
128+ def __init__(self):
129+ super().__init__()
130+ self.linear = torch.nn.Linear(2, 4, bias=False)
131+ self.linear.weight.data = torch.tensor(
132+ [[1, 2], [10, 20], [100, 200], [1000, 2000]],
133+ dtype=torch.float32,
134+ )
135+ 
136+ def forward(self, inputs):
137+ return self.linear(inputs)
138+ 
139+ model = LinearModel()
140+ tmp_onnx = BytesIO()
141+ Parser.export_onnx(model, torch.ones(1, 2), tmp_onnx)
142+ graph = Parser.parse_net_to_graph(tmp_onnx)
143+ node = graph.get_node_by_name('linear')
144+ self.assertEqual(node.type, 'MatMul')
145+ records = {
146+ 'linear': {
147+ 'weight_scale': np.array([1, 10, 100, 1000], dtype=np.float32),
148+ 'weight_offset': np.zeros(4, dtype=np.int8),
149+ 'wts_type': 'INT8',
150+ }
151+ }
152+ 
153+ InsertWeightQuantPass(records).do_pass(graph, node)
154+ 
155+ weight_node = QuantOpInfo.get_weight_node(node)
156+ quantized = TensorProtoHelper(weight_node.proto).get_data()
157+ np.testing.assert_array_equal(
158+ quantized,
159+ np.array([[1, 1, 1, 1], [2, 2, 2, 2]], dtype=np.int8),
160+ )
161+ 
162+ def test_matmul_multidimensional_weight_quantizes_per_output_channel(self):
163+ weight = np.stack(
164+ [
165+ np.full((2, 3), 1, dtype=np.float32),
166+ np.full((2, 3), 10, dtype=np.float32),
167+ np.full((2, 3), 100, dtype=np.float32),
168+ np.full((2, 3), 1000, dtype=np.float32),
169+ ],
170+ axis=-1,
171+ )
172+ model = helper.make_model(
173+ helper.make_graph(
174+ [
175+ helper.make_node(
176+ 'MatMul', ['inputs', 'weight'], ['output'], name='linear'
177+ )
178+ ],
179+ 'linear_graph',
180+ [helper.make_tensor_value_info('inputs', TensorProto.FLOAT, [1, 2, 3])],
181+ [helper.make_tensor_value_info('output', TensorProto.FLOAT, None)],
182+ [numpy_helper.from_array(weight, name='weight')],
183+ )
184+ )
185+ graph = Graph(model)
186+ node = graph.get_node_by_name('linear')
187+ records = {
188+ 'linear': {
189+ 'weight_scale': np.array([1, 10, 100, 1000], dtype=np.float32),
190+ 'weight_offset': np.zeros(4, dtype=np.int8),
191+ 'wts_type': 'INT8',
192+ }
193+ }
194+ 
195+ InsertWeightQuantPass(records).do_pass(graph, node)
196+ 
197+ quantized = TensorProtoHelper(graph.get_node_by_name('weight').proto).get_data()
198+ self.assertEqual(list(quantized.shape), [2, 3, 4])
199+ np.testing.assert_array_equal(quantized, np.ones((2, 3, 4), dtype=np.int8))
200+ 
201+ def test_matmul_transposed_weight_quantizes_per_output_channel(self):
202+ weight = np.array(
203+ [[1, 2], [10, 20], [100, 200], [1000, 2000]], dtype=np.float32
204+ )
205+ model = helper.make_model(
206+ helper.make_graph(
207+ [
208+ helper.make_node(
209+ 'Transpose', ['weight'], ['weight_t'], name='weight_trans'
210+ ),
211+ helper.make_node(
212+ 'MatMul', ['inputs', 'weight_t'], ['output'], name='linear'
213+ ),
214+ ],
215+ 'linear_graph',
216+ [helper.make_tensor_value_info('inputs', TensorProto.FLOAT, [1, 2])],
217+ [helper.make_tensor_value_info('output', TensorProto.FLOAT, [1, 4])],
218+ [numpy_helper.from_array(weight, name='weight')],
219+ )
220+ )
221+ graph = Graph(model)
222+ node = graph.get_node_by_name('linear')
223+ records = {
224+ 'linear': {
225+ 'weight_scale': np.array([1, 10, 100, 1000], dtype=np.float32),
226+ 'weight_offset': np.zeros(4, dtype=np.int8),
227+ 'wts_type': 'INT8',
228+ }
229+ }
230+ 
231+ InsertWeightQuantPass(records).do_pass(graph, node)
232+ 
233+ weight_node = graph.get_node_by_name('weight')
234+ quantized = TensorProtoHelper(weight_node.proto).get_data()
235+ self.assertEqual(list(weight_node.proto.dims), [4, 2])
236+ np.testing.assert_array_equal(
237+ quantized,
238+ np.array([[1, 2], [1, 2], [1, 2], [1, 2]], dtype=np.int8),
239+ )
240+ 
111 def test_rnn_weight_quant_success(self):241 def test_rnn_weight_quant_success(self):
112 class RNNModule(torch.nn.Module):242 class RNNModule(torch.nn.Module):
113 def __init__(self):243 def __init__(self):
@@ -142,38 +272,6 @@ class TestWeightQuantPass(unittest.TestCase):
142 passer = InsertWeightQuantPass(records)272 passer = InsertWeightQuantPass(records)
143 passer.quant_recurrence_weight(node)273 passer.quant_recurrence_weight(node)
144 274 
145- def test_deploy_packs_int4_weight(self):
146- # 4 个 INT4 权重 → deploy 应 pack 成 2 个 INT8 字节
147- from amct_pytorch.classic.graph_based.amct_pytorch.utils.onnx_initializer_util import (
148- pack_int4_to_int8,
149- )
150- 
151- int4_vals = np.array([1, -2, 7, -8], dtype=np.int8)
152- packed = pack_int4_to_int8(int4_vals)
153- self.assertEqual(packed.size, 2)
154- 
155- def test_deploy_packs_int4_recurrence_weight(self):
156- """LSTM A8W4: deploy finalize packs recurrence_weight INT4 → INT8 (packed.size == n//2).
157- Guards the ReplaceRNNPass ordering bug where recurrence_weight was silently skipped."""
158- from amct_pytorch.classic.graph_based.amct_pytorch.utils.onnx_initializer_util import (
159- pack_int4_to_int8,
160- )
161- 
162- # Simulate a recurrence_weight tensor for an LSTM with hidden_size=20,
163- # input_size=10: shape is (4, 20, 20) → 1600 INT4 elements (even count).
164- n_elements = 1600
165- rng = np.random.default_rng(42)
166- int4_vals = rng.integers(-8, 8, size=n_elements, dtype=np.int8)
167- 
168- packed = pack_int4_to_int8(int4_vals)
169- 
170- # Two INT4 nibbles packed into each INT8 byte → exactly n_elements // 2 bytes.
171- self.assertEqual(
172- packed.size,
173- n_elements // 2,
174- msg='expected {} packed bytes, got {}'.format(n_elements // 2, packed.size),
175- )
176- 
177 def build_lstm_int4_case(self):275 def build_lstm_int4_case(self):
178 """Build the LSTM graph node and INT4 records for recurrence-weight UT."""276 """Build the LSTM graph node and INT4 records for recurrence-weight UT."""
179 model = models.LSTMNet(10, 20, 1)277 model = models.LSTMNet(10, 20, 1)
@@ -279,62 +377,3 @@ class TestWeightQuantPass(unittest.TestCase):
279 actual_dtype377 actual_dtype
280 ),378 ),
281 )379 )
282- 
283- def test_conv_int4_finalize_deploy_no_crash(self):
284- """C-1 regression: Conv A8W4 deploy finalize loop must not crash.
285- get_recurrence_weight_node must return None (not raise) for non-RNN nodes."""
286- from amct_pytorch.classic.graph_based.amct_pytorch.utils.quant_node import (
287- QuantOpInfo,
288- )
289- 
290- # Use the Conv graph already built in setUpClass (Net001 has Conv2d layers).
291- conv_node = None
292- for node in self.graph.nodes:
293- if node.type == 'Conv':
294- conv_node = node
295- break
296- self.assertIsNotNone(
297- conv_node, 'Expected at least one Conv node in Net001 graph'
298- )
299- 
300- # assert get_recurrence_weight_node returns None, not crash
301- rw_node = QuantOpInfo.get_recurrence_weight_node(conv_node)
302- self.assertIsNone(
303- rw_node,
304- 'get_recurrence_weight_node must return None for a Conv node, not crash',
305- )
306- 
307- # get the weight node and set up INT4 data
308- weight_node = QuantOpInfo.get_weight_node(conv_node)
309- self.assertIsNotNone(weight_node, 'Conv node must have a weight node')
310- 
311- from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.pack_int4_weight_pass import (
312- pack_along_axis,
313- )
314- 
315- weight_helper = TensorProtoHelper(weight_node.proto, weight_node.model_path)
316- orig_dims = list(weight_node.proto.dims)
317- int4_vals = np.clip(weight_helper.get_data().astype(np.int8), -8, 7)
318- 
319- # deploy path: Conv packs along the Cin axis (axis 1), other axes unchanged
320- cin_axis = 1
321- packed, new_dims = pack_along_axis(int4_vals, orig_dims, cin_axis)
322- expected = orig_dims.copy()
323- expected[cin_axis] = (orig_dims[cin_axis] + 1) // 2
324- self.assertEqual(new_dims, expected, 'Cin axis must become ceil(axis/2)')
325- 
326- weight_helper.clear_data()
327- weight_helper.set_data(packed, 'INT8', dims=new_dims)
328- 
329- # only the quant axis halves; other axes stay identical
330- self.assertEqual(
331- [d for i, d in enumerate(new_dims) if i != cin_axis],
332- [d for i, d in enumerate(orig_dims) if i != cin_axis],
333- 'non-quant axes must stay unchanged after packing',
334- )
335- # self-consistent: prod(new_dims) == raw_data byte length
336- self.assertEqual(
337- int(np.prod(new_dims)),
338- len(weight_node.proto.raw_data),
339- 'packed INT8 tensor must satisfy prod(dims) == raw_data bytes',
340- )
@@ -83,6 +83,42 @@ class TestParser(unittest.TestCase):
83 torch_out = Parser.export_onnx(model, self.args, tmp_onnx)83 torch_out = Parser.export_onnx(model, self.args, tmp_onnx)
84 self.assertIsNone(torch_out)84 self.assertIsNone(torch_out)
85 85 
86+ @patch(
87+ 'amct_pytorch.classic.graph_based.amct_pytorch.parser.parser._export_to_onnx'
88+ )
89+ def test_export_onnx_disables_dynamo_by_default(self, mock_export):
90+ mock_export.return_value = None
91+ 
92+ Parser.export_onnx(torch.nn.Identity(), self.args, BytesIO())
93+ 
94+ self.assertFalse(mock_export.call_args.args[3]['dynamo'])
95+ 
96+ @patch(
97+ 'amct_pytorch.classic.graph_based.amct_pytorch.parser.parser._export_to_onnx'
98+ )
99+ def test_export_onnx_preserves_explicit_dynamo_setting(self, mock_export):
100+ mock_export.return_value = None
101+ 
102+ Parser.export_onnx(torch.nn.Identity(), self.args, BytesIO(), {'dynamo': True})
103+ 
104+ self.assertTrue(mock_export.call_args.args[3]['dynamo'])
105+ 
106+ @patch(
107+ 'amct_pytorch.classic.graph_based.amct_pytorch.parser.parser._export_to_onnx'
108+ )
109+ def test_export_onnx_legacy_api_omits_dynamo(self, mock_export):
110+ def legacy_export(model, args, path, opset_version=16):
111+ pass
112+ 
113+ with patch('torch.onnx.export', new=legacy_export):
114+ for settings in (None, {'dynamo': False}):
115+ Parser.export_onnx(torch.nn.Identity(), self.args, BytesIO(), settings)
116+ self.assertNotIn('dynamo', mock_export.call_args.args[3])
117+ with self.assertRaisesRegex(ValueError, 'does not support dynamo'):
118+ Parser.export_onnx(
119+ torch.nn.Identity(), self.args, BytesIO(), {'dynamo': True}
120+ )
121+ 
86 @patch('torch.onnx.export')122 @patch('torch.onnx.export')
87 def test_parse_unsupport_bn(self, mock_export):123 def test_parse_unsupport_bn(self, mock_export):
88 mock_export.side_effect = RuntimeError()124 mock_export.side_effect = RuntimeError()
@@ -763,25 +763,29 @@ class TestConvLinearPTQA8W4(unittest.TestCase):
763 config_defination=self.cfg,763 config_defination=self.cfg,
764 )764 )
765 self.assertTrue(os.path.exists(config_file))765 self.assertTrue(os.path.exists(config_file))
766- # cfg entry drives weight INT4; Net001 has group/depthwise conv whose Cin766+ # The cfg drives weight INT4. Layers with an odd final Deploy pack axis
767- # axis cannot be nibble-packed, so those layers are downgraded to INT8.767+ # are skipped instead of being downgraded to INT8.
768 with open(config_file) as fh:768 with open(config_file) as fh:
769 cfg = _json.load(fh)769 cfg = _json.load(fh)
770 wts_bits = []770 wts_bits = []
771+ skipped_layers = []
772+ enabled_layers = []
771 for layer, lcfg in cfg.items():773 for layer, lcfg in cfg.items():
772 if isinstance(lcfg, dict) and 'weight_quant_params' in lcfg:774 if isinstance(lcfg, dict) and 'weight_quant_params' in lcfg:
773 nb = lcfg['weight_quant_params'].get('num_bits')775 nb = lcfg['weight_quant_params'].get('num_bits')
774 if nb is not None:776 if nb is not None:
775 wts_bits.append(nb)777 wts_bits.append(nb)
778+ target = (
779+ enabled_layers if lcfg.get('quant_enable') else skipped_layers
780+ )
781+ target.append(layer)
776 self.assertTrue(wts_bits, 'no weight_quant_params.num_bits found in config')782 self.assertTrue(wts_bits, 'no weight_quant_params.num_bits found in config')
777- # regular conv/linear stay INT4; group/depthwise conv downgraded to INT8783+ self.assertTrue(enabled_layers, 'packable A8W4 layers should remain enabled')
778- self.assertIn(4, wts_bits, 'A8W4 cfg should yield INT4 for regular layers')784+ self.assertTrue(skipped_layers, 'odd-axis A8W4 layers should be skipped')
779- self.assertIn(785+ self.assertEqual(
780- 8,786+ set(wts_bits),
781- wts_bits,787+ {4},
782- 'group/depthwise conv should be downgraded to INT8, got {}'.format(788+ 'skipped A8W4 layers must not be downgraded to INT8',
783- wts_bits
784- ),
785 )789 )
786 790 
787 @unittest.skipUnless(_INT4_SUPPORTED, _SKIP_INT4_MSG)791 @unittest.skipUnless(_INT4_SUPPORTED, _SKIP_INT4_MSG)
@@ -973,6 +977,7 @@ class TestConvTransposePTQA8W4(unittest.TestCase):
973 @classmethod977 @classmethod
974 def setUpClass(cls):978 def setUpClass(cls):
975 cls.model = models.NetConvDeconv()979 cls.model = models.NetConvDeconv()
980+ cls.model.layer2[0] = torch.nn.ConvTranspose2d(16, 16, kernel_size=2, bias=True)
976 cls.model.eval()981 cls.model.eval()
977 cls.input = torch.randn(1, 2, 28, 28)982 cls.input = torch.randn(1, 2, 28, 28)
978 cls.temp_folder = os.path.join(CUR_DIR, 'test_convtranspose_ptq_a8w4')983 cls.temp_folder = os.path.join(CUR_DIR, 'test_convtranspose_ptq_a8w4')
@@ -1017,7 +1022,7 @@ class TestConvTranspose1dPTQA8W4(unittest.TestCase):
1017 1022 
1018 @classmethod1023 @classmethod
1019 def setUpClass(cls):1024 def setUpClass(cls):
1020- cls.model = rnn_model.ConvTranspose1dNet()1025+ cls.model = rnn_model.ConvTranspose1dNet(kernel_size=2)
1021 cls.model.eval()1026 cls.model.eval()
1022 cls.input = torch.randn(1, 3, 32)1027 cls.input = torch.randn(1, 3, 32)
1023 cls.temp_folder = os.path.join(CUR_DIR, 'test_convtranspose1d_ptq_a8w4')1028 cls.temp_folder = os.path.join(CUR_DIR, 'test_convtranspose1d_ptq_a8w4')
@@ -22,6 +22,7 @@ from unittest import mock
22 22 
23import torch23import torch
24import torch.nn as nn24import torch.nn as nn
25+from onnx import helper
25 26 
26from amct_pytorch.classic.graph_based.amct_pytorch.parser.parser import Parser27from amct_pytorch.classic.graph_based.amct_pytorch.parser.parser import Parser
27from amct_pytorch.classic.graph_based.amct_pytorch.utils.quant_node import (28from amct_pytorch.classic.graph_based.amct_pytorch.utils.quant_node import (
@@ -119,6 +120,37 @@ class TestQuantOpInfo(unittest.TestCase):
119 self.assertEqual(QuantOpInfo.get_scale_shape(node1, False), ([4], 4))120 self.assertEqual(QuantOpInfo.get_scale_shape(node1, False), ([4], 4))
120 self.assertEqual(QuantOpInfo.get_scale_shape(node2, False), ([3], 3))121 self.assertEqual(QuantOpInfo.get_scale_shape(node2, False), ([3], 3))
121 122 
123+ def test_get_scale_shape_linear_per_channel(self):
124+ weight = mock.MagicMock()
125+ node = mock.MagicMock()
126+ 
127+ weight.dims = [3, 4]
128+ node.type = 'MatMul'
129+ node.has_attr.return_value = False
130+ with mock.patch.object(QuantOpInfo, 'get_weight_tensor', return_value=weight):
131+ self.assertEqual(QuantOpInfo.get_scale_shape(node, True), ([4], 4))
132+ 
133+ weight.dims = [2, 3, 4]
134+ with mock.patch.object(QuantOpInfo, 'get_weight_tensor', return_value=weight):
135+ self.assertEqual(QuantOpInfo.get_scale_shape(node, True), ([4], 4))
136+ 
137+ weight.dims = [4, 3]
138+ node.has_attr.return_value = True
139+ node.get_attr.return_value = True
140+ with mock.patch.object(QuantOpInfo, 'get_weight_tensor', return_value=weight):
141+ self.assertEqual(QuantOpInfo.get_scale_shape(node, True), ([4], 4))
142+ 
143+ weight.dims = [3, 4]
144+ node.type = 'Gemm'
145+ node.proto = helper.make_node('Gemm', ['x', 'w'], ['y'], transB=0)
146+ with mock.patch.object(QuantOpInfo, 'get_weight_tensor', return_value=weight):
147+ self.assertEqual(QuantOpInfo.get_scale_shape(node, True), ([4], 4))
148+ 
149+ weight.dims = [4, 3]
150+ node.proto = helper.make_node('Gemm', ['x', 'w'], ['y'], transB=1)
151+ with mock.patch.object(QuantOpInfo, 'get_weight_tensor', return_value=weight):
152+ self.assertEqual(QuantOpInfo.get_scale_shape(node, True), ([4], 4))
153+ 
122 def test_get_bias_for_matmul(self):154 def test_get_bias_for_matmul(self):
123 class MatmulAddModel(torch.nn.Module):155 class MatmulAddModel(torch.nn.Module):
124 def __init__(self):156 def __init__(self):