Skip to content

Commit 257d4f2

Browse files
authored
fix(dynamo): keep the complex decomposition inside the TRT op set (#4580)
1 parent 2794f78 commit 257d4f2

2 files changed

Lines changed: 224 additions & 27 deletions

File tree

py/torch_tensorrt/dynamo/lowering/passes/complex_decomposition_adapter.py

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
1. capture the complex I/O signature before rewriting (drives the boundary
99
adapters in ``_compiler._insert_complex_io_adapters``);
1010
2. call upstream to expand every complex op into real ops on separate re/im;
11-
3. normalize the SoA seams (``aten.complex`` / ``aten.real`` / ``aten.imag``)
12-
into the interleaved ``[..., 2]`` layout the rest of the TRT flow expects,
13-
so the engine only ever sees real tensors (Option A in the RFC).
11+
3. put back what the retrace undid: upstream returns a module rebuilt from
12+
scratch, so the decomposition and constant-folding stages that ran earlier
13+
find their work partly reverted;
14+
4. normalize the seams (``aten.view_as_real`` / ``aten.complex`` /
15+
``aten.real`` / ``aten.imag``) into the interleaved ``[..., 2]`` layout the
16+
rest of the TRT flow expects, so the engine only ever sees real tensors
17+
(Option A in the RFC).
1418
1519
Gated by ``settings.use_complex_decomposition``; falls back to the legacy pass on
1620
older torch (the upstream API only exists in torch>=2.14.0.dev), or if
@@ -26,6 +30,10 @@
2630
from torch.fx import GraphModule
2731
from torch_tensorrt._features import has_complex_decomposition
2832
from torch_tensorrt.dynamo._settings import CompilationSettings
33+
from torch_tensorrt.dynamo.lowering.passes.constant_folding import constant_fold
34+
from torch_tensorrt.dynamo.lowering.passes.mark_constant_fold_exclusions import (
35+
mark_constant_fold_exclusions,
36+
)
2937
from torch_tensorrt.dynamo.lowering.passes.pass_utils import (
3038
clean_up_graph_after_modifications,
3139
)
@@ -91,7 +99,9 @@ def complex_decomposition_adapter(
9199
# call either fully succeeds or raises; it never partially mutates
92100
# anything, since it builds a new GraphModule rather than editing
93101
# the existing one in place).
94-
decomposed_gm = decompose_complex_in_graph(gm, flat_args)
102+
decomposed_gm = decompose_complex_in_graph(
103+
gm, flat_args, decompositions=_trt_decomposition_table(settings)
104+
)
95105
except Exception as e:
96106
# decompose_complex_in_graph is upstream, experimental PyTorch code
97107
# (torch._functorch._aot_autograd.complex_decomposition) with
@@ -107,11 +117,18 @@ def complex_decomposition_adapter(
107117
return complex_graph_detection(gm, settings)
108118
gm = decomposed_gm
109119

110-
# (3) Normalize SoA seams into the interleaved [..., 2] layout used by TRT.
120+
# (3) Fold constants again. The retrace builds the module from scratch, so
121+
# a constant that the constant-folding stage had already reduced to one
122+
# frozen tensor comes back as a live chain of ops reading it, and the
123+
# converters for those ops expect a TRT tensor rather than a weight.
124+
gm = mark_constant_fold_exclusions(gm, settings)
125+
gm = constant_fold(gm, settings)
126+
127+
# (4) Normalize the seams into the interleaved [..., 2] layout used by TRT.
111128
gm = _normalize_complex_boundary_for_trt(gm)
112129
gm = clean_up_graph_after_modifications(gm)
113130

114-
# (4) Re-attach the captured I/O signature onto the RETURNED module so
131+
# (5) Re-attach the captured I/O signature onto the RETURNED module so
115132
# _insert_complex_io_adapters can restore the complex boundary.
116133
gm.meta["complex_output_indices"] = complex_output_indices
117134
gm.meta["complex_input_names"] = complex_input_names
@@ -123,6 +140,29 @@ def complex_decomposition_adapter(
123140
return gm
124141

125142

143+
def _trt_decomposition_table(
144+
settings: CompilationSettings,
145+
) -> dict[Any, Any]:
146+
"""The op set the rest of the TRT flow expects to see.
147+
148+
``decompose_complex_in_graph`` re-traces through ``make_fx``, so the module
149+
it returns is built from whatever ``ComplexTensor`` dispatched to, not from
150+
the ops that survived the decomposition run at export time. Without a table
151+
that retrace re-introduces ops the flow has already decided to expand
152+
(``aten.stack``) or to drop (``aten.alias``), long after the stage that
153+
would have handled them, and the partitioner then cuts the graph around
154+
them. Handing the retrace the same table keeps the two in step.
155+
"""
156+
from torch_tensorrt.dynamo.lowering import get_decompositions
157+
158+
return get_decompositions(
159+
settings.enable_experimental_decompositions,
160+
settings.decompose_attention,
161+
settings.use_distributed_mode_trace,
162+
use_fp32_acc=settings.use_fp32_acc,
163+
)
164+
165+
126166
def _graph_has_complex(gm: GraphModule) -> bool:
127167
from torch_tensorrt.dynamo.utils import COMPLEX_DTYPES
128168

@@ -174,20 +214,30 @@ def _fake_flat_args(gm: GraphModule) -> list[torch.Tensor]:
174214

175215

176216
def _normalize_complex_boundary_for_trt(gm: GraphModule) -> GraphModule:
177-
"""Fold upstream's SoA seams into the interleaved ``[..., 2]`` real layout.
217+
"""Fold upstream's seams into the interleaved ``[..., 2]`` real layout.
178218
179-
Upstream leaves the graph in terms of separate re/im joined by
180-
``aten.complex(re, im)`` and unpacked by ``aten.real`` / ``aten.imag``. TRT
181-
has no converter for any of those. We:
219+
Every op below reinterprets a complex value as a pair of real halves, or
220+
the other way round, and TRT has no converter for any of them. Leaving one
221+
in the graph does not just fail an op check: the partitioner cuts the graph
222+
around it and the model comes back split into several engines with PyTorch
223+
blocks in between. We rewrite:
182224
225+
* ``view_as_real(z)`` -> the interleaved ``[..., 2]`` tensor it would
226+
have returned, for each producer ``z`` can have (see Pass A)
183227
* ``real(z)`` / ``imag(z)`` where ``z = complex(re, im)`` -> re / im
184228
(cancel the pack/unpack round-trip)
185-
* remaining ``aten.complex(re, im)`` -> ``stack([re, im], -1)`` tagged as
229+
* remaining ``aten.complex(re, im)`` -> ``cat([re, im], -1)`` tagged as
186230
complex-layout, so the [..., 2] tensor flows to the boundary adapters.
187231
188-
Confirmed against real decompose_complex_in_graph output: the boundary op
189-
set is aten.complex/real/imag as assumed above, unpacked via real/imag
190-
(not view_as_real).
232+
Which of these appear depends on where the complex region sits. A graph
233+
whose own inputs or outputs are complex meets upstream at ``aten.complex``,
234+
because that is what ``ComplexTensor`` packs with on the way out. A graph
235+
that is real on both ends but complex in the middle, as a rotary embedding
236+
is, meets it at ``view_as_real`` instead: the entry ``view_as_complex`` is
237+
left alone (its argument is a plain real tensor, so ``ComplexTensor`` never
238+
sees it), and ``ComplexTensor`` splits its halves with ``torch.real`` /
239+
``torch.imag``, which are composite ops that expand to ``view_as_real``
240+
plus ``select`` before any subclass can intercept them.
191241
"""
192242
g = gm.graph
193243
aten = torch.ops.aten
@@ -210,7 +260,43 @@ def _propagate_meta(node: torch.fx.Node) -> None:
210260
with fake_mode:
211261
node.meta["val"] = node.target(*arg_vals)
212262

213-
# Pass A: cancel real(complex(re,im)) / imag(complex(re,im)) round-trips.
263+
def _pack_interleaved(re: Any, im: Any) -> torch.fx.Node:
264+
"""Build the ``[..., 2]`` tensor holding ``re`` and ``im`` side by side.
265+
266+
Callers set the insertion point. ``cat`` of two unsqueezed halves
267+
rather than ``stack`` because the stage that expands ``stack`` has
268+
already run by the time this pass does.
269+
"""
270+
re_u = g.call_function(aten.unsqueeze.default, (re, -1))
271+
_propagate_meta(re_u)
272+
im_u = g.call_function(aten.unsqueeze.default, (im, -1))
273+
_propagate_meta(im_u)
274+
packed = g.call_function(aten.cat.default, ([re_u, im_u], -1))
275+
_propagate_meta(packed)
276+
return packed
277+
278+
# Pass A: rewrite view_as_real over each producer it can have.
279+
for node in list(g.nodes):
280+
if node.op != "call_function" or node.target != aten.view_as_real.default:
281+
continue
282+
src = node.args[0]
283+
if not isinstance(src, torch.fx.Node):
284+
continue
285+
if src.target == aten.view_as_complex.default:
286+
# view_as_complex consumes a trailing dimension of 2 and
287+
# view_as_real puts it back holding the same values, so the pair is
288+
# an identity on the real tensor that went in. Dropping it leaves
289+
# the halves to be picked out of that tensor directly.
290+
replacement = src.args[0]
291+
elif src.target == aten.complex.default:
292+
with g.inserting_before(node):
293+
replacement = _pack_interleaved(src.args[0], src.args[1])
294+
else:
295+
continue
296+
node.replace_all_uses_with(replacement)
297+
g.erase_node(node)
298+
299+
# Pass B: cancel real(complex(re,im)) / imag(complex(re,im)) round-trips.
214300
for node in list(g.nodes):
215301
if node.op != "call_function" or node.target not in (
216302
aten.real.default,
@@ -233,18 +319,12 @@ def _propagate_meta(node: torch.fx.Node) -> None:
233319
node.replace_all_uses_with(src.args[idx])
234320
g.erase_node(node)
235321

236-
# Pass B: turn surviving aten.complex(re, im) into stack([re, im], -1).
322+
# Pass C: turn surviving aten.complex(re, im) into the interleaved layout.
237323
for node in list(g.nodes):
238324
if node.op != "call_function" or node.target != aten.complex.default:
239325
continue
240-
re, im = node.args[0], node.args[1]
241326
with g.inserting_before(node):
242-
re_u = g.call_function(aten.unsqueeze.default, (re, -1))
243-
_propagate_meta(re_u)
244-
im_u = g.call_function(aten.unsqueeze.default, (im, -1))
245-
_propagate_meta(im_u)
246-
packed = g.call_function(aten.cat.default, ([re_u, im_u], -1))
247-
_propagate_meta(packed)
327+
packed = _pack_interleaved(node.args[0], node.args[1])
248328
packed.meta["is_complex_layout"] = True
249329
node.replace_all_uses_with(packed)
250330
g.erase_node(node)

tests/py/dynamo/lowering/test_complex_decomposition_adapter.py

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
These tests exercise the TRT-specific glue around PyTorch's upstream complex
44
decomposition -- they do NOT require a GPU or a TRT build:
55
6-
* _normalize_complex_boundary_for_trt: folds the aten.complex / real / imag
7-
seams that decompose_complex_in_graph leaves behind into the interleaved
8-
[..., 2] real layout the rest of the TRT flow expects.
6+
* _normalize_complex_boundary_for_trt: folds the aten.view_as_real /
7+
aten.complex / real / imag seams that decompose_complex_in_graph leaves
8+
behind into the interleaved [..., 2] real layout the rest of the TRT flow
9+
expects.
910
* the torch-version feature gate: when the upstream API is unavailable the
1011
adapter must fall back to the legacy complex_graph_detection pass.
1112
"""
@@ -81,6 +82,118 @@ def forward(self, re, im):
8182
assert torch.equal(out[..., 1], im)
8283

8384

85+
def test_normalize_folds_view_as_real_of_view_as_complex():
86+
"""view_as_real(view_as_complex(x)) -> x, and both nodes are erased."""
87+
88+
class M(torch.nn.Module):
89+
def forward(self, x):
90+
z = torch.ops.aten.view_as_complex.default(x)
91+
return torch.ops.aten.view_as_real.default(z)
92+
93+
from torch_tensorrt.dynamo.lowering.passes.pass_utils import (
94+
clean_up_graph_after_modifications,
95+
)
96+
97+
x = torch.randn(3, 2)
98+
gm = torch.fx.symbolic_trace(M())
99+
100+
gm = cda._normalize_complex_boundary_for_trt(gm)
101+
# The view_as_complex node is left without users, and the adapter runs this
102+
# right after the pass to collect nodes in exactly that state.
103+
gm = clean_up_graph_after_modifications(gm)
104+
targets = _targets(gm)
105+
106+
assert aten.view_as_real.default not in targets
107+
assert aten.view_as_complex.default not in targets
108+
assert torch.equal(gm(x), x)
109+
110+
111+
def test_normalize_folds_view_as_real_of_complex():
112+
"""view_as_real(complex(re,im)) -> the interleaved [..., 2] layout.
113+
114+
Without this, Pass C would rewrite the complex() node into that layout and
115+
leave a view_as_real reading it, which is no longer a complex tensor.
116+
"""
117+
118+
class M(torch.nn.Module):
119+
def forward(self, re, im):
120+
z = torch.ops.aten.complex.default(re, im)
121+
return torch.ops.aten.view_as_real.default(z)
122+
123+
re = torch.randn(3)
124+
im = torch.randn(3)
125+
gm = torch.fx.symbolic_trace(M())
126+
127+
gm = cda._normalize_complex_boundary_for_trt(gm)
128+
targets = _targets(gm)
129+
130+
assert aten.view_as_real.default not in targets
131+
assert aten.complex.default not in targets
132+
133+
out = gm(re, im)
134+
assert out.shape == (3, 2)
135+
assert torch.equal(out[..., 0], re)
136+
assert torch.equal(out[..., 1], im)
137+
138+
139+
# ---------------------------------------------------------------------------
140+
# whole pass over a graph that is real at both ends and complex in the middle
141+
# ---------------------------------------------------------------------------
142+
143+
144+
@pytest.mark.skipif(
145+
not cda.has_complex_decomposition(),
146+
reason="decompose_complex_in_graph requires torch>=2.14.dev",
147+
)
148+
def test_interior_complex_region_leaves_nothing_trt_cannot_convert():
149+
"""A rotary-embedding shaped graph must come out with real ops only.
150+
151+
The complex region here sits in the middle: the graph takes a real tensor
152+
and returns one, so upstream meets it at view_as_real rather than at
153+
aten.complex, and its retrace also brings back ops the decomposition and
154+
constant-folding stages had already dealt with. Any of those left behind
155+
is not just an unconverted op: the partitioner cuts the graph around it and
156+
the model comes back as several engines with PyTorch blocks in between.
157+
"""
158+
from torch_tensorrt.dynamo._settings import CompilationSettings
159+
from torch_tensorrt.dynamo.lowering import get_decompositions
160+
161+
class M(torch.nn.Module):
162+
def __init__(self):
163+
super().__init__()
164+
self.register_buffer(
165+
"freqs",
166+
torch.complex(torch.randn(1, 2, 1, 1), torch.randn(1, 2, 1, 1)),
167+
)
168+
169+
def forward(self, x):
170+
z = torch.view_as_complex(x.reshape(1, 2, 1, 1, 2))
171+
return torch.view_as_real(z * self.freqs)
172+
173+
model = M().eval()
174+
x = torch.randn(1, 2, 1, 2)
175+
expected = model(x)
176+
177+
gm = (
178+
torch.export.export(model, (x,))
179+
.run_decompositions(get_decompositions())
180+
.module()
181+
)
182+
out = cda.complex_decomposition_adapter(gm, CompilationSettings())
183+
184+
targets = set(_targets(out))
185+
for target in (
186+
aten.view_as_complex.default,
187+
aten.view_as_real.default,
188+
aten.complex.default,
189+
aten.alias.default,
190+
aten.stack.default,
191+
):
192+
assert target not in targets, f"{target} survived the pass"
193+
194+
torch.testing.assert_close(out(x), expected)
195+
196+
84197
# ---------------------------------------------------------------------------
85198
# feature gate / fallback
86199
# ---------------------------------------------------------------------------
@@ -172,7 +285,11 @@ def fake_decompose(g, flat_args, *a, **k):
172285
)
173286
monkeypatch.setattr(cda, "has_complex_decomposition", lambda: True)
174287

175-
out = cda.complex_decomposition_adapter(gm, settings=object())
288+
# Real settings, not a stub: the adapter reads the decomposition options off
289+
# them to build the table it hands to the retrace.
290+
from torch_tensorrt.dynamo._settings import CompilationSettings
291+
292+
out = cda.complex_decomposition_adapter(gm, settings=CompilationSettings())
176293

177294
# Functional: we did NOT get the original module back...
178295
assert out is not gm

0 commit comments

Comments
 (0)