@@ -2128,8 +2128,17 @@ def __call__(self, model: ModelProto):
21282128class TieWordEmbeddings (ProtoSurgeon ):
21292129 """Tie word embeddings.
21302130
2131- Only supports when the embeddings are not quantized or when both are quantized
2132- using GatherBlockQuantized and MatMulNBits
2131+ Supports three cases:
2132+
2133+ * both unquantized (``Gather`` + ``MatMul``),
2134+ * both quantized (``GatherBlockQuantized`` + ``MatMulNBits``), and
2135+ * a *reuse* case where the embedding is quantized (``GatherBlockQuantized``)
2136+ but the LM head is still a float ``MatMul``. The LM head is rebuilt as a
2137+ ``MatMulNBits`` that shares the embedding's quantized weight table, so the
2138+ large word-embedding matrix is stored once as INT4 instead of once as INT4
2139+ (embedding) plus once as float16 (LM head). This is useful after quantizing
2140+ only the embedding ``Gather`` (e.g. ``OnnxBlockWiseRtnQuantization`` with the
2141+ body left for a separate pass such as ``OnnxKQuantQuantization``).
21332142 """
21342143
21352144 def __call__ (self , model : onnx .ModelProto ):
@@ -2151,9 +2160,19 @@ def __call__(self, model: onnx.ModelProto):
21512160 if embed_name is None :
21522161 return dag .model
21532162
2154- lm_head_name , lm_head_op_type = self .get_name_op_type (
2155- dag , [dag .get_producer ("logits" )], ["MatMul" , "MatMulNBits" ], 1
2156- )
2163+ # Reuse case: embedding already quantized but the LM head is still a float
2164+ # MatMul. Its weight is the tied embedding, possibly reached through a
2165+ # Transpose, so the weight input is not necessarily a direct initializer
2166+ # (which get_name_op_type would require). Detect it directly here.
2167+ logits_producer = dag .get_producer ("logits" )
2168+ if (
2169+ embed_op_type == "GatherBlockQuantized"
2170+ and logits_producer is not None
2171+ and dag .get_node_op_type (logits_producer ) == "MatMul"
2172+ ):
2173+ return self .handle_reuse (dag , embed_name , logits_producer )
2174+
2175+ lm_head_name , lm_head_op_type = self .get_name_op_type (dag , [logits_producer ], ["MatMul" , "MatMulNBits" ], 1 )
21572176 if lm_head_name is None :
21582177 return dag .model
21592178
@@ -2348,6 +2367,176 @@ def handle_quantized(self, dag: OnnxDAG, embed_name: str, lm_head_name: str):
23482367 dag .update ()
23492368 return dag .model
23502369
2370+ def handle_reuse (self , dag : OnnxDAG , embed_name : str , lm_head_name : str ):
2371+ """Rebuild a float ``MatMul`` LM head as a ``MatMulNBits`` reusing the embedding's INT4 table.
2372+
2373+ The embedding is a ``GatherBlockQuantized`` node with inputs
2374+ ``[qweight, indices, scales, zero_point?]``; the LM head is a float
2375+ ``MatMul(activation, W)`` whose weight ``W`` is the tied embedding matrix,
2376+ possibly reached through a ``Transpose``. Because ``W`` equals the
2377+ embedding table, its quantized form is exactly the embedding's INT4 table,
2378+ so we point the new ``MatMulNBits`` at the embedding's ``qweight`` / scales
2379+ / zero-point instead of quantizing ``W`` a second time. The float ``W``
2380+ (and any feeding ``Transpose``) then becomes dead and is pruned.
2381+ """
2382+ embed_inputs = dag .get_node_inputs (embed_name )
2383+ embed_qweight = embed_inputs [0 ]
2384+ embed_scales = embed_inputs [2 ]
2385+ embed_zero_point = embed_inputs [3 ] if len (embed_inputs ) > 3 else None
2386+
2387+ embed_attrs = dag .get_node_attributes (embed_name )
2388+ bits = int (embed_attrs ["bits" ])
2389+ block_size = int (embed_attrs ["block_size" ])
2390+
2391+ # LM head weight: trace through an optional Transpose to the source initializer.
2392+ lm_head_inputs = dag .get_node_inputs (lm_head_name )
2393+ lm_head_activation = lm_head_inputs [0 ]
2394+ weight_input = lm_head_inputs [1 ]
2395+ fp16_weight = weight_input
2396+ transpose_name = None
2397+ if not dag .is_initializer (weight_input ):
2398+ weight_producer = dag .get_producer (weight_input )
2399+ if weight_producer is not None and dag .get_node_op_type (weight_producer ) == "Transpose" :
2400+ transpose_name = weight_producer
2401+ fp16_weight = dag .get_node_inputs (weight_producer )[0 ]
2402+ if not dag .is_initializer (fp16_weight ):
2403+ logger .debug ("LM head weight is not a (transposed) initializer; cannot reuse embedding quantization." )
2404+ return dag .model
2405+
2406+ # embed qweight is [vocab, hidden * bits / 8]; derive N (vocab) and K (hidden).
2407+ embed_qweight_shape = dag .get_io_shape (embed_qweight )
2408+ vocab = int (embed_qweight_shape [0 ])
2409+ hidden = int (embed_qweight_shape [1 ]) * 8 // bits
2410+
2411+ # Correctness gate: only reuse when the float LM head weight really is the
2412+ # (dequantized) embedding table, so an untied projection is never tied.
2413+ if not self .reuse_weights_match (dag , embed_name , fp16_weight , vocab , hidden , bits , block_size ):
2414+ logger .debug ("LM head weight does not match the embedding table; skipping reuse tie." )
2415+ return dag .model
2416+
2417+ logger .debug ("Reusing quantized embedding weights for the LM head" )
2418+
2419+ graph_idx = dag .get_graph_idx (lm_head_name )
2420+ n_blocks = hidden // block_size
2421+ blob_size = block_size * bits // 8
2422+
2423+ # GatherBlockQuantized stores qweight 2D [vocab, hidden * bits / 8]; MatMulNBits
2424+ # expects 3D [N, n_blocks, blob_size]. The bytes are identical, only reshaped.
2425+ reshape_output = self .add_reshape_node (
2426+ dag ,
2427+ graph_idx ,
2428+ self .create_new_name (lm_head_name , "MatMul" , "Reshape_tied" ),
2429+ embed_qweight ,
2430+ [vocab , n_blocks , blob_size ],
2431+ dag .get_io_elem_type (embed_qweight ),
2432+ )
2433+
2434+ mmnb_inputs = [lm_head_activation , reshape_output , embed_scales ]
2435+ if embed_zero_point is not None :
2436+ mmnb_inputs .append (embed_zero_point )
2437+
2438+ lm_head_output = dag .get_node_outputs (lm_head_name )[0 ]
2439+ mmnb_name = self .create_new_name (lm_head_name , "MatMul" , "MatMulNBits" )
2440+ mmnb_output = f"{ mmnb_name } _output"
2441+ dag .add_node (
2442+ onnx .helper .make_node (
2443+ "MatMulNBits" ,
2444+ mmnb_inputs ,
2445+ [mmnb_output ],
2446+ name = mmnb_name ,
2447+ domain = "com.microsoft" ,
2448+ K = hidden ,
2449+ N = vocab ,
2450+ bits = bits ,
2451+ block_size = block_size ,
2452+ ),
2453+ graph_idx ,
2454+ )
2455+
2456+ # Redirect consumers of the old logits, then swap the graph output over.
2457+ # remove_node deletes the output IO, so preserve the logits value_info by
2458+ # copying it onto the new output before renaming (as in handle_unquantized).
2459+ mmnb_output_vi = onnx .ValueInfoProto ()
2460+ mmnb_output_vi .CopyFrom (dag .get_value_info_proto (lm_head_output ))
2461+ mmnb_output_vi .name = mmnb_output
2462+ dag .add_value_info (mmnb_output_vi , graph_idx )
2463+
2464+ for consumer in dag .get_consumers (lm_head_name ):
2465+ dag .replace_node_input (consumer , lm_head_output , mmnb_output )
2466+ dag .remove_output (lm_head_output )
2467+ dag .remove_node (lm_head_name )
2468+ # Drop the now-orphaned Transpose (if any) so the float embedding weight it
2469+ # held loses its last consumer and is pruned from the serialized model.
2470+ if transpose_name is not None and not dag .get_consumers (transpose_name ):
2471+ dag .remove_node (transpose_name )
2472+ dag .rename_node_output (mmnb_name , mmnb_output , lm_head_output )
2473+ dag .make_output (lm_head_output )
2474+
2475+ dag .update ()
2476+ return dag .model
2477+
2478+ def reuse_weights_match (
2479+ self ,
2480+ dag : OnnxDAG ,
2481+ embed_name : str ,
2482+ fp16_weight_name : str ,
2483+ vocab : int ,
2484+ hidden : int ,
2485+ bits : int ,
2486+ block_size : int ,
2487+ n_check : int = 256 ,
2488+ ) -> bool :
2489+ """Check that ``fp16_weight`` equals the dequantized embedding table.
2490+
2491+ Only a slice of ``n_check`` rows is compared, which is enough to reject an
2492+ untied projection while keeping the check cheap. Only 4-bit packing is
2493+ supported; other widths conservatively return ``False``.
2494+ """
2495+ if bits != 4 :
2496+ return False
2497+
2498+ embed_inputs = dag .get_node_inputs (embed_name )
2499+ qweight = dag .get_initializer_np_array (embed_inputs [0 ]) # [vocab, hidden * bits / 8] uint8
2500+ scales = dag .get_initializer_np_array (embed_inputs [2 ]).astype (np .float32 ) # [vocab, n_blocks]
2501+ zero_point = dag .get_initializer_np_array (embed_inputs [3 ]) if len (embed_inputs ) > 3 else None
2502+
2503+ n = min (n_check , vocab )
2504+ n_blocks = hidden // block_size
2505+
2506+ # Unpack two 4-bit codes per byte (low nibble first), matching MatMulNBits packing.
2507+ q = qweight [:n ]
2508+ codes = np .empty ((n , hidden ), np .float32 )
2509+ codes [:, 0 ::2 ] = (q & 0x0F ).astype (np .float32 )
2510+ codes [:, 1 ::2 ] = (q >> 4 ).astype (np .float32 )
2511+ codes = codes .reshape (n , n_blocks , block_size )
2512+
2513+ if zero_point is not None :
2514+ zp = zero_point [:n ]
2515+ zcodes = np .empty ((n , n_blocks ), np .float32 )
2516+ zcodes [:, 0 ::2 ] = (zp & 0x0F ).astype (np .float32 )
2517+ zcodes [:, 1 ::2 ] = (zp >> 4 ).astype (np .float32 )
2518+ zcodes = zcodes .reshape (n , n_blocks , 1 )
2519+ else :
2520+ # Symmetric quantization centers codes at the midpoint of the 4-bit range.
2521+ zcodes = np .float32 (2 ** (bits - 1 ))
2522+
2523+ block_scales = scales [:n ].reshape (n , n_blocks , 1 )
2524+ dequant = ((codes - zcodes ) * block_scales ).reshape (n , hidden ) # ~= embedding[:n]
2525+
2526+ fp16 = dag .get_initializer_np_array (fp16_weight_name ).astype (np .float32 )
2527+ if list (fp16 .shape ) == [hidden , vocab ]:
2528+ ref = fp16 [:, :n ].T
2529+ elif list (fp16 .shape ) == [vocab , hidden ]:
2530+ ref = fp16 [:n ]
2531+ else :
2532+ return False
2533+
2534+ # Quantization error per element is bounded by one block step; use a
2535+ # generous multiple so a genuinely tied weight always passes while an
2536+ # untied projection (differing by ~weight magnitude) is rejected.
2537+ atol = max (4.0 * float (block_scales .max ()), 1e-2 )
2538+ return np .allclose (dequant , ref , atol = atol , rtol = 0.0 )
2539+
23512540 def equal_weights (self , dag : OnnxDAG , init0 : str , init1 : str , transpose : bool = False ) -> bool :
23522541 shape0 , shape1 = dag .get_io_shape (init0 ), dag .get_io_shape (init1 )
23532542 if np .prod (shape0 ) != np .prod (shape1 ):
0 commit comments