@@ -697,9 +697,23 @@ def quantize(self, node: NodeProto, graph_stack: list[GraphProto]) -> list[NodeP
697697 return [node ] # only care about constant weight
698698
699699 b_array = onnx .numpy_helper .to_array (b_pb )
700- if len (b_array .shape ) != 2 :
701- logger .info ("MatMul weight is not 2D. Skip to quantize" )
702- return [node ] # can only process 2-D matrix
700+ b_original_shape = b_array .shape
701+ if len (b_original_shape ) != 2 :
702+ if len (b_original_shape ) < 2 :
703+ logger .info ("MatMul weight has fewer than 2 dimensions. Skip to quantize." )
704+ return [node ]
705+ leading = b_original_shape [:- 2 ]
706+ if any (d != 1 for d in leading ):
707+ logger .info (
708+ "MatMul weight has non-unit batch dims %s; N-D batched quantization not supported. "
709+ "Skip to quantize." ,
710+ list (leading ),
711+ )
712+ return [node ]
713+ # Squeeze all unit leading dims to obtain a 2-D weight [K, N]
714+ b_array = b_array .reshape (b_original_shape [- 2 ], b_original_shape [- 1 ])
715+ else :
716+ b_original_shape = None # already 2-D, no reshape needed
703717 b_array_torch = torch .from_numpy (b_array )
704718 if torch .cuda .is_available ():
705719 b_array_torch = b_array_torch .cuda ()
@@ -755,18 +769,42 @@ def quantize(self, node: NodeProto, graph_stack: list[GraphProto]) -> list[NodeP
755769 kwargs ["bits" ] = bits
756770 kwargs ["block_size" ] = self .config .block_size
757771
772+ # When rank(A) >= rank_b_orig, the reshape would be a no-op since MatMulNBits
773+ # already produces the correct output shape.
774+ a_static_rank = _get_static_rank (node .input [0 ], graph_stack )
775+ rank_b_orig = len (b_original_shape ) if b_original_shape is not None else 0
776+ needs_reshape = b_original_shape is not None and (a_static_rank is None or a_static_rank < rank_b_orig )
777+ matmul_q_output = node .output [0 ] if not needs_reshape else node .output [0 ] + "_pre_reshape"
758778 matmul_q_node = onnx .helper .make_node (
759779 "MatMulNBits" ,
760780 inputs = input_names ,
761- outputs = [node . output [ 0 ] ],
781+ outputs = [matmul_q_output ],
762782 name = node .name + "_Q" + str (bits ) if node .name else "" ,
763783 domain = "com.microsoft" ,
764784 ** kwargs ,
765785 )
766786
787+ output_nodes = [matmul_q_node ]
788+ if needs_reshape :
789+ # Restore ONNX MatMul broadcast shape on the output.
790+ # MatMul(A, B_orig) output shape (with B_orig leading dims all 1) is:
791+ # [1] * max(rank(B_orig) - rank(A), 0) + A.shape[:-1] + [N]
792+ # MatMulNBits with squeezed B produces [...A.shape[:-1], N] (rank=rank(A)),
793+ # so we only need to prepend leading 1s when rank(B_orig) > rank(A).
794+ output_nodes .extend (
795+ _build_nbits_output_reshape (
796+ a_input_name = node .input [0 ],
797+ b_original_shape = b_original_shape ,
798+ target_graph = bs_graph ,
799+ name_prefix = (node .name + "_Q" + str (bits )) if node .name else (b_pb .name + "_Q" + str (bits )),
800+ pre_reshape_output = matmul_q_output ,
801+ final_output = node .output [0 ],
802+ )
803+ )
804+
767805 logger .info (f"complete quantization of { node .name } ..." )
768806
769- return [ matmul_q_node ]
807+ return output_nodes
770808
771809
772810def get_initializer (name , graph_path : list [GraphProto ]) -> tuple [TensorProto , GraphProto ]:
@@ -778,6 +816,155 @@ def get_initializer(name, graph_path: list[GraphProto]) -> tuple[TensorProto, Gr
778816 return None , None
779817
780818
819+ def _get_static_rank (tensor_name : str , graph_path : list [GraphProto ]) -> int | None :
820+ """Return the static rank of a tensor if its shape is known, else None.
821+
822+ Searches graph inputs, value_info, and outputs in the graph stack (inner-most
823+ graph first). A known shape requires ``HasField('shape')`` to be true on the
824+ tensor_type; the rank is then ``len(shape.dim)``. Individual dim sizes may
825+ still be symbolic — only the rank (dimension count) matters here.
826+ """
827+ for gid in range (len (graph_path ) - 1 , - 1 , - 1 ):
828+ graph = graph_path [gid ]
829+ for vi in list (graph .input ) + list (graph .value_info ) + list (graph .output ):
830+ if vi .name == tensor_name :
831+ tt = vi .type .tensor_type
832+ if tt .HasField ("shape" ):
833+ return len (tt .shape .dim )
834+ return None
835+ return None
836+
837+
838+ def _build_nbits_output_reshape (
839+ a_input_name : str ,
840+ b_original_shape : tuple ,
841+ target_graph : GraphProto ,
842+ name_prefix : str ,
843+ pre_reshape_output : str ,
844+ final_output : str ,
845+ ) -> list [NodeProto ]:
846+ """Build the reshape chain that restores the ONNX MatMul-broadcast output shape.
847+
848+ MatMulNBits produces shape ``[...A_batch_dims, M, N]`` (rank = rank(A)). To match
849+ the original ``MatMul(A, B_orig)`` output, where B_orig has all-unit leading
850+ dims, we need:
851+
852+ a_rank_eff = max(rank(A), 2) # ONNX promotes 1-D A to rank-2
853+ out_shape = [1] * max(rank(B_orig) - a_rank_eff, 0) + A.shape[:-1] + [N]
854+
855+ This is built dynamically via Shape/Gather/Max/Sub/Max/ConstantOfShape/Slice/Concat
856+ so it works regardless of A's static rank (handles rank(A) == 1, rank(A) == 2
857+ — the common transformer case — as well as rank(A) >= rank(B_orig) where no
858+ leading-1 prepending is needed). All ops used are valid from opset 11 onward.
859+
860+ Args:
861+ a_input_name: name of the activation input edge (A) feeding MatMulNBits.
862+ b_original_shape: the original (pre-squeeze) shape of B, e.g. ``(1, K, N)``.
863+ target_graph: graph proto to append helper initializers into.
864+ name_prefix: unique prefix for generated node/initializer names.
865+ pre_reshape_output: name of the MatMulNBits output edge (the input of the
866+ generated Reshape).
867+ final_output: name of the final edge produced by the generated Reshape
868+ (must match the original MatMul output edge).
869+
870+ Returns:
871+ List of nodes to append to the consumer's ``output_nodes`` after the
872+ MatMulNBits node. Initializers are appended to ``target_graph`` in place.
873+ """
874+ rank_b_orig = len (b_original_shape )
875+ n_dim = b_original_shape [- 1 ]
876+
877+ # Incorporate the unique output tensor name so initializer names are unique
878+ # even when multiple MatMul nodes share the same node.name (Fix 2).
879+ p = name_prefix + "_" + final_output
880+ init_zero = p + "_zero"
881+ init_one = p + "_one"
882+ init_two = p + "_two"
883+ init_one_vec = p + "_one_vec"
884+ init_rank_b = p + "_rank_b"
885+ init_n_vec = p + "_n_vec"
886+ init_zero_vec = p + "_zero_vec"
887+ init_one_value_template = p + "_one_value"
888+
889+ target_graph .initializer .extend (
890+ [
891+ onnx .numpy_helper .from_array (np .array (0 , dtype = np .int64 ), name = init_zero ),
892+ onnx .numpy_helper .from_array (np .array (1 , dtype = np .int64 ), name = init_one ),
893+ onnx .numpy_helper .from_array (np .array (2 , dtype = np .int64 ), name = init_two ),
894+ onnx .numpy_helper .from_array (np .array ([1 ], dtype = np .int64 ), name = init_one_vec ),
895+ onnx .numpy_helper .from_array (np .array (rank_b_orig , dtype = np .int64 ), name = init_rank_b ),
896+ onnx .numpy_helper .from_array (np .array ([n_dim ], dtype = np .int64 ), name = init_n_vec ),
897+ onnx .numpy_helper .from_array (np .array ([0 ], dtype = np .int64 ), name = init_zero_vec ),
898+ ]
899+ )
900+
901+ a_shape = p + "_a_shape"
902+ a_shape_of_shape = p + "_a_shape_of_shape"
903+ a_rank = p + "_a_rank"
904+ a_rank_eff = p + "_a_rank_eff"
905+ extra_raw = p + "_extra_raw"
906+ extra_count = p + "_extra_count"
907+ extra_count_vec = p + "_extra_count_vec"
908+ extra_ones = p + "_extra_ones"
909+ a_rank_minus_one = p + "_a_rank_m1"
910+ a_rank_minus_one_vec = p + "_a_rank_m1_vec"
911+ a_prefix_shape = p + "_a_prefix_shape"
912+ target_shape = p + "_target_shape"
913+
914+ nodes = [
915+ onnx .helper .make_node ("Shape" , [a_input_name ], [a_shape ], name = p + "_shape_a" ),
916+ # Use Shape(shape) + Gather instead of Size to stay within opset 11
917+ # (Size requires opset >= 13 when applied to a shape tensor).
918+ # Shape applied to the 1-D shape vector yields [rank_a] as a 1-element
919+ # tensor; Gather with scalar index 0 extracts it as a scalar int64.
920+ onnx .helper .make_node ("Shape" , [a_shape ], [a_shape_of_shape ], name = p + "_shape_of_a_shape" ),
921+ onnx .helper .make_node ("Gather" , [a_shape_of_shape , init_zero ], [a_rank ], name = p + "_gather_rank" ),
922+ # ONNX MatMul promotes a 1-D activation to rank-2 before computing the
923+ # output shape, so use Max(a_rank, 2) as the effective rank when
924+ # computing how many leading 1s to prepend.
925+ onnx .helper .make_node ("Max" , [a_rank , init_two ], [a_rank_eff ], name = p + "_max_rank_eff" ),
926+ onnx .helper .make_node ("Sub" , [init_rank_b , a_rank_eff ], [extra_raw ], name = p + "_sub" ),
927+ onnx .helper .make_node ("Max" , [extra_raw , init_zero ], [extra_count ], name = p + "_max" ),
928+ onnx .helper .make_node ("Reshape" , [extra_count , init_one_vec ], [extra_count_vec ], name = p + "_reshape_extra" ),
929+ onnx .helper .make_node (
930+ "ConstantOfShape" ,
931+ [extra_count_vec ],
932+ [extra_ones ],
933+ name = p + "_const_ones" ,
934+ value = onnx .helper .make_tensor (
935+ name = init_one_value_template ,
936+ data_type = TensorProto .INT64 ,
937+ dims = [1 ],
938+ vals = [1 ],
939+ ),
940+ ),
941+ onnx .helper .make_node ("Sub" , [a_rank , init_one ], [a_rank_minus_one ], name = p + "_sub_one" ),
942+ onnx .helper .make_node (
943+ "Reshape" , [a_rank_minus_one , init_one_vec ], [a_rank_minus_one_vec ], name = p + "_reshape_rank_m1"
944+ ),
945+ onnx .helper .make_node (
946+ "Slice" ,
947+ [a_shape , init_zero_vec , a_rank_minus_one_vec , init_zero_vec ],
948+ [a_prefix_shape ],
949+ name = p + "_slice_a_prefix" ,
950+ ),
951+ onnx .helper .make_node (
952+ "Concat" ,
953+ [extra_ones , a_prefix_shape , init_n_vec ],
954+ [target_shape ],
955+ name = p + "_concat_target" ,
956+ axis = 0 ,
957+ ),
958+ onnx .helper .make_node (
959+ "Reshape" ,
960+ [pre_reshape_output , target_shape ],
961+ [final_output ],
962+ name = p + "_reshape_out" ,
963+ ),
964+ ]
965+ return nodes
966+
967+
781968# transpose int4 matrix (packed as uint8)
782969def transpose_packed_int4_matrix (packed , rows , cols ):
783970 # unpack to int4 matrix
@@ -855,7 +1042,8 @@ def qbits_block_quant(self, fp32weight: npt.ArrayLike) -> tuple[np.ndarray, np.n
8551042 def quantize_matmul (self , node : NodeProto , graph_stack : list [GraphProto ]) -> list [NodeProto ]:
8561043 """
8571044 Quantize weight B of MatMul node to int4 or int8.
858- Currently only support 2D constant matrix and axis 0 blockwise quantization.
1045+ Supports 2D constant matrix, and N-D constant matrices whose leading dimensions are all 1
1046+ (which are squeezed to 2D before quantization). Axis 0 blockwise quantization only.
8591047 """
8601048 bits = self .config .bits
8611049 if bits == 8 :
@@ -869,9 +1057,23 @@ def quantize_matmul(self, node: NodeProto, graph_stack: list[GraphProto]) -> lis
8691057 return [node ] # only care about constant weight
8701058
8711059 b_ndarray = ir .from_proto (b_tensor ).numpy ()
872- if len (b_ndarray .shape ) != 2 :
873- logger .info ("MatMul weight is not 2D. Skip to quantize" )
874- return [node ] # can only process 2-D matrix
1060+ b_original_shape = b_ndarray .shape
1061+ if len (b_original_shape ) != 2 :
1062+ if len (b_original_shape ) < 2 :
1063+ logger .info ("MatMul weight has fewer than 2 dimensions. Skip to quantize." )
1064+ return [node ]
1065+ leading = b_original_shape [:- 2 ]
1066+ if any (d != 1 for d in leading ):
1067+ logger .info (
1068+ "MatMul weight has non-unit batch dims %s; N-D batched quantization not supported. "
1069+ "Skip to quantize." ,
1070+ list (leading ),
1071+ )
1072+ return [node ]
1073+ # Squeeze all unit leading dims to obtain a 2-D weight [K, N]
1074+ b_ndarray = b_ndarray .reshape (b_original_shape [- 2 ], b_original_shape [- 1 ])
1075+ else :
1076+ b_original_shape = None # already 2-D, no reshape needed
8751077
8761078 bfloat16 = b_ndarray .dtype == "bfloat16"
8771079 if bfloat16 :
@@ -931,16 +1133,33 @@ def quantize_matmul(self, node: NodeProto, graph_stack: list[GraphProto]) -> lis
9311133 if self .config .accuracy_level :
9321134 kwargs ["accuracy_level" ] = self .config .accuracy_level
9331135
1136+ # When rank(A) >= rank_b_orig, the reshape would be a no-op since MatMulNBits
1137+ # already produces the correct output shape.
1138+ a_static_rank = _get_static_rank (node .input [0 ], graph_stack )
1139+ rank_b_orig = len (b_original_shape ) if b_original_shape is not None else 0
1140+ needs_reshape = b_original_shape is not None and (a_static_rank is None or a_static_rank < rank_b_orig )
1141+ qop_output = node .output [0 ] if not needs_reshape else node .output [0 ] + "_pre_reshape"
9341142 matmul_qbit_node = onnx .helper .make_node (
9351143 "MatMulNBits" ,
9361144 inputs = input_names ,
937- outputs = [node . output [ 0 ] ],
1145+ outputs = [qop_output ],
9381146 name = node .name + f"_Q{ bits } " if node .name else "" ,
9391147 domain = "com.microsoft" ,
9401148 ** kwargs ,
9411149 )
9421150
9431151 output_nodes .append (matmul_qbit_node )
1152+ if needs_reshape :
1153+ output_nodes .extend (
1154+ _build_nbits_output_reshape (
1155+ a_input_name = node .input [0 ],
1156+ b_original_shape = b_original_shape ,
1157+ target_graph = b_graph ,
1158+ name_prefix = (node .name + f"_Q{ bits } " ) if node .name else (b_tensor .name + f"_Q{ bits } " ),
1159+ pre_reshape_output = qop_output ,
1160+ final_output = node .output [0 ],
1161+ )
1162+ )
9441163 else :
9451164 dq_input_names = [b_quant .name , scales_tensor .name ]
9461165 dq_output_names = [b_quant .name + "_output" ]
@@ -950,7 +1169,13 @@ def quantize_matmul(self, node: NodeProto, graph_stack: list[GraphProto]) -> lis
9501169 node .input [0 ],
9511170 tp_output_names [0 ] if qdq_opt_for_intel_npu_enabled else dq_output_names [0 ],
9521171 ]
953- matmul_output_names = [node .output [0 ]]
1172+ # When rank(A) >= rank_b_orig, the reshape would be a no-op since MatMul
1173+ # already produces the correct output shape.
1174+ a_static_rank = _get_static_rank (node .input [0 ], graph_stack )
1175+ rank_b_orig = len (b_original_shape ) if b_original_shape is not None else 0
1176+ needs_reshape = b_original_shape is not None and (a_static_rank is None or a_static_rank < rank_b_orig )
1177+ qdq_matmul_out = node .output [0 ] if not needs_reshape else node .output [0 ] + "_pre_reshape"
1178+ matmul_output_names = [qdq_matmul_out ]
9541179 if not self .config .is_symmetric :
9551180 zp_tensor = onnx .helper .make_tensor (
9561181 b_tensor .name + "_DQ_zero_points" , qtype , scales .shape , zero_points .tobytes (), True
@@ -985,6 +1210,17 @@ def quantize_matmul(self, node: NodeProto, graph_stack: list[GraphProto]) -> lis
9851210 output_nodes .extend ([dq_node , tp_node , matmul_node ])
9861211 else :
9871212 output_nodes .extend ([dq_node , matmul_node ])
1213+ if needs_reshape :
1214+ output_nodes .extend (
1215+ _build_nbits_output_reshape (
1216+ a_input_name = node .input [0 ],
1217+ b_original_shape = b_original_shape ,
1218+ target_graph = b_graph ,
1219+ name_prefix = (node .name + f"_DQ_Q{ bits } " ) if node .name else (b_tensor .name + f"_DQ_Q{ bits } " ),
1220+ pre_reshape_output = qdq_matmul_out ,
1221+ final_output = node .output [0 ],
1222+ )
1223+ )
9881224
9891225 return output_nodes
9901226
0 commit comments