@@ -942,6 +942,256 @@ def test_duplicate_shared_initializers_shape_consumer_is_skipped(self):
942942 "Shape consumer must not trigger duplication of the shared initializer"
943943 )
944944
945+ @staticmethod
946+ def _make_qdq_model (share_scale_across_pairs : bool = False ) -> onnx .ModelProto :
947+ """
948+ Build a minimal QDQ model.
949+
950+ When ``share_scale_across_pairs=False`` (default):
951+ A single QDQ pair wrapping a Conv weight:
952+
953+ weight → QuantizeLinear(scale, zp) → DequantizeLinear(scale, zp) → Conv
954+
955+ When ``share_scale_across_pairs=True``:
956+ Two independent QDQ pairs that happen to share the same scale/zp:
957+
958+ weight → QLinear1(scale, zp) → DQLinear1(scale, zp) → Conv1
959+ weight2 → QLinear2(scale, zp) → DQLinear2(scale, zp) → Conv2
960+ """
961+ CHANNELS = 4
962+ weight = numpy_helper .from_array (
963+ np .random .randn (CHANNELS , CHANNELS , 1 , 1 ).astype (np .float32 ),
964+ name = "weight" ,
965+ )
966+ scale = numpy_helper .from_array (np .array (0.1 , dtype = np .float32 ), name = "scale" )
967+ zp = numpy_helper .from_array (np .array (0 , dtype = np .int8 ), name = "zp" )
968+
969+ nodes = [
970+ helper .make_node (
971+ "QuantizeLinear" ,
972+ inputs = ["weight" , "scale" , "zp" ],
973+ outputs = ["weight_q" ],
974+ name = "q1" ,
975+ ),
976+ helper .make_node (
977+ "DequantizeLinear" ,
978+ inputs = ["weight_q" , "scale" , "zp" ],
979+ outputs = ["weight_dq" ],
980+ name = "dq1" ,
981+ ),
982+ helper .make_node (
983+ "Conv" ,
984+ inputs = ["model_input" , "weight_dq" ],
985+ outputs = ["model_output" ],
986+ name = "conv1" ,
987+ ),
988+ ]
989+ initializers = [weight , scale , zp ]
990+
991+ if share_scale_across_pairs :
992+ weight2 = numpy_helper .from_array (
993+ np .random .randn (CHANNELS , CHANNELS , 1 , 1 ).astype (np .float32 ),
994+ name = "weight2" ,
995+ )
996+ nodes += [
997+ helper .make_node (
998+ "QuantizeLinear" ,
999+ inputs = ["weight2" , "scale" , "zp" ],
1000+ outputs = ["weight2_q" ],
1001+ name = "q2" ,
1002+ ),
1003+ helper .make_node (
1004+ "DequantizeLinear" ,
1005+ inputs = ["weight2_q" , "scale" , "zp" ],
1006+ outputs = ["weight2_dq" ],
1007+ name = "dq2" ,
1008+ ),
1009+ helper .make_node (
1010+ "Conv" ,
1011+ inputs = ["model_input" , "weight2_dq" ],
1012+ outputs = ["branch2_output" ],
1013+ name = "conv2" ,
1014+ ),
1015+ helper .make_node (
1016+ "Add" ,
1017+ inputs = ["model_output" , "branch2_output" ],
1018+ outputs = ["final_output" ],
1019+ name = "add" ,
1020+ ),
1021+ ]
1022+ initializers .append (weight2 )
1023+
1024+ final_out = "final_output" if share_scale_across_pairs else "model_output"
1025+ graph = helper .make_graph (
1026+ nodes ,
1027+ "QDQModel" ,
1028+ inputs = [
1029+ helper .make_tensor_value_info (
1030+ "model_input" , TensorProto .FLOAT , [1 , CHANNELS , 8 , 8 ]
1031+ )
1032+ ],
1033+ outputs = [
1034+ helper .make_tensor_value_info (
1035+ final_out , TensorProto .FLOAT , [1 , CHANNELS , 8 , 8 ]
1036+ )
1037+ ],
1038+ initializer = initializers ,
1039+ )
1040+ model = models_for_tests .make_model (graph )
1041+ onnx .checker .check_model (model )
1042+ return model
1043+
1044+ def test_duplicate_shared_initializers_qdq_scale_not_duplicated (self ):
1045+ """
1046+ Given a QDQ pair where scale and zero_point are shared between
1047+ QuantizeLinear and DequantizeLinear:
1048+
1049+ weight → QuantizeLinear(scale, zp) → DequantizeLinear(scale, zp) → Conv
1050+
1051+ When: duplicate_shared_initializers is called
1052+ Then: scale and zp must NOT be duplicated — Q and DQ of the same pair
1053+ must always refer to the same quantization parameters.
1054+ """
1055+ model = self ._make_qdq_model (share_scale_across_pairs = False )
1056+
1057+ n_duplicates = duplicate_shared_initializers (model .graph )
1058+
1059+ assert n_duplicates == 0 , (
1060+ "scale/zp shared within a QDQ pair must not be duplicated"
1061+ )
1062+ init_names = {init .name for init in model .graph .initializer }
1063+ assert "scale" in init_names
1064+ assert "zp" in init_names
1065+ assert "scale_copy_1" not in init_names
1066+ assert "zp_copy_1" not in init_names
1067+
1068+ def test_duplicate_shared_initializers_qdq_weight_still_duplicated (self ):
1069+ """
1070+ In a QDQ model, a plain weight initializer (not scale/zp) that is
1071+ shared between two Conv nodes must still be duplicated, while the
1072+ QDQ pair's scale and zero_point remain untouched.
1073+
1074+ Graph:
1075+ shared_weight → Conv1
1076+ shared_weight → Conv2 ← ordinary shared weight; must be duplicated
1077+
1078+ qdq_weight → QuantizeLinear(scale, zp)
1079+ → DequantizeLinear(scale, zp) → Conv3
1080+ ↑ scale/zp shared within QDQ pair; must NOT be duplicated
1081+ """
1082+ CHANNELS = 4
1083+ shared_weight = numpy_helper .from_array (
1084+ np .random .randn (CHANNELS , CHANNELS , 1 , 1 ).astype (np .float32 ),
1085+ name = "shared_weight" ,
1086+ )
1087+ qdq_weight = numpy_helper .from_array (
1088+ np .random .randn (CHANNELS , CHANNELS , 1 , 1 ).astype (np .float32 ),
1089+ name = "qdq_weight" ,
1090+ )
1091+ scale = numpy_helper .from_array (np .array (0.1 , dtype = np .float32 ), name = "scale" )
1092+ zp = numpy_helper .from_array (np .array (0 , dtype = np .int8 ), name = "zp" )
1093+
1094+ nodes = [
1095+ # Two Conv nodes directly sharing shared_weight
1096+ helper .make_node (
1097+ "Conv" ,
1098+ inputs = ["model_input" , "shared_weight" ],
1099+ outputs = ["conv1_out" ],
1100+ name = "conv1" ,
1101+ ),
1102+ helper .make_node (
1103+ "Conv" ,
1104+ inputs = ["model_input" , "shared_weight" ],
1105+ outputs = ["conv2_out" ],
1106+ name = "conv2" ,
1107+ ),
1108+ helper .make_node (
1109+ "Add" ,
1110+ inputs = ["conv1_out" , "conv2_out" ],
1111+ outputs = ["branch1_out" ],
1112+ name = "add1" ,
1113+ ),
1114+ # QDQ pair with scale/zp shared between Q and DQ
1115+ helper .make_node (
1116+ "QuantizeLinear" ,
1117+ inputs = ["qdq_weight" , "scale" , "zp" ],
1118+ outputs = ["qdq_weight_q" ],
1119+ name = "q1" ,
1120+ ),
1121+ helper .make_node (
1122+ "DequantizeLinear" ,
1123+ inputs = ["qdq_weight_q" , "scale" , "zp" ],
1124+ outputs = ["qdq_weight_dq" ],
1125+ name = "dq1" ,
1126+ ),
1127+ helper .make_node (
1128+ "Conv" ,
1129+ inputs = ["model_input" , "qdq_weight_dq" ],
1130+ outputs = ["conv3_out" ],
1131+ name = "conv3" ,
1132+ ),
1133+ helper .make_node (
1134+ "Add" ,
1135+ inputs = ["branch1_out" , "conv3_out" ],
1136+ outputs = ["model_output" ],
1137+ name = "add2" ,
1138+ ),
1139+ ]
1140+ graph = helper .make_graph (
1141+ nodes ,
1142+ "QDQWithSharedWeight" ,
1143+ inputs = [
1144+ helper .make_tensor_value_info (
1145+ "model_input" , TensorProto .FLOAT , [1 , CHANNELS , 8 , 8 ]
1146+ )
1147+ ],
1148+ outputs = [
1149+ helper .make_tensor_value_info (
1150+ "model_output" , TensorProto .FLOAT , [1 , CHANNELS , 8 , 8 ]
1151+ )
1152+ ],
1153+ initializer = [shared_weight , qdq_weight , scale , zp ],
1154+ )
1155+ model = models_for_tests .make_model (graph )
1156+ onnx .checker .check_model (model )
1157+
1158+ n_duplicates = duplicate_shared_initializers (model .graph )
1159+
1160+ # shared_weight is used by two Conv nodes → must be duplicated once
1161+ assert n_duplicates == 1
1162+ init_names = {init .name for init in model .graph .initializer }
1163+ assert "shared_weight_copy_1" in init_names
1164+
1165+ # Conv1 and Conv2 must now reference distinct initializers
1166+ conv_inputs = [
1167+ node .input [1 ]
1168+ for node in model .graph .node
1169+ if node .op_type == "Conv" and node .name in ("conv1" , "conv2" )
1170+ ]
1171+ assert conv_inputs [0 ] != conv_inputs [1 ]
1172+
1173+ # scale and zp must NOT have been duplicated
1174+ assert "scale_copy_1" not in init_names
1175+ assert "zp_copy_1" not in init_names
1176+
1177+ def test_duplicate_shared_initializers_qdq_cross_pair_scale_excluded (self ):
1178+ """
1179+ When the same scale/zp initializer is shared across two different QDQ
1180+ pairs, it is currently excluded from duplication.
1181+
1182+ Graph:
1183+ weight → QLinear1(scale, zp) → DQLinear1(scale, zp) → Conv1
1184+ weight2 → QLinear2(scale, zp) → DQLinear2(scale, zp) → Conv2
1185+ """
1186+ model = self ._make_qdq_model (share_scale_across_pairs = True )
1187+
1188+ n_duplicates = duplicate_shared_initializers (model .graph )
1189+
1190+ assert n_duplicates == 0 , "Cross-pair shared scale/zp is excluded"
1191+ init_names = {init .name for init in model .graph .initializer }
1192+ assert "scale_copy_1" not in init_names
1193+ assert "zp_copy_1" not in init_names
1194+
9451195
9461196class TestORTInferenceSession :
9471197 """
0 commit comments