Skip to content

Commit 57177b7

Browse files
justinchubyCopilot
andauthored
Quantize GGUF output head (lm_head) → MatMulNBits (#401)
Follow-up to #400 (quantized embedding). Profiling showed the CPU model shipped an untied `lm_head` as a plain fp32 MatMul (~544 MB) run every token. This emits the output head as Q4 `MatMulNBits` (untied) / shares the packed embedding table (tied). - Result: 169 MatMulNBits, 1 GatherBlockQuantized, **0 plain fp32 MatMul**; no 544 MB fp32 tables. Model ~1.2 GB → ~399 MB. - Coherent output verified ('Paris'). - Note: a quick 6-thread decode check showed ~38 vs ~40 tok/s (no clear speedup) — a rigorous benchmark is pending; the win is model size + correct all-quantized graph shape (matches llama.cpp). Stacks on #400's embedding commit. lintrunner clean; gguf pytest 157 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 400808d commit 57177b7

6 files changed

Lines changed: 182 additions & 25 deletions

File tree

src/mobius/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -598,8 +598,8 @@ def main(argv: list[str] | None = None) -> None:
598598
"--keep-quantized",
599599
action="store_true",
600600
help=(
601-
"Preserve supported projection and embedding quantization via "
602-
"MatMulNBits/GatherBlockQuantized."
601+
"Preserve supported projection, output-head, and embedding "
602+
"quantization via MatMulNBits/GatherBlockQuantized."
603603
),
604604
)
605605
gguf_parser.add_argument(

src/mobius/_configs/_quantization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class QuantizationConfig:
3131
# Olive RTN exports and quantized GGUF imports.
3232
quantize_embeddings: bool = False
3333
# When True, the LM head projection is block-wise quantized (MatMulNBits).
34-
# Used by Olive RTN exports and tied quantized GGUF imports.
34+
# Used by Olive RTN exports and quantized GGUF imports.
3535
quantize_lm_head: bool = False
3636
# When True, the input embedding and LM head share one weight table. Olive
3737
# RTN records this in its own config (``tie_word_embeddings``) and may clear

src/mobius/components/_quantized_linear.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ def __init__(
186186

187187
self._bits = bits
188188
self._block_size = block_size
189+
self._embedding_dim = embedding_dim
189190
self.padding_idx = padding_idx
190191

191192
n_blocks = embedding_dim // block_size
@@ -219,14 +220,18 @@ def forward(self, op: OpBuilder, input_ids: ir.Value) -> ir.Value:
219220
if self.zero_points is not None:
220221
inputs.append(self.zero_points)
221222

222-
return op.GatherBlockQuantized(
223+
result = op.GatherBlockQuantized(
223224
*inputs,
224225
bits=self._bits,
225226
block_size=self._block_size,
226227
gather_axis=0,
227228
quantize_axis=1,
228229
_domain=_MICROSOFT_DOMAIN,
229230
)
231+
result.dtype = self.scales.dtype
232+
if input_ids.shape is not None:
233+
result.shape = ir.Shape([*input_ids.shape, self._embedding_dim])
234+
return result
230235

231236

232237
class TiedQuantizedLMHead(nn.Module):

src/mobius/components/_quantized_linear_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ def test_graph_has_gather_block_quantized_node(self):
299299
result = qe(op, ids)
300300
b._adapt_outputs([result], "")
301301
assert count_op_type(graph, "GatherBlockQuantized") == 1
302+
assert result.dtype == ir.DataType.FLOAT
303+
assert result.shape == ir.Shape([1, 4, self.DIM])
302304

303305
def test_node_domain_and_attributes(self):
304306
import onnx_ir as ir

src/mobius/integrations/gguf/_builder.py

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
99
- **Dequantized** (default): All quantized tensors are dequantized to
1010
float. Simple, but loses the compression benefit of quantization.
11-
- **Quantized** (``keep_quantized=True``): Linear-layer weights are
12-
normalized to a common MatMulNBits layout, including mixed presets
13-
such as Q4_K_M. Compatible quantized token embeddings are repacked
14-
for GatherBlockQuantized. Other tensors are dequantized.
11+
- **Quantized** (``keep_quantized=True``): Linear-layer weights, including
12+
a quantized output head, are repacked into MatMulNBits format and token
13+
embeddings into GatherBlockQuantized format. Mixed presets such as
14+
Q4_K_M are normalized to one quantization layout. Other tensors are
15+
dequantized.
1516
"""
1617

1718
from __future__ import annotations
@@ -175,6 +176,11 @@ def build_from_gguf(
175176
bits=bits,
176177
block_size=block_size,
177178
)
179+
quantize_lm_head = (
180+
quantize_embeddings
181+
if config.tie_word_embeddings
182+
else _can_quantize_lm_head(gguf_model, gguf_arch)
183+
)
178184
config = dataclasses.replace(
179185
config,
180186
quantization=QuantizationConfig(
@@ -184,17 +190,19 @@ def build_from_gguf(
184190
sym=is_sym,
185191
float_zero_point=float_zp,
186192
quantize_embeddings=quantize_embeddings,
187-
quantize_lm_head=quantize_embeddings and config.tie_word_embeddings,
188-
tie_word_embeddings=quantize_embeddings and config.tie_word_embeddings,
193+
quantize_lm_head=quantize_lm_head,
194+
tie_word_embeddings=quantize_lm_head and config.tie_word_embeddings,
189195
),
190196
)
191197
logger.info(
192-
"Quantized mode: bits=%d, block_size=%d, symmetric=%s, float_zp=%s, embedding=%s",
198+
"Quantized mode: bits=%d, block_size=%d, symmetric=%s, "
199+
"float_zp=%s, embedding=%s, lm_head=%s",
193200
bits,
194201
block_size,
195202
is_sym,
196203
float_zp,
197204
quantize_embeddings,
205+
quantize_lm_head,
198206
)
199207

200208
# 4. Look up module class and resolve task
@@ -462,6 +470,45 @@ def _can_quantize_embedding(
462470
return False
463471

464472

473+
def _can_quantize_lm_head(gguf_model, gguf_arch: str) -> bool:
474+
"""Return whether an untied GGUF output head can be kept quantized."""
475+
from mobius.integrations.gguf._tensor_mapping import map_gguf_to_hf_names
476+
477+
supported_types = {
478+
"Q1_0",
479+
"Q2_K",
480+
"Q3_K",
481+
"Q4_0",
482+
"Q4_1",
483+
"Q4_K",
484+
"Q5_0",
485+
"Q5_1",
486+
"Q5_K",
487+
"Q6_K",
488+
"Q8_0",
489+
}
490+
for name, _raw, qtype, shape in gguf_model.tensor_items_raw():
491+
if map_gguf_to_hf_names(name, gguf_arch) != "lm_head.weight":
492+
continue
493+
return len(shape) == 2 and getattr(qtype, "name", None) in supported_types
494+
return False
495+
496+
497+
def _require_supported_requantization(
498+
*,
499+
bits: int,
500+
block_size: int,
501+
tensor_name: str,
502+
) -> None:
503+
if bits != 4 or block_size != 32:
504+
raise ValueError(
505+
"keep_quantized MatMulNBits requantization currently supports only "
506+
f"4-bit/block-32 targets; got bits={bits} block={block_size} "
507+
f"for tensor {tensor_name}. Use keep_quantized=False or a "
508+
"4-bit/block-32 target."
509+
)
510+
511+
465512
def _load_dequantized_state_dict(
466513
gguf_model,
467514
gguf_arch: str,
@@ -500,10 +547,10 @@ def _load_quantized_state_dict(
500547
) -> dict:
501548
"""Load tensors, normalizing quantized projections to MatMulNBits.
502549
503-
Projection weights (Q/K/V/O and MLP) are converted to the graph's
504-
common MatMulNBits format. Mixed source types are dequantized and
505-
requantized when they do not already match that target. Compatible
506-
quantized embeddings are converted for GatherBlockQuantized. Norms
550+
Projection weights (Q/K/V/O, MLP, and a quantized output head) are
551+
converted to the graph's common MatMulNBits format, and token embeddings
552+
to GatherBlockQuantized format. Mixed or unsupported source types are
553+
dequantized and requantized when they do not match that target. Norms
507554
and other non-linear tensors remain dequantized.
508555
509556
For llama-family models, quantized Q/K weights receive the
@@ -603,6 +650,11 @@ def _load_quantized_state_dict(
603650
shape_2d,
604651
)
605652
if repacked.bits != target_bits or repacked.block_size != target_block_size:
653+
_require_supported_requantization(
654+
bits=target_bits,
655+
block_size=target_block_size,
656+
tensor_name=hf_name,
657+
)
606658
values = gguf_model.dequantize_raw_tensor(raw, qtype, np_shape)
607659
repacked = repack_dequantized_tensor(
608660
values,
@@ -612,6 +664,11 @@ def _load_quantized_state_dict(
612664
)
613665
n_requantized += 1
614666
else:
667+
_require_supported_requantization(
668+
bits=target_bits,
669+
block_size=target_block_size,
670+
tensor_name=hf_name,
671+
)
615672
values = gguf_model.dequantize_raw_tensor(raw, qtype, np_shape)
616673
repacked = repack_dequantized_tensor(
617674
values,

src/mobius/integrations/gguf/_builder_test.py

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def _write_quantized_gguf(
2121
intermediate_size: int = 128,
2222
vocab_size: int = 256,
2323
quantize_embedding: bool = False,
24+
projection_quantization: str = "q4_0",
25+
output_quantization: str | None = None,
2426
tie_embeddings: bool = False,
2527
) -> None:
2628
"""Write a GGUF file with Q4_0 quantized projection weights.
@@ -66,44 +68,72 @@ def _add_q4_0(name: str, n_out: int, k_in: int) -> None:
6668
)
6769
writer.add_tensor(name, raw, raw_dtype=GGMLQuantizationType.Q4_0)
6870

71+
def _add_q8_0(name: str, n_out: int, k_in: int) -> None:
72+
"""Write a Q8_0-quantized weight tensor."""
73+
block_size = 32
74+
block_bytes = 34 # 2B scale + 32B quants
75+
n_blocks = k_in // block_size
76+
bytes_per_row = n_blocks * block_bytes
77+
raw = np.zeros((n_out, bytes_per_row), dtype=np.uint8)
78+
for row in range(n_out):
79+
for b in range(n_blocks):
80+
off = b * block_bytes
81+
scale = np.random.uniform(0.01, 1.0)
82+
raw[row, off : off + 2] = np.array([scale], dtype=np.float16).view(np.uint8)
83+
raw[row, off + 2 : off + 34] = (
84+
np.random.randint(-127, 128, size=32, dtype=np.int16)
85+
.astype(np.int8, copy=False)
86+
.view(np.uint8)
87+
)
88+
writer.add_tensor(name, raw, raw_dtype=GGMLQuantizationType.Q8_0)
89+
6990
if quantize_embedding:
7091
_add_q4_0("token_embd.weight", vocab_size, hidden_size)
7192
else:
7293
_add_f32("token_embd.weight", (vocab_size, hidden_size))
7394

95+
add_projection = {
96+
"q4_0": _add_q4_0,
97+
"q8_0": _add_q8_0,
98+
}[projection_quantization]
99+
74100
for i in range(num_layers):
75-
# Projection weights (Q4_0)
76-
_add_q4_0(
101+
add_projection(
77102
f"blk.{i}.attn_q.weight",
78103
num_heads * head_dim,
79104
hidden_size,
80105
)
81-
_add_q4_0(
106+
add_projection(
82107
f"blk.{i}.attn_k.weight",
83108
num_kv_heads * head_dim,
84109
hidden_size,
85110
)
86-
_add_q4_0(
111+
add_projection(
87112
f"blk.{i}.attn_v.weight",
88113
num_kv_heads * head_dim,
89114
hidden_size,
90115
)
91-
_add_q4_0(
116+
add_projection(
92117
f"blk.{i}.attn_output.weight",
93118
hidden_size,
94119
num_heads * head_dim,
95120
)
96-
_add_q4_0(f"blk.{i}.ffn_gate.weight", intermediate_size, hidden_size)
97-
_add_q4_0(f"blk.{i}.ffn_up.weight", intermediate_size, hidden_size)
98-
_add_q4_0(f"blk.{i}.ffn_down.weight", hidden_size, intermediate_size)
121+
add_projection(f"blk.{i}.ffn_gate.weight", intermediate_size, hidden_size)
122+
add_projection(f"blk.{i}.ffn_up.weight", intermediate_size, hidden_size)
123+
add_projection(f"blk.{i}.ffn_down.weight", hidden_size, intermediate_size)
99124
# Norms (float32)
100125
_add_f32(f"blk.{i}.attn_norm.weight", (hidden_size,))
101126
_add_f32(f"blk.{i}.ffn_norm.weight", (hidden_size,))
102127

103-
# Output norm + optional untied lm_head (float32)
128+
# Output norm + optional untied lm_head
104129
_add_f32("output_norm.weight", (hidden_size,))
105130
if not tie_embeddings:
106-
_add_f32("output.weight", (vocab_size, hidden_size))
131+
if output_quantization == "q4_0":
132+
_add_q4_0("output.weight", vocab_size, hidden_size)
133+
elif output_quantization == "q8_0":
134+
_add_q8_0("output.weight", vocab_size, hidden_size)
135+
else:
136+
_add_f32("output.weight", (vocab_size, hidden_size))
107137

108138
writer.write_header_to_file()
109139
writer.write_kv_data_to_file()
@@ -135,6 +165,30 @@ def q4_0_tied_embedding_gguf(tmp_path: Path) -> Path:
135165
return path
136166

137167

168+
@pytest.fixture
169+
def q4_0_embedding_q8_head_gguf(tmp_path: Path) -> Path:
170+
"""Create a GGUF with a Q4 embedding and an untied Q8 output head."""
171+
path = tmp_path / "test_q4_0_embedding_q8_head.gguf"
172+
_write_quantized_gguf(
173+
path,
174+
quantize_embedding=True,
175+
output_quantization="q8_0",
176+
)
177+
return path
178+
179+
180+
@pytest.fixture
181+
def q8_0_projection_q4_head_gguf(tmp_path: Path) -> Path:
182+
"""Create a GGUF whose Q4 output head would require Q8 requantization."""
183+
path = tmp_path / "test_q8_0_projection_q4_head.gguf"
184+
_write_quantized_gguf(
185+
path,
186+
projection_quantization="q8_0",
187+
output_quantization="q4_0",
188+
)
189+
return path
190+
191+
138192
class TestBuildQuantizedGguf:
139193
"""Tests for build_from_gguf(keep_quantized=True)."""
140194

@@ -191,6 +245,45 @@ def test_tied_quantized_embedding_drives_matmulnbits_head(
191245
assert "model.embed_tokens.qweight" in model.graph.initializers
192246
assert not any(name.startswith("lm_head.") for name in model.graph.initializers)
193247

248+
def test_untied_quantized_head_uses_q4_matmulnbits(
249+
self, q4_0_embedding_q8_head_gguf: Path
250+
):
251+
"""An untied quantized output is requantized to the graph's Q4 layout."""
252+
import onnx_ir as ir
253+
254+
from mobius.integrations.gguf import build_from_gguf
255+
256+
model = build_from_gguf(q4_0_embedding_q8_head_gguf, keep_quantized=True)["model"]
257+
head_nodes = [
258+
node
259+
for node in model.graph
260+
if node.op_type == "MatMulNBits" and node.outputs[0].name == "logits"
261+
]
262+
assert len(head_nodes) == 1
263+
assert head_nodes[0].attributes["bits"].value == 4
264+
assert head_nodes[0].attributes["block_size"].value == 32
265+
266+
qweight = model.graph.initializers["lm_head.weight"]
267+
assert qweight.dtype == ir.DataType.UINT8
268+
assert list(qweight.shape) == [256, 2, 16]
269+
assert list(model.graph.initializers["lm_head.scales"].shape) == [256, 2]
270+
assert "lm_head.weight_t" not in model.graph.initializers
271+
272+
def test_unsupported_requantization_target_has_clear_error(
273+
self, q8_0_projection_q4_head_gguf: Path
274+
):
275+
"""Mixed targets outside 4-bit/block-32 fail before the Q4 repacker."""
276+
from mobius.integrations.gguf import build_from_gguf
277+
278+
with pytest.raises(
279+
ValueError,
280+
match=(
281+
"keep_quantized MatMulNBits requantization currently supports only "
282+
r"4-bit/block-32 targets; got bits=8 block=32 for tensor lm_head\.weight"
283+
),
284+
):
285+
build_from_gguf(q8_0_projection_q4_head_gguf, keep_quantized=True)
286+
194287
def test_norms_are_float(self, q4_0_gguf: Path):
195288
"""Norm weights remain float, not quantized."""
196289
import onnx_ir as ir

0 commit comments

Comments
 (0)