Skip to content

Commit e934dec

Browse files
committed
fix: bind ExecuTorch TRT engine inputs in delegate arg order
## The problem When a model with more than one input is compiled to a TensorRT engine and run through ExecuTorch, the inputs can be delivered to the wrong engine slots. The failure looks like this at runtime: input 'timesteps' rank 4 does not match profile rank 1 The model is fine -- the wrong tensor is simply arriving at that slot. Why it happens: the ExecuTorch TensorRT runtime backend matches inputs to engine bindings *by position*. Argument `i` handed to `execute()` is fed to `input_binding_names[i]`. There are no names attached to the incoming tensors at runtime; the backend just trusts that the i-th tensor belongs to the i-th binding name. But those two lists are built by two different stages that never coordinate: 1. `input_binding_names` is recorded during TensorRT conversion, in the order the interpreter visits the subgraph placeholders (`_TRTInterpreter.placeholder`). 2. The tensors passed to `executorch_call_delegate` at runtime are ordered by ExecuTorch's own lowering: its FX fuser sorts the delegate's placeholder inputs by top-level graph order, which can be a different permutation. For a single-input engine there is nothing to reorder, so it always works. For a multi-input engine whose inputs get laid out differently after lowering, the two orders disagree and a tensor lands in the wrong binding -- which either fails loudly (rank/shape mismatch, as above) or, if two inputs happen to share a shape, silently produces wrong results. ## Alternatives considered - Match tensors to bindings by name at runtime. Not possible: the ExecuTorch delegate ABI hands `execute()` only positional values, with no names. Adding names would require changing the serialized blob format and the runtime ABI. - Match names to placeholders by name at serialization time. Does not work: the TensorRT binding names are the original semantic placeholder targets (e.g. `timesteps`, `position_ids`) while the lowered ExecuTorch placeholders are generic (`arg_0`, `arg_1`, ...). There is no reliable name correspondence. - Fix the order earlier, during TensorRT conversion/export. Does not stick: ExecuTorch performs another lowering/fusion pass afterward that can re-permute the placeholders, so any alignment done before that pass is undone. ## The solution Reorder `input_binding_names` inside the ExecuTorch backend `preprocess()` -- the first point where both the TensorRT binding order and the final delegate argument order are visible. The permutation is recovered by *node identity*, not by name: the engine node's first argument lists its input nodes in binding order, and each of those nodes is a placeholder in the lowered graph whose position is exactly the runtime argument slot it will occupy. So we sort the binding names by each input node's placeholder position. Single-input engines are naturally identity, so models that already worked are unaffected. The runtime is unchanged, and the per-binding optimization-profile bounds follow automatically because they are looked up by name at engine init (`getProfileShape`). Adds unit tests in `tests/py/dynamo/executorch/test_backend.py`: single-input identity, node-identity reorder, generic-name multi-input permutation, and a guard for a binding-count mismatch.
1 parent 2aac435 commit e934dec

2 files changed

Lines changed: 210 additions & 15 deletions

File tree

py/torch_tensorrt/executorch/backend.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,43 @@ def _parse_device_id(value: Any) -> int:
198198
return 0
199199

200200

201+
def _reorder_input_names_for_executorch(
202+
edge_program: ExportedProgram, input_names: List[str]
203+
) -> List[str]:
204+
"""Reorder TRT binding names into executorch_call_delegate argument order.
205+
206+
The runtime binds positionally (``execute()`` arg ``i`` -> input_binding_names
207+
``[i]``), but ExecuTorch fusion may permute the delegate placeholders relative
208+
to the TRT-submodule order that produced ``input_binding_names``. The names
209+
can't be matched (TRT names are semantic, lowered placeholders are generic
210+
``arg_N``), so recover the permutation by node identity: the engine node's
211+
first arg lists its input nodes in binding order, so sort the names by each
212+
node's slot among the graph placeholders (its runtime delegate-arg position).
213+
"""
214+
input_nodes = list(_get_engine_nodes_from_edge_program(edge_program)[0].args[0])
215+
if len(input_nodes) != len(input_names):
216+
raise ValueError(
217+
"TensorRT ExecuTorch backend: engine has "
218+
f"{len(input_names)} input binding names but {len(input_nodes)} "
219+
"engine input nodes; cannot establish a reliable binding order."
220+
)
221+
slot = {
222+
node: i
223+
for i, node in enumerate(
224+
n for n in edge_program.graph_module.graph.nodes if n.op == "placeholder"
225+
)
226+
}
227+
missing = [n for n in input_nodes if n not in slot]
228+
if missing:
229+
raise ValueError(
230+
"TensorRT ExecuTorch backend: engine inputs "
231+
f"{[n.name for n in missing]} are not delegate runtime placeholders; "
232+
"cannot determine their argument position."
233+
)
234+
order = sorted(range(len(input_nodes)), key=lambda i: slot[input_nodes[i]])
235+
return [input_names[i] for i in order]
236+
237+
201238
def _get_str(engine_info: List[Any], index: int, default: str = "") -> str:
202239
if index < 0 or index >= len(engine_info):
203240
return default
@@ -240,8 +277,9 @@ def preprocess(
240277
)
241278
elif not isinstance(serialized_engine, (bytes, bytearray)):
242279
engine_info[ENGINE_IDX] = bytes(serialized_engine)
243-
input_names = _split_binding_names(
244-
_get_str(engine_info, INPUT_BINDING_NAMES_IDX)
280+
input_names = _reorder_input_names_for_executorch(
281+
edge_program,
282+
_split_binding_names(_get_str(engine_info, INPUT_BINDING_NAMES_IDX)),
245283
)
246284
output_names = _split_binding_names(
247285
_get_str(engine_info, OUTPUT_BINDING_NAMES_IDX)

tests/py/dynamo/executorch/test_backend.py

Lines changed: 170 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
executorch = pytest.importorskip("executorch.exir")
66

77
import torch # noqa: E402
8+
from torch.export.graph_signature import InputKind # noqa: E402
89
from torch_tensorrt.dynamo.runtime._TorchTensorRTModule import ( # noqa: E402
910
DEVICE_IDX,
1011
ENGINE_IDX,
@@ -24,22 +25,82 @@
2425

2526

2627
class _SchemaTarget:
28+
"""Callable stand-in for a tensorrt:: op overload carrying a `_schema`.
29+
30+
fx `call_function` requires a callable target; the backend only reads
31+
`target._schema.name`, so a no-op callable with the schema attribute is
32+
enough to build a realistic engine node in a real fx graph.
33+
"""
34+
2735
def __init__(self, name):
2836
self._schema = SimpleNamespace(name=name)
37+
self.__name__ = name.replace("::", "_")
38+
39+
def __call__(self, *args, **kwargs): # pragma: no cover - never executed
40+
raise AssertionError("engine op target is not meant to be called")
41+
42+
43+
_ENGINE_OP = _SchemaTarget("tensorrt::no_op_placeholder_for_execute_engine")
44+
2945

46+
def _build_edge_program(
47+
engine_info,
48+
input_names=("x",),
49+
binding_order=None,
50+
):
51+
"""Construct a real fx-graph-backed edge program for the backend.
3052
31-
def _make_placeholder_node(*engine_info):
53+
The engine node's first arg is the ordered list of input placeholder NODES
54+
(mirroring torch_tensorrt's inline_trt_modules), in `binding_order` — the
55+
TRT binding order. The graph placeholders themselves are laid out in
56+
`input_names` order — the fusion-arranged runtime arg order. The backend must
57+
recover the permutation from node identity, not from names.
58+
59+
A bare `torch.fx.Graph` (not a GraphModule) is used so the nodes carry real
60+
fx identity — which is what the reorder logic keys on — without triggering
61+
GraphModule codegen of the fake engine op / raw engine tensor.
62+
"""
63+
if binding_order is None:
64+
binding_order = tuple(input_names)
65+
66+
graph = torch.fx.Graph()
67+
placeholders = {name: graph.placeholder(name) for name in input_names}
68+
engine_inputs = [placeholders[name] for name in binding_order]
69+
engine_node = graph.call_function(
70+
_ENGINE_OP,
71+
(engine_inputs, *engine_info),
72+
)
73+
graph.output((engine_node,))
74+
75+
graph_signature = SimpleNamespace(
76+
input_specs=[
77+
SimpleNamespace(kind=InputKind.USER_INPUT, arg=SimpleNamespace(name=name))
78+
for name in input_names
79+
]
80+
)
3281
return SimpleNamespace(
33-
op="call_function",
34-
target=_SchemaTarget("tensorrt::no_op_placeholder_for_execute_engine"),
35-
args=(["x"], *engine_info),
36-
name="trt_node",
82+
graph_module=SimpleNamespace(graph=graph),
83+
graph_signature=graph_signature,
84+
constants={},
3785
)
3886

3987

40-
def _make_edge_program(*nodes):
88+
def _make_multi_engine_program(engine_info):
89+
"""An edge program whose graph contains two engine nodes (invalid)."""
90+
graph = torch.fx.Graph()
91+
x = graph.placeholder("x")
92+
n1 = graph.call_function(_ENGINE_OP, ([x], *engine_info))
93+
n2 = graph.call_function(_ENGINE_OP, ([x], *engine_info))
94+
graph.output((n1, n2))
4195
return SimpleNamespace(
42-
graph_module=SimpleNamespace(graph=SimpleNamespace(nodes=list(nodes))),
96+
graph_module=SimpleNamespace(graph=graph),
97+
graph_signature=SimpleNamespace(
98+
input_specs=[
99+
SimpleNamespace(
100+
kind=InputKind.USER_INPUT, arg=SimpleNamespace(name="x")
101+
)
102+
]
103+
),
43104
constants={},
44105
)
45106

@@ -52,10 +113,7 @@ def _engine_tensor(payload: bytes) -> torch.Tensor:
52113
def test_get_engine_info_rejects_multiple_engine_nodes():
53114
engine_info = [""] * SERIALIZATION_LEN
54115
engine_info[ENGINE_IDX] = _engine_tensor(b"engine")
55-
edge_program = _make_edge_program(
56-
_make_placeholder_node(*engine_info),
57-
_make_placeholder_node(*engine_info),
58-
)
116+
edge_program = _make_multi_engine_program(engine_info)
59117

60118
with pytest.raises(RuntimeError, match="exactly 1 engine node"):
61119
_get_engine_info_from_edge_program(edge_program)
@@ -66,7 +124,7 @@ def test_preprocess_rejects_output_allocator():
66124
engine_info = [""] * SERIALIZATION_LEN
67125
engine_info[ENGINE_IDX] = _engine_tensor(b"engine")
68126
engine_info[REQUIRES_OUTPUT_ALLOCATOR_IDX] = "1"
69-
edge_program = _make_edge_program(_make_placeholder_node(*engine_info))
127+
edge_program = _build_edge_program(engine_info)
70128

71129
with pytest.raises(RuntimeError, match="output allocator"):
72130
TensorRTBackend.preprocess(edge_program, [])
@@ -79,7 +137,7 @@ def test_preprocess_serializes_engine_blob():
79137
engine_info[DEVICE_IDX] = "2%8%0%0%GPU"
80138
engine_info[INPUT_BINDING_NAMES_IDX] = "x"
81139
engine_info[OUTPUT_BINDING_NAMES_IDX] = "y"
82-
edge_program = _make_edge_program(_make_placeholder_node(*engine_info))
140+
edge_program = _build_edge_program(engine_info)
83141

84142
result = TensorRTBackend.preprocess(edge_program, [])
85143

@@ -90,3 +148,102 @@ def test_preprocess_serializes_engine_blob():
90148
assert metadata.device_id == 2
91149
assert [binding.name for binding in metadata.io_bindings] == ["x", "y"]
92150
assert [binding.is_input for binding in metadata.io_bindings] == [True, False]
151+
152+
153+
@pytest.mark.unit
154+
def test_preprocess_single_input_is_identity():
155+
# Single-input engines have zero ordering ambiguity: the one binding maps to
156+
# the one placeholder regardless of name (TRT name may be semantic, the
157+
# runtime placeholder generic). Must never reorder or reject.
158+
engine_info = [""] * SERIALIZATION_LEN
159+
engine_info[ENGINE_IDX] = _engine_tensor(b"engine-bytes")
160+
engine_info[INPUT_BINDING_NAMES_IDX] = "pixel_values"
161+
engine_info[OUTPUT_BINDING_NAMES_IDX] = "out"
162+
# Graph placeholder is generic ("arg_0"); the binding name differs entirely.
163+
edge_program = _build_edge_program(engine_info, input_names=("arg_0",))
164+
165+
_, metadata = deserialize_engine(
166+
TensorRTBackend.preprocess(edge_program, []).processed_bytes
167+
)
168+
assert [binding.name for binding in metadata.io_bindings] == [
169+
"pixel_values",
170+
"out",
171+
]
172+
173+
174+
@pytest.mark.unit
175+
def test_preprocess_reorders_by_node_identity_not_name():
176+
# TRT binding order is [second, first]; the engine node feeds those input
177+
# placeholders in that order. The graph lays the placeholders out as
178+
# [first, second] (the runtime delegate arg order). The backend must emit
179+
# the binding names permuted to runtime order: [first, second].
180+
engine_info = [""] * SERIALIZATION_LEN
181+
engine_info[ENGINE_IDX] = _engine_tensor(b"engine-bytes")
182+
engine_info[INPUT_BINDING_NAMES_IDX] = "second%first"
183+
engine_info[OUTPUT_BINDING_NAMES_IDX] = "out"
184+
edge_program = _build_edge_program(
185+
engine_info,
186+
input_names=("first", "second"),
187+
binding_order=("second", "first"),
188+
)
189+
190+
_, metadata = deserialize_engine(
191+
TensorRTBackend.preprocess(edge_program, []).processed_bytes
192+
)
193+
assert [binding.name for binding in metadata.io_bindings] == [
194+
"first",
195+
"second",
196+
"out",
197+
]
198+
assert [binding.is_input for binding in metadata.io_bindings] == [
199+
True,
200+
True,
201+
False,
202+
]
203+
204+
205+
@pytest.mark.unit
206+
def test_preprocess_generic_names_multi_input_permutation():
207+
# Realistic lowered case: TRT names are semantic, runtime placeholders are
208+
# generic arg_N and DO NOT match the TRT names. Reorder must still work via
209+
# node identity. Binding order [timesteps, prefix, x]; graph placeholder
210+
# (runtime) order is [arg_0=x, arg_1=timesteps, arg_2=prefix].
211+
engine_info = [""] * SERIALIZATION_LEN
212+
engine_info[ENGINE_IDX] = _engine_tensor(b"engine-bytes")
213+
engine_info[INPUT_BINDING_NAMES_IDX] = "timesteps%prefix%x"
214+
engine_info[OUTPUT_BINDING_NAMES_IDX] = "out"
215+
# runtime placeholder order: arg_0, arg_1, arg_2
216+
# semantic meaning of each: x, timesteps, prefix
217+
# engine feeds bindings in order: timesteps(arg_1), prefix(arg_2), x(arg_0)
218+
edge_program = _build_edge_program(
219+
engine_info,
220+
input_names=("arg_0", "arg_1", "arg_2"),
221+
binding_order=("arg_1", "arg_2", "arg_0"),
222+
)
223+
224+
_, metadata = deserialize_engine(
225+
TensorRTBackend.preprocess(edge_program, []).processed_bytes
226+
)
227+
# Binding names are carried with their node; sorted by runtime slot the
228+
# result is [name-fed-by-arg_0, name-fed-by-arg_1, name-fed-by-arg_2] =
229+
# [x, timesteps, prefix].
230+
assert [binding.name for binding in metadata.io_bindings] == [
231+
"x",
232+
"timesteps",
233+
"prefix",
234+
"out",
235+
]
236+
237+
238+
@pytest.mark.unit
239+
def test_preprocess_rejects_binding_count_mismatch():
240+
# Defensive invariant: number of binding names must equal number of engine
241+
# input nodes. Two names but one node -> refuse rather than corrupt.
242+
engine_info = [""] * SERIALIZATION_LEN
243+
engine_info[ENGINE_IDX] = _engine_tensor(b"engine-bytes")
244+
engine_info[INPUT_BINDING_NAMES_IDX] = "a%b"
245+
engine_info[OUTPUT_BINDING_NAMES_IDX] = "out"
246+
edge_program = _build_edge_program(engine_info, input_names=("arg_0",))
247+
248+
with pytest.raises(ValueError, match="input binding names"):
249+
TensorRTBackend.preprocess(edge_program, [])

0 commit comments

Comments
 (0)