Skip to content

Commit 2979bab

Browse files
authored
fix: support N-D weights with unit leading dims in MatMulNBitsQuantizer (microsoft#28178)
## Description `MatMulNBitsQuantizer` previously skipped any MatMul whose weight initializer wasn't exactly 2-D, logging: ``` MatMul weight is not 2D. Skip to quantize ``` This PR enables quantization of N-D weights that have unit leading batch dims (e.g. `[1, K, N]`, `[1, 1, K, N]`). For those, the quantizer squeezes the weight to 2-D `[K, N]`, runs the existing Q4 quantization path, and appends a `Reshape` node after the quantized op to restore the original output shape following ONNX MatMul broadcast rules (`[*b_shape[:-2], -1, b_shape[-1]]`). Both the HQQ and Default (QOperator + QDQ) paths are updated consistently. Weights with non-unit batch dims (true batched matmul) keep the skip behavior but now emit a more specific message. 1-D weight inputs also remain a safe skip. ## Motivation and Context Fixes microsoft#25362. Users pre-rearrange LLM attention projection weights into 3-D at model-prep time to avoid runtime transposes (the issue shows typical attention code for `q_proj`/`k_proj`/`v_proj`/`o_proj`). The Q8 `quantize_dynamic` tool already supports this; Q4 via `matmul_nbits_quantizer` did not, forcing users back to fp16 for those layers. This PR closes the gap for the common case without changing MatMul semantics. ## Testing Added three unit tests in `test_op_matmul_4bits.py` covering the Default QOperator, Default QDQ, and HQQ paths with a `[1, 52, 288]` weight. Each asserts the expected quantized op count **and** the presence of the inserted `Reshape` node, plus end-to-end correctness via `check_model_correctness`. All 10 tests in the file pass (2 skipped — optional `neural_compressor`-dependent GPTQ/RTN).
1 parent 50d04b9 commit 2979bab

2 files changed

Lines changed: 476 additions & 11 deletions

File tree

onnxruntime/python/tools/quantization/matmul_nbits_quantizer.py

Lines changed: 247 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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

772810
def 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)
782969
def 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

Comments
 (0)