Skip to content

Commit ab3100f

Browse files
SS-JIApytorchbot
authored andcommitted
[ET-VK][conv2d] Fuse residual adds and activations into conv2d during lowering
Pull Request resolved: #24808 ## Ulterior Motive Closing the DSR FP16 gap to LiteRT. DSR has 83 skip connections of the form `conv(...) + x * scale`, each lowered today to a separate `mul` and `add` dispatch. The previous commit added a conv2d epilogue that applies them inside every conv2d shader; this commit makes lowering use it. It also makes `et_vk.conv2d.with_epilogue` the single fused op for conv2d, replacing `et_vk.conv_with_clamp` there. ## Rationale **What**: A `conv2d_with_epilogue` pattern for `FusePatternsPass` that rewrites either of ``` activation(residual_op(convolution(...), residual [* scale])) activation(convolution(...)) ``` into `et_vk.conv2d.with_epilogue`, plus the op's definition and `op_registry` entry. A new `skip_fusion_patterns` compile option lets `WebGPUPartitioner` skip the pattern. **Why**: Without the pattern, no exported model reaches the epilogue. On DSR, an earlier revision of this stack shrank the Vulkan delegate from 469 to 229 operators and cut end-to-end latency 9.6% on Galaxy S23 and Pixel 8 Pro. The WebGPU runtime consumes Vulkan lowering but does not implement `et_vk.conv2d.with_epilogue`. ## Details - **Behavior** - conv2d + activation - **Before**: `FuseClampPass` rewrote it into `et_vk.conv_with_clamp`. - **After**: `et_vk.conv2d.with_epilogue`. At runtime both reach `add_conv2d` with an activation epilogue. - conv2d + residual, with or without an activation - **Before**: separate `aten.convolution`, `mul`, `add` and activation ops. - **After**: one `et_vk.conv2d.with_epilogue`. - conv1d + activation: unchanged. `Conv1dAsConv2dPass` and `FuseClampPass` still run after `FusePatternsPass`, so conv1d, including conv1d rewritten as conv2d, fuses into `et_vk.conv_with_clamp`, which stays registered for conv1d, WebGPU and existing `.pte` files. - WebGPU lowering: unchanged. `WebGPUPartitioner` skips `conv2d_with_epilogue`, so conv2d + activation still becomes `et_vk.conv_with_clamp`. - **Match** - A 2D, batch 1, FP16/FP32 `aten.convolution` (direct, pointwise, depthwise, grouped or transposed) with one user, whose input and output fit channels-packed textures. - Feeding `aten.add.Tensor`, `aten.sub.Tensor` or `aten.mul.Tensor` with a residual of the conv output's shape and dtype. The conv may be either operand of `mul`, and either operand of `add` when `alpha` is 1; it must be the first operand of `sub`. - When the residual is a `mul` of `x` and a `[1, C, 1, 1]` or `[C, 1, 1]` placeholder `scale`, and the residual op is the `mul`'s only user, `x` becomes the residual and `scale` the residual scale. The runtime binds the residual as a tensor and prepacks the scale, so when `scale` is not a constant or `x` is one, the `mul` stays separate and its output is the residual. - The residual cannot be the conv itself (`y + y` stays unfused), and a constant residual without a scale stays unfused. - `relu`, `hardtanh`, `clamp` or `leaky_relu` is fused as the activation when it is the only user of the binary op, or of the conv when there is no residual. A missing `hardtanh` bound defaults to -1 or 1. Activations with non-constant parameters, such as a `clamp` bound by a symbolic size, stay unfused. - **Partitioning**: As for the other patterns, `VulkanPartitioner` delegates a match's nodes without checking their support. The detector therefore repeats the conv's own checks (rank, batch, dtype, texture limits), so a conv that exceeds the texture limits stays on CPU unfused. - **Pattern replacement** - `replace_all_fusable_subgraphs` removes dead code after the detectors replace a match at a node, so nodes another pattern already replaced (for example the conv and relu of a quantized conv) cannot match again. - A replacement function may return `False` when it leaves the graph unchanged, as this pattern does for a constant residual. Such matches are not counted, so `FusePatternsPass` does not retrace the graph for them. - **Registry**: The residual is channels-packed texture3d like the input and output; weight, bias and scale are prepacked. - **`skip_fusion_patterns`**: a collection of pattern names; a string or an unknown name raises `ValueError`. It is serialized as a compile spec, and both the partitioner's pattern detection and `FusePatternsPass` honor it: ```python VulkanPartitioner(compile_options={"skip_fusion_patterns": {"conv2d_with_epilogue"}}) ``` - **Compatibility**: `.pte` files exported after this commit contain `et_vk.conv2d.with_epilogue` for every FP conv2d followed by an activation or a residual op, so they need a runtime that includes the previous commit. Existing `.pte` files keep loading, since `et_vk.conv_with_clamp` stays registered. - **WebGPU tests**: The op test generator and `test_conv_with_clamp.py` lowered through `VulkanPartitioner` directly; they now use `WebGPUPartitioner`. ``` schema: conv2d.with_epilogue(Tensor input, Tensor weight, Tensor? bias, SymInt[] stride, SymInt[] padding, SymInt[] dilation, bool transposed, SymInt[] output_padding, SymInt groups, Tensor? residual, Tensor? residual_scale, str residual_op, Scalar alpha, str activation, float activation_a, float activation_b) -> Tensor ``` ghstack-source-id: 469036207 Differential Revision: [D130216290](https://our.internmc.facebook.com/intern/diff/D130216290/)
1 parent 1bb706b commit ab3100f

20 files changed

Lines changed: 753 additions & 37 deletions

‎backends/vulkan/_passes/fuse_patterns.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
from typing import Optional
7+
from typing import Collection, Optional
88

99
import executorch.backends.vulkan.patterns as vk_patterns
1010

@@ -15,15 +15,16 @@
1515

1616

1717
class FusePatternsPass(ExportPass):
18-
def __init__(self) -> None:
18+
def __init__(self, skip_patterns: Collection[str] = ()) -> None:
1919
super().__init__()
2020
self._exported_program: Optional[ExportedProgram] = None
21+
self.skip_patterns = skip_patterns
2122

2223
def call(self, graph_module: torch.fx.GraphModule):
2324
assert self._exported_program is not None
2425

2526
total_replaced = vk_patterns.replace_all_fusable_subgraphs(
26-
self._exported_program, graph_module
27+
self._exported_program, graph_module, self.skip_patterns
2728
)
2829

2930
if total_replaced > 0:

‎backends/vulkan/custom_ops_lib.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,64 @@ def conv_with_clamp_out_impl(
110110
)
111111
lib.impl(name, conv_with_clamp_out_impl, "CompositeExplicitAutograd")
112112

113+
##########################
114+
## conv2d.with_epilogue ##
115+
##########################
116+
117+
118+
def conv2d_with_epilogue_impl(
119+
input,
120+
weight,
121+
bias,
122+
stride,
123+
padding,
124+
dilation,
125+
transposed,
126+
output_padding,
127+
groups,
128+
residual=None,
129+
residual_scale=None,
130+
residual_op="add",
131+
alpha=1,
132+
activation="none",
133+
activation_a=0.0,
134+
activation_b=0.0,
135+
):
136+
out = torch.convolution(
137+
input,
138+
weight,
139+
bias,
140+
stride,
141+
padding,
142+
dilation,
143+
transposed,
144+
output_padding,
145+
groups,
146+
)
147+
if residual is not None:
148+
if residual_scale is not None:
149+
residual = residual * residual_scale
150+
if residual_op == "add":
151+
out = torch.add(out, residual, alpha=alpha)
152+
elif residual_op == "sub":
153+
out = torch.sub(out, residual, alpha=alpha)
154+
else:
155+
out = out * residual
156+
if activation == "relu":
157+
out = torch.relu(out)
158+
elif activation == "clamp":
159+
out = torch.clamp(out, activation_a, activation_b)
160+
elif activation == "leaky_relu":
161+
out = torch.nn.functional.leaky_relu(out, activation_a)
162+
return out
163+
164+
165+
name = "conv2d.with_epilogue"
166+
lib.define(
167+
f"{name}(Tensor input, Tensor weight, Tensor? bias, SymInt[] stride, SymInt[] padding, SymInt[] dilation, bool transposed, SymInt[] output_padding, SymInt groups, Tensor? residual, Tensor? residual_scale, str residual_op, Scalar alpha, str activation, float activation_a, float activation_b) -> Tensor"
168+
)
169+
lib.impl(name, conv2d_with_epilogue_impl, "CompositeExplicitAutograd")
170+
113171
#################
114172
## grid_priors ##
115173
#################

‎backends/vulkan/op_registry.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,33 @@ def pick_conv_storage(
10161016
)
10171017

10181018

1019+
@update_features(exir_ops.edge.et_vk.conv2d.with_epilogue)
1020+
def register_conv2d_with_epilogue():
1021+
return OpFeatures(
1022+
inputs_storage=[
1023+
utils.CHANNELS_PACKED_TEXTURE, # input
1024+
utils.NO_STORAGE, # weight (prepacked)
1025+
utils.NO_STORAGE, # bias (prepacked)
1026+
utils.NO_STORAGE, # stride (non tensor)
1027+
utils.NO_STORAGE, # padding (non tensor)
1028+
utils.NO_STORAGE, # dilation (non tensor)
1029+
utils.NO_STORAGE, # transposed (non tensor)
1030+
utils.NO_STORAGE, # output_padding (non tensor)
1031+
utils.NO_STORAGE, # groups (non tensor)
1032+
utils.CHANNELS_PACKED_TEXTURE, # residual
1033+
utils.NO_STORAGE, # residual_scale (prepacked)
1034+
utils.NO_STORAGE, # residual_op (non tensor)
1035+
utils.NO_STORAGE, # alpha (non tensor)
1036+
utils.NO_STORAGE, # activation (non tensor)
1037+
utils.NO_STORAGE, # activation_a (non tensor)
1038+
utils.NO_STORAGE, # activation_b (non tensor)
1039+
],
1040+
inputs_dtypes=utils.FP_T,
1041+
supports_resize=True,
1042+
supports_prepacking=True,
1043+
)
1044+
1045+
10191046
# =============================================================================
10201047
# convolution/conv2d/q8ta/**/Q8taConv2d*.cpp
10211048
# =============================================================================

‎backends/vulkan/partitioner/vulkan_partitioner.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,18 @@ def parse_compile_options(compile_options: Dict[str, Any]) -> List[CompileSpec]:
397397
compile_specs.append(CompileSpec(key, value.to_bytes(8, "little")))
398398
continue
399399

400+
if key == "skip_fusion_patterns":
401+
if (
402+
isinstance(value, str)
403+
or not set(value) <= vk_patterns.fusable_patterns.keys()
404+
):
405+
raise ValueError(
406+
"skip_fusion_patterns must be a collection of pattern names "
407+
f"from {sorted(vk_patterns.fusable_patterns)}, got {value!r}"
408+
)
409+
compile_specs.append(CompileSpec(key, ",".join(sorted(value)).encode()))
410+
continue
411+
400412
if isinstance(value, (VkStorageType, VkMemoryLayout)):
401413
value_bytes = int(value).to_bytes(4, byteorder="little")
402414
compile_specs.append(CompileSpec(key, value_bytes))
@@ -516,7 +528,8 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult:
516528

517529
# Get all fusable subgraphs from fuse_patterns
518530
fusable_subgraphs = vk_patterns.get_all_fusable_subgraphs(
519-
exported_program.graph_module
531+
exported_program.graph_module,
532+
self.options.get("skip_fusion_patterns", ()),
520533
)
521534

522535
# small_texture_limits opts into the conservative 3D texture limit that is

‎backends/vulkan/patterns/BUCK‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ fbcode_target(_kind = runtime.python_library,
99
srcs = [
1010
"__init__.py",
1111
"pattern_registry.py",
12+
"conv2d_with_epilogue.py",
1213
"rope.py",
1314
"rope_hf.py",
1415
"quantized_embedding.py",

‎backends/vulkan/patterns/__init__.py‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
from typing import List
7+
from typing import Collection, List
8+
9+
import executorch.backends.vulkan.patterns.conv2d_with_epilogue # noqa
810

911
import executorch.backends.vulkan.patterns.quantized_binary # noqa
1012

@@ -38,6 +40,7 @@
3840
DetectorFn,
3941
fusable_patterns,
4042
GetGraphFn,
43+
PatternEntry,
4144
PatternMatch,
4245
register_pattern_detector,
4346
register_pattern_graph,
@@ -66,9 +69,17 @@
6669
]
6770

6871

69-
def all_fusable_graph_patterns() -> List[torch.fx.GraphModule]:
72+
def enabled_patterns(skip_patterns: Collection[str] = ()) -> List[PatternEntry]:
73+
return [
74+
entry for name, entry in fusable_patterns.items() if name not in skip_patterns
75+
]
76+
77+
78+
def all_fusable_graph_patterns(
79+
skip_patterns: Collection[str] = (),
80+
) -> List[torch.fx.GraphModule]:
7081
all_patterns = []
71-
for entry in fusable_patterns.values():
82+
for entry in enabled_patterns(skip_patterns):
7283
if entry.get_graphs_fn is not None:
7384
all_patterns.extend(entry.get_graphs_fn())
7485

@@ -77,18 +88,20 @@ def all_fusable_graph_patterns() -> List[torch.fx.GraphModule]:
7788

7889
def get_all_fusable_subgraphs(
7990
graph_module: torch.fx.GraphModule,
91+
skip_patterns: Collection[str] = (),
8092
) -> List[PatternMatch]:
8193
fusable_subgraphs = []
8294

83-
fuse_patterns = all_fusable_graph_patterns()
95+
fuse_patterns = all_fusable_graph_patterns(skip_patterns)
8496
for pattern in fuse_patterns:
8597
sm = SubgraphMatcher(pattern.graph, ignore_literals=True)
8698
matches = list(sm.match(graph_module.graph))
8799
for match in matches:
88100
fusable_subgraphs.append(create_pattern_match_from_internal_match(match))
89101

102+
entries = enabled_patterns(skip_patterns)
90103
for node in graph_module.graph.nodes:
91-
for entry in fusable_patterns.values():
104+
for entry in entries:
92105
if entry.detector_fn is not None:
93106
maybe_match = entry.detector_fn(node)
94107
if maybe_match is not None:
@@ -111,7 +124,8 @@ def create_replacement_for_pattern(
111124

112125
for partition_to_replace in matches:
113126
pattern = create_pattern_match_from_internal_match(partition_to_replace)
114-
create_replacement_func(ep, graph_module, pattern)
127+
if create_replacement_func(ep, graph_module, pattern) is False:
128+
continue
115129
total_replaced += 1
116130
# Remove dead code so they won't be matched again
117131
graph_module.graph.eliminate_dead_code()
@@ -122,11 +136,13 @@ def create_replacement_for_pattern(
122136
def replace_all_fusable_subgraphs(
123137
ep: ExportedProgram,
124138
graph_module: torch.fx.GraphModule,
139+
skip_patterns: Collection[str] = (),
125140
) -> int:
126141
total_replaced = 0
142+
entries = enabled_patterns(skip_patterns)
127143

128144
# Handle patterns identified with SubgraphMatcher
129-
for entry in fusable_patterns.values():
145+
for entry in entries:
130146
if entry.get_graphs_fn is not None and entry.create_replacement_fn is not None:
131147
total_replaced += create_replacement_for_pattern(
132148
ep,
@@ -138,16 +154,23 @@ def replace_all_fusable_subgraphs(
138154

139155
# Handle patterns identified with custom detector function
140156
for node in graph_module.graph.nodes:
141-
for entry in fusable_patterns.values():
157+
replaced_before = total_replaced
158+
for entry in entries:
142159
if (
143160
entry.detector_fn is not None
144161
and entry.create_replacement_fn is not None
145162
):
146163
maybe_match = entry.detector_fn(node)
147164
if maybe_match is not None:
148165
assert entry.create_replacement_fn is not None
149-
entry.create_replacement_fn(ep, graph_module, maybe_match)
150-
total_replaced += 1
166+
if (
167+
entry.create_replacement_fn(ep, graph_module, maybe_match)
168+
is not False
169+
):
170+
total_replaced += 1
171+
if total_replaced > replaced_before:
172+
# Remove dead code so they won't be matched again
173+
graph_module.graph.eliminate_dead_code()
151174

152175
graph_module.graph.eliminate_dead_code()
153176
return total_replaced

0 commit comments

Comments
 (0)