Skip to content

Commit 8f56065

Browse files
committed
Arm backend: Handle grid_sample qparams in shader
Signed-off-by: Rob Elliott <Robert.Elliott@arm.com> Change-Id: I53c143e323827ec4f280006bf34fcec3093af62d Signed-off-by: Rob Elliott <Robert.Elliott@arm.com>
1 parent 2b8cc80 commit 8f56065

11 files changed

Lines changed: 430 additions & 40 deletions

backends/arm/_passes/arm_pass_manager.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,16 +220,17 @@ def __init__(self, graph_pass: Callable[[GraphModule], PassResult | None]) -> No
220220

221221
def call(self, exported_program: ExportedProgram) -> ExportedProgramPassResult:
222222
graph_pass = cast(Any, self.graph_pass)
223+
has_exported_program_attr = hasattr(graph_pass, "exported_program")
223224
pass_exported_program = getattr(graph_pass, "exported_program", None)
224-
if pass_exported_program is not None:
225+
if has_exported_program_attr:
225226
# ExportedProgramPassManager works on a shallow copy; Arm graph
226227
# passes that store an ExportedProgram must update that copy.
227228
graph_pass.exported_program = exported_program
228229

229230
try:
230231
result = self.graph_pass(exported_program.graph_module)
231232
finally:
232-
if pass_exported_program is not None:
233+
if has_exported_program_attr:
233234
graph_pass.exported_program = pass_exported_program
234235

235236
if result is None:

backends/arm/quantizer/quantization_annotator.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -474,11 +474,12 @@ def _match_pattern(
474474
8: _QParams((0.999 - (-0.999)) / (1 << 8), 0),
475475
16: _QParams((0.99999 - (-0.99999)) / (1 << 16), 0),
476476
},
477-
# grid_sampler image input/output use SNORM-compatible qparams. The broader
478-
# quantized graph currently quantizes the grid-producing path as well, so
479-
# input 1 follows the standard activation qspec and lowering materializes a
480-
# dequant boundary before the shader. This is a functional stopgap; we may
481-
# want to preserve float grid coordinates or use a higher-precision path.
477+
# grid_sampler image input/output use SNORM-compatible qparams. Input 1
478+
# follows the standard activation qspec, but the supported VGF lowering
479+
# modes are still only:
480+
# - float image / float grid / float output
481+
# - int8 image / int8 grid / int8 output
482+
# Mixed int8-image / float-grid shader lowering is not supported.
482483
torch.ops.aten.grid_sampler.default: {
483484
8: _QParams(1.0 / 127.0, 0, -127, 127),
484485
},

backends/arm/test/misc/test_custom_shader_payload.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
build_grid_sampler_2d_payload,
1313
decode_payload,
1414
encode_payload,
15+
GRID_SAMPLER_2D_QUANTIZED_GRID_VK_FORMAT,
1516
GRID_SAMPLER_2D_SAMPLER_ALIGN_CORNERS_SHADER_BINARY,
1617
GRID_SAMPLER_2D_SAMPLER_ALIGN_CORNERS_SHADER_SOURCE,
1718
GRID_SAMPLER_2D_SAMPLER_INT8_ALIGN_CORNERS_SHADER_BINARY,
@@ -109,6 +110,8 @@ def test_grid_sampler_2d_custom_shader_payload_no_target_uses_int8_sampler_for_c
109110
output_shape=(1, 4, 4, 4),
110111
input_dtype=torch.int8,
111112
output_dtype=torch.int8,
113+
grid_dtype=torch.int8,
114+
extra_tensor_input_vkformats=["VK_FORMAT_R32_SFLOAT", "VK_FORMAT_R32_SINT"],
112115
)
113116

114117
assert payload["shader_language"] == GRID_SAMPLER_2D_SHADER_LANGUAGE
@@ -121,13 +124,46 @@ def test_grid_sampler_2d_custom_shader_payload_no_target_uses_int8_sampler_for_c
121124
== "VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER"
122125
)
123126
assert payload["input_1_type"] == "Tensor"
124-
assert payload["input_1_vkformat"] == GRID_SAMPLER_2D_VK_FORMAT
127+
assert payload["input_1_vkformat"] == GRID_SAMPLER_2D_QUANTIZED_GRID_VK_FORMAT
125128
assert payload["input_1_vkdescriptortype"] == "VK_DESCRIPTOR_TYPE_TENSOR_ARM"
129+
assert payload["input_2_type"] == "Tensor"
130+
assert payload["input_2_vkformat"] == "VK_FORMAT_R32_SFLOAT"
131+
assert payload["input_2_binding"] == 3
132+
assert payload["input_3_type"] == "Tensor"
133+
assert payload["input_3_vkformat"] == "VK_FORMAT_R32_SINT"
134+
assert payload["input_3_binding"] == 4
126135
assert payload["output_0_type"] == "Image"
127136
assert payload["output_0_vkformat"] == GRID_SAMPLER_2D_SAMPLER_INT8_VK_FORMAT
128137
assert payload["output_0_vkdescriptortype"] == "VK_DESCRIPTOR_TYPE_STORAGE_IMAGE"
129138

130139

140+
def test_grid_sampler_2d_custom_shader_payload_uses_quantized_grid_for_int8_sampler():
141+
payload = build_grid_sampler_2d_payload(
142+
interpolation_mode=0,
143+
padding_mode=0,
144+
align_corners=False,
145+
input_shape=(1, 4, 8, 8),
146+
output_shape=(1, 4, 4, 4),
147+
input_dtype=torch.int8,
148+
output_dtype=torch.int8,
149+
grid_dtype=torch.int8,
150+
extra_tensor_input_vkformats=["VK_FORMAT_R32_SFLOAT", "VK_FORMAT_R32_SINT"],
151+
)
152+
153+
assert payload["input_0_type"] == "Image"
154+
assert payload["input_0_vkformat"] == GRID_SAMPLER_2D_SAMPLER_INT8_VK_FORMAT
155+
assert payload["input_1_type"] == "Tensor"
156+
assert payload["input_1_vkformat"] == GRID_SAMPLER_2D_QUANTIZED_GRID_VK_FORMAT
157+
assert payload["input_1_vkdescriptortype"] == "VK_DESCRIPTOR_TYPE_TENSOR_ARM"
158+
assert payload["input_2_type"] == "Tensor"
159+
assert payload["input_2_vkformat"] == "VK_FORMAT_R32_SFLOAT"
160+
assert payload["input_3_type"] == "Tensor"
161+
assert payload["input_3_vkformat"] == "VK_FORMAT_R32_SINT"
162+
assert payload["output_0_type"] == "Image"
163+
assert payload["output_0_vkformat"] == GRID_SAMPLER_2D_SAMPLER_INT8_VK_FORMAT
164+
assert payload["output_0_binding"] == 2
165+
166+
131167
def test_grid_sampler_2d_custom_shader_payload_no_target_keeps_c3_on_buffer():
132168
payload = build_grid_sampler_2d_payload(
133169
interpolation_mode=0,
@@ -212,6 +248,8 @@ def test_grid_sampler_2d_custom_shader_payload_no_target_int8_align_corners_samp
212248
output_shape=(1, 4, 8, 8),
213249
input_dtype=torch.int8,
214250
output_dtype=torch.int8,
251+
grid_dtype=torch.int8,
252+
extra_tensor_input_vkformats=["VK_FORMAT_R32_SFLOAT", "VK_FORMAT_R32_SINT"],
215253
)
216254

217255
assert payload["input_0_type"] == "Image"
@@ -228,6 +266,22 @@ def test_grid_sampler_2d_custom_shader_payload_no_target_int8_align_corners_samp
228266
)
229267

230268

269+
def test_grid_sampler_2d_custom_shader_payload_rejects_float_grid_for_int8_sampler():
270+
with pytest.raises(
271+
ValueError,
272+
match="Int8 sampler grid-sample payload requires an int8 grid",
273+
):
274+
build_grid_sampler_2d_payload(
275+
interpolation_mode=0,
276+
padding_mode=0,
277+
align_corners=False,
278+
input_shape=(1, 4, 8, 8),
279+
output_shape=(1, 4, 4, 4),
280+
input_dtype=torch.int8,
281+
output_dtype=torch.int8,
282+
)
283+
284+
231285
def test_grid_sampler_2d_custom_shader_payload_no_target_bicubic_buffer():
232286
payload = build_grid_sampler_2d_payload(
233287
interpolation_mode=2,

backends/arm/test/passes/test_rewrite_grid_sampler_to_tosa_custom_pass.py

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
CUSTOM_SHADER_DOMAIN_NAME,
2727
decode_payload,
2828
grid_sampler_2d_operator_name,
29+
GRID_SAMPLER_2D_QUANTIZED_GRID_VK_FORMAT,
2930
GRID_SAMPLER_2D_SAMPLER_INT8_VK_FORMAT,
3031
GRID_SAMPLER_2D_SAMPLER_VK_FORMAT,
3132
GRID_SAMPLER_2D_SHADER_ENTRY_POINT,
@@ -34,6 +35,7 @@
3435
)
3536
from executorch.exir import to_edge
3637
from executorch.exir.dialects._ops import ops as exir_ops
38+
from executorch.exir.pass_base import ExportedProgramPassBase, ExportedProgramPassResult
3739
from torch.export import export
3840
from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e
3941

@@ -56,6 +58,43 @@ def forward(self, x, grid):
5658
)
5759

5860

61+
class _RewriteGridSamplerToTosaCustomExportPass(ExportedProgramPassBase):
62+
# The quantized grid-sampler rewrite materializes exported constant
63+
# placeholders for grid scale/zero-point, so it needs ExportedProgram
64+
# context. Production VGF lowering injects that context via the Arm pass
65+
# manager adapter, but this unit test drives the pass through the generic
66+
# EXIR transform path instead. Wrap the graph pass so the test exercises
67+
# the same rewrite logic without depending on the Arm-specific adapter.
68+
def call(self, exported_program):
69+
rewrite_pass = RewriteGridSamplerToTosaCustomPass(exported_program)
70+
result = rewrite_pass(exported_program.graph_module)
71+
exported_program._graph_module = result.graph_module
72+
return ExportedProgramPassResult(exported_program, result.modified)
73+
74+
75+
def test_get_first_user_input_placeholder_accepts_renamed_placeholder_node():
76+
model = GridSampler2d()
77+
example_inputs = (
78+
torch.randn(1, 3, 8, 8),
79+
torch.randn(1, 4, 4, 2),
80+
)
81+
82+
exported_program = to_edge(export(model, example_inputs)).exported_program()
83+
first_placeholder = next(
84+
node for node in exported_program.graph.nodes if node.op == "placeholder"
85+
)
86+
original_target = first_placeholder.target
87+
first_placeholder.name = f"{original_target}_renamed"
88+
89+
rewrite_pass = RewriteGridSamplerToTosaCustomPass(exported_program)
90+
91+
assert (
92+
rewrite_pass._get_first_user_input_placeholder(exported_program.graph)
93+
is first_placeholder
94+
)
95+
assert first_placeholder.target == original_target
96+
97+
5998
def test_rewrite_grid_sampler_to_tosa_custom_vgf_no_target():
6099
model = GridSampler2d()
61100
example_inputs = (
@@ -123,7 +162,8 @@ def test_rewrite_grid_sampler_to_tosa_custom_sampler_dispatch_rounds_up_output()
123162
edge_model = to_edge(export(model, example_inputs))
124163
with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+FP")):
125164
edge_model = edge_model.transform([RewriteGridSamplerToTosaCustomPass()])
126-
nodes = list(edge_model.exported_program().graph.nodes)
165+
exported_program = edge_model.exported_program()
166+
nodes = list(exported_program.graph.nodes)
127167

128168
custom_node = next(
129169
node for node in nodes if node.target == exir_ops.backend.tosa.CUSTOM.default
@@ -145,7 +185,8 @@ def test_rewrite_grid_sampler_to_tosa_custom_no_target_uses_sampler_for_c4():
145185
edge_model = to_edge(export(model, example_inputs))
146186
with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+FP")):
147187
edge_model = edge_model.transform([RewriteGridSamplerToTosaCustomPass()])
148-
nodes = list(edge_model.exported_program().graph.nodes)
188+
exported_program = edge_model.exported_program()
189+
nodes = list(exported_program.graph.nodes)
149190

150191
custom_node = next(
151192
node for node in nodes if node.target == exir_ops.backend.tosa.CUSTOM.default
@@ -187,29 +228,62 @@ def test_quantized_grid_sampler_uses_int8_sampler_payload(
187228

188229
edge_model = to_edge(export(converted, example_inputs, strict=True))
189230
with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+FP+INT")):
231+
edge_model = edge_model.transform([FoldAndAnnotateQParamsPass()])
232+
grid_sampler_node = next(
233+
node
234+
for node in edge_model.exported_program().graph.nodes
235+
if node.target == exir_ops.edge.aten.grid_sampler_2d.default
236+
)
237+
expected_grid_qparams = grid_sampler_node.meta["input_qparams"][1]
238+
expected_grid_scale = torch.tensor(
239+
[expected_grid_qparams.get_scale_per_tensor()], dtype=torch.float32
240+
)
241+
expected_grid_zero_point = torch.tensor(
242+
[expected_grid_qparams.get_zp_per_tensor()], dtype=torch.int32
243+
)
190244
edge_model = edge_model.transform(
191245
[
192-
FoldAndAnnotateQParamsPass(),
193246
InsertGridSamplerGridDequantPass(),
194-
RewriteGridSamplerToTosaCustomPass(),
247+
_RewriteGridSamplerToTosaCustomExportPass(),
195248
]
196249
)
197-
nodes = list(edge_model.exported_program().graph.nodes)
250+
exported_program = edge_model.exported_program()
251+
nodes = list(exported_program.graph.nodes)
198252

199253
custom_node = next(
200254
node for node in nodes if node.target == exir_ops.backend.tosa.CUSTOM.default
201255
)
202256
payload = decode_payload(custom_node.kwargs["implementation_attrs"])
203257
grid_input = custom_node.args[0][1]
204-
258+
grid_scale_input = custom_node.args[0][2]
259+
grid_zero_point_input = custom_node.args[0][3]
205260
assert payload["input_0_type"] == "Image"
206261
assert payload["input_0_vkformat"] == GRID_SAMPLER_2D_SAMPLER_INT8_VK_FORMAT
207262
assert payload["input_1_type"] == "Tensor"
208-
assert payload["input_1_vkformat"] == GRID_SAMPLER_2D_VK_FORMAT
263+
assert payload["input_1_vkformat"] == GRID_SAMPLER_2D_QUANTIZED_GRID_VK_FORMAT
264+
assert payload["input_2_type"] == "Tensor"
265+
assert payload["input_2_vkformat"] == "VK_FORMAT_R32_SFLOAT"
266+
assert payload["input_2_binding"] == 3
267+
assert payload["input_3_type"] == "Tensor"
268+
assert payload["input_3_vkformat"] == "VK_FORMAT_R32_SINT"
269+
assert payload["input_3_binding"] == 4
209270
assert payload["output_0_type"] == "Image"
210271
assert payload["output_0_vkformat"] == GRID_SAMPLER_2D_SAMPLER_INT8_VK_FORMAT
211-
assert grid_input.meta["val"].dtype == torch.float32
212-
assert grid_input.target in (
272+
assert payload["output_0_binding"] == 2
273+
assert grid_input.meta["val"].dtype == torch.int8
274+
assert grid_scale_input.op == "placeholder"
275+
assert grid_scale_input.meta["val"].dtype == torch.float32
276+
assert grid_scale_input.meta["val"].shape == expected_grid_scale.shape
277+
assert torch.equal(
278+
exported_program.constants[grid_scale_input.name], expected_grid_scale
279+
)
280+
assert grid_zero_point_input.op == "placeholder"
281+
assert grid_zero_point_input.meta["val"].dtype == torch.int32
282+
assert grid_zero_point_input.meta["val"].shape == expected_grid_zero_point.shape
283+
assert torch.equal(
284+
exported_program.constants[grid_zero_point_input.name], expected_grid_zero_point
285+
)
286+
assert grid_input.target not in (
213287
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
214288
exir_ops.edge.quantized_decomposed.dequantize_per_channel.default,
215289
)
@@ -220,6 +294,42 @@ def test_quantized_grid_sampler_uses_int8_sampler_payload(
220294
assert next(iter(custom_node.meta["output_qparams"].values())).qmax == 127
221295

222296

297+
def test_quantized_grid_sampler_dequantizes_grid_for_non_sampler_path():
298+
model = GridSampler2d().eval()
299+
model.interpolation_mode_ = 2
300+
example_inputs = (
301+
torch.randn(1, 4, 8, 8),
302+
torch.rand(1, 4, 4, 2),
303+
)
304+
quantizer = VgfQuantizer(VgfCompileSpec("TOSA-1.0+INT"))
305+
quantizer.set_global(get_symmetric_quantization_config(is_per_channel=False))
306+
307+
exported = export(model, example_inputs, strict=True)
308+
prepared = prepare_pt2e(exported.module(), quantizer)
309+
prepared(*example_inputs)
310+
converted = convert_pt2e(prepared)
311+
312+
edge_model = to_edge(export(converted, example_inputs, strict=True))
313+
with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+FP+INT")):
314+
edge_model = edge_model.transform(
315+
[FoldAndAnnotateQParamsPass(), InsertGridSamplerGridDequantPass()]
316+
)
317+
318+
grid_sampler_node = next(
319+
node
320+
for node in edge_model.exported_program().graph.nodes
321+
if node.target == exir_ops.edge.aten.grid_sampler_2d.default
322+
)
323+
grid_input = grid_sampler_node.args[1]
324+
325+
assert (
326+
grid_input.target
327+
== exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default
328+
)
329+
assert grid_input.meta["val"].dtype == torch.float32
330+
assert 1 not in grid_sampler_node.meta["input_qparams"]
331+
332+
223333
def test_quantized_grid_sampler_rejects_dequantized_grid_with_int8_image_payload():
224334
model = GridSampler2d().eval()
225335
example_inputs = (

0 commit comments

Comments
 (0)