Repository navigation
Isolate cached delegates by Module lifetime (#24462) - #24462
Conversation
🔗 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. |
|
|
|
@alirezamonfared has exported this pull request. If you are a Meta employee, you can view the originating Diff in D122851825. |
This PR needs a
|
9179591 to
362a88f
Compare
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
|
@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
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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}; |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Minor: pre-existing typo, I guess should be temporary, not temporal.
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>
362a88f to
2f58db1
Compare
|
@alirezamonfared both CLA & linters need a fix |
|
@claude can you review mine and Arik's comments on this PR and check if they all have been addressd |
|
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)
@psiddh
Remaining items
|
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>
2f58db1 to
8de26ed
Compare
|
Can you fix Lint / lintrunner issues and should be good to land (@qti-horodnic let me know if that is not the case) |
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>
8de26ed to
d938d14
Compare
qti-horodnic
left a comment
There was a problem hiding this comment.
Thanks @alirezamonfared, I think this pr can proceed. The comment below is non-blocking but something we should consider if possible
| group = get_spill_fill_group( | ||
| {runtime_allocator, htp_options->max_sf_buf_size()}); |
There was a problem hiding this comment.
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.
|
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
d938d14 to
c44220f
Compare
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
QnnExecuTorchBackendis process-global and caches expensiveQnnManagerinstances 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.ptebytes 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.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_emplacepreserves 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. FullQnnManagerdestruction also releases its input/output tensor wrappers and other non-trivial members; their storage was not released by callingDestroy()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.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.
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()callsupdate_spill_fill_size()over the shard programs before writing separate.ptefiles. The helper sets each participating context'smax_sf_buf_sizeto the largest requirement in the export group. The QAI Hub Llama runner constructs a separateModulefor each shard and loads every shard method; defaultModuleconstruction gives each one a separate runtime allocator.On device,
HtpContextCustomConfig::CreateContextCustomConfig()registers an HTP group usingfirstGroupHandleandmaxSpillFillBuffer. 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.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