Skip to content

Commit fb60708

Browse files
authored
Qualcomm AI Engine Direct - accept an ExportedProgram in to_edge_tran… (#23025)
…sform_and_lower_to_qnn to_edge_transform_and_lower_to_qnn only took a module and always re-exported it internally, so a caller that has already captured a program - with its own dynamic shapes, fx fixes or graph surgery - had no way to hand that program in and had to reimplement the transform/partition/lower sequence against backends.qualcomm._passes directly. Accept ExportedProgram (and Dict[str, ExportedProgram]) and skip the internal re-export for those entries. Module inputs are unchanged. cc @cbilgin
1 parent fb3fff7 commit fb60708

4 files changed

Lines changed: 121 additions & 13 deletions

File tree

‎backends/qualcomm/tests/rework/src/utils.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,3 +633,16 @@ def test(subtests):
633633
assert len(weight_fqs) > 0, "no int8-range weight FQ found for fp16a8w"
634634
# should complete without error
635635
__class__._get_converted_module(module, prepared, inputs)
636+
637+
638+
class LoweringWithExportedProgram:
639+
@staticmethod
640+
def test(compile_spec):
641+
from unittest import mock
642+
643+
module = _UtilsModel()
644+
inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8))
645+
exported = torch.export.export(module, inputs, strict=True)
646+
with mock.patch("torch.export.export", wraps=torch.export.export) as export_spy:
647+
to_edge_transform_and_lower_to_qnn(exported, None, compile_spec)
648+
export_spy.assert_not_called()

‎backends/qualcomm/tests/rework/utils/test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ def test_skip_node_quantizer(subtests, quantizer, compile_spec):
3939

4040
def test_qat(subtests):
4141
QAT.test(subtests) # noqa: F405
42+
43+
44+
def test_lowering_with_exported_program(compile_spec):
45+
LoweringWithExportedProgram.test(compile_spec) # noqa: F405

‎backends/qualcomm/tests/test_passes.py‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import unittest
2-
from unittest.mock import MagicMock
2+
from unittest.mock import MagicMock, patch
33

44
import torch
55
from executorch.backends.qualcomm._passes import (
@@ -783,6 +783,73 @@ def forward(self, x):
783783
if "QNN" in str(e) or "qnn" in str(e):
784784
self.skipTest(f"QNN SDK not available: {e}")
785785

786+
def test_lowering_accepts_exported_program_without_reexport(self):
787+
"""to_edge_transform_and_lower_to_qnn must lower a caller-supplied
788+
ExportedProgram as captured, never re-exporting it.
789+
790+
Callers that have already captured and patched a program - HF transformers'
791+
ExecuTorch exporter, for instance, which owns its own dynamic shapes and fx
792+
fixes - cannot express that through a module argument.
793+
794+
A shape assertion alone does not discriminate here: re-exporting
795+
exported.module() reproduces the original graph, so the contract is checked
796+
by spying on torch.export.export and requiring that lowering never calls it.
797+
"""
798+
799+
class RetraceSensitive(torch.nn.Module):
800+
def __init__(self):
801+
super().__init__()
802+
self.narrow = False
803+
804+
def forward(self, x):
805+
return torch.relu(x[:, :1] if self.narrow else x)
806+
807+
module = RetraceSensitive().eval()
808+
exported = torch.export.export(module, (torch.randn(1, 4),), strict=True)
809+
810+
compiler_specs = generate_qnn_executorch_compiler_spec(
811+
soc_model=QcomChipset.SM8650,
812+
backend_options=generate_htp_compiler_spec(use_fp16=True),
813+
)
814+
with patch("torch.export.export", wraps=torch.export.export) as export_spy:
815+
try:
816+
edge = to_edge_transform_and_lower_to_qnn(
817+
exported, None, compiler_specs
818+
)
819+
except RuntimeError as e:
820+
if "QNN" in str(e) or "qnn" in str(e):
821+
self.skipTest(f"QNN SDK not available: {e}")
822+
raise
823+
export_spy.assert_not_called()
824+
825+
# The lowered graph is the one that was handed in, not a fresh capture.
826+
output_node = edge.exported_program("forward").graph.output_node()
827+
self.assertEqual(tuple(output_node.args[0][0].meta["val"].shape), (1, 4))
828+
829+
def test_lowering_warns_when_inputs_ignored_for_exported_program(self):
830+
"""inputs and dynamic_shapes cannot affect an already-captured program, so
831+
supplying them alongside one must warn rather than silently drop them."""
832+
833+
class Tiny(torch.nn.Module):
834+
def forward(self, x):
835+
return torch.relu(x)
836+
837+
sample_input = (torch.randn(1, 4),)
838+
exported = torch.export.export(Tiny().eval(), sample_input, strict=True)
839+
compiler_specs = generate_qnn_executorch_compiler_spec(
840+
soc_model=QcomChipset.SM8650,
841+
backend_options=generate_htp_compiler_spec(use_fp16=True),
842+
)
843+
try:
844+
with self.assertWarnsRegex(UserWarning, "inputs ignored"):
845+
to_edge_transform_and_lower_to_qnn(
846+
exported, sample_input, compiler_specs
847+
)
848+
except RuntimeError as e:
849+
if "QNN" in str(e) or "qnn" in str(e):
850+
self.skipTest(f"QNN SDK not available: {e}")
851+
raise
852+
786853
def test_index_put_int64_value_not_quantized(self):
787854
"""QNN's IndexPut annotator must skip a non-float (int64) value arg.
788855

‎backends/qualcomm/utils/utils.py‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -347,10 +347,12 @@ def to_edge_transform_and_lower_to_qnn(
347347
module: Union[
348348
torch.nn.Module,
349349
torch.fx.GraphModule,
350+
ExportedProgram,
350351
Dict[str, torch.nn.Module],
351352
Dict[str, torch.fx.GraphModule],
353+
Dict[str, ExportedProgram],
352354
],
353-
inputs: Union[Tuple[torch.Tensor], Dict[str, Tuple[torch.Tensor]]],
355+
inputs: Optional[Union[Tuple[torch.Tensor], Dict[str, Tuple[torch.Tensor]]]],
354356
compiler_specs: Union[List[Any], Dict[str, List[Any]]],
355357
constant_methods: Optional[Dict[str, Any]] = None,
356358
dynamic_shapes: Optional[Dict] = None,
@@ -366,17 +368,20 @@ def to_edge_transform_and_lower_to_qnn(
366368
Transforms and lowers a given PyTorch module to the QNN backend.
367369
368370
Args:
369-
module (Union[torch.nn.Module, torch.fx.GraphModule,Dict[str, torch.nn.Module], Dict[str, torch.fx.GraphModule]]):
370-
The PyTorch module or fx.GraphModule to be transformed.
371-
inputs (Union[Tuple[torch.Tensor], Dict[str, Tuple[torch.Tensor]]]):
372-
The input tensors for the module.
371+
module (Union[torch.nn.Module, torch.fx.GraphModule, ExportedProgram, Dict[str, torch.nn.Module], Dict[str, torch.fx.GraphModule], Dict[str, ExportedProgram]]):
372+
The PyTorch module or fx.GraphModule to be transformed, or an already captured
373+
ExportedProgram to lower as-is.
374+
inputs (Optional[Union[Tuple[torch.Tensor], Dict[str, Tuple[torch.Tensor]]]]):
375+
The input tensors for the module. Ignored, and may be None, for graphs supplied
376+
as an ExportedProgram: the program already carries its own example inputs.
373377
compiler_specs (Union[List[Any], Dict[str, List[Any]]]):
374378
Compiler specifications for Qualcomm AI Engine Direct.
375379
constant_methods (Optional[Dict[str, Any]]):
376380
An optional dictionary mapping method names to constant values returned by those methods in eager mode.
377381
Often used to store configuration information on Edge models.
378382
dynamic_shapes (Optional[Dict]):
379-
Information about dynamic shapes.
383+
Information about dynamic shapes. Ignored for graphs supplied as an
384+
ExportedProgram, whose shape constraints were fixed at capture time.
380385
dep_table (Optional[Dict]):
381386
Dependency table for the transformation passes.
382387
passes_job (Optional[Union[OrderedDict, Dict[str, OrderedDict]]]):
@@ -473,12 +478,31 @@ def ensure_graph_specific_dict(value, graph_names):
473478
}
474479

475480
for graph_name, m in module.items():
476-
ep = torch.export.export(
477-
m,
478-
inputs[graph_name],
479-
dynamic_shapes=dynamic_shapes[graph_name],
480-
strict=True,
481-
)
481+
if isinstance(m, ExportedProgram):
482+
# Capture already happened; inputs and dynamic_shapes cannot influence a
483+
# finished program, so say so rather than silently dropping them.
484+
ignored = [
485+
name
486+
for name, value in (
487+
("inputs", inputs[graph_name]),
488+
("dynamic_shapes", dynamic_shapes[graph_name]),
489+
)
490+
if value is not None
491+
]
492+
if ignored:
493+
warnings.warn(
494+
f"{', '.join(ignored)} ignored for graph '{graph_name}': an "
495+
"ExportedProgram is lowered as captured.",
496+
stacklevel=2,
497+
)
498+
ep = m
499+
else:
500+
ep = torch.export.export(
501+
m,
502+
inputs[graph_name],
503+
dynamic_shapes=dynamic_shapes[graph_name],
504+
strict=True,
505+
)
482506
option = generate_qnn_executorch_option(compiler_specs[graph_name])
483507
python_options = flatbuffer_to_option(option)
484508
backend_type = python_options.backend_options.backend_type

0 commit comments

Comments
 (0)