Skip to content

Let a backend share one delegate across calls of the same lowered module - #24127

Merged
shoumikhin merged 1 commit into
pytorch:mainfrom
shoumikhin:share-delegate-across-calls
Oct 27, 2026
Merged

shoumikhin merged 1 commit into
pytorch:mainfrom
shoumikhin:share-delegate-across-calls

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

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.

@shoumikhin shoumikhin added the release notes: exir Changes to any dialects and passes on these dialects, such as memory planning label Oct 23, 2026
Copilot AI balanced review requested due to automatic review settings October 23, 2026 17:47
@pytorch-bot

pytorch-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24127

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit 8fcaa95 with merge base 4db778e (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 23, 2026
Copilot AI balanced review requested due to automatic review settings October 23, 2026 18:39
@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from dc8afe1 to 65fe314 Compare October 23, 2026 18:39

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from 65fe314 to dc84b5a Compare October 23, 2026 19:06
Copilot AI balanced review requested due to automatic review settings October 23, 2026 19:06

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 19:47
@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from dc84b5a to 897a1e6 Compare October 23, 2026 19:47

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@JacobSzwejbka

Copy link
Copy Markdown
Contributor

why does the TRT backend not just recognize that its already loaded this blob before?

@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from 897a1e6 to d617bc5 Compare October 23, 2026 19:55
Copilot AI balanced review requested due to automatic review settings October 23, 2026 19:55

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from d617bc5 to 8dc9eec Compare October 23, 2026 21:57
Copilot AI balanced review requested due to automatic review settings October 23, 2026 21:57

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@JacobSzwejbka It could, but deciding this at export time is simpler and safer. The blob is stored once in the .pte, but each delegate loads it on its own, and with the file or mmap loader each init gets its own copy or mapping, so the pointer does not identify the blob. So a backend cache would need a hash of the blob (or an ID written at export), a reference count across destroy(), and a lock, since a cache shared by several methods or programs can be used from more than one thread. Every backend that wants this would repeat that work, and the file loader would still read the whole blob once for each call when the method loads. The emitter already knows that these calls use one lowered module and run one after another in one method, so it can share one handle before anything is loaded.

@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from 8dc9eec to dfba074 Compare October 26, 2026 04:30
Copilot AI balanced review requested due to automatic review settings October 26, 2026 04:30

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from dfba074 to c08cf8c Compare October 26, 2026 05:17
Copilot AI balanced review requested due to automatic review settings October 26, 2026 05:17

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from c08cf8c to 48f6ea7 Compare October 26, 2026 05:19
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 3m 24s —— View job


This PR lets an opted-in backend share one BackendDelegate across all calls of a lowered module, and nothing blocks merging. The review found four minor problems: the backend contract in the docs is missing a compatibility rule, a flag combination is ignored without any warning, sharing after exir.load depends on an implicit side effect, and there is no C++ test for the new Method::init checks.

Detailed review

PR Review: #24127

Summary

When a backend opts in, the emitter now emits one BackendDelegate per lowered module instead of one per call. Method::init checks every delegate index at load and rejects a delegate that has scratch and more than one call site. I found no correctness bugs in the emitter keying, the runtime check, or the serde round trip. The problems below are a missing compatibility rule in the docs, one combination of options that is silently ignored, one serde dependency nobody states, and gaps in test coverage.

API Design

  • runtime/backend/interface.h:95-102, exir/backend/backend_details.py:77-84: the contract for a shared handle is incomplete.
    • Nothing in the .pte says a handle is shared, so the runtime BackendInterface cannot detect it or refuse it.
    • So share_handle_across_calls=True is a promise about every runtime build of that backend that might load the program, not only the current one. Example: a newer AOT release opts in, and an older runtime build of the backend keeps per-call state on the handle (cached arg pointers, I/O bindings). Every call site then runs on one handle and the results can be silently wrong.
    • The doc also says the handle is shared only "if the backend set share_handle_across_calls". That is not true for .pte files written before Dedup delegate blobs in emitter #14564, which shared delegates by blob for every backend, or for any other producer that writes the same delegate_index more than once. Method::init still accepts those, and only checks scratch.
    • Please state both points in the interface doc. Opting in commits all runtime versions of the backend to the contract, and execute() can receive a shared handle even when the flag was never set. If you want the backend to be able to refuse a shared handle in init(), the export side could also add a compile spec.

Testing

  • runtime/executor/method.cpp:1157-1176: the new runtime checks have no C++ test.
    • Both checks are tested only through pybindings in test_runtime_rejects_a_shared_delegate_with_scratch, and pytest-windows.ini deselects that test.
    • The general index bounds check is a behavior change for programs without scratch: an out-of-range index used to fail at execute with Internal and now fails at load with InvalidProgram. No test covers it.
    • No test asserts that init() actually runs once. The tests compare outputs and count plan.delegates.
    • A method_test.cpp case with a counting backend and a hand-built program would cover all three.
  • Two paths are also untested:
    • sharing when the same lowered module is called inside cond, map or while bodies. delegate_cache lives on the shared _EmitterState, and a while_loop denoising step is the main use case;
    • passing the flag through in lower_all_submodules_to_backend (exir/backend/backend_api.py:617-624).

Code Quality

  • exir/emit/_emitter.py:1517-1525: the flag is ignored without any warning when scratch is declared.
    • PreprocessResult(scratch_specs=[...], share_handle_across_calls=True) falls back to one delegate per call. The backend author gets N init() calls with no signal.
    • The docstring mentions this, but raising (or at least warning) when the LoweredBackendModule is built in backend_api.py would make the conflict visible.
  • exir/serde/serialize.py:630-643: sharing after exir.load depends on an implicit side effect.
    • deserialize_lowered_module runs once per call_delegate node. Each run builds a new LoweredBackendModule and calls register_module under the same name, so every get_attr ends up pointing at the last module registered.
    • The emitter cache is keyed by object identity, so sharing survives the round trip only because of that overwrite. If the deserializer ever uses unique names, sharing disappears without an error.
    • Caching the module by name inside deserialize_lowered_module would make this explicit. It would also stop the loader from deserializing N copies of original_module for N calls.

Recommendation

Approve. All findings are minor. The most important change is to write down the cross-version contract in interface.h. A C++ test for the new Method::init checks is strongly recommended.

When a method calls the same lowered module several times, for example a
denoising step that runs 10 times, the emitter writes one BackendDelegate
per call. The runtime then runs the backend's init() once per call and
keeps every resulting handle alive, even though all of them come from the
same blob.

A backend can now return PreprocessResult(share_handle_across_calls=True).
Every call of that lowered module in a method then points at one
BackendDelegate, so init() runs once and only one handle stays in memory.
Each call still passes its own arguments. Backends that do not set the flag
keep one delegate per call, as today. A delegate that declares scratch is
never shared, because the runtime stores scratch on the delegate and scratch
is planned per call. Method::init now rejects a program in which a delegate
with scratch has more than one call site, which the emitter never writes.

The flag is carried like scratch_specs, including through exir save and
load, which bumps the serde schema to 5.6. The docs and the execute() comment state what a backend must
guarantee before it sets it.

The demo test backend opts in, since its runtime keeps no state and reads
its arguments on every call.
@shoumikhin
shoumikhin force-pushed the share-delegate-across-calls branch from 48f6ea7 to 8fcaa95 Compare October 26, 2026 05:56
Copilot AI balanced review requested due to automatic review settings October 26, 2026 05:56

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

  • Contract: done. The execute() docs in interface.h, the PreprocessResult comment and the delegate docs now say the flag is a promise for every runtime version of the backend. interface.h also says that programs from ExecuTorch 0.7 and earlier can share a handle for any backend.
  • C++ test: one would be nice, but it needs a new hand-built test program and a counting backend, which is more than this change should carry. The Python test runs the real runtime and fails without the new check. The description lists what has no dedicated test, including calls inside control flow and the number of init() calls.
  • Scratch together with sharing: the flag means sharing is allowed, not required. With scratch, the emitter keeps one delegate per call, as the field's comment and the docs say.
  • Sharing after exir.load: test_sharing_survives_a_serde_round_trip checks exactly this, so a deserializer change that broke it would fail that test.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 3m 45s —— View job


This PR lets a backend that opts in share one BackendDelegate across every call of a lowered module, and nothing blocks merging. The one new problem is minor: exir.save now writes the new field into every lowered module, so exir.load on an older ExecuTorch fails on any such file.

Detailed review

PR Review: #24127

Summary

When a backend sets share_handle_across_calls, the emitter now emits one delegate per lowered module instead of one per call, and Method::init checks delegate indices and scratch call sites at load.

Three independent reviewers went through all 14 changed files, and I re-read the code behind each finding. None of us found a correctness bug in:

  • the emitter cache, which _EmitterState scopes to one method and which control-flow sub-emitters share;
  • the runtime index and scratch checks;
  • flag propagation through backend_api.py, __deepcopy__ and serde.

This review leaves out the points from the previous round that you already answered. Those are: scratch combined with sharing, serde aliasing, and the missing C++ test and init() call count.

Backward Compatibility

  • exir/serde/serialize.py:317, exir/serde/schema.py:418: files written by exir.save on this branch can't be read by older ExecuTorch.
    • Why: the field is written even when it is False. _dict_to_dataclass builds the dataclass with cls(**data) (exir/serde/export_serialize.py:2400). On a 5.5 reader that raises TypeError: __init__() got an unexpected keyword argument 'share_handle_across_calls'.
    • Why the version bump doesn't catch it: the schema check only compares the major version (export_serialize.py:2294), so a 5.6 file gets past it and then fails with that unclear error.
    • Scope: this affects every saved edge program that contains a lowered module, for any backend, not only backends that opt in.
    • Precedent: scratch_specs (5.5) did the same thing, so this follows existing practice. Still, the default case doesn't need it. Leaving the key out when it is False keeps files from non-sharing backends readable by older releases. Loading is already fine in the other direction: a missing key falls back to the dataclass default.
    • Low confidence: runtime/backend/interface.h:98 says "ExecuTorch 0.7 and earlier". The checkout is shallow, so we couldn't confirm which release first included Dedup delegate blobs in emitter #14564. Please check that this version boundary is right.

Recommendation

Approve. The serde point is minor and matches an existing pattern. It's worth fixing if older readers should keep loading files from backends that don't opt in.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks.

  • Older readers: right, a file saved with this change cannot be loaded by an older ExecuTorch. Adding scratch_specs in schema 5.5 did the same, and I followed that precedent instead of adding a special case for this one field.
  • Version boundary: checked. The v0.7.0 emitter still has the per-blob delegate cache that Dedup delegate blobs in emitter #14564 removed, and v1.0.0, the next release, does not.

const bool has_scratch = scratch != nullptr && scratch->size() > 0;
// Scratch is planned for one call site and kept on the delegate, so
// a delegate with scratch must not serve any other call site.
ET_CHECK_OR_RETURN_ERROR(

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.

AoT we should stop this from happening right?

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.

Yes, and it already does. The emitter only reuses a delegate when the backend set share_handle_across_calls and the lowered module has no scratch_specs:

if (
    lowered_module.share_handle_across_calls
    and not lowered_module.scratch_specs
):
    self.emitter_state.delegate_cache[lowered_module] = backend_delegate_index

So a program from to_executorch() never has a delegate with scratch and two call sites. test_a_delegate_with_scratch_is_not_shared_across_calls checks this: two calls give two delegates, each with its own scratch.

This runtime check is only for a .pte that did not come from our emitter, for example one edited by hand or written by another tool. Method::init already checks the program instead of trusting it, like the delegate index check just above. It costs one bool per delegate. Older programs that share a delegate (from before #14564) have no scratch, so they still load.

# outputs only into the output tensors it is given. Ignored when
# scratch_specs is not empty, because the runtime keeps scratch on the
# delegate and each call has its own.
share_handle_across_calls: bool = False

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.

How does this get actually set? My understanding is the partitioner will select N partitions each getting its own preprocessed bytes. How does one bytes then end up serving multiple calls?

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.

The backend sets it in preprocess(), the same way as scratch_specs:

return PreprocessResult(processed_bytes=blob, share_handle_across_calls=True)

to_backend then copies it onto the LoweredBackendModule.

You are right about partitions. N partitions give N lowered modules and N delegates, and this PR does not change that.

Sharing only happens when one lowered module (so one blob) is called more than once in a method. That happens when you lower a piece once and then call it from a bigger model, for example a denoising step in a loop:

step = to_edge_transform_and_lower(export(Step(), x), partitioner=[p]).exported_program().module()

class Denoise(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.step = step

    def forward(self, x):
        for _ in range(10):
            x = self.step(x)
        return x

Export unrolls the loop into 10 calls of the same lowered module. Before this PR the emitter wrote 10 delegates for them, all pointing at one blob, so init() ran 10 times. Now, when the backend sets the flag, it writes 1. test_partitioned_module_shares_one_delegate_across_calls covers this path, and test_calls_of_one_lowered_module_share_one_delegate covers the to_backend path.

If you export the whole loop and partition it instead, the partitioner sees the unrolled graph. If every op is supported, the partitioner usually merges the 10 steps into one partition. If a CPU op sits between steps, each step becomes its own partition with its own delegate. This PR keys sharing on the lowered module, not on the bytes, so it does not merge those.

@shoumikhin
shoumikhin merged commit cef2de7 into pytorch:main Oct 27, 2026
248 checks passed
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@pytorchbot cherry-pick --onto release/1.6 -c fixnewfeature

@pytorchbot

Copy link
Copy Markdown
Collaborator

Cherry picking #24127

The cherry pick PR is at #24207 and it is recommended to link a fixnewfeature cherry pick PR with an issue.

Details for Dev Infra team Raised by workflow job

Erik-Lundell pushed a commit to Erik-Lundell/executorch that referenced this pull request Oct 28, 2026
…ule (pytorch#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 pytorch#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: exir Changes to any dialects and passes on these dialects, such as memory planning

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants