Skip to content

Commit d938d14

Browse files
Isolate cached delegates by Module lifetime (#24462)
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>
1 parent 4411b8d commit d938d14

11 files changed

Lines changed: 612 additions & 69 deletions

‎backends/qualcomm/runtime/QnnExecuTorchBackend.cpp‎

Lines changed: 124 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
#include <executorch/backends/qualcomm/runtime/backends/QnnCustomProtocol.h>
1515
#include <executorch/runtime/backend/interface.h>
1616
#include <executorch/runtime/backend/options.h>
17+
18+
#include <memory>
19+
1720
namespace executorch {
1821
namespace backends {
1922
namespace qnn {
@@ -65,36 +68,58 @@ Result<DelegateHandle*> QnnExecuTorchBackend::init(
6568
QNN_EXECUTORCH_LOG_WARN("unknown argument: %s", compile_spec.key);
6669
}
6770

68-
// Create QnnManager
6971
MemoryAllocator* runtime_allocator = context.get_runtime_allocator();
70-
QnnManager* qnn_manager = runtime_allocator->allocateInstance<QnnManager>();
71-
if (qnn_manager == nullptr) {
72-
return Error::MemoryAllocationFailed;
73-
}
74-
75-
// NOTE: Since we use placement new and since this type is not trivially
76-
// destructible, we must call the destructor manually in destroy().
77-
new (qnn_manager) QnnManager(qnn_executorch_options, qnn_context_blob);
78-
// TODO: this is a temporal solution for multi-graph support, will be
72+
// TODO: this is a temporary solution for multi-graph support, will be
7973
// removed once framework starts to accept runtime configuration
8074
// ---
8175
// check if current context binary has already been initialized
8276
// return cached one for reducing memory footprint
83-
84-
auto iter = delegate_map_.find(signature);
85-
if (iter != delegate_map_.end()) {
77+
const DelegateKey key{signature, runtime_allocator};
78+
if (auto cached_handle = get_cached_delegate(key)) {
8679
QNN_EXECUTORCH_LOG_INFO(
8780
"Use cached delegate handle for current method: %s",
8881
context.get_method_name());
89-
return iter->second;
82+
return *cached_handle;
83+
}
84+
85+
std::shared_ptr<SpillFillGroup> group;
86+
std::unique_lock<std::mutex> group_lock;
87+
auto* backend_options = qnn_executorch_options->backend_options();
88+
if (backend_options->backend_type() ==
89+
QnnExecuTorchBackendType::kHtpBackend) {
90+
auto* htp_options = backend_options->htp_options();
91+
if (htp_options->use_multi_contexts() &&
92+
htp_options->max_sf_buf_size() != 0) {
93+
group = get_spill_fill_group(
94+
{runtime_allocator, htp_options->max_sf_buf_size()});
95+
group_lock = std::unique_lock<std::mutex>(group->mutex);
96+
if (auto cached_handle = get_cached_delegate(key)) {
97+
return *cached_handle;
98+
}
99+
}
100+
}
101+
102+
// Create QnnManager
103+
QnnManager* qnn_manager = runtime_allocator->allocateInstance<QnnManager>();
104+
if (qnn_manager == nullptr) {
105+
return Error::MemoryAllocationFailed;
90106
}
91107

108+
// The runtime allocator owns the storage, so only invoke the destructor.
109+
new (qnn_manager) QnnManager(qnn_executorch_options, qnn_context_blob);
110+
auto destroy_manager = [](QnnManager* manager) { manager->~QnnManager(); };
111+
std::shared_ptr<QnnManager> manager_guard(qnn_manager, destroy_manager);
112+
92113
ET_CHECK_OR_RETURN_ERROR(
93114
qnn_manager->InitBackend() == Error::Ok,
94115
Internal,
95116
"Fail to initialize Qnn Manager");
96117
ET_CHECK_OR_RETURN_ERROR(
97-
qnn_manager->InitContext() == Error::Ok,
118+
qnn_manager->InitContext(
119+
std::nullopt,
120+
group && group->owner
121+
? static_cast<QnnManager*>(group->owner.get())->GetContextHandle()
122+
: nullptr) == Error::Ok,
98123
Internal,
99124
"Fail to initialize Qnn Manager");
100125

@@ -111,24 +136,26 @@ Result<DelegateHandle*> QnnExecuTorchBackend::init(
111136
"Fail to allocate tensor");
112137
}
113138
}
114-
add_cached_delegate(signature, qnn_manager);
139+
DelegateHandle* cached_handle =
140+
add_cached_delegate(key, qnn_manager, manager_guard, group);
141+
if (group && !group->owner && cached_handle == qnn_manager) {
142+
group->owner = manager_guard;
143+
}
115144

116145
#ifndef __hexagon__
117146
// This backend does not need its processed data after Init.
118147
processed->Free();
119148
#endif
120149

121-
return qnn_manager;
150+
return cached_handle;
122151
}
123152

124153
Error QnnExecuTorchBackend::execute(
125154
BackendExecutionContext& context,
126155
DelegateHandle* handle,
127156
Span<EValue*> args) const {
128157
ET_CHECK_OR_RETURN_ERROR(
129-
delegate_map_rev_.count(handle) != 0,
130-
Internal,
131-
"DelegateHandle has been deleted");
158+
is_cached_delegate(handle), Internal, "DelegateHandle has been deleted");
132159
QnnManager* qnn_manager = static_cast<QnnManager*>(handle);
133160

134161
std::string method_name = context.get_method_name();
@@ -225,11 +252,9 @@ Error QnnExecuTorchBackend::execute(
225252
}
226253

227254
void QnnExecuTorchBackend::destroy(DelegateHandle* handle) const {
228-
if (handle != nullptr && delegate_map_rev_.count(handle)) {
229-
QnnManager* qnn_manager = static_cast<QnnManager*>(handle);
230-
qnn_manager->Destroy();
231-
erase_cached_delegate(handle);
232-
}
255+
// Releasing the final shared owner invokes ~QnnManager(), which calls
256+
// Destroy(). The returned entry releases owners after mutex_ is unlocked.
257+
(void)erase_cached_delegate(handle);
233258
}
234259

235260
executorch::runtime::Error QnnExecuTorchBackend::set_option(
@@ -365,41 +390,98 @@ bool QnnExecuTorchBackend::is_available() const {
365390
return true;
366391
}
367392

368-
void QnnExecuTorchBackend::add_cached_delegate(
369-
const std::int64_t& signature,
393+
std::optional<executorch::runtime::DelegateHandle*>
394+
QnnExecuTorchBackend::get_cached_delegate(const DelegateKey& key) const {
395+
std::lock_guard<std::mutex> guard(mutex_);
396+
auto iter = delegate_map_.find(key);
397+
if (iter == delegate_map_.end()) {
398+
return std::nullopt;
399+
}
400+
++iter->second.users;
401+
return iter->second.handle;
402+
}
403+
404+
std::shared_ptr<QnnExecuTorchBackend::SpillFillGroup>
405+
QnnExecuTorchBackend::get_spill_fill_group(const SpillFillKey& key) const {
406+
std::lock_guard<std::mutex> guard(mutex_);
407+
for (auto iter = spill_fill_groups_.begin();
408+
iter != spill_fill_groups_.end();) {
409+
if (iter->second.expired()) {
410+
iter = spill_fill_groups_.erase(iter);
411+
} else {
412+
++iter;
413+
}
414+
}
415+
auto& weak_group = spill_fill_groups_[key];
416+
auto group = weak_group.lock();
417+
if (!group) {
418+
group = std::make_shared<SpillFillGroup>();
419+
weak_group = group;
420+
}
421+
return group;
422+
}
423+
424+
executorch::runtime::DelegateHandle* QnnExecuTorchBackend::add_cached_delegate(
425+
const DelegateKey& key,
426+
executorch::runtime::DelegateHandle* handle,
427+
std::shared_ptr<executorch::runtime::DelegateHandle> manager,
428+
std::shared_ptr<SpillFillGroup> group) const {
429+
std::lock_guard<std::mutex> guard(mutex_);
430+
auto [iter, inserted] = delegate_map_.try_emplace(
431+
key, DelegateEntry{handle, 1, std::move(group), std::move(manager)});
432+
if (inserted) {
433+
delegate_map_rev_.emplace(handle, key);
434+
} else {
435+
++iter->second.users;
436+
}
437+
return iter->second.handle;
438+
}
439+
440+
bool QnnExecuTorchBackend::is_cached_delegate(
370441
executorch::runtime::DelegateHandle* handle) const {
371442
std::lock_guard<std::mutex> guard(mutex_);
372-
delegate_map_[signature] = handle;
373-
delegate_map_rev_[handle] = signature;
443+
return delegate_map_rev_.count(handle) != 0;
374444
}
375445

376-
void QnnExecuTorchBackend::erase_cached_delegate(
446+
std::optional<QnnExecuTorchBackend::DelegateEntry>
447+
QnnExecuTorchBackend::erase_cached_delegate(
377448
executorch::runtime::DelegateHandle* handle) const {
378449
std::lock_guard<std::mutex> guard(mutex_);
379450
auto iter = delegate_map_rev_.find(handle);
380451
if (iter == delegate_map_rev_.end()) {
381-
return;
452+
return std::nullopt;
382453
}
383-
delegate_map_.erase(iter->second);
384-
delegate_map_rev_.erase(handle);
454+
auto delegate_iter = delegate_map_.find(iter->second);
455+
if (delegate_iter == delegate_map_.end()) {
456+
QNN_EXECUTORCH_LOG_ERROR("Cached delegate has no matching entry.");
457+
delegate_map_rev_.erase(iter);
458+
return std::nullopt;
459+
}
460+
if (--delegate_iter->second.users != 0) {
461+
return std::nullopt;
462+
}
463+
DelegateEntry entry = std::move(delegate_iter->second);
464+
delegate_map_.erase(delegate_iter);
465+
delegate_map_rev_.erase(iter);
466+
return entry;
385467
}
386468

387469
namespace {
470+
// Loaded modules may outlive static teardown. Their managers must be released
471+
// by destroy(), while their allocators and QNN libraries are still alive.
472+
const executorch::runtime::Backend& qnn_backend() {
473+
static const executorch::runtime::Backend backend{
474+
QNN_BACKEND, new QnnExecuTorchBackend()};
475+
return backend;
476+
}
388477
#if !defined(_MSC_VER)
389-
auto cls = QnnExecuTorchBackend();
390-
executorch::runtime::Backend backend{QNN_BACKEND, &cls};
391478
// On non-Windows platforms a single copy of executorch_core is shared between
392479
// the DLL and the exe, so the static initializer writes into the correct
393480
// registered_backends[] array.
394-
static auto success_with_compiler = register_backend(backend);
481+
const auto success_with_compiler = register_backend(qnn_backend());
395482
#endif
396483
} // namespace
397484

398-
#if defined(_MSC_VER)
399-
static QnnExecuTorchBackend cls_msvc;
400-
static executorch::runtime::Backend backend_msvc{QNN_BACKEND, &cls_msvc};
401-
#endif
402-
403485
} // namespace qnn
404486
} // namespace backends
405487
} // namespace executorch
@@ -417,7 +499,7 @@ void QnnExecuTorchBackendRegister(void* register_fn) {
417499
return;
418500
}
419501
executorch::runtime::Error err = reinterpret_cast<RegisterFn>(register_fn)(
420-
executorch::backends::qnn::backend_msvc);
502+
executorch::backends::qnn::qnn_backend());
421503
if (err != executorch::runtime::Error::Ok) {
422504
ET_LOG(
423505
Error,

‎backends/qualcomm/runtime/QnnExecuTorchBackend.h‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212
#include <executorch/runtime/core/error.h>
1313
#include <executorch/runtime/core/evalue.h>
1414

15+
#include <map>
16+
#include <memory>
1517
#include <mutex>
18+
#include <optional>
1619
#include <unordered_map>
20+
#include <utility>
1721

1822
namespace executorch {
1923
namespace backends {
@@ -22,7 +26,7 @@ namespace qnn {
2226
class QnnExecuTorchBackend final
2327
: public ::executorch::runtime::BackendInterface {
2428
public:
25-
~QnnExecuTorchBackend(){};
29+
~QnnExecuTorchBackend() override = default;
2630

2731
executorch::runtime::Result<executorch::runtime::DelegateHandle*> init(
2832
executorch::runtime::BackendInitContext& context,
@@ -51,16 +55,49 @@ class QnnExecuTorchBackend final
5155
bool is_available() const override;
5256

5357
private:
54-
void add_cached_delegate(
55-
const std::int64_t& signature,
58+
friend class QnnExecuTorchBackendTest;
59+
60+
// A context binary signature plus the runtime allocator that the methods of
61+
// one Module share. Other Modules can be destroyed independently, so they
62+
// must never receive this Module's QnnManager.
63+
using DelegateKey =
64+
std::pair<std::int64_t, const executorch::runtime::MemoryAllocator*>;
65+
66+
struct SpillFillGroup {
67+
std::mutex mutex;
68+
// HTP requires the first context to outlive every other group member.
69+
std::shared_ptr<executorch::runtime::DelegateHandle> owner;
70+
};
71+
using SpillFillKey =
72+
std::pair<const executorch::runtime::MemoryAllocator*, uint64_t>;
73+
74+
struct DelegateEntry {
75+
executorch::runtime::DelegateHandle* handle;
76+
size_t users;
77+
// Destroy each member context before releasing the group's first context.
78+
std::shared_ptr<SpillFillGroup> group;
79+
std::shared_ptr<executorch::runtime::DelegateHandle> manager;
80+
};
81+
82+
std::shared_ptr<SpillFillGroup> get_spill_fill_group(
83+
const SpillFillKey& key) const;
84+
std::optional<executorch::runtime::DelegateHandle*> get_cached_delegate(
85+
const DelegateKey& key) const;
86+
executorch::runtime::DelegateHandle* add_cached_delegate(
87+
const DelegateKey& key,
88+
executorch::runtime::DelegateHandle* handle,
89+
std::shared_ptr<executorch::runtime::DelegateHandle> manager = nullptr,
90+
std::shared_ptr<SpillFillGroup> group = nullptr) const;
91+
bool is_cached_delegate(executorch::runtime::DelegateHandle* handle) const;
92+
std::optional<DelegateEntry> erase_cached_delegate(
5693
executorch::runtime::DelegateHandle* handle) const;
57-
void erase_cached_delegate(executorch::runtime::DelegateHandle* handle) const;
5894

5995
mutable std::mutex mutex_;
6096
mutable std::mutex runtime_option_mutex_;
61-
mutable std::unordered_map<int64_t, executorch::runtime::DelegateHandle*>
62-
delegate_map_;
63-
mutable std::unordered_map<executorch::runtime::DelegateHandle*, std::int64_t>
97+
mutable std::map<DelegateKey, DelegateEntry> delegate_map_;
98+
mutable std::map<SpillFillKey, std::weak_ptr<SpillFillGroup>>
99+
spill_fill_groups_;
100+
mutable std::unordered_map<executorch::runtime::DelegateHandle*, DelegateKey>
64101
delegate_map_rev_;
65102

66103
RuntimeOption qnn_runtime_log_level_{false, 0};

‎backends/qualcomm/runtime/QnnManager.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,8 @@ Error QnnManager::InitBackend() {
342342
}
343343

344344
Error QnnManager::InitContext(
345-
std::optional<std::vector<std::string>> graph_names) {
345+
const std::optional<std::vector<std::string>>& graph_names,
346+
Qnn_ContextHandle_t spill_fill_handle) {
346347
if (backend_params_ptr_->backend_init_state_ ==
347348
BackendInitializeState::UNINITIALIZED) {
348349
QNN_EXECUTORCH_LOG_INFO(
@@ -356,7 +357,8 @@ Error QnnManager::InitContext(
356357
backend_bundle_ptr_->qnn_device_ptr.get(),
357358
qnn_context_blob_,
358359
options_,
359-
qnn_dlc_manager_.get());
360+
qnn_dlc_manager_.get(),
361+
spill_fill_handle);
360362
ET_CHECK_OR_RETURN_ERROR(
361363
backend_params_ptr_ != nullptr,
362364
Internal,

‎backends/qualcomm/runtime/QnnManager.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ class QnnManager {
3838
// provided graph_names. Note: For online_prepare or deserialization, the
3939
// graph name will be obtained from the binary.
4040
executorch::runtime::Error InitContext(
41-
std::optional<std::vector<std::string>> graph_names = std::nullopt);
41+
const std::optional<std::vector<std::string>>& graph_names = std::nullopt,
42+
Qnn_ContextHandle_t spill_fill_handle = nullptr);
43+
44+
Qnn_ContextHandle_t GetContextHandle() const {
45+
return backend_params_ptr_->qnn_context_ptr_->GetHandle();
46+
}
4247
// This function only initialize the context cache to get spill fill buffer
4348
// size
4449
executorch::runtime::Error InitContextCache();

‎backends/qualcomm/runtime/backends/QnnBackendFactory.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ std::unique_ptr<BackendConfigParameters> QnnBackendFactory::Create(
2020
QnnDevice* qnn_device_ptr,
2121
const QnnExecuTorchContextBinary& qnn_context_blob,
2222
const QnnExecuTorchOptions* options,
23-
QnnDlcManager* qnn_dlc_manager) {
23+
QnnDlcManager* qnn_dlc_manager,
24+
Qnn_ContextHandle_t spill_fill_handle) {
2425
auto backend_params = std::make_unique<BackendConfigParameters>();
2526

2627
switch (options->backend_options()->backend_type()) {
@@ -79,7 +80,8 @@ std::unique_ptr<BackendConfigParameters> QnnBackendFactory::Create(
7980
htp_options,
8081
options->target_options(),
8182
qnn_dlc_manager,
82-
get_option(options->profile_level(), QNN_RUNTIME_PROFILE_LEVEL));
83+
get_option(options->profile_level(), QNN_RUNTIME_PROFILE_LEVEL),
84+
spill_fill_handle);
8385

8486
backend_params->qnn_graph_ptr_ = std::make_unique<HtpGraph>(
8587
implementation_ptr,

‎backends/qualcomm/runtime/backends/QnnBackendFactory.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ class QnnBackendFactory {
6868
QnnDevice* qnn_device_ptr,
6969
const QnnExecuTorchContextBinary& qnn_context_blob,
7070
const QnnExecuTorchOptions* options,
71-
QnnDlcManager* qnn_dlc_manager);
71+
QnnDlcManager* qnn_dlc_manager,
72+
Qnn_ContextHandle_t spill_fill_handle = nullptr);
7273
};
7374
} // namespace qnn
7475
} // namespace backends

0 commit comments

Comments
 (0)