1414 register_pattern_detector ,
1515 register_pattern_replacement ,
1616)
17+ from executorch .backends .vulkan .patterns .weight_packing_utils import (
18+ pack_4bit_weight_tensor ,
19+ )
1720from executorch .exir import ExportedProgram
1821from executorch .exir .dialects ._ops import ops as exir_ops
1922
2023
24+ embedding_4bit_target = exir_ops .edge .quantized_decomposed .embedding_4bit .dtype
25+ embedding_target = exir_ops .edge .aten .embedding .default
26+ torchao_dequantize_affine_target = exir_ops .edge .torchao .dequantize_affine .default
27+
28+
2129class QuantizedEmbeddingMatch (PatternMatch ):
2230 def __init__ (self , node : torch .fx .Node ) -> None :
2331 self .anchor_node = node
@@ -65,51 +73,22 @@ def __init__(self, node: torch.fx.Node) -> None:
6573 self .scales_node = scales_node
6674 self .all_nodes .extend (arg_chain )
6775
68- self .match_found = True
69-
70-
71- embedding_4bit_target = exir_ops .edge .quantized_decomposed .embedding_4bit .dtype
72-
73-
74- def _detect_tied_linear_weight (
75- ep : ExportedProgram ,
76- weight_node : torch .fx .Node ,
77- weight_tensor : torch .Tensor ,
78- ) -> bool :
79- """Check if this embedding weight is tied to a linear weight.
80-
81- The embedding weight is packed uint8 [vocab_size, embed_dim/2]. The linear
82- output weight may be stored as unpacked int8 [vocab_size, embed_dim]. If we
83- find a placeholder whose int8 values match our unpacked embedding values,
84- the weights are tied and we should use the linear packing to enable dedup.
85- """
86- vocab_size = weight_tensor .shape [0 ]
87- embed_dim = weight_tensor .shape [1 ] * 2
88-
89- # Unpack embedding weight using embedding convention (high nibble first)
90- emb_high = (weight_tensor >> 4 ).to (torch .int8 ) - 8
91- emb_low = (weight_tensor & 0xF ).to (torch .int8 ) - 8
92- emb_unpacked = torch .stack ([emb_high , emb_low ], dim = - 1 ).reshape (
93- vocab_size , embed_dim
94- )
95-
96- for node in ep .graph_module .graph .nodes :
97- if node .op != "placeholder" or node == weight_node :
98- continue
99-
100- try :
101- candidate = get_param_tensor (ep , node )
102- except RuntimeError :
103- continue
104- if candidate is None :
105- continue
106- if candidate .shape != (vocab_size , embed_dim ) or candidate .dtype != torch .int8 :
107- continue
108-
109- if torch .equal (emb_unpacked , candidate ):
110- return True
76+ # The weight placeholder stores values PACKED as uint8 [vocab,
77+ # embed_dim / 2], so embed_dim is twice the inner dim. The op
78+ # implementation requires that embed dim % 32 == 0 due to load/store
79+ # granularity for the weight tensor; enforce that check now.
80+ weight_val = (
81+ self .weight_node .meta .get ("val" , None )
82+ if isinstance (self .weight_node , torch .fx .Node )
83+ else None
84+ )
85+ if not isinstance (weight_val , torch .Tensor ) or weight_val .ndim != 2 :
86+ return
87+ embed_dim = int (weight_val .shape [- 1 ]) * 2 # packed, 2 values per byte
88+ if embed_dim % 32 != 0 :
89+ return
11190
112- return False
91+ self . match_found = True
11392
11493
11594@register_pattern_detector ("quantized_embedding" )
@@ -137,23 +116,25 @@ def replace_quantized_embedding_patterns(
137116 scales_tensor = get_param_tensor (ep , match .scales_node )
138117 assert scales_tensor is not None
139118
140- is_linear_weight = _detect_tied_linear_weight (ep , match .weight_node , weight_tensor )
141-
142- if is_linear_weight :
119+ # The quantized_decomposed.embedding_4bit op (which is being replaced)
120+ # already stores weights as packed uint8 [vocab, embed_dim / 2] (low nibble = odd,
121+ # high nibble = even). However, in the Vulkan runtime 4-bit linear layers
122+ # expect the reverse nibble packing (low nibble = even, high nibble = odd).
123+ # In LLMs, where quantized embeddings are most frequently used, the embedding
124+ # layer will share weights with the final LM head linear layer. For simplicity,
125+ # always repack the weight tensor in the format expected by 4 bit linear layers;
126+ # the runtime shader supports both the original and repacked packing formats
127+ # for weights.
128+ if utils .register_param_mutation (ep , match .weight_node , "4 bit linear weight" ):
143129 # Repack using linear convention (low nibble = even, high nibble = odd)
144130 vocab_size = weight_tensor .shape [0 ]
145131 high = (weight_tensor >> 4 ).to (torch .int8 ) - 8
146132 low = (weight_tensor & 0xF ).to (torch .int8 ) - 8
147133 unpacked = torch .stack ([high , low ], dim = - 1 ).reshape (vocab_size , - 1 )
148- repacked = unpacked .to (torch .uint8 ) + 8
149- weight_tensor = repacked [:, 1 ::2 ] << 4 | repacked [:, ::2 ]
150- # Update the state dict with repacked tensor
151- original_weight = get_param_tensor (ep , match .weight_node )
152- if original_weight is not None :
153- for key , value in ep .state_dict .items ():
154- if value .data_ptr () == original_weight .data_ptr ():
155- ep .state_dict [key ] = weight_tensor
156- break
134+ weight_tensor = pack_4bit_weight_tensor (unpacked )
135+ utils .align_width_and_update_state_dict (
136+ ep , match .weight_node , weight_tensor , align_to = 1 , force_update = True
137+ )
157138
158139 # Compute group_size from weight and scales shapes
159140 embed_dim = weight_tensor .shape [1 ] * 2 # packed, 2 values per byte
@@ -169,7 +150,191 @@ def replace_quantized_embedding_patterns(
169150 match .scales_node ,
170151 group_size ,
171152 match .indices_node ,
172- is_linear_weight ,
153+ True , # is_linear_weight
154+ ),
155+ )
156+
157+ embedding_q4gsw_node .meta ["val" ] = match .anchor_node .meta ["val" ]
158+ match .anchor_node .replace_all_uses_with (embedding_q4gsw_node )
159+
160+
161+ class TorchAOQuantizedEmbeddingMatch (PatternMatch ):
162+ """Matches a torchao 4-bit weight-only quantized embedding and rewrites it
163+ as a single et_vk.embedding_q4gsw.default node.
164+
165+ The recognized graph shape is a split torchao.dequantize_affine ->
166+ aten.embedding, whose weight is unpacked int8 [vocab, embed_dim] with values
167+ in [-8, 7]. This requires symmetric 4-bit signed quantization (quant_min=-8,
168+ quant_max=7, zero_point=0) and per-row groupwise blocks (block_size=[1, G]),
169+ which the runtime shader assumes via a fixed subtract-8 offset.
170+ """
171+
172+ def __init__ (self , node : torch .fx .Node ) -> None : # noqa: C901
173+ self .anchor_node = node
174+ self .match_found = False
175+ self .all_nodes = [node ]
176+
177+ # aten.embedding.default args: (weight, indices, *)
178+ dequant_node = node .args [0 ]
179+ self .indices_node = node .args [1 ]
180+
181+ if not isinstance (dequant_node , torch .fx .Node ):
182+ return
183+ if dequant_node .target != torchao_dequantize_affine_target :
184+ return
185+
186+ self .all_nodes .append (dequant_node )
187+
188+ # torchao.dequantize_affine args:
189+ # (input, block_size, scale, zero_point, input_dtype, quant_min,
190+ # quant_max, ...)
191+ block_size = dequant_node .args [1 ]
192+ input_dtype = dequant_node .args [4 ] if len (dequant_node .args ) > 4 else None
193+ quant_min = dequant_node .args [5 ] if len (dequant_node .args ) > 5 else None
194+ quant_max = dequant_node .args [6 ] if len (dequant_node .args ) > 6 else None
195+
196+ # The shader hardcodes the 4-bit signed offset (subtract 8), which
197+ # corresponds to quant_min=-8, quant_max=7, zero_point=0.
198+ if quant_min != - 8 or quant_max != 7 :
199+ return
200+
201+ # Key off the dequant node's declared input_dtype, not the weight
202+ # placeholder's live meta: a sibling match sharing the same (tied) weight
203+ # may have repacked that placeholder in place (flipping it to packed
204+ # uint8 [vocab, embed_dim / 2]), which would spuriously reject us here.
205+ if input_dtype != torch .int8 :
206+ return
207+
208+ # block_size must be per-row groupwise: [1, group_size]
209+ if not isinstance (block_size , (list , tuple )) or len (block_size ) != 2 :
210+ return
211+ if block_size [0 ] != 1 :
212+ return
213+ self .group_size = int (block_size [1 ])
214+
215+ # Trace weight (args[0]) and scales (args[2]) to their placeholders. A
216+ # placeholder-backed zero_point's symmetric (zero_point == 0)
217+ # requirement is verified on the real tensor in the replacement
218+ # function, where the ExportedProgram is available; checking the fake
219+ # meta tensor here would trigger a data-dependent guard error.
220+ weight_node , arg_chain = utils .trace_args_until_placeholder (
221+ dequant_node .args [0 ]
222+ )
223+ if weight_node is None :
224+ return
225+ self .weight_node = weight_node
226+ self .all_nodes .extend (arg_chain )
227+
228+ # Read embed_dim from the dequant node's float output meta, not the
229+ # weight placeholder's meta: a tied weight may have been repacked in
230+ # place by a sibling match (halving its inner dim), but this output meta
231+ # is stable. Runtime shader requires embed_dim % 32 == 0 and the groups
232+ # to tile the row exactly; reject otherwise rather than emit an op the
233+ # runtime would abort on.
234+ dequant_val = dequant_node .meta .get ("val" , None )
235+ if not isinstance (dequant_val , torch .Tensor ) or dequant_val .ndim != 2 :
236+ return
237+ embed_dim = int (dequant_val .shape [- 1 ])
238+ if self .group_size <= 0 or embed_dim % self .group_size != 0 :
239+ return
240+ if embed_dim % 32 != 0 :
241+ return
242+
243+ scales_node , arg_chain = utils .trace_args_until_placeholder (
244+ dequant_node .args [2 ]
245+ )
246+ if scales_node is None :
247+ return
248+ self .scales_node = scales_node
249+ self .all_nodes .extend (arg_chain )
250+
251+ # zero_point (args[3]) must be provably zero, since the shader hardcodes
252+ # a subtract-8 offset that assumes symmetric quantization. Reject the
253+ # match otherwise so the op falls back cleanly rather than miscomputing.
254+ zero_point = dequant_node .args [3 ]
255+ self .zero_point_node = None
256+ if zero_point is None :
257+ # Symmetric quant; zero_point == 0 is implied.
258+ pass
259+ elif isinstance (zero_point , torch .fx .Node ):
260+ zero_point_node , arg_chain = utils .trace_args_until_placeholder (zero_point )
261+ if zero_point_node is None :
262+ # Untraceable to a placeholder; cannot verify it is zero.
263+ return
264+ self .zero_point_node = zero_point_node
265+ self .all_nodes .extend (arg_chain )
266+ else :
267+ # Inline scalar / list / tuple; verify the literal value(s) are zero.
268+ values = (
269+ zero_point if isinstance (zero_point , (list , tuple )) else [zero_point ]
270+ )
271+ if any (v != 0 for v in values ):
272+ return
273+
274+ self .match_found = True
275+
276+
277+ @register_pattern_detector ("torchao_quantized_embedding" )
278+ def find_torchao_quantized_embedding_patterns (
279+ node : torch .fx .Node ,
280+ ) -> Optional [TorchAOQuantizedEmbeddingMatch ]:
281+ if node .target != embedding_target :
282+ return None
283+
284+ matched_pattern = TorchAOQuantizedEmbeddingMatch (node )
285+ if matched_pattern .match_found :
286+ return matched_pattern
287+ return None
288+
289+
290+ @register_pattern_replacement ("torchao_quantized_embedding" )
291+ def replace_torchao_quantized_embedding_patterns (
292+ ep : ExportedProgram ,
293+ graph_module : torch .fx .GraphModule ,
294+ match : TorchAOQuantizedEmbeddingMatch ,
295+ ):
296+ # Always repack with the packing expected by 4 bit linear layers for
297+ # simplicity. See replace_quantized_embedding_patterns() for more details
298+ if utils .register_param_mutation (ep , match .weight_node , "4 bit linear weight" ):
299+ weight_tensor = get_param_tensor (ep , match .weight_node )
300+ assert weight_tensor is not None
301+
302+ # The shader applies a fixed signed-4-bit offset (subtract 8), which
303+ # assumes symmetric quantization (zero_point == 0). The None / inline
304+ # literal cases were already proven zero in the matcher; a placeholder
305+ # was committed to during matching, so its backing tensor must be
306+ # fetchable and verifiable here.
307+ if match .zero_point_node is not None :
308+ zero_point_tensor = get_param_tensor (ep , match .zero_point_node )
309+ if zero_point_tensor is None :
310+ raise RuntimeError (
311+ "embedding_q4gsw: zero_point traced to placeholder "
312+ f"{ match .zero_point_node .name !r} but its backing tensor "
313+ "could not be fetched to verify symmetric quantization "
314+ "(zero_point == 0)."
315+ )
316+ assert torch .all (
317+ zero_point_tensor == 0
318+ ), "embedding_q4gsw requires symmetric quantization (zero_point == 0)"
319+
320+ packed_weight = pack_4bit_weight_tensor (weight_tensor )
321+
322+ utils .align_width_and_update_state_dict (
323+ ep , match .weight_node , packed_weight , align_to = 1 , force_update = True
324+ )
325+
326+ group_size = match .group_size
327+
328+ with graph_module .graph .inserting_before (match .anchor_node ):
329+ embedding_q4gsw_node = graph_module .graph .create_node (
330+ "call_function" ,
331+ exir_ops .edge .et_vk .embedding_q4gsw .default ,
332+ args = (
333+ match .weight_node ,
334+ match .scales_node ,
335+ group_size ,
336+ match .indices_node ,
337+ True , # is_linear_weight
173338 ),
174339 )
175340
0 commit comments