Skip to content

Commit 73c259e

Browse files
ssjiaSS-JIA
authored andcommitted
[ET-VK][patterns] Fuse torchao 4-bit quantized embedding to embedding_q4gsw
Pull Request resolved: #20381 TISO and other torchao-quantized models emit a `torchao.dequantize_affine -> aten.embedding` subgraph for their weight-only int4 quantized embedding. The existing `QuantizedEmbeddingMatch` only matches the `quantized_decomposed.embedding_4bit.dtype` fused op, so the torchao embedding never fused: its `dequantize_affine` const-folded to an fp32 weight, the resulting `aten.embedding` exceeded the buffer-element limit and fell back to CPU, and the fp32 constant bloated the serialized model. This adds a separate `TorchAOQuantizedEmbeddingMatch` matcher that recognizes the torchao int4 `dequantize_affine -> aten.embedding` shape (qmin=-8/qmax=7, per-row group block_size `[1, G]`) and rewrites it to the existing `et_vk.embedding_q4gsw.default` op, repacking the unpacked int8 weight into the packed 4-bit layout. It asserts symmetric quantization (zero_point == 0, which the shader assumes) and guards against repacking a shared/tied weight more than once by recording the repack against the weight's state-dict FQN via `register_param_mutation` (a per-ExportedProgram registry on `ep._et_vk_param_modification_tags`); a second match on the same tied weight sees the recorded tag and skips the in-place repack. It is kept as a separate class from `QuantizedEmbeddingMatch` because the two dialects produce different graph shapes (one fused op vs a split dequant+gather), so a single class would only co-locate two disjoint parse paths. On the en_US TISO backbone the embedding now delegates to Vulkan instead of falling back to CPU, and the serialized `.pte` drops from 418 MiB to 348 MiB. This change was authored with Claude. ghstack-source-id: 397529341 @exported-using-ghexport Differential Revision: [D108457797](https://our.internmc.facebook.com/intern/diff/D108457797/)
1 parent d78d5e4 commit 73c259e

7 files changed

Lines changed: 669 additions & 130 deletions

File tree

backends/vulkan/patterns/BUCK

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ fbcode_target(_kind = runtime.python_library,
1818
"quantized_pixel_shuffle.py",
1919
"quantized_unary.py",
2020
"rms_norm.py",
21+
"weight_packing_utils.py",
2122
"sdpa.py",
2223
"select_as_symint.py",
2324
],

backends/vulkan/patterns/quantized_convolution.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def find_quantized_convolution_patterns(
183183

184184

185185
@register_pattern_replacement("quantized_convolution")
186-
def make_q8ta_conv2d_custom_op(
186+
def make_q8ta_conv2d_custom_op( # noqa: C901
187187
ep: ExportedProgram,
188188
graph_module: torch.fx.GraphModule,
189189
match: QuantizedConvolutionMatch,
@@ -249,9 +249,10 @@ def make_q8ta_conv2d_custom_op(
249249
# Need to make sure that OC dim is a multiple of 4 so that data load/stores are well
250250
# aligned with texel boundaries. Add padding to align to the next multiple of 4 if
251251
# needed.
252-
utils.align_width_and_update_state_dict(
253-
ep, match.weight_node, weight_tensor, force_update=True
254-
)
252+
if utils.register_param_mutation(ep, match.weight_node, "8 bit conv2d weight"):
253+
utils.align_width_and_update_state_dict(
254+
ep, match.weight_node, weight_tensor, force_update=True
255+
)
255256
utils.align_width_and_update_state_dict(
256257
ep, match.weight_scales_node, weight_scales_tensor
257258
)

backends/vulkan/patterns/quantized_embedding.py

Lines changed: 222 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,18 @@
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+
)
1720
from executorch.exir import ExportedProgram
1821
from 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+
2129
class 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

Comments
 (0)