已合并
feat: graph-based PTQ/QAT 支持 A8W4 量化 #269
QQR创建于 26 天前
feat: graph-based PTQ/QAT 支持 A8W4 量化 #269
已合并
共 46 个文件变更+2002-550
| @@ -9,8 +9,8 @@ QUANTIZABLE_TYPES, list,Conv2d,Conv3d,Linear,ConvTranspose2d,AvgPool2d,Conv1d,Co | |||
| 9 | QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,MatMul,ConvTranspose,AveragePool | 9 | QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,MatMul,ConvTranspose,AveragePool |
| 10 | INT16_QUANTIZABLE_TYPES, list,Conv2d,Linear,ConvTranspose2d,Conv1d,ConvTranspose1d,GRU,LSTM | 10 | INT16_QUANTIZABLE_TYPES, list,Conv2d,Linear,ConvTranspose2d,Conv1d,ConvTranspose1d,GRU,LSTM |
| 11 | INT16_QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,ConvTranspose,LSTM,GRU | 11 | INT16_QUANTIZABLE_ONNX_TYPES,list,Conv,Gemm,ConvTranspose,LSTM,GRU |
| 12 | -CHANNEL_WISE_TYPES, list,Conv2d,Conv3d,ConvTranspose2d,Conv1d,LSTM,GRU,ConvTranspose1d | 12 | +CHANNEL_WISE_TYPES, list,Conv2d,Conv3d,ConvTranspose2d,Conv1d,LSTM,GRU,ConvTranspose1d,Linear |
| 13 | -CHANNEL_WISE_ONNX_TYPES,list,Conv,ConvTranspose,LSTM,GRU | 13 | +CHANNEL_WISE_ONNX_TYPES,list,Conv,ConvTranspose,LSTM,GRU,Gemm,MatMul |
| 14 | FUSE_TYPES, list,Conv2d,Conv3d,Conv1d | 14 | FUSE_TYPES, list,Conv2d,Conv3d,Conv1d |
| 15 | FUSE_ONNX_TYPES,list,Conv | 15 | FUSE_ONNX_TYPES,list,Conv |
| 16 | AMCT_OPERATIONS, list,IFMR,HFMG,Recorder,AscendQuant,AscendDeQuant,RetrainQuant,RNNRetrainQuant,MarkedQuantizableModule,QuantIdentity,SelectiveMaskGen,DMQBalancer,Conv2dQAT,LinearQAT,LSTMQAT,GRUQAT,OFMRQuant,ConvTranspose1dQAT | 16 | AMCT_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 layers | 325 | return layers |
| 326 | 326 | ||
| 327 | 327 | ||
| 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 True | 338 | return True |
| 344 | - # group/depthwise conv: onnx weight Cin dim is Cin/groups, cannot pack | 339 | + |
| 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') > 1 | 346 | + and attr_helper.get_attr_value('transB') == 1 |
| 350 | ): | 347 | ): |
| 351 | - return False | 348 | + 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 | continue | 355 | continue |
| 358 | dims = QuantOpInfo.get_node_tensor(wnode).dims | 356 | 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 False | 378 | return False |
| 361 | return True | 379 | return True |
| 362 | 380 | ||
| @@ -25,10 +25,15 @@ from ....amct_pytorch.custom_op import arq_retrain_backward_pytorch | |||
| 25 | from ....amct_pytorch.custom_op.utils import check_quant_data | 25 | from ....amct_pytorch.custom_op.utils import check_quant_data |
| 26 | from ....amct_pytorch.custom_op.utils import check_group_param | 26 | from ....amct_pytorch.custom_op.utils import check_group_param |
| 27 | from ....amct_pytorch.custom_op.utils import process_tensor_shape | 27 | from ....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 | ||
| 30 | from ....amct_pytorch.utils.vars import TRANSPOSE | 28 | from ....amct_pytorch.utils.vars import TRANSPOSE |
| 31 | from ....amct_pytorch.utils.weight_quant_api import adjust_axis_for_group_wise | 29 | from ....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 | ||
| 34 | class ArqRetrainFunction(Function): | 39 | class 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 | |||
| 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 tensor | 76 | # 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 | 108 | ||
| 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 = 1 | 32 | + 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'): | ||
| 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) | ||
| 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 | |||
| 23 | from ....amct_pytorch.custom_op import ulq_retrain_forward_pytorch | 23 | from ....amct_pytorch.custom_op import ulq_retrain_forward_pytorch |
| 24 | from ....amct_pytorch.custom_op import ulq_retrain_backward_pytorch | 24 | from ....amct_pytorch.custom_op import ulq_retrain_backward_pytorch |
| 25 | from ....amct_pytorch.custom_op.utils import check_quant_data | 25 | from ....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 | ||
| 28 | class UlqRetrainFunction(Function): | 29 | class 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, |
Mamct_pytorch/classic/graph_based/amct_pytorch/custom_op/ulq_scale_retrain/ulq_scale_retrain.py+53-17
| @@ -25,10 +25,15 @@ from ....amct_pytorch.custom_op import ulq_scale_retrain_backward_pytorch | |||
| 25 | from ....amct_pytorch.custom_op.utils import check_quant_data | 25 | from ....amct_pytorch.custom_op.utils import check_quant_data |
| 26 | from ....amct_pytorch.custom_op.utils import check_group_param | 26 | from ....amct_pytorch.custom_op.utils import check_group_param |
| 27 | from ....amct_pytorch.custom_op.utils import process_tensor_shape | 27 | from ....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 | ||
| 30 | from ....amct_pytorch.utils.vars import TRANSPOSE | 28 | from ....amct_pytorch.utils.vars import TRANSPOSE |
| 31 | from ....amct_pytorch.utils.weight_quant_api import adjust_axis_for_group_wise | 29 | from ....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 | ||
| 34 | MODULE_TYPE = 'module_type' | 39 | MODULE_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 data | 80 | # 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 | 127 | ||
| 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 torch | 130 | + 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 | |||
| 19 | from torch.nn import functional as F | 19 | from torch.nn import functional as F |
| 20 | 20 | ||
| 21 | from .....amct_pytorch.nn.module.quantization.qat_base import QATBase | 21 | from .....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 | ||
| 23 | SUPPORTED_DATA_DIMS = 4 | 25 | SUPPORTED_DATA_DIMS = 4 |
| 24 | 26 | ||
| 25 | 27 | ||
| 26 | class Conv2dQAT(nn.Conv2d, QATBase): | 28 | class Conv2dQAT(nn.Conv2d, QATBase): |
| 27 | _float_module = nn.Conv2d | 29 | _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 | ||
| 23 | from .....amct_pytorch.utils.log import LOGGER | 23 | from .....amct_pytorch.utils.log import LOGGER |
| 24 | from .....amct_pytorch.nn.module.quantization.qat_base import QATBase | 24 | from .....amct_pytorch.nn.module.quantization.qat_base import QATBase |
| 25 | -from .....amct_pytorch.utils.vars import CHANNEL_WISE | 25 | +from .....amct_pytorch.common.utils.vars_util import INT4, INT8 |
| 26 | +from .....amct_pytorch.utils.vars import CHANNEL_WISE, DST_TYPE | ||
| 26 | 27 | ||
| 27 | RETRAIN_WEIGHT_CONFIG = 'retrain_weight_config' | 28 | RETRAIN_WEIGHT_CONFIG = 'retrain_weight_config' |
| 28 | 29 | ||
| @@ -35,6 +36,7 @@ class LinearQAT(nn.Linear, QATBase): | |||
| 35 | 36 | ||
| 36 | _float_module = nn.Linear | 37 | _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=None | 42 | 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 True | 94 | 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 | ||
| 26 | from .....amct_pytorch.utils.log import LOGGER | 26 | from .....amct_pytorch.utils.log import LOGGER |
| 27 | from .....amct_pytorch.common.utils.check_params import check_params | 27 | from .....amct_pytorch.common.utils.check_params import check_params |
| 28 | -from .....amct_pytorch.common.utils.vars_util import INT8, INT16 | 28 | +from .....amct_pytorch.common.utils.vars_util import INT4, INT8, INT16 |
| 29 | from .....amct_pytorch.common.utils.vars_util import RNN_TENSOR_NUM | 29 | from .....amct_pytorch.common.utils.vars_util import RNN_TENSOR_NUM |
| 30 | from .....amct_pytorch.custom_op.ifmr.ifmr import IFMR | 30 | from .....amct_pytorch.custom_op.ifmr.ifmr import IFMR |
| 31 | from .....amct_pytorch.custom_op.utils import copy_tensor | 31 | from .....amct_pytorch.custom_op.utils import copy_tensor |
| @@ -35,6 +35,12 @@ from .....amct_pytorch.custom_op.arq_retrain.arq_retrain import ArqRetrainFuncQA | |||
| 35 | from .....amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain import ( | 35 | from .....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 | +) | ||
| 38 | from .....amct_pytorch.utils.vars import ( | 44 | from .....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 = None | 73 | _float_module = None |
| 68 | _required_params = list() | 74 | _required_params = list() |
| 75 | + _supported_weight_dst_types = (INT8,) | ||
| 69 | 76 | ||
| 70 | 77 | ||
| 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.dtype | 331 | 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.weight | 347 | quantized_acts, quantized_weights = inputs, self.weight |
| 339 | return quantized_acts, quantized_weights | 348 | 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 weights | 548 | # 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 | |||
| 146 | from .replace_bias_quant_pass import ReplaceBiasQuantPass | 145 | from .replace_bias_quant_pass import ReplaceBiasQuantPass |
| 147 | from .replace_sync_bn_pass import RepalceSyncBNPass | 146 | from .replace_sync_bn_pass import RepalceSyncBNPass |
| 148 | from .replace_rnn_pass import ReplaceRNNPass | 147 | from .replace_rnn_pass import ReplaceRNNPass |
| 149 | -from .pack_int4_weight_pass import PackInt4WeightPass | ||
| 150 | from .set_recorder_pass import SetRecorderPass | 148 | from .set_recorder_pass import SetRecorderPass |
| 151 | from .share_act_comp_pass import ShareActCompPass | 149 | from .share_act_comp_pass import ShareActCompPass |
| 152 | from .weight_calibration import WeightsCalibrationPass | 150 | from .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'] = False | 69 | + quant_params['channel_wise'] = len(quant_params['weight_scale'].flatten()) > 1 |
| 70 | 70 | ||
| 71 | # Step2: fake quant | 71 | # Step2: fake quant |
| 72 | fakequant_linear_module = FakeQuantizedLinear( | 72 | fakequant_linear_module = FakeQuantizedLinear( |
| @@ -19,6 +19,7 @@ import numpy as np | |||
| 19 | 19 | ||
| 20 | from ...amct_pytorch.optimizer.base_fusion_pass import BaseFusionPass | 20 | from ...amct_pytorch.optimizer.base_fusion_pass import BaseFusionPass |
| 21 | from ...amct_pytorch.custom_op.arq.arq import weight_quant_np | 21 | from ...amct_pytorch.custom_op.arq.arq import weight_quant_np |
| 22 | +from ...amct_pytorch.common.utils.onnx_node_util import AttributeProtoHelper | ||
| 22 | from ...amct_pytorch.utils.onnx_initializer_util import TensorProtoHelper | 23 | from ...amct_pytorch.utils.onnx_initializer_util import TensorProtoHelper |
| 23 | from ...amct_pytorch.utils.quant_node import QuantOpInfo | 24 | from ...amct_pytorch.utils.quant_node import QuantOpInfo |
| 24 | from ...amct_pytorch.utils.log import LOGGER | 25 | from ...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 | |||
| 25 | from ...amct_pytorch.custom_op.fake_quant import FAKE_MODULES | 25 | from ...amct_pytorch.custom_op.fake_quant import FAKE_MODULES |
| 26 | from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV_TRANSPOSE | 26 | from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV_TRANSPOSE |
| 27 | from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV | 27 | from ...amct_pytorch.custom_op.fake_quant import FAKE_CONV |
| 28 | +from ...amct_pytorch.custom_op.fake_quant import FAKE_LINEAR | ||
| 28 | from ...amct_pytorch.utils.log import LOGGER | 29 | from ...amct_pytorch.utils.log import LOGGER |
| 29 | from ...amct_pytorch.utils.weight_quant_api import adjust_deconv_weight_shape | 30 | from ...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_shape | 96 | 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_offset | 105 | 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 | ||
| 19 | import os | 19 | import os |
| 20 | +import inspect | ||
| 20 | from shutil import copyfileobj | 21 | from shutil import copyfileobj |
| 21 | from io import BytesIO | 22 | from io import BytesIO |
| 22 | import pathlib | 23 | import 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'] = 16 | 131 | 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 tool | 63 | Function: Create quantize configuration json file for amct_pytorch tool |
| 64 | Parameter: config_file: file path of quantize configuration json file | 64 | Parameter: config_file: file path of quantize configuration json file |
| 65 | model: user mode instance of Torch.nn.Module | 65 | model: user mode instance of Torch.nn.Module |
| 66 | - input_data: used to compile model, can be ramdom data | 66 | + input_data: used to compile model, can be random data |
| 67 | skip_layers: list of layers that not do quantize, default empty | 67 | skip_layers: list of layers that not do quantize, default empty |
| 68 | batch_num: number of batch that used for calibration | 68 | batch_num: number of batch that used for calibration |
| 69 | activation_offset: whether activation quantize with offset | 69 | 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 data | 107 | + input_data: used to compile model, can be random data |
| 108 | Returns: | 108 | Returns: |
| 109 | None | 109 | None |
| 110 | """ | 110 | """ |
| @@ -168,7 +168,7 @@ def quantize_model( | |||
| 168 | fusion. | 168 | fusion. |
| 169 | record_file: temporary file to store scale and offset | 169 | record_file: temporary file to store scale and offset |
| 170 | model: user pytorch model's model file | 170 | model: user pytorch model's model file |
| 171 | - input_data: used to compile model, can be ramdom data | 171 | + input_data: used to compile model, can be random data |
| 172 | input_names: list of strings, names to assign to the | 172 | input_names: list of strings, names to assign to the |
| 173 | input nodes of the graph, in order | 173 | input nodes of the graph, in order |
| 174 | output_names: names to assign to the | 174 | 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 offset | 212 | record_file: temporary file to store scale and offset |
| 213 | model: user pytorch model's model file | 213 | model: user pytorch model's model file |
| 214 | - input_data: used to compile model, can be ramdom data | 214 | + input_data: used to compile model, can be random data |
| 215 | input_names: list of strings, names to assign to the | 215 | input_names: list of strings, names to assign to the |
| 216 | input nodes of the graph, in order | 216 | input nodes of the graph, in order |
| 217 | output_names: names to assign to the | 217 | output_names: names to assign to the |
| @@ -362,7 +362,7 @@ def create_quant_retrain_config(config_file, model, input_data, config_definatio | |||
| 362 | tool | 362 | tool |
| 363 | Parameter: config_file: file path of quantize configuration json file | 363 | Parameter: config_file: file path of quantize configuration json file |
| 364 | model: user mode instance of Torch.nn.Module | 364 | model: user mode instance of Torch.nn.Module |
| 365 | - input_data: used to compile model, can be ramdom data | 365 | + input_data: used to compile model, can be random data |
| 366 | config_defination: simply config file from user to set | 366 | config_defination: simply config file from user to set |
| 367 | Return: None | 367 | 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 file | 400 | Parameter: config_file: retrain quantize configuration json file |
| 401 | model: user pytorch model's model file | 401 | model: user pytorch model's model file |
| 402 | record_file: temporary file to store scale and offset | 402 | record_file: temporary file to store scale and offset |
| 403 | - input_data: used to compile model, can be ramdom data | 403 | + 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 file | 440 | Parameter: config_file: retrain quantize configuration json file |
| 441 | model: user pytorch model's model file | 441 | model: user pytorch model's model file |
| 442 | record_file: temporary file to store scale and offset | 442 | record_file: temporary file to store scale and offset |
| 443 | - input_data: used to compile model, can be ramdom data | 443 | + input_data: used to compile model, can be random data |
| 444 | pth_file: user quant aware training checkpoint file path | 444 | pth_file: user quant aware training checkpoint file path |
| 445 | state_dict_name: key value of weight parameter in pth_file | 445 | 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 model | 489 | model: retrain model |
| 490 | record_file: temporary file to store scale and offset | 490 | 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 data | 492 | + input_data: used to compile model, can be random data |
| 493 | input_names: list of strings, names to assign to the input nodes of | 493 | input_names: list of strings, names to assign to the input nodes of |
| 494 | the graph, in order | 494 | the graph, in order |
| 495 | output_names: names to assign to the output nodes of the graph | 495 | 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 model | 587 | model: original model |
| 588 | - input_data: used to compile model, can be ramdom data | 588 | + input_data: used to compile model, can be random data |
| 589 | config_file: retrain quantize configuration json file | 589 | config_file: retrain quantize configuration json file |
| 590 | record_file: temporary file to store scale and offset | 590 | 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 bn | 639 | 2. fuse bn |
| 640 | Inputs: | 640 | Inputs: |
| 641 | model: retrain model | 641 | model: retrain model |
| 642 | - input_data: used to compile model, can be ramdom data | 642 | + input_data: used to compile model, can be random data |
| 643 | config_file: retrain quantize configuration json file | 643 | config_file: retrain quantize configuration json file |
| 644 | Returns: | 644 | Returns: |
| 645 | model_copy: a model processed | 645 | 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 model | 724 | # 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 | ||
| 34 | class QuantOpInfo: | 34 | class QuantOpInfo: |
| 35 | ''' | 35 | ''' |
| 36 | - Find infomation of quant_op. | 36 | + Find information of quant_op. |
| 37 | ''' | 37 | ''' |
| 38 | 38 | ||
| 39 | 39 | ||
| @@ -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] = length | 69 | 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 conv3D | 74 | # 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 node | 112 | Function: Get the dequant scale's shape from node |
| 110 | - Inputs: node: the node te be quantized | 113 | + Inputs: node: the node to be quantized |
| 111 | Returns: the shape of dequant scale | 114 | 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 case | 239 | # 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_config | 260 | 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 | continue | 328 | 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 | continue | 330 | 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'] = 8 | 332 | + 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 graph | 589 | # 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_TYPES | 669 | # 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 layer | 671 | + # 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_types | 765 | 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_types | 189 | + 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_layers | 219 | 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 | continue | 240 | 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 | continue | 243 | 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 types | 469 | # Step2: check not support layers and not support types |
| 472 | - # check gloabl | 470 | + # 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 retrain | 472 | # 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 = val | 294 | 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 | ||
| 471 | class TestCheckGraph(unittest.TestCase): | 471 | class 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 = CONV1 | 843 | 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 | 913 | ||
| 918 | def _graph_with(node): | 914 | def _graph_with(node): |
| @@ -921,54 +917,132 @@ class TestCheckInt4CinPackSupported(unittest.TestCase): | |||
| 921 | return graph | 917 | 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 = None | 923 | + 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 = 1 | 933 | + m_qoi.get_recurrence_weight_node.return_value = None |
| 939 | - m_attr.return_value.has_attr.return_value = True | 934 | + for dims in (None, [], [2], [2, 2]): |
| 940 | - m_attr.return_value.get_attr_value.return_value = 16 # groups>1 | 935 | + 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 = wnode | 948 | m_qoi.get_weight_node.return_value = wnode |
| 954 | m_qoi.get_recurrence_weight_node.return_value = None | 949 | 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 | # ---------------------------------------------------------------------------- |
| 18 | import logging | 18 | import logging |
| 19 | +import json | ||
| 20 | +import tempfile | ||
| 19 | import unittest | 21 | import unittest |
| 22 | +from pathlib import Path | ||
| 20 | from unittest.mock import MagicMock | 23 | from unittest.mock import MagicMock |
| 21 | 24 | ||
| 22 | from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY | 25 | from 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 | # ---------------------------------------------------------------------------- |
| 18 | import logging | 18 | import logging |
| 19 | import unittest | 19 | import unittest |
| 20 | +from types import SimpleNamespace | ||
| 20 | from unittest.mock import MagicMock | 21 | from unittest.mock import MagicMock |
| 21 | 22 | ||
| 22 | from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY | 23 | from 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 | ||
| 30 | logger = logging.getLogger(__name__) | 34 | logger = 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 | ||
| 103 | class TestConfigBaseModuleFuncs(unittest.TestCase): | 129 | class 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 | ||
| 200 | class TestContainerFields(unittest.TestCase): | 200 | class 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, True | 374 | + |
| 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 | # ---------------------------------------------------------------------------- |
| 18 | import logging | 18 | import logging |
| 19 | +import json | ||
| 20 | +import tempfile | ||
| 19 | import unittest | 21 | import unittest |
| 22 | +from pathlib import Path | ||
| 20 | from unittest.mock import MagicMock | 23 | from unittest.mock import MagicMock |
| 21 | 24 | ||
| 22 | from amct_pytorch.classic.graph_based.amct_pytorch.capacity import CAPACITY | 25 | from 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 支持 -> 保持 INT4 | 128 | + 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 = False | 152 | + 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 | ||
| 155 | if __name__ == '__main__': | 187 | if __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 | ||
| 23 | import unittest | 23 | import unittest |
| 24 | -from unittest.mock import MagicMock | 24 | +from types import SimpleNamespace |
| 25 | +from unittest import mock | ||
| 25 | 26 | ||
| 26 | import torch | 27 | import torch |
| 28 | +from onnx import TensorProto | ||
| 27 | 29 | ||
| 28 | from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.arq_retrain.arq_retrain import ( | 30 | from 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 | ||
| 33 | def _make_graph(): | 36 | def _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 g | 40 | 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_size | 60 | 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_point | 64 | # 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 | ||
| 52 | class TestArqRetrainSymbolic(unittest.TestCase): | 78 | class 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 | ||
| 23 | import unittest | 23 | import unittest |
| 24 | -from unittest.mock import MagicMock | 24 | +from types import SimpleNamespace |
| 25 | +from unittest import mock | ||
| 25 | 26 | ||
| 26 | import torch | 27 | import torch |
| 28 | +from onnx import TensorProto | ||
| 27 | 29 | ||
| 28 | from amct_pytorch.classic.graph_based.amct_pytorch.custom_op.ulq_scale_retrain.ulq_scale_retrain import ( | 30 | from 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 | ||
| 33 | def _make_graph(): | 36 | def _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 g | 39 | 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_size | 58 | 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 5 | 70 | # 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 | ||
| 50 | class TestUlqScaleRetrainSymbolic(unittest.TestCase): | 74 | class 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 | ||
| 305 | class TestConv2dQAT(unittest.TestCase): | 349 | class TestConv2dQAT(unittest.TestCase): |
| 306 | 350 | ||
| @@ -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 | ||
| 380 | class TestConvTranspose2dQAT(unittest.TestCase): | 458 | class TestConvTranspose2dQAT(unittest.TestCase): |
| 381 | 459 | ||
| @@ -519,9 +597,68 @@ class TestLinearQAT(unittest.TestCase): | |||
| 519 | def test_down(self): | 597 | def test_down(self): |
| 520 | pass | 598 | 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 | + | ||
| 112 | class TestInsertRetrainConv3dPass(unittest.TestCase): | 135 | class TestInsertRetrainConv3dPass(unittest.TestCase): |
| 113 | 136 | ||
| 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 | + | ||
| 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 | ||
| 23 | import numpy as np | 23 | import numpy as np |
| 24 | import torch | 24 | import torch |
| 25 | -from onnx import onnx_pb | 25 | +from onnx import TensorProto, helper, numpy_helper, onnx_pb |
| 26 | 26 | ||
| 27 | from amct_pytorch.classic.graph_based.amct_pytorch.graph.graph import Graph | 27 | from amct_pytorch.classic.graph_based.amct_pytorch.graph.graph import Graph |
| 28 | from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.insert_quant_pass import ( | 28 | from 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 | ||
| 23 | import numpy as np | 23 | import numpy as np |
| 24 | import torch | 24 | import torch |
| 25 | +from onnx import TensorProto, helper, numpy_helper | ||
| 25 | 26 | ||
| 27 | +from amct_pytorch.classic.graph_based.amct_pytorch.graph.graph import Graph | ||
| 26 | from amct_pytorch.classic.graph_based.amct_pytorch.optimizer.graph_optimizer import ( | 28 | from 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 | |||
| 33 | from amct_pytorch.classic.graph_based.amct_pytorch.utils.onnx_initializer_util import ( | 35 | from 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 | ||
| 36 | from amct_pytorch.classic.graph_based.amct_pytorch.utils.vars import ( | 39 | from 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 | 99 | ||
| 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_dtype | 377 | 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | 122 | ||
| 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 Cin | 766 | + # 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 INT8 | 783 | + 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 | 791 | ||
| @@ -973,6 +977,7 @@ class TestConvTransposePTQA8W4(unittest.TestCase): | |||
| 973 | 977 | ||
| 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 | 1023 | ||
| 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 | ||
| 23 | import torch | 23 | import torch |
| 24 | import torch.nn as nn | 24 | import torch.nn as nn |
| 25 | +from onnx import helper | ||
| 25 | 26 | ||
| 26 | from amct_pytorch.classic.graph_based.amct_pytorch.parser.parser import Parser | 27 | from amct_pytorch.classic.graph_based.amct_pytorch.parser.parser import Parser |
| 27 | from amct_pytorch.classic.graph_based.amct_pytorch.utils.quant_node import ( | 28 | from 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): |


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