Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backends/qualcomm/_passes/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fbcode_target(_kind = runtime.python_library,
"//executorch/backends/transforms:addmm_mm_to_linear",
"//executorch/backends/transforms:decompose_sdpa",
"//executorch/backends/transforms:fuse_batch_norm_with_conv",
"//executorch/backends/transforms:utils",
"//executorch/exir/backend:backend_details",
"//executorch/exir/backend:compile_spec_schema",
"//executorch/backends/qualcomm/quantizer:quantizer",
Expand Down
2 changes: 2 additions & 0 deletions backends/qualcomm/_passes/qnn_pass_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
from executorch.backends.transforms.decompose_sdpa import (
DecomposeScaledDotProductAttention,
)
from executorch.backends.transforms.utils import reserve_node_names
from executorch.exir import ExportedProgram
from executorch.exir.pass_manager import PassManager
from executorch.exir.program._program import (
Expand All @@ -100,6 +101,7 @@
class QnnPassManager(PassManager):

def _transform(self, graph_module: GraphModule):

@psiddh psiddh Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transform_for_to_edge_pipeline doesn't call this, so the name reservation doesn't cover it. Should we call the same reservation there as well ? (in a follow up PR)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking. I looked at this, and I think that pipeline is already safe. It always runs on an edge program, and to_edge rebuilds the graph with its own passes, so every node name is recorded again. I tested it with a model whose input is named "mul". Right after torch.export, the model's own mul node gets the name mul_1, and that name is not recorded. After to_edge, all names are recorded, and a new node named "mul" correctly becomes "mul_1". So I left it out, to keep the reservation only where it is needed.

reserve_node_names(graph_module)
return self(graph_module).graph_module

def _reset(self):
Expand Down
75 changes: 75 additions & 0 deletions backends/qualcomm/tests/test_passes.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,81 @@ def forward(self, x):
)
torch.testing.assert_close(gm(x), model(x))

def test_export_pipeline_keeps_node_names_unique(self):
# torch.export leaves the input name t_1 unrecorded, and renames
# input * 2 to mul_1 to make room for the input mul without recording
# that either. The decompositions would otherwise reuse those names.
class Autocast(torch.nn.Module):
def __init__(self):
super().__init__()
self.w = torch.nn.Parameter(torch.randn(8, 8))

def forward(self, x, t_1):
with torch.autocast("cpu", dtype=torch.bfloat16):
# The second transpose is named t_1 inside the region, and
# the decomposition copies it next to the input t_1.
y = x @ self.w.t() @ self.w.t()
return y.float() + t_1

class Attention(torch.nn.Module):
# input is a Python builtin name. It has to survive the reservation
# as it is, because the graph signature refers to inputs by name.
def forward(self, input, k, v, mul):
return (
torch.nn.functional.scaled_dot_product_attention(input * 2, k, v)
+ mul
)

cases = (
(
Autocast(),
(torch.randn(4, 8), torch.randn(4, 8)),
torch._higher_order_ops.wrap.wrap_with_autocast,
),
(
Attention(),
tuple(torch.randn(1, 2, 4, 8) for _ in range(4)),
torch.ops.aten.scaled_dot_product_attention.default,
),
)
no_collision = []
for model, inputs, decomposed_op in cases:
with self.subTest(model=type(model).__name__):
ep = torch.export.export(model.eval(), inputs)
self.assertIn(decomposed_op, [n.target for n in ep.graph.nodes])
ep = get_qnn_pass_manager_cls()().transform_for_export_pipeline(ep)
self.assertNotIn(decomposed_op, [n.target for n in ep.graph.nodes])
# ep.module() copies the graph and renames duplicates, so lint
# the graph the passes produced.
ep.graph.lint()
self.assertEqual(
[n.name for n in ep.graph.nodes if n.op == "placeholder"],
[spec.arg.name for spec in ep.graph_signature.input_specs],
)
torch.testing.assert_close(ep.module()(*inputs), model(*inputs))
ep = torch.export.export(model.eval(), inputs)
with patch(
"executorch.backends.qualcomm._passes.qnn_pass_manager."
"reserve_node_names"
) as reserve:
try:
get_qnn_pass_manager_cls()().transform_for_export_pipeline(
ep
).graph.lint()
except Exception as e:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a pass fails for a different reason, this shows as an assertIn failure, and we lose the original traceback. I suggest doing if "Node redefined name" not in str(e.__cause__ or e): raise instead

# The pass manager wraps the lint error in its own.
self.assertIn("Node redefined name", str(e.__cause__ or e))
else:
no_collision.append(type(model).__name__)
reserve.assert_called_once()
# A skip, not a failure: a torch update that removes the collision is fine.
if no_collision:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If only one case stops colliding, the whole test is skipped, but the other case still tests the helper. We can skip only when len(no_collision) == len(cases), or call skipTest inside each subTest.

self.skipTest(
f"{no_collision} no longer collide without reserve_node_names, so "
"those cases no longer test it; see "
"https://github.com/pytorch/pytorch/issues/202927"
)

def test_build_op_wrappers_returns_context_binary(self):
op_name = "ctx_loader_build"
ctx_bin = b"qnn_context_binary"
Expand Down
2 changes: 2 additions & 0 deletions backends/samsung/_passes/enn_pass_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
)
from executorch.backends.transforms.remove_clone_ops import RemoveCloneOpsTransform
from executorch.backends.transforms.remove_getitem_op import RemoveGetItemPass
from executorch.backends.transforms.utils import reserve_node_names

from executorch.exir import ExportedProgram
from executorch.exir.pass_manager import PassManager
Expand All @@ -44,6 +45,7 @@ def __init__(self) -> None:
super().__init__()

def _transform(self, graph_module: GraphModule):
reserve_node_names(graph_module)
return self(graph_module).graph_module

# before annotator, for quant models
Expand Down
13 changes: 13 additions & 0 deletions backends/transforms/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ def _get_fake_tensor_mode(graph: torch.fx.Graph, data: torch.Tensor) -> torch.Te
return FakeTensorConverter().from_real_tensor(example_fake_tensor.fake_mode, t=data)


def reserve_node_names(graph_module: torch.fx.GraphModule) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a torch-only unit test in backends/transforms/test/? E.g., export forward(self, x, mul): return x * 2 + mul, call reserve_node_names, add a node with create_node(..., name="mul_1"), and call graph.lint(). This will also cover the Samsung path, which has no test

"""
Records every node name of the top-level graph in its namespace, so that
nodes a pass adds there later get names that are not already taken.
"""
# TODO: Remove once torch.export records the names it gives inputs and the
# ops it renames for them (https://github.com/pytorch/pytorch/issues/202927).
# torch.fx has no public way to reserve a name.
namespace = graph_module.graph._graph_namespace
for node in graph_module.graph.nodes:
namespace._used_names.add(node.name)


def is_get_attr_node(node: torch.fx.Node) -> bool:
"""
Returns true if the given node is a get attr node for a tensor of the model
Expand Down
Loading