Skip to content

Commit 54ad494

Browse files
ykhrustalevclaude
andauthored
Add more quants (#35)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 956be2d commit 54ad494

2 files changed

Lines changed: 351 additions & 44 deletions

File tree

‎src/liquidonnx/lfm2/builder.py‎

Lines changed: 223 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,66 @@
2929

3030
logger = 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
3494
class 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

Comments
 (0)