Skip to content

Commit cef2de7

Browse files
authored
Let a backend share one delegate across calls of the same lowered module (#24127)
## What is wrong today A method can call the same lowered module several times, for example a denoising step that is lowered once and called 10 times in a loop. The emitter then writes one `BackendDelegate` per call. So at load time the runtime calls the backend's `init()` once per call and keeps every handle alive, even though all of them come from the same compiled blob. This wastes load time and memory. ## What this change does A backend can now return `PreprocessResult(..., share_handle_across_calls=True)`. Then every call of that lowered module in a method uses one `BackendDelegate`, so `init()` runs once. Each call still passes its own arguments. - It is off by default, so existing backends behave as before. Some backends keep argument pointers on the handle, so sharing is not safe for all of them. - A delegate that declares scratch memory is never shared, because scratch is planned per call and the runtime keeps it on the delegate. `Method::init` now rejects a program where such a delegate has more than one call site. It also checks every delegate index at load, not only at run time. - Only calls of one lowered module share a handle. Separate partitions keep separate delegates, even when their blobs are identical. - The flag is saved and loaded like `scratch_specs`, including through `exir.save` and `exir.load`, so the serde schema moves to version 5.6. The `.pte` file format does not change. - The demo test backend opts in. Before #14564, the emitter shared one delegate between calls with the same blob for every backend. Now it shares by lowered module, and only for backends that ask for it. ## What was tested - New tests cover one delegate for repeated calls with the same output as separate calls, the partitioner path, separate modules keeping their own delegates, save and load, the default, and scratch, including a hand-made program that the runtime rejects. The runtime test for scratch fails without the new runtime check. The load-time index check, calls inside control flow, the multi-method lowering path and the number of `init()` calls have no dedicated test. - On an NVIDIA Jetson, a TensorRT step that a method calls 10 times went from 10 delegates to 1 shared delegate: load time 435 ms to 74 ms, memory 1060 MiB to 284 MiB, with the same outputs and speed.
1 parent a519331 commit cef2de7

14 files changed

Lines changed: 381 additions & 28 deletions

‎docs/source/compiler-delegate-and-partitioner.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,36 @@ number in the program, lets the planner reuse the bytes for tensors whose
132132
lifetimes do not overlap the delegate call, and makes the requirement visible to
133133
the memory planning inspection tools.
134134

135+
### Sharing one handle across calls
136+
137+
A method can call the same lowered module more than once. For example, you lower
138+
a step once with `to_backend`, call it in a loop from a wrapper module, and
139+
export the wrapper again. By default each call gets its own handle (the object
140+
that `init()` returns), so `init()` runs once per call. A backend can ask for
141+
one handle for all of these calls:
142+
143+
```python
144+
return PreprocessResult(
145+
processed_bytes=blob,
146+
share_handle_across_calls=True,
147+
)
148+
```
149+
150+
Then `init()` runs once, and every call runs `execute()` on that handle with its
151+
own arguments. Set it only if `execute()` does all of the following, in every
152+
runtime version of the backend and with any runtime options:
153+
154+
- It reads its arguments again on every call.
155+
- It keeps nothing on the handle that is tied to one call or that changes later
156+
results, for example saved argument pointers or a cache that each call
157+
updates.
158+
- It writes outputs only into the output tensors it is given.
159+
160+
Sharing applies to calls of one lowered module. Separate partitions get separate
161+
lowered modules, so they keep separate handles even when their blobs are
162+
identical. A backend that declares scratch always gets one handle per call,
163+
because the runtime keeps scratch on the delegate and each call has its own.
164+
135165
## Backend Interfaces: Runtime Initialization and Execution
136166

137167
During the runtime, the compiled blob from the `preprocess` function will be

‎exir/backend/backend_api.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def to_backend(
125125
compile_specs=compile_specs,
126126
named_data_store_output=preprocess_result.data_store_output,
127127
scratch_specs=preprocess_result.scratch_specs,
128+
share_handle_across_calls=preprocess_result.share_handle_across_calls,
128129
)
129130
lowered_module.meta = {
130131
"debug_handle_map": preprocess_result.debug_handle_map
@@ -620,6 +621,7 @@ def lower_all_submodules_to_backend(
620621
compile_specs=compile_spec,
621622
named_data_store_output=preprocess_result.data_store_output,
622623
scratch_specs=preprocess_result.scratch_specs,
624+
share_handle_across_calls=preprocess_result.share_handle_across_calls,
623625
)
624626
lowered_module.meta = {
625627
"debug_handle_map": preprocess_result.debug_handle_map,

‎exir/backend/backend_details.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ class PreprocessResult:
7474
# in this order. They are not arguments of the delegate call.
7575
scratch_specs: Optional[List[DelegateScratchSpec]] = None
7676

77+
# Whether every call of this lowered module in a method may run on one
78+
# delegate handle, so the runtime initializes the backend once instead of
79+
# once per call. Set it only if execute(), in every runtime version of the
80+
# backend, reads its arguments again on every call, keeps nothing on the
81+
# handle that is tied to one call or that changes later results, and writes
82+
# outputs only into the output tensors it is given. Ignored when
83+
# scratch_specs is not empty, because the runtime keeps scratch on the
84+
# delegate and each call has its own.
85+
share_handle_across_calls: bool = False
86+
7787

7888
"""
7989
How to create a backend (for example, BackendWithCompilerDemo):

‎exir/backend/test/BUCK‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,12 @@ fbcode_target(_kind = runtime.python_test,
294294
"fbsource//third-party/pypi/hypothesis:hypothesis",
295295
":backend_with_compiler_demo",
296296
":demo_backend",
297+
":op_partitioner_demo",
297298
"//caffe2:torch",
298299
"//executorch/exir:lib",
299300
"//executorch/exir:schema",
301+
"//executorch/exir:tensor",
302+
"//executorch/exir/_serialize:lib",
300303
"//executorch/exir/backend:backend_api",
301304
"//executorch/exir/backend:compile_spec_schema",
302305
"//executorch/exir/tests:models",

‎exir/backend/test/backend_with_compiler_demo.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,6 @@ def preprocess(
139139
),
140140
debug_handle_map=debug_handle_map,
141141
_delegate_info_meta="test",
142+
# The runtime keeps no state and reads its arguments on every call.
143+
share_handle_across_calls=True,
142144
)

‎exir/backend/test/test_delegate_scratch.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ def preprocess(
9797
assert TWO_BUFFER_SIZES[1] % ALIGNMENT != 0
9898

9999

100+
@final
101+
class SharedHandleScratchTestBackend(BackendDetails):
102+
"""Asks for one handle across calls and for scratch, which cannot be shared."""
103+
104+
@staticmethod
105+
def preprocess(
106+
edge_program: ExportedProgram, compile_specs: List[CompileSpec]
107+
) -> PreprocessResult:
108+
return PreprocessResult(
109+
processed_bytes=b"shared-handle-scratch-backend-blob",
110+
scratch_specs=[DelegateScratchSpec(nbytes=TWO_BUFFER_SIZES[0])],
111+
share_handle_across_calls=True,
112+
)
113+
114+
100115
@final
101116
class TwoBufferScratchTestBackend(BackendDetails):
102117
"""Declares two buffers, to pin down ordering and independent sizing."""
@@ -584,14 +599,14 @@ def test_scratch_is_inserted_when_view_copies_are_kept(self):
584599
(delegate_call,) = _delegate_calls(plan)
585600
self.assertEqual(_scratch_sizes(delegate_call), [12 * BYTES_PER_ELEMENT])
586601

587-
def _lowered_single_delegate(self):
602+
def _lowered_single_delegate(self, backend_id="DelegateScratchTestBackend"):
588603
exported = torch.export.export(
589604
SingleDelegateModule().eval(), (torch.randn(3, 4), torch.randn(3, 4))
590605
)
591606
edge = to_edge_transform_and_lower(
592607
exported, compile_config=EdgeCompileConfig(_check_ir_validity=False)
593608
)
594-
return to_backend("DelegateScratchTestBackend", edge.exported_program(), [])
609+
return to_backend(backend_id, edge.exported_program(), [])
595610

596611
def test_lowered_module_emits_its_own_program_with_scratch(self):
597612
# program() and buffer() run their own small pipeline rather than
@@ -602,6 +617,33 @@ def test_lowered_module_emits_its_own_program_with_scratch(self):
602617
self.assertEqual(_scratch_sizes(delegate_call), [12 * BYTES_PER_ELEMENT])
603618
self.assertTrue(lowered.buffer())
604619

620+
def test_a_delegate_with_scratch_is_not_shared_across_calls(self):
621+
# The runtime resolves scratch onto delegates_[delegate_index], so each
622+
# call site that declares scratch needs its own entry even when the
623+
# backend allows one handle across calls.
624+
lowered = self._lowered_single_delegate("SharedHandleScratchTestBackend")
625+
626+
class TwoCalls(torch.nn.Module):
627+
def __init__(self):
628+
super().__init__()
629+
self.lowered = lowered
630+
631+
def forward(self, x, y):
632+
return self.lowered(self.lowered(x, y), y)
633+
634+
inputs = (torch.randn(3, 4), torch.randn(3, 4))
635+
plan = (
636+
exir.to_edge(torch.export.export(TwoCalls(), inputs))
637+
.to_executorch()
638+
.executorch_program.execution_plan[0]
639+
)
640+
641+
first, second = _delegate_calls(plan)
642+
self.assertEqual(len(plan.delegates), 2)
643+
self.assertNotEqual(first.delegate_index, second.delegate_index)
644+
self.assertEqual(_scratch_sizes(first), [TWO_BUFFER_SIZES[0]])
645+
self.assertEqual(_scratch_sizes(second), [TWO_BUFFER_SIZES[0]])
646+
605647
def test_emitting_without_the_pass_is_an_error_not_a_silent_drop(self):
606648
# Any emit path that skips the pass must fail. The count comes from the
607649
# graph, not the declaration, so otherwise the program ships without

‎exir/backend/test/test_lowered_backend_module.py‎

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7+
import copy
8+
import io
79
import operator
810
import unittest
911
from unittest.mock import patch
@@ -12,14 +14,17 @@
1214

1315
import torch
1416
from executorch import exir
15-
from executorch.exir import to_edge
17+
from executorch.exir import EdgeProgramManager, to_edge, to_edge_transform_and_lower
18+
from executorch.exir._serialize import _PTEFile, _serialize_pte_binary
1619
from executorch.exir.backend.backend_api import to_backend
1720
from executorch.exir.backend.compile_spec_schema import CompileSpec
1821
from executorch.exir.backend.test.backend_with_compiler_demo import (
1922
BackendWithCompilerDemo,
2023
)
2124
from executorch.exir.backend.test.demo_backend import DemoBackend
22-
from executorch.exir.schema import DelegateCall, Program
25+
from executorch.exir.backend.test.op_partitioner_demo import AddMulPartitionerDemo
26+
from executorch.exir.schema import DelegateCall, DelegateScratch, Program
27+
from executorch.exir.tensor import make_allocation_info
2328

2429
from executorch.extension.pybindings.portable_lib import ( # @manual
2530
_load_for_executorch_from_buffer,
@@ -107,6 +112,206 @@ def forward(self, x):
107112
torch.allclose(model_outputs[0], expected_res, atol=1e-03, rtol=1e-03)
108113
)
109114

115+
def _repeated_sin_calls(self, backend_id, compile_specs, num_calls):
116+
"""Lowers sin once and returns it with a module that calls it num_calls times."""
117+
118+
class SinModule(torch.nn.Module):
119+
def forward(self, x):
120+
return torch.sin(x)
121+
122+
model_inputs = (torch.ones(1),)
123+
lowered_sin_module = to_backend(
124+
backend_id,
125+
to_edge(export(SinModule(), model_inputs, strict=True)).exported_program(),
126+
compile_specs,
127+
)
128+
129+
class RepeatedCalls(torch.nn.Module):
130+
def __init__(self):
131+
super().__init__()
132+
self.sin = lowered_sin_module
133+
134+
def forward(self, x):
135+
for _ in range(num_calls):
136+
x = self.sin(x)
137+
return x
138+
139+
edge = to_edge(export(RepeatedCalls(), model_inputs, strict=True))
140+
return lowered_sin_module, edge, model_inputs
141+
142+
def _delegate_indices(self, plan):
143+
return [
144+
instruction.instr_args.delegate_index
145+
for instruction in plan.chains[0].instructions
146+
if isinstance(instruction.instr_args, DelegateCall)
147+
]
148+
149+
def test_calls_of_one_lowered_module_share_one_delegate(self):
150+
lowered_sin_module, edge, _ = self._repeated_sin_calls(
151+
BackendWithCompilerDemo.__name__,
152+
[CompileSpec("max_value", bytes([1]))],
153+
num_calls=3,
154+
)
155+
self.assertTrue(copy.deepcopy(lowered_sin_module).share_handle_across_calls)
156+
157+
plan = edge.to_executorch().executorch_program.execution_plan[0]
158+
self.assertEqual(len(plan.delegates), 1)
159+
self.assertEqual(self._delegate_indices(plan), [0, 0, 0])
160+
args = [
161+
tuple(instruction.instr_args.args)
162+
for instruction in plan.chains[0].instructions
163+
if isinstance(instruction.instr_args, DelegateCall)
164+
]
165+
self.assertEqual(len(set(args)), 3)
166+
167+
def test_shared_delegate_matches_separate_calls_at_runtime(self):
168+
lowered_sin_module, edge, model_inputs = self._repeated_sin_calls(
169+
BackendWithCompilerDemo.__name__,
170+
[CompileSpec("max_value", bytes([1]))],
171+
num_calls=3,
172+
)
173+
program = edge.to_executorch()
174+
self.assertEqual(len(program.executorch_program.execution_plan[0].delegates), 1)
175+
176+
expected = model_inputs[0]
177+
single_call = _load_for_executorch_from_buffer(lowered_sin_module.buffer())
178+
for _ in range(3):
179+
expected = single_call.forward([expected])[0]
180+
outputs = _load_for_executorch_from_buffer(program.buffer).forward(
181+
list(model_inputs)
182+
)
183+
self.assertTrue(torch.equal(outputs[0], expected))
184+
185+
def test_sharing_survives_a_serde_round_trip(self):
186+
_, edge, _ = self._repeated_sin_calls(
187+
BackendWithCompilerDemo.__name__,
188+
[CompileSpec("max_value", bytes([1]))],
189+
num_calls=2,
190+
)
191+
buffer = io.BytesIO()
192+
exir.save(edge.exported_program(), buffer)
193+
buffer.seek(0)
194+
195+
plan = (
196+
EdgeProgramManager(exir.load(buffer))
197+
.to_executorch()
198+
.executorch_program.execution_plan[0]
199+
)
200+
self.assertEqual(self._delegate_indices(plan), [0, 0])
201+
202+
def test_runtime_rejects_a_shared_delegate_with_scratch(self):
203+
_, edge, model_inputs = self._repeated_sin_calls(
204+
BackendWithCompilerDemo.__name__,
205+
[CompileSpec("max_value", bytes([1]))],
206+
num_calls=2,
207+
)
208+
209+
def delegate_calls(program):
210+
return [
211+
instruction.instr_args
212+
for instruction in program.execution_plan[0].chains[0].instructions
213+
if isinstance(instruction.instr_args, DelegateCall)
214+
]
215+
216+
program = edge.to_executorch().executorch_program
217+
scratch = [DelegateScratch(allocation=make_allocation_info(1, 0), size=4)]
218+
219+
def run(program):
220+
return _load_for_executorch_from_buffer(
221+
bytes(_serialize_pte_binary(pte_file=_PTEFile(program=program)))
222+
).forward(list(model_inputs))
223+
224+
# The same scratch on two separate delegates is valid, so the errors
225+
# below come from the sharing alone.
226+
separate = copy.deepcopy(program)
227+
delegates = separate.execution_plan[0].delegates
228+
delegates.append(copy.deepcopy(delegates[0]))
229+
delegate_calls(separate)[1].delegate_index = 1
230+
for call in delegate_calls(separate):
231+
call.scratch = scratch
232+
run(separate)
233+
234+
# The emitter never writes these, but another producer could.
235+
for calls_with_scratch in ([0, 1], [0], [1]):
236+
shared = copy.deepcopy(program)
237+
for index in calls_with_scratch:
238+
delegate_calls(shared)[index].scratch = scratch
239+
with self.assertRaisesRegex(RuntimeError, "error: 0x23"):
240+
run(shared)
241+
242+
def test_partitioned_module_shares_one_delegate_across_calls(self):
243+
class AddModule(torch.nn.Module):
244+
def forward(self, x):
245+
return x + x
246+
247+
model_inputs = (torch.ones(2, 2),)
248+
add = (
249+
to_edge_transform_and_lower(
250+
export(AddModule(), model_inputs, strict=True),
251+
partitioner=[AddMulPartitionerDemo()],
252+
)
253+
.exported_program()
254+
.module()
255+
)
256+
257+
class TwoCalls(torch.nn.Module):
258+
def __init__(self):
259+
super().__init__()
260+
self.add = add
261+
262+
def forward(self, x):
263+
return self.add(self.add(x))
264+
265+
plan = (
266+
to_edge(export(TwoCalls(), model_inputs, strict=False))
267+
.to_executorch()
268+
.executorch_program.execution_plan[0]
269+
)
270+
self.assertEqual(self._delegate_indices(plan), [0, 0])
271+
272+
def test_each_call_uses_the_delegate_of_its_own_module(self):
273+
class SinModule(torch.nn.Module):
274+
def forward(self, x):
275+
return torch.sin(x)
276+
277+
model_inputs = (torch.ones(1),)
278+
edge_sin = to_edge(export(SinModule(), model_inputs, strict=True))
279+
compile_specs = [CompileSpec("max_value", bytes([1]))]
280+
first, second = (
281+
to_backend(
282+
BackendWithCompilerDemo.__name__,
283+
edge_sin.exported_program(),
284+
compile_specs,
285+
)
286+
for _ in range(2)
287+
)
288+
289+
class TwoModules(torch.nn.Module):
290+
def __init__(self):
291+
super().__init__()
292+
self.first = first
293+
self.second = second
294+
295+
def forward(self, x):
296+
return self.first(self.second(self.first(x)))
297+
298+
program = (
299+
to_edge(export(TwoModules(), model_inputs, strict=True))
300+
.to_executorch()
301+
.executorch_program
302+
)
303+
# Identical blobs are stored once, but separate modules keep separate
304+
# delegates, and a repeated call goes back to its own module's delegate.
305+
self.assertEqual(len(program.backend_delegate_data), 1)
306+
self.assertEqual(self._delegate_indices(program.execution_plan[0]), [0, 1, 0])
307+
308+
def test_calls_keep_their_own_delegate_unless_the_backend_allows_sharing(self):
309+
_, edge, _ = self._repeated_sin_calls(DemoBackend.__name__, [], num_calls=2)
310+
311+
plan = edge.to_executorch().executorch_program.execution_plan[0]
312+
self.assertEqual(len(plan.delegates), 2)
313+
self.assertEqual(self._delegate_indices(plan), [0, 1])
314+
110315
def test_emit_lowered_backend_module(self):
111316
module_list = [
112317
models.Emformer(),

0 commit comments

Comments
 (0)