2929
3030logger = logging .getLogger (__name__ )
3131
32+ # === INT4 Block Quantization ===
33+
34+ INT4_BITS = 4
35+ INT4_MAX = (1 << INT4_BITS ) - 1 # 15, max value for unsigned 4-bit
36+ DEFAULT_BLOCK_SIZE = 32
37+ SCALE_EPS = 1e-10
38+
39+
40+ def quantize_int4_block (
41+ weight : np .ndarray , block_size : int = DEFAULT_BLOCK_SIZE
42+ ) -> tuple [np .ndarray , np .ndarray , np .ndarray ]:
43+ """Quantize weight tensor to INT4 with block-wise scales and zero points.
44+
45+ Args:
46+ weight: FP32 weight tensor of shape [..., K] where K is quantized dimension
47+ block_size: Number of elements per quantization block
48+
49+ Returns:
50+ quant: UINT8 tensor with packed INT4 values (2 per byte)
51+ scales: FP32 scales, one per block
52+ zero_points: UINT8 packed zero points (2 per byte)
53+ """
54+ * batch_dims , K = weight .shape
55+ n_blocks = (K + block_size - 1 ) // block_size
56+
57+ pad_K = n_blocks * block_size
58+ if pad_K != K :
59+ pad_shape = list (weight .shape )
60+ pad_shape [- 1 ] = pad_K - K
61+ weight = np .concatenate ([weight , np .zeros (pad_shape , dtype = weight .dtype )], axis = - 1 )
62+
63+ weight_blocked = weight .reshape (* batch_dims , n_blocks , block_size )
64+
65+ w_min = weight_blocked .min (axis = - 1 , keepdims = True )
66+ w_max = weight_blocked .max (axis = - 1 , keepdims = True )
67+
68+ scale = (w_max - w_min ) / float (INT4_MAX )
69+ scale = np .where (scale < SCALE_EPS , 1.0 , scale )
70+ zero_point = np .round (- w_min / scale ).clip (0 , INT4_MAX ).astype (np .uint8 )
71+
72+ # q = round(w/s + zp) to match community
73+ quant = np .round (weight_blocked / scale + zero_point ).clip (0 , INT4_MAX ).astype (np .uint8 )
74+
75+ # Pack two INT4 values into one UINT8 (low nibble first)
76+ quant_packed = quant [..., 0 ::2 ] | (quant [..., 1 ::2 ] << 4 )
77+
78+ scales = scale .squeeze (- 1 ).astype (np .float32 )
79+
80+ # Pack zero points
81+ zero_point = zero_point .squeeze (- 1 )
82+ if n_blocks % 2 == 1 :
83+ zp_shape = list (zero_point .shape )
84+ zp_shape [- 1 ] = 1
85+ zero_point = np .concatenate ([zero_point , np .zeros (zp_shape , dtype = np .uint8 )], axis = - 1 )
86+ zp_packed = zero_point [..., 0 ::2 ] | (zero_point [..., 1 ::2 ] << 4 )
87+
88+ quant_final = quant_packed .reshape (* batch_dims , - 1 )
89+
90+ return quant_final , scales , zp_packed
91+
3292
3393@dataclass
3494class LFM2Config :
@@ -86,7 +146,12 @@ class LFM2Builder(ONNXBuilderBase):
86146 """
87147
88148 def __init__ (
89- self , config : LFM2Config , use_integrated_rope : bool = False , vl_naming : bool = False
149+ self ,
150+ config : LFM2Config ,
151+ use_integrated_rope : bool = False ,
152+ vl_naming : bool = False ,
153+ use_q4 : bool = False ,
154+ q4_block_size : int = DEFAULT_BLOCK_SIZE ,
90155 ):
91156 """
92157 Args:
@@ -96,17 +161,114 @@ def __init__(
96161 vl_naming: Use VL-style node naming (Shape, Gather_1) instead of
97162 LFM2-style (Shape_for_slice, Gather_for_slice). Community VL and LFM2
98163 models use different conventions.
164+ use_q4: Use INT4 quantized embedding (GatherBlockQuantized) and lm_head
165+ (MatMulNBits). Other MatMul layers are left as FP32 for post-export
166+ quantization.
167+ q4_block_size: Block size for INT4 quantization (default: 32).
99168 """
100169 super ().__init__ ()
101170 self .config = config
102171 self .head_dim = config .hidden_size // config .num_attention_heads
103172 self .use_integrated_rope = use_integrated_rope
104173 self .vl_naming = vl_naming
174+ self .use_q4 = use_q4
175+ self .q4_block_size = q4_block_size
105176
106177 # Categorize layers
107178 self .conv_indices = [i for i , t in enumerate (config .layer_types ) if t == "conv" ]
108179 self .attn_indices = [i for i , t in enumerate (config .layer_types ) if t == "full_attention" ]
109180
181+ # === Q4 Quantization Methods ===
182+
183+ def _quantize_for_matmul_nbits (
184+ self , weight : np .ndarray , name : str
185+ ) -> tuple [str , str , str , int , int ]:
186+ """Quantize weight for MatMulNBits operator.
187+
188+ Args:
189+ weight: FP32 weight tensor of shape [K, N] (already transposed for MatMul)
190+ name: Base name for initializers
191+
192+ Returns:
193+ Tuple of (quant_name, scales_name, zp_name, K, N)
194+ """
195+ K , N = weight .shape
196+ block_size = self .q4_block_size
197+
198+ weight_t = weight .T # [N, K]
199+ quant , scales , zp = quantize_int4_block (weight_t , block_size )
200+
201+ n_blocks = (K + block_size - 1 ) // block_size
202+ quant_3d = quant .reshape (N , n_blocks , block_size // 2 )
203+
204+ quant_name = f"{ name } _quant"
205+ scales_name = f"{ name } _scales"
206+ zp_name = f"{ name } _zp"
207+
208+ self .add_initializer (quant_name , quant_3d , dtype = np .uint8 )
209+ self .add_initializer (scales_name , scales )
210+ self .add_initializer (zp_name , zp , dtype = np .uint8 )
211+
212+ return quant_name , scales_name , zp_name , K , N
213+
214+ def make_matmul_nbits (
215+ self , input_name : str , weight : np .ndarray , name : str , output_name : str
216+ ) -> str :
217+ """Create MatMulNBits node for INT4 quantized linear layer.
218+
219+ Args:
220+ input_name: Input tensor name
221+ weight: Weight matrix [K, N] (already transposed for MatMul)
222+ name: Base name for the operation
223+ output_name: Output tensor name
224+ """
225+ quant_name , scales_name , zp_name , K , N = self ._quantize_for_matmul_nbits (weight , name )
226+
227+ return self .make_node (
228+ "MatMulNBits" ,
229+ [input_name , quant_name , scales_name , zp_name ],
230+ [output_name ],
231+ domain = "com.microsoft" ,
232+ K = K ,
233+ N = N ,
234+ bits = 4 ,
235+ block_size = self .q4_block_size ,
236+ )
237+
238+ def make_gather_block_quantized (
239+ self , weight : np .ndarray , indices_name : str , name : str , output_name : str
240+ ) -> str :
241+ """Create GatherBlockQuantized node for INT4 quantized embedding lookup.
242+
243+ Args:
244+ weight: Embedding weight [vocab_size, hidden_size]
245+ indices_name: Input token IDs tensor name
246+ name: Base name for initializers
247+ output_name: Output tensor name
248+ """
249+ block_size = self .q4_block_size
250+
251+ quant , scales , zp = quantize_int4_block (weight , block_size )
252+
253+ quant_name = f"{ name } _quant"
254+ scales_name = f"{ name } _scales"
255+ zp_name = f"{ name } _zp"
256+
257+ self .add_initializer (quant_name , quant , dtype = np .uint8 )
258+ self .add_initializer (scales_name , scales )
259+ self .add_initializer (zp_name , zp , dtype = np .uint8 )
260+
261+ return self .make_node (
262+ "GatherBlockQuantized" ,
263+ [quant_name , indices_name , scales_name , zp_name ],
264+ [output_name ],
265+ domain = "com.microsoft" ,
266+ bits = 4 ,
267+ block_size = block_size ,
268+ gather_axis = 0 ,
269+ quantize_axis = 1 ,
270+ )
271+
110272 def make_simple_layernorm (
111273 self , input_name : str , weight_name : str , path : str , name : str = None
112274 ) -> str :
@@ -318,7 +480,17 @@ def build_outputs(self):
318480 )
319481
320482 def build_embedding (self ) -> str :
321- self .add_initializer ("model.embed_tokens.weight" , self .weights ["model.embed_tokens.weight" ])
483+ embed_weight = self .weights ["model.embed_tokens.weight" ]
484+
485+ if self .use_q4 :
486+ return self .make_gather_block_quantized (
487+ embed_weight ,
488+ "input_ids" ,
489+ "model_embed_tokens_weight" ,
490+ "/model/embed_tokens/GatherBlockQuantized/output_0" ,
491+ )
492+
493+ self .add_initializer ("model.embed_tokens.weight" , embed_weight )
322494 return self .make_node (
323495 "Gather" ,
324496 ["model.embed_tokens.weight" , "input_ids" ],
@@ -830,8 +1002,46 @@ def build_lm_head(self, hidden_state: str) -> str:
8301002 name = f"/model/layers.{ num_layers } /final_norm_layernorm/SkipLayerNorm" ,
8311003 )
8321004
833- # LM head with tied embeddings (community approach)
834- # Transpose embed_tokens at runtime instead of storing a copy (saves 256MB)
1005+ if self .use_q4 :
1006+ # Q4: Use MatMulNBits for lm_head with shared embedding weights
1007+ embed_quant_name = "model_embed_tokens_weight_quant"
1008+ embed_quant = None
1009+ for init in self .initializers :
1010+ if init .name == embed_quant_name :
1011+ embed_quant = onnx .numpy_helper .to_array (init )
1012+ break
1013+
1014+ if embed_quant is None :
1015+ raise ValueError ("Embedding quant not found - build_embedding must be called first" )
1016+
1017+ vocab_size = embed_quant .shape [0 ]
1018+ K = self .config .hidden_size
1019+ n_blocks = (K + self .q4_block_size - 1 ) // self .q4_block_size
1020+
1021+ # Reshape to 3D for MatMulNBits: [N, n_blocks, block_size/2]
1022+ embed_quant_matmul = embed_quant .reshape (vocab_size , n_blocks , self .q4_block_size // 2 )
1023+ self .add_initializer (
1024+ "model_embed_tokens_weight_quant_matmul" , embed_quant_matmul , dtype = np .uint8
1025+ )
1026+
1027+ # Reuse scales and zero points from embedding
1028+ return self .make_node (
1029+ "MatMulNBits" ,
1030+ [
1031+ normed ,
1032+ "model_embed_tokens_weight_quant_matmul" ,
1033+ "model_embed_tokens_weight_scales" ,
1034+ "model_embed_tokens_weight_zp" ,
1035+ ],
1036+ ["logits" ],
1037+ domain = "com.microsoft" ,
1038+ K = K ,
1039+ N = vocab_size ,
1040+ bits = 4 ,
1041+ block_size = self .q4_block_size ,
1042+ )
1043+
1044+ # FP32: Transpose embed_tokens at runtime instead of storing a copy
8351045 # embed_tokens.weight [vocab, hidden] → [hidden, vocab]
8361046 lm_head_weight = self .make_node (
8371047 "Transpose" ,
@@ -876,11 +1086,11 @@ def build_value_info(self):
8761086 self .add_value_info (f"{ mask_prefix } /{ gather_name } /Cast/output_0" , TensorProto .INT32 , [])
8771087
8781088 # === Embedding output ===
879- self .add_value_info (
880- "/model/embed_tokens/Gather /output_0" ,
881- TensorProto . FLOAT ,
882- [ "batch_size" , "sequence_length" , H ],
883- )
1089+ if self .use_q4 :
1090+ embed_output = "/model/embed_tokens/GatherBlockQuantized /output_0"
1091+ else :
1092+ embed_output = "/model/embed_tokens/Gather/output_0"
1093+ self . add_value_info ( embed_output , TensorProto . FLOAT , [ "batch_size" , "sequence_length" , H ] )
8841094
8851095 # === Per-layer outputs ===
8861096 for layer_idx in range (num_layers ):
@@ -1083,9 +1293,10 @@ def build_value_info(self):
10831293 TensorProto .FLOAT ,
10841294 ["batch_size" , "sequence_length" , H ],
10851295 )
1086- self .add_value_info (
1087- "/lm_head/Transpose/output_0" , TensorProto .FLOAT , [H , self .config .vocab_size ]
1088- )
1296+ if not self .use_q4 :
1297+ self .add_value_info (
1298+ "/lm_head/Transpose/output_0" , TensorProto .FLOAT , [H , self .config .vocab_size ]
1299+ )
10891300
10901301 def load_weights (self , model_path : str ):
10911302 """Load weights from HuggingFace model."""
0 commit comments