Skip to content

Commit ae227c2

Browse files
authored
[Qualcomm] Scope ExpandBroadcastTensorShape rewrite to the broadcast node (#21583)
# [Qualcomm] Scope `ExpandBroadcastTensorShape` rewrite to the broadcast node (fix rank-0 mutable-buffer `to_executorch()` failure) > Draft for discussion with the Qualcomm backend team before opening. Standalone: one file, +6/-3. ## Summary `ExpandBroadcastTensorShape` reshapes a lower-rank broadcast input up to the broadcast op's output rank so `LayoutTransform` sees equal ranks. It redirected **every** user of that input to the rank-promoted view, instead of only the broadcast node it is processing. This over-broad rewrite breaks any *other* consumer of the same tensor — most visibly an in-place mutation of a rank-0 user input. ## Symptom A model that mutates a rank-0 tensor **input** in place (a scalar counter, `counter += N`) fails at `to_executorch()` on the QNN path: ``` RuntimeError: expand: the requested shape has too few dimensions ... at exir/passes/spec_prop_pass.py -> meta_copy_ -> expand_copy ``` XNNPACK / CoreML / generic `to_edge` are unaffected — `ExpandBroadcastTensorShape` is QNN-only, so only the QNN lowering produces the rank-mismatched graph. ## Root cause The scalar counter feeds two things: (1) a broadcast `add` (`arange(N) + counter`) and (2) the in-place `counter.add_`. The pass promotes `counter` to `(1,)` for the broadcast, then redirects **all** users to the `(1,)` view — including the mutation. ExecuTorch records the mutation as a `USER_INPUT_MUTATION`, so at the end it writes the value back into the input buffer via `copy_(counter, new_value)`. That write-back is now `copy_(() <- (1,))`; `SpecPropPass` re-traces it and `expand_copy` cannot shrink `(1,)` into `()` → the error above. Confirmed by graph dump: input placeholder `counter` = `()` vs the `USER_INPUT_MUTATION` output = `(1,)`; toggling this pass off makes both rank-0 and rank-1 models lower cleanly. ## Fix Redirect only the current broadcast node's input, not every user of `arg`: ```diff - users = list(arg.users.keys()) reshape_node = graph_module.graph.create_node( ... ) ... - for user in users: - user.replace_input_with(arg, reshape_node) + # Redirect ONLY the current broadcast node's input to the reshaped + # view, not every user of `arg`. + node.replace_input_with(arg, reshape_node) ``` The mutation and the input placeholder both stay rank-0 → write-back stays `copy_(() <- ())`. This also fixes a latent correctness bug: blanket replacement is wrong when one tensor feeds multiple broadcasts that require different output ranks. HTP's no-rank-0 requirement is still satisfied by the runtime `dims=[1]` promotion in `builders/node_visitor.py` (blob level), independent of this edge-graph rewrite. ## Reproduction ```python import torch from executorch.backends.qualcomm.serialization.qc_schema import QcomChipset from executorch.backends.qualcomm.utils.utils import ( generate_qnn_executorch_compiler_spec, generate_htp_compiler_spec, to_edge_transform_and_lower_to_qnn, ) class CounterModel(torch.nn.Module): def __init__(self): super().__init__() self.register_buffer("counter", torch.zeros((), dtype=torch.long)) # rank-0 def forward(self, x): self.counter.add_(x.shape[-1]) # in-place mutation of rank-0 input return x + self.counter # broadcast add -> triggers the pass m = CounterModel().eval() ex = (torch.randn(4),) ep = torch.export.export(m, ex) backend = generate_htp_compiler_spec(use_fp16=True) specs = generate_qnn_executorch_compiler_spec(soc_model=QcomChipset.SM8650, backend_options=backend) edge = to_edge_transform_and_lower_to_qnn(ep, ex, specs) edge.to_executorch() # BEFORE: RuntimeError (expand: too few dimensions); AFTER: OK ``` ## Testing - Repro above: rank-0 `to_executorch()` **FAILED → OK**; rank-1 counter still OK. - Stress cases (all pass): one operand feeding two broadcasts of different rank (each gets its own correctly-shaped `view_copy`); non-mutated rank-0 broadcast; tensor-valued increment. Negative control (revert to the all-users redirect) reproduces the crash → change is necessary and sufficient. - **TODO before merge:** graph-level unit test, a real-model QNN regression export, `lintrunner`. ## Questions for the Qualcomm team 1. Was the all-users rewrite intentional for any case (e.g. a shared reshape feeding several broadcasts), or is scoping to the single node always correct? I believe single-node is correct and strictly safer, but want your read. 2. The original all-users redirect also silently rebased **non-broadcast** consumers (e.g. a `relu`) of the same operand onto the rank-promoted view — a latent aliasing bug this scoping also fixes. Confirm that was never relied upon. (A rank>0 value written back into a rank-0 input is a separate, model-level mismatch no operand fix addresses — out of scope here.) 3. Relation to the transformers ExecuTorch-exporter QNN work: `StaticLayer.cumulative_length` is exactly this rank-0-counter pattern, so this is a real blocker for that flow. cc @cbilgin
1 parent 812c7f0 commit ae227c2

3 files changed

Lines changed: 208 additions & 25 deletions

File tree

backends/qualcomm/_passes/expand_broadcast_tensor_shape.py

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ def __init__(self):
2727
exir_ops.edge.aten.expand_copy.default,
2828
]
2929

30-
def traverse_broadcast_node(self, graph_module: torch.fx.GraphModule):
30+
def traverse_broadcast_node(
31+
self, graph_module: torch.fx.GraphModule, reshape_cache
32+
):
3133
for node in graph_module.graph.nodes:
3234
if node.target in self.broadcast_op_targets:
3335
for arg in node.args:
@@ -36,31 +38,43 @@ def traverse_broadcast_node(self, graph_module: torch.fx.GraphModule):
3638
input_rank = len(arg.meta["val"].shape)
3739
output_rank = len(node.meta["val"].shape)
3840
if input_rank != output_rank:
39-
with graph_module.graph.inserting_after(arg):
40-
new_rank = [1] * (output_rank - input_rank) + list(
41-
arg.meta["val"].shape
42-
)
43-
users = list(arg.users.keys())
44-
reshape_node = graph_module.graph.create_node(
45-
"call_function",
46-
exir_ops.edge.aten.view_copy.default,
47-
(arg, tuple(new_rank)),
48-
)
49-
# try skip dq_ops to get correct param node if applicable
50-
arg_meta = (
51-
arg.args[0].meta if arg.target in dq_ops else arg.meta
52-
)
53-
# meta needs to be copied elementwisely for fake-tensor
54-
# to be updated correctly and not affect meta of arg
55-
for k, v in arg_meta.items():
56-
reshape_node.meta[k] = v
57-
reshape_node.meta["val"] = reshape_node.meta["val"].reshape(
58-
new_rank
59-
)
60-
for user in users:
61-
user.replace_input_with(arg, reshape_node)
41+
new_rank = [1] * (output_rank - input_rank) + list(
42+
arg.meta["val"].shape
43+
)
44+
# Redirect ONLY the current broadcast node's input to the reshaped
45+
# view, not every user of `arg`. Rewriting all users leaks the rank
46+
# promotion into unrelated consumers (e.g. an in-place mutation of a
47+
# rank-0 user input), producing a rank-mismatched USER_INPUT_MUTATION
48+
# write-back that fails in to_executorch().
49+
# Dedupe reshapes by (arg, new_rank) so that multiple broadcast ops
50+
# sharing the same operand and target rank reuse a single view_copy
51+
# instead of each creating their own.
52+
cache_key = (arg, tuple(new_rank))
53+
reshape_node = reshape_cache.get(cache_key)
54+
if reshape_node is None:
55+
with graph_module.graph.inserting_after(arg):
56+
reshape_node = graph_module.graph.create_node(
57+
"call_function",
58+
exir_ops.edge.aten.view_copy.default,
59+
(arg, tuple(new_rank)),
60+
)
61+
# try skip dq_ops to get correct param node if applicable
62+
arg_meta = (
63+
arg.args[0].meta
64+
if arg.target in dq_ops
65+
else arg.meta
66+
)
67+
# meta needs to be copied elementwisely for fake-tensor
68+
# to be updated correctly and not affect meta of arg
69+
for k, v in arg_meta.items():
70+
reshape_node.meta[k] = v
71+
reshape_node.meta["val"] = reshape_node.meta[
72+
"val"
73+
].reshape(new_rank)
74+
reshape_cache[cache_key] = reshape_node
75+
node.replace_input_with(arg, reshape_node)
6276

6377
def call(self, graph_module: torch.fx.GraphModule):
64-
self.traverse_broadcast_node(graph_module)
78+
self.traverse_broadcast_node(graph_module, {})
6579
dead_code_elimination_pass(graph_module)
6680
return PassResult(graph_module, True)

backends/qualcomm/tests/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3049,3 +3049,12 @@ def forward(self, x):
30493049
input1 = torch.zeros(1)
30503050
selected_element = torch.select(input1, 0, 0)
30513051
return torch.add(x, selected_element)
3052+
3053+
3054+
# rank-0 input `counter` that is both broadcast (arange + counter) and mutated in place;
3055+
# exercises ExpandBroadcastTensorShape's rank promotion and the USER_INPUT_MUTATION write-back.
3056+
class BroadcastAndMutate(torch.nn.Module):
3057+
def forward(self, x, counter):
3058+
position = torch.arange(x.shape[-1]) + counter
3059+
counter.add_(x.shape[-1])
3060+
return x + position.to(x.dtype)

backends/qualcomm/tests/test_passes.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
AnnotateQuantAttrs,
77
ConvertBmmToMatmul,
88
ConvertMhaToSha,
9+
ExpandBroadcastTensorShape,
910
FoldQDQ,
1011
InsertIOQDQ,
1112
InsertReshapeForReduceOps,
@@ -23,6 +24,7 @@
2324
QnnExecuTorchBackendType,
2425
)
2526
from executorch.backends.qualcomm.tests.models import (
27+
BroadcastAndMutate,
2628
HardSigmoid,
2729
Reciprocal,
2830
TopKandIndex,
@@ -35,6 +37,7 @@
3537
from executorch.exir import EdgeCompileConfig, to_edge
3638
from executorch.exir.debug_handle_utils import DEBUG_HANDLE_KEY
3739
from executorch.exir.dialects._ops import ops as exir_ops
40+
from torch.export.exported_program import OutputKind
3841
from torch.library import Library
3942
from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e
4043

@@ -470,6 +473,163 @@ def test_decompose_hardsigmoid_backend_aware(self):
470473
f"hardsigmoid {'should' if should_decompose else 'should NOT'} be decomposed for {backend.name}",
471474
)
472475

476+
def test_expand_broadcast_preserves_rank0_input_mutation(self):
477+
"""A rank-0 user input that is BOTH broadcast (needs rank promotion) AND mutated in
478+
place must keep its USER_INPUT_MUTATION write-back on the rank-0 value.
479+
480+
Regression for the all-users rewrite: it redirected *every* consumer of the operand to
481+
the promoted (1,) view, including the in-place mutation, so the write-back became
482+
copy_(dst=(), src=(1,)) and to_executorch() failed with "expand: too few dimensions".
483+
Asserts the broadcast consumes the promoted view while the mutation path stays rank-0.
484+
"""
485+
add = exir_ops.edge.aten.add.Tensor
486+
view_copy = exir_ops.edge.aten.view_copy.default
487+
488+
exported = torch.export.export(
489+
BroadcastAndMutate().eval(),
490+
(torch.randn(1, 4), torch.tensor(0)),
491+
strict=True,
492+
)
493+
ep = to_edge(exported).exported_program()
494+
gm = ExpandBroadcastTensorShape()(ep.graph_module).graph_module
495+
496+
broadcast = [
497+
n for n in gm.graph.nodes if n.target == add and n.meta["val"].dim() == 1
498+
]
499+
self.assertTrue(broadcast)
500+
self.assertTrue(
501+
any(
502+
isinstance(a, torch.fx.Node) and a.target == view_copy
503+
for n in broadcast
504+
for a in n.args
505+
),
506+
"the broadcast operand should be rank-promoted to a view_copy",
507+
)
508+
509+
mutated = {
510+
spec.arg.name
511+
for spec in ep.graph_signature.output_specs
512+
if spec.kind == OutputKind.USER_INPUT_MUTATION
513+
}
514+
self.assertTrue(mutated)
515+
checked = 0
516+
for node in gm.graph.nodes:
517+
if node.name in mutated:
518+
checked += 1
519+
self.assertFalse(
520+
any(
521+
isinstance(a, torch.fx.Node) and a.target == view_copy
522+
for a in node.args
523+
),
524+
"rank promotion leaked into the USER_INPUT_MUTATION write-back path",
525+
)
526+
self.assertEqual(
527+
checked,
528+
len(mutated),
529+
"did not find every mutation-output node in the graph",
530+
)
531+
532+
def test_expand_broadcast_promotes_per_consumer(self):
533+
"""One tensor feeding two broadcasts of different output rank must be promoted
534+
independently per broadcast node, not shared. The all-users rewrite reshaped the
535+
operand once (to the first broadcast's rank) and redirected the second broadcast too,
536+
mis-shaping it."""
537+
add = exir_ops.edge.aten.add.Tensor
538+
view_copy = exir_ops.edge.aten.view_copy.default
539+
540+
class TwoBroadcasts(torch.nn.Module):
541+
def forward(self, a2d, a1d, b):
542+
return (
543+
a2d + b,
544+
a1d + b,
545+
) # b:(4,) -> rank-2 needs a view; rank-1 does not
546+
547+
exported = torch.export.export(
548+
TwoBroadcasts().eval(),
549+
(torch.randn(3, 4), torch.randn(4), torch.randn(4)),
550+
strict=True,
551+
)
552+
ep = to_edge(exported).exported_program()
553+
gm = ExpandBroadcastTensorShape()(ep.graph_module).graph_module
554+
555+
for node in gm.graph.nodes:
556+
if node.target != add:
557+
continue
558+
has_view = any(
559+
isinstance(a, torch.fx.Node) and a.target == view_copy
560+
for a in node.args
561+
)
562+
if node.meta["val"].dim() == 2:
563+
self.assertTrue(has_view, "rank-2 broadcast operand should be promoted")
564+
if node.meta["val"].dim() == 1:
565+
self.assertFalse(
566+
has_view, "rank-1 broadcast operand should not be promoted"
567+
)
568+
569+
def test_expand_broadcast_dedupes_shared_same_rank(self):
570+
"""One operand feeding two broadcasts of the SAME output rank must be promoted with a
571+
single shared view_copy, not one per broadcast node.
572+
573+
Regression for the per-node redirect (PR #21583): redirecting only the current node's
574+
input fixed the rank-0 mutation bug but created a separate view_copy for every broadcast
575+
consumer, so `a2d + b, a2d - b` (b:(4,)) produced two identical (1,4) reshapes. Deduping
576+
by (operand, new_rank) collapses them to one while keeping the rank-0 mutation path (whose
577+
operand is never rank-promoted) at rank-0 -- so this coexists with
578+
test_expand_broadcast_preserves_rank0_input_mutation.
579+
"""
580+
add = exir_ops.edge.aten.add.Tensor
581+
sub = exir_ops.edge.aten.sub.Tensor
582+
view_copy = exir_ops.edge.aten.view_copy.default
583+
584+
class TwoSameRankBroadcasts(torch.nn.Module):
585+
def forward(self, a2d, b):
586+
return a2d + b, a2d - b # b:(4,) promoted to (1,4) for both
587+
588+
exported = torch.export.export(
589+
TwoSameRankBroadcasts().eval(),
590+
(torch.randn(3, 4), torch.randn(4)),
591+
strict=True,
592+
)
593+
ep = to_edge(exported).exported_program()
594+
gm = ExpandBroadcastTensorShape()(ep.graph_module).graph_module
595+
596+
views = [n for n in gm.graph.nodes if n.target == view_copy]
597+
self.assertEqual(
598+
len(views), 1, "the shared operand should yield exactly one view_copy"
599+
)
600+
shared = views[0]
601+
for target in (add, sub):
602+
broadcast = [n for n in gm.graph.nodes if n.target == target]
603+
self.assertTrue(broadcast)
604+
self.assertTrue(
605+
all(shared in n.args for n in broadcast),
606+
"both same-rank broadcasts should consume the shared view_copy",
607+
)
608+
609+
exported = torch.export.export(
610+
BroadcastAndMutate().eval(),
611+
(torch.randn(1, 4), torch.tensor(0)),
612+
strict=True,
613+
)
614+
ep = to_edge(exported).exported_program()
615+
gm = ExpandBroadcastTensorShape()(ep.graph_module).graph_module
616+
617+
mutated = {
618+
spec.arg.name
619+
for spec in ep.graph_signature.output_specs
620+
if spec.kind == OutputKind.USER_INPUT_MUTATION
621+
}
622+
self.assertTrue(mutated)
623+
for node in gm.graph.nodes:
624+
if node.name in mutated:
625+
self.assertFalse(
626+
any(
627+
isinstance(a, torch.fx.Node) and a.target == view_copy
628+
for a in node.args
629+
),
630+
"dedupe must not rank-promote the USER_INPUT_MUTATION write-back",
631+
)
632+
473633

474634
if __name__ == "__main__":
475635
unittest.main()

0 commit comments

Comments
 (0)