Skip to content

Isolate cached delegates by Module lifetime (#24462) - #24462

Merged
alirezamonfared merged 1 commit into
pytorch:mainfrom
alirezamonfared:export-D122851825
Nov 10, 2026
Merged

alirezamonfared merged 1 commit into
pytorch:mainfrom
alirezamonfared:export-D122851825

Conversation

@alirezamonfared

@alirezamonfared alirezamonfared commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

TL;DR

Give independently loaded ExecuTorch Modules independent QNN manager lifetimes while preserving context sharing between methods within one module. Key the delegate cache by (context signature, runtime allocator), count method references, and release complete manager state on final destruction or initialization failure.

Context

QnnExecuTorchBackend is process-global and caches expensive QnnManager instances across methods. A manager is placement-allocated from a module's runtime allocator; methods in that module share its allocator, but independent modules can load identical .pte bytes and be destroyed separately. D122851825 hardens this backend contract without changing FCB export contents or artifact selection.

Problem

A context signature alone also matched independent modules, allowing one module to destroy manager state still used by another. Calling Destroy() alone also omitted non-trivial manager member destruction, and competing initializers could publish or abandon duplicate candidates. A process-global HTP spill-fill handle did not represent the lifetime of its owning context.

Before: process cache[signature] -> Manager M (allocator A)

Module A methods ----> M <---- Module B, same program bytes
release Module A ----> M destroyed
Module B executes ---> stale context

Solution

Use a cache key containing the signature and allocator, with mutex-protected lookup, membership, publication and release. Each loaded method holds a reference; try_emplace preserves the first published manager and competing initializers destroy their unused candidate. A destructor-only ownership guard covers backend, context, DLC compilation and tensor allocation failures. Full QnnManager destruction also releases its input/output tensor wrappers and other non-trivial members; their storage was not released by calling Destroy() alone. On final release, erase cache state under the mutex and destroy the manager after unlocking. A missing forward entry is reported and its stale reverse entry removed safely.

After: cache[(signature, allocator A)] -> Manager A -> A's methods
       cache[(signature, allocator B)] -> Manager B -> B's methods

release A's last method -> unlock cache -> ~Manager A -> Destroy()
B's methods            -> Manager B remains alive

Scope HTP spill-fill groups by allocator and configured buffer size. Thread the group handle explicitly through context initialization instead of using a process-global handle. Serialize initialization within each group, retain its first manager until all members and pending initializations are gone, and destroy member contexts before the owner. The registry holds weak group references, preventing an ownership cycle and allowing cleanup followed by a fresh group.

weak registry[(allocator, spill-fill bytes)] -> Group
method entry -> member manager + Group
Group        -> first manager (HTP owner)

last member/pending initializer releases Group
  -> member context destroyed
  -> first context destroyed
  -> registry's weak reference expires

Keep the registered backend alive for the process lifetime so loaded modules cannot destroy QNN contexts through global cache teardown after allocators or the QNN library have finalized. Normal method destruction still releases manager state; the explicit defaulted backend destructor avoids conflicting empty-body formatting between local and OSS ClangFormat versions. Python lifecycle imports follow the ordering reported by OSS UFMT.

Compatibility and evidence limits

Separate live modules allocate separate QNN contexts, which can increase HTP memory use for duplicate blobs. Methods within one module still share a manager. FCB weight sharing inside an artifact does not supply shared runtime ownership across independent modules. Concurrent execution through one module and execution racing destruction require client coordination.

QAI Hub shard spill-fill cost

gen_pte_from_ctx_bin() calls update_spill_fill_size() over the shard programs before writing separate .pte files. The helper sets each participating context's max_sf_buf_size to the largest requirement in the export group. The QAI Hub Llama runner constructs a separate Module for each shard and loads every shard method; default Module construction gives each one a separate runtime allocator.

On device, HtpContextCustomConfig::CreateContextCustomConfig() registers an HTP group using firstGroupHandle and maxSpillFillBuffer. The former process-global handle allowed shard contexts to join one group. With this diff, the (allocator, max_sf_buf_size) key isolates those independently allocated Modules, so each shard starts its own spill-fill group even when the exported size matches.

N resident shard Modules, common configured S bytes (S > 0)

Before: shard 1 ... shard N -> one HTP spill-fill group -> S bytes
After:  shard i / allocator i -> group i              -> S bytes each

Expected configured reservation: S -> N * S bytes
Expected additional reservation: (N - 1) * S bytes

This is a projection of the spill-fill reservation for the described shard layout, not measured RSS, PSS, DSP heap usage or peak device memory. SDK allocation behavior, alignment and other context/model memory affect the observed totals. The existing host simulator tests do not issue target spill-fill group registration and cannot measure this device cost. No connected physical device was available for this follow-up, so the magnitude on a representative QAI Hub shard model remains unmeasured.

If device evidence shows a material cost, an opt-in cross-Module group ID is a possible follow-up. Such a group must keep its first context and allocator-owned manager storage alive until all shard Modules and pending initializations release it; changing the key to an ID alone would not satisfy that lifetime requirement. This diff does not introduce that opt-in.

Exported lifecycle tests cover both module destruction orders, multiple methods, cleanup/reload and sibling unload/reload on the HTP simulator, including multi-context mode with a nonzero spill-fill size. C++ tests check handle identity, allocator isolation, reference accounting, concurrent publication, stale-map cleanup, spill-fill ownership and process exit. Target SDK spill-fill registration still requires device verification; the recorded FCB AOT failure occurred before runtime module loading and does not establish FCB runtime compatibility.

Authored with AI assistance from Codex.

Differential Revision: D122851825

@pytorch-bot

pytorch-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

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

@linux-foundation-easycla

linux-foundation-easycla Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: alirezamonfared / name: Ali Monfared (8de26ed)

@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 Nov 2, 2026
@meta-codesync

meta-codesync Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

@alirezamonfared has exported this pull request. If you are a Meta employee, you can view the originating Diff in D122851825.

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@meta-codesync meta-codesync Bot changed the title Isolate cached delegates by Module lifetime Isolate cached delegates by Module lifetime (#24462) Nov 2, 2026
alirezamonfared added a commit to alirezamonfared/executorch that referenced this pull request Nov 2, 2026
Summary:
Pull Request resolved: pytorch#24462

## Summary

The QNN backend caches a `QnnManager` so methods in one ExecuTorch `Module` can share an expensive QNN context. The previous cache key used only the exported context signature, so independent modules loading equivalent programs could share a manager that was placement-allocated from one module's runtime allocator.

Key the cache by both context signature and runtime allocator. Methods in one module continue to share a context, while independent modules receive managers with independent lifetimes.

The change also:

- serializes cache lookup, insertion, membership checks, and removal;
- avoids allocating a manager on cache hits;
- removes cache state before running the non-trivial `QnnManager` destructor.

## Scope

This fixes sequential independent-module lifetime isolation. It does not change exported program contents, backend selection, or model behavior. Broader same-module concurrent execution and initialization races remain outside this change.

Differential Revision: D122851825
@psiddh psiddh added the module: qnn Issues related to Qualcomm's QNN delegate and code under backends/qualcomm/ label Nov 2, 2026
@psiddh

psiddh commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@alirezamonfared another suggestion while we're here: if InitBackend/InitContext/AllocateTensor fails below, we return without calling ~QnnManager(). Since init failed, ExecuTorch never calls destroy() for this handle, so the QNN backend/context handles leak. Matters for apps that retry or fall back to another backend. A scope guard right after the placement new, dismissed just before add_cached_delegate, would cover all three error paths. wdyt ?

@qti-horodnic qti-horodnic 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.

Thanks @alirezamonfared, left a few comments. As I mentioned in one of them, for modules that load the same blob, memory usage might increase since there will multiple contexts. Can you confirm that no FCB flow loads the same blob bytes into several Modules (e.g. weight-sharing bins split across .pte files)? I think it should be fine but I want to make sure there are no FCB regressions

std::lock_guard<std::mutex> guard(mutex_);
delegate_map_[signature] = handle;
delegate_map_rev_[handle] = signature;
delegate_map_[key] = handle;

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.

Two init calls with the same key can both miss the cache. The second call overwrites delegate_map_[key], but delegate_map_rev_ keeps both handles, so a later erase removes the wrong entry. Can we use try_emplace instead? And if the key exists, destroy the new manager and return the cached handle.

// destructible, we must call the destructor manually in destroy().
new (qnn_manager) QnnManager(qnn_executorch_options, qnn_context_blob);

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.

This is a pre-existing issue, but if InitBackend, InitContext, CompileDlc or AllocateTensor fails, ~QnnManager() doesn't run. Can we call qnn_manager->~QnnManager() before each error return or use a scope guard?


auto iter = delegate_map_.find(signature);
if (iter != delegate_map_.end()) {
const DelegateKey key{signature, runtime_allocator};

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.

Modules that load the same blob no longer share one QNN context, so HTP memory can increase. Also, destroy() now runs the full destructor, so the tensor wrappers are freed. Please mention this in the PR description.

QnnManager* qnn_manager = static_cast<QnnManager*>(handle);
qnn_manager->Destroy();
erase_cached_delegate(handle);
if (handle != nullptr && erase_cached_delegate(handle)) {

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.

Can we add a test for this branch? A unit test that loads the same .pte in 2 programs, deletes the first, and executes the second. Also can we check that 2 methods of one Module still get the same handle?

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.

Also, looking a bit more at this, the methods of one Module share a handle but nothing counts the users. So there can be case where unload_method("prefill") destroys the manager while decode is loaded, then decode fails with "DelegateHandle has been deleted". This is another existing issue which not caused by this pr, do you want to handle this here too? Maybe keeping a user count per cache entry and run ~QnnManager() only when the count is 0? This will make the solution more robust, but I'm fine if you want to keep the scope more contained.

// NOTE: Since we use placement new and since this type is not trivially
// destructible, we must call the destructor manually in destroy().
new (qnn_manager) QnnManager(qnn_executorch_options, qnn_context_blob);
// TODO: this is a temporal solution for multi-graph support, will be

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.

Minor: pre-existing typo, I guess should be temporary, not temporal.

alirezamonfared added a commit to alirezamonfared/executorch that referenced this pull request Nov 4, 2026
Summary:

The QNN backend caches a `QnnManager` so methods in one ExecuTorch `Module` can share a QNN context. A context signature alone also matched independent modules loading identical program bytes, even though the manager was placement-allocated from only one module's runtime allocator. Key the cache by both signature and allocator to give independent modules independent manager lifetimes.

Count each loaded method's reference to its cached manager and destroy the manager after its last method releases it. Protect cache operations with the mutex and use `try_emplace` to preserve the first manager published for a key. A competing initializer returns that manager and destroys its own candidate. A destructor-only ownership guard cleans up every initialization error path, including backend, context, DLC compilation, and tensor allocation failures.

Scope HTP spill-fill groups by runtime allocator and configured buffer size. Each context receives its group handle explicitly, replacing the process-global static handle. Keep the first group manager alive until all group members and pending initializations are gone, and free each member before the first context. Serialize initialization within a group so concurrent loads select one first context.

Separate live modules now allocate separate QNN contexts, which can increase HTP memory use when they load identical blobs. Sharing between methods within one module remains intact. Full `QnnManager` destruction also releases its tensor wrappers and other non-trivial members. FCB export contents and selection are unchanged; FCB weight sharing within an artifact does not provide shared runtime ownership across independent modules.

Export the lifecycle tests so they are visible in the PR. They cover both module destruction orders, same-module sharing, cleanup/reload, and method unload/reload on the HTP simulator. C++ tests cover allocator isolation, reference accounting, concurrent cache publication, spill-fill group isolation, cleanup/reload, and owner destruction order. The simulator lifecycle suite also runs with multi-context enabled and a nonzero spill-fill size; target SDK registration still requires device verification. Concurrent execution through one module and execution racing destruction still require client coordination.

Authored with AI assistance from Codex.

Differential Revision: D122851825




Signed-off-by: Ali Monfared <alimonfared@meta.com>
@psiddh

psiddh commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@alirezamonfared both CLA & linters need a fix

@psiddh

psiddh commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@claude can you review mine and Arik's comments on this PR and check if they all have been addressd

@claude

claude Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Claude finished @psiddh's task in 2m 15s —— View job


All of @qti-horodnic's and @psiddh's code comments are addressed in the current revision, but lintrunner is still failing (psiddh's last comment), and the CLA check still needs the author to link their commit email.

Comment-by-comment status

@qti-horodnic (review, CHANGES_REQUESTED)

# Comment Status Where
1 Use try_emplace so two racing inits don't overwrite delegate_map_ and leave delegate_map_rev_ out of sync; destroy the losing manager and return the cached handle ✅ Addressed add_cached_delegate uses try_emplace, adds to delegate_map_rev_ only on insert, and returns the winner's handle. init returns that handle. The loser's manager_guard goes out of scope and runs ~QnnManager(). ConcurrentPublicationKeepsOneManager tests this.
2 ~QnnManager() doesn't run if InitBackend/InitContext/CompileDlc/AllocateTensor fails ✅ Addressed std::shared_ptr<QnnManager> manager_guard(qnn_manager, destroy_manager) is created right after the placement new, so every early ET_CHECK_OR_RETURN_ERROR return destroys the manager. ~QnnManager() calls Destroy() (QnnManager.cpp:66).
3 Mention the possible HTP memory increase and the full-destructor change in the PR description ✅ Addressed The description now covers both ("Separate live modules now allocate separate QNN contexts…" / "Full QnnManager destruction also releases its tensor wrappers…").
4 Add a test that loads the same .pte twice, deletes the first, and runs the second; also check that two methods of one Module share a handle ✅ Addressed, with a caveat test_qnn_shared_delegate.py covers both destruction orders, same-module sharing (it checks the "Use cached delegate handle … shift" log), cleanup/reload, and sibling unload/reload. It also runs with spill-fill enabled. The new QnnExecuTorchBackendTest.cpp covers the cache logic. Caveat: both targets exist only in backends/qualcomm/tests/BUCK, so the OSS "Test QNN Backend" job never runs them. Its green result says nothing about these tests.
5 Typo: temporal should be temporary ✅ Fixed QnnExecuTorchBackend.cpp:71
6 Count each method's use of a shared handle so unload_method("prefill") doesn't destroy the manager while decode is still loaded ✅ Addressed DelegateEntry::users goes up on every cache hit in get_cached_delegate and on a lost race in add_cached_delegate. erase_cached_delegate only drops the entry when the count reaches 0. SharesUntilLastMethodIsReleased and test_method_survives_sibling_unload_and_reload test this.
7 Confirm no FCB flow loads the same blob bytes into several Modules ⚠️ Answered in the description, not confirmed in a reply The description says "FCB export contents and selection are unchanged; FCB weight sharing within an artifact does not provide shared runtime ownership across independent modules." I spot-checked the in-tree runners. qnn_llama_runner.cpp:301-311 and qnn_multimodal_runner.cpp:271-288 each create one Module per different .pte, so their signatures already differ. extension::Module shares one memory_allocator_ across its methods (module.cpp:630), so prefill/decode in one Module still share a context. I found no in-tree regression. Out-of-tree apps that load the same .pte into two Modules will now use two contexts. @qti-horodnic may want the author to confirm this explicitly.

@psiddh

# Comment Status Where
1 Use a scope guard right after the placement new, dismissed before add_cached_delegate, to cover the init error paths ✅ Addressed Same fix as horodnic #2. It's a shared_ptr guard rather than a dismissable one: on success, ownership is shared with the cache entry (and with group->owner for the first spill-fill context), so dropping the local copy does not destroy the manager.
2 CLA and linters need a fix ❌ Not addressed Lint (run 41780739632) fails in two places: (a) CLANGFORMAT on QnnExecuTorchBackend.h:29, because the PR changed ~QnnExecuTorchBackend(){}; to ~QnnExecuTorchBackend() {};. Reverting it also removes an unrelated diff line. (b) UFMT on test_qnn_shared_delegate.py: the torch.testing.assert_close(...) calls at lines 136-139 and 143-146 should be on one line, and class TestQnnSpillFillSharedDelegate at line 155 needs two blank lines before it. lintrunner -a fixes both. CLA: EasyCLA says the commit email for 2f58db1 isn't linked to the author's GitHub account. Only the author can fix this.

Remaining items

  • Run lintrunner -a and push the result. Fix this →
  • Fix the CLA email link (author).
  • Optional: say in the PR how the new BUCK-only tests were run, since OSS CI doesn't run them.

alirezamonfared added a commit to alirezamonfared/executorch that referenced this pull request Nov 9, 2026
Summary:

The QNN backend caches a `QnnManager` so methods in one ExecuTorch `Module` can share a QNN context. A context signature alone also matched independent modules loading identical program bytes, even though the manager was placement-allocated from only one module's runtime allocator. Key the cache by both signature and allocator to give independent modules independent manager lifetimes.

If a reverse-cache entry has no matching forward entry during destruction, report it and remove the stale reverse entry before returning. The regression deliberately creates this inconsistent state and verifies safe cleanup and a repeated release.

Count each loaded method's reference to its cached manager and destroy the manager after its last method releases it. Protect cache operations with the mutex and use `try_emplace` to preserve the first manager published for a key. A competing initializer returns that manager and destroys its own candidate. A destructor-only ownership guard cleans up every initialization error path, including backend, context, DLC compilation, and tensor allocation failures.

Scope HTP spill-fill groups by runtime allocator and configured buffer size. Each context receives its group handle explicitly, replacing the process-global static handle. Keep the first group manager alive until all group members and pending initializations are gone, and free each member before the first context. Serialize initialization within a group so concurrent loads select one first context.

Separate live modules now allocate separate QNN contexts, which can increase HTP memory use when they load identical blobs. Sharing between methods within one module remains intact. Full `QnnManager` destruction also releases its tensor wrappers and other non-trivial members. FCB export contents and selection are unchanged; FCB weight sharing within an artifact does not provide shared runtime ownership across independent modules.

Export the lifecycle tests so they are visible in the PR. They cover both module destruction orders, execution of multiple methods in one module, cleanup/reload, and method unload/reload on the HTP simulator. C++ tests verify shared cache handle identity, allocator isolation, reference accounting, concurrent cache publication, spill-fill group isolation, cleanup/reload, and owner destruction order. The simulator lifecycle suite also runs with multi-context enabled and a nonzero spill-fill size; target SDK registration still requires device verification. Concurrent execution through one module and execution racing destruction still require client coordination.

Authored with AI assistance from Codex.

Differential Revision: D122851825




Signed-off-by: Ali Monfared <alimonfared@meta.com>
@psiddh

psiddh commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Can you fix Lint / lintrunner issues and should be good to land (@qti-horodnic let me know if that is not the case)

alirezamonfared added a commit to alirezamonfared/executorch that referenced this pull request Nov 9, 2026
Summary:

## TL;DR

Give independently loaded ExecuTorch `Module`s independent QNN manager lifetimes while preserving context sharing between methods within one module. Key the delegate cache by `(context signature, runtime allocator)`, count method references, and release complete manager state on final destruction or initialization failure.

## Context

`QnnExecuTorchBackend` is process-global and caches expensive `QnnManager` instances across methods. A manager is placement-allocated from a module's runtime allocator; methods in that module share its allocator, but independent modules can load identical `.pte` bytes and be destroyed separately. D122851825 hardens this backend contract without changing FCB export contents or artifact selection.

## Problem

A context signature alone also matched independent modules, allowing one module to destroy manager state still used by another. Calling `Destroy()` alone also omitted non-trivial manager member destruction, and competing initializers could publish or abandon duplicate candidates. A process-global HTP spill-fill handle did not represent the lifetime of its owning context.

```text
Before: process cache[signature] -> Manager M (allocator A)

Module A methods ----> M <---- Module B, same program bytes
release Module A ----> M destroyed
Module B executes ---> stale context
```

## Solution

Use a cache key containing the signature and allocator, with mutex-protected lookup, membership, publication and release. Each loaded method holds a reference; `try_emplace` preserves the first published manager and competing initializers destroy their unused candidate. A destructor-only ownership guard covers backend, context, DLC compilation and tensor allocation failures. On final release, erase cache state under the mutex and destroy the manager after unlocking. A missing forward entry is reported and its stale reverse entry removed safely.

```text
After: cache[(signature, allocator A)] -> Manager A -> A's methods
       cache[(signature, allocator B)] -> Manager B -> B's methods

release A's last method -> unlock cache -> ~Manager A -> Destroy()
B's methods            -> Manager B remains alive
```

Scope HTP spill-fill groups by allocator and configured buffer size. Thread the group handle explicitly through context initialization instead of using a process-global handle. Serialize initialization within each group, retain its first manager until all members and pending initializations are gone, and destroy member contexts before the owner. The registry holds weak group references, preventing an ownership cycle and allowing cleanup followed by a fresh group.

```text
weak registry[(allocator, spill-fill bytes)] -> Group
method entry -> member manager + Group
Group        -> first manager (HTP owner)

last member/pending initializer releases Group
  -> member context destroyed
  -> first context destroyed
  -> registry's weak reference expires
```

Keep the registered backend alive for the process lifetime so loaded modules cannot destroy QNN contexts through global cache teardown after allocators or the QNN library have finalized. Normal method destruction still releases manager state; the explicit defaulted backend destructor avoids conflicting empty-body formatting between local and OSS ClangFormat versions. Python lifecycle imports follow the ordering reported by OSS UFMT.

## Compatibility and evidence limits

Separate live modules allocate separate QNN contexts, which can increase HTP memory use for duplicate blobs. Methods within one module still share a manager. FCB weight sharing inside an artifact does not supply shared runtime ownership across independent modules. Concurrent execution through one module and execution racing destruction require client coordination.

Exported lifecycle tests cover both module destruction orders, multiple methods, cleanup/reload and sibling unload/reload on the HTP simulator, including multi-context mode with a nonzero spill-fill size. C++ tests check handle identity, allocator isolation, reference accounting, concurrent publication, stale-map cleanup, spill-fill ownership and process exit. Target SDK spill-fill registration still requires device verification; the recorded FCB AOT failure occurred before runtime module loading and does not establish FCB runtime compatibility.

Authored with AI assistance from Codex.

Differential Revision: D122851825




Signed-off-by: Ali Monfared <alimonfared@meta.com>

@qti-horodnic qti-horodnic 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.

Thanks @alirezamonfared, I think this pr can proceed. The comment below is non-blocking but something we should consider if possible

Comment on lines +93 to +94
group = get_spill_fill_group(
{runtime_allocator, htp_options->max_sf_buf_size()});

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.

The qaihub shard Modules are exported to share one spill-fill buffer (update_spill_fill_size). The allocator in the key now gives each shard its own buffer, so the extra memory is (N−1) × max_sf_buf_size. Can we measure this on device and mention it in the description? If the cost is large, we can add an opt-in cross-Module sharing in a follow-up, for example with a group ID.

@psiddh

psiddh commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Need the linter fix, accepting to unblock

Summary:
Pull Request resolved: pytorch#24462

## TL;DR

Give independently loaded ExecuTorch `Module`s independent QNN manager lifetimes while preserving context sharing between methods within one module. Key the delegate cache by `(context signature, runtime allocator)`, count method references, and release complete manager state on final destruction or initialization failure.

## Context

`QnnExecuTorchBackend` is process-global and caches expensive `QnnManager` instances across methods. A manager is placement-allocated from a module's runtime allocator; methods in that module share its allocator, but independent modules can load identical `.pte` bytes and be destroyed separately. D122851825 hardens this backend contract without changing FCB export contents or artifact selection.

## Problem

A context signature alone also matched independent modules, allowing one module to destroy manager state still used by another. Calling `Destroy()` alone also omitted non-trivial manager member destruction, and competing initializers could publish or abandon duplicate candidates. A process-global HTP spill-fill handle did not represent the lifetime of its owning context.

```text
Before: process cache[signature] -> Manager M (allocator A)

Module A methods ----> M <---- Module B, same program bytes
release Module A ----> M destroyed
Module B executes ---> stale context
```

## Solution

Use a cache key containing the signature and allocator, with mutex-protected lookup, membership, publication and release. Each loaded method holds a reference; `try_emplace` preserves the first published manager and competing initializers destroy their unused candidate. A destructor-only ownership guard covers backend, context, DLC compilation and tensor allocation failures. Full `QnnManager` destruction also releases its input/output tensor wrappers and other non-trivial members; their storage was not released by calling `Destroy()` alone. On final release, erase cache state under the mutex and destroy the manager after unlocking. A missing forward entry is reported and its stale reverse entry removed safely.

```text
After: cache[(signature, allocator A)] -> Manager A -> A's methods
       cache[(signature, allocator B)] -> Manager B -> B's methods

release A's last method -> unlock cache -> ~Manager A -> Destroy()
B's methods            -> Manager B remains alive
```

Scope HTP spill-fill groups by allocator and configured buffer size. Thread the group handle explicitly through context initialization instead of using a process-global handle. Serialize initialization within each group, retain its first manager until all members and pending initializations are gone, and destroy member contexts before the owner. The registry holds weak group references, preventing an ownership cycle and allowing cleanup followed by a fresh group.

```text
weak registry[(allocator, spill-fill bytes)] -> Group
method entry -> member manager + Group
Group        -> first manager (HTP owner)

last member/pending initializer releases Group
  -> member context destroyed
  -> first context destroyed
  -> registry's weak reference expires
```

Keep the registered backend alive for the process lifetime so loaded modules cannot destroy QNN contexts through global cache teardown after allocators or the QNN library have finalized. Normal method destruction still releases manager state; the explicit defaulted backend destructor avoids conflicting empty-body formatting between local and OSS ClangFormat versions. Python lifecycle imports follow the ordering reported by OSS UFMT.

## Compatibility and evidence limits

Separate live modules allocate separate QNN contexts, which can increase HTP memory use for duplicate blobs. Methods within one module still share a manager. FCB weight sharing inside an artifact does not supply shared runtime ownership across independent modules. Concurrent execution through one module and execution racing destruction require client coordination.

### QAI Hub shard spill-fill cost

`gen_pte_from_ctx_bin()` calls `update_spill_fill_size()` over the shard programs before writing separate `.pte` files. The helper sets each participating context's `max_sf_buf_size` to the largest requirement in the export group. The QAI Hub Llama runner constructs a separate `Module` for each shard and loads every shard method; default `Module` construction gives each one a separate runtime allocator.

On device, `HtpContextCustomConfig::CreateContextCustomConfig()` registers an HTP group using `firstGroupHandle` and `maxSpillFillBuffer`. The former process-global handle allowed shard contexts to join one group. With this diff, the `(allocator, max_sf_buf_size)` key isolates those independently allocated Modules, so each shard starts its own spill-fill group even when the exported size matches.

```text
N resident shard Modules, common configured S bytes (S > 0)

Before: shard 1 ... shard N -> one HTP spill-fill group -> S bytes
After:  shard i / allocator i -> group i              -> S bytes each

Expected configured reservation: S -> N * S bytes
Expected additional reservation: (N - 1) * S bytes
```

This is a projection of the spill-fill reservation for the described shard layout, not measured RSS, PSS, DSP heap usage or peak device memory. SDK allocation behavior, alignment and other context/model memory affect the observed totals. The existing host simulator tests do not issue target spill-fill group registration and cannot measure this device cost. No connected physical device was available for this follow-up, so the magnitude on a representative QAI Hub shard model remains unmeasured.

If device evidence shows a material cost, an opt-in cross-Module group ID is a possible follow-up. Such a group must keep its first context and allocator-owned manager storage alive until all shard Modules and pending initializations release it; changing the key to an ID alone would not satisfy that lifetime requirement. This diff does not introduce that opt-in.

Exported lifecycle tests cover both module destruction orders, multiple methods, cleanup/reload and sibling unload/reload on the HTP simulator, including multi-context mode with a nonzero spill-fill size. C++ tests check handle identity, allocator isolation, reference accounting, concurrent publication, stale-map cleanup, spill-fill ownership and process exit. Target SDK spill-fill registration still requires device verification; the recorded FCB AOT failure occurred before runtime module loading and does not establish FCB runtime compatibility.

Authored with AI assistance from Codex.

Differential Revision: D122851825
@alirezamonfared
alirezamonfared merged commit baf1cf7 into pytorch:main Nov 10, 2026
200 checks passed
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. meta-exported module: qnn Issues related to Qualcomm's QNN delegate and code under backends/qualcomm/

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants