@@ -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+
138192class 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