Skip to content

Commit 3d86cc7

Browse files
authored
Module deep-copies LoadBackendOptionsMap on load (pytorch#19673)
Differential Revision: D105100372 Pull Request resolved: pytorch#19673
1 parent 4c5e722 commit 3d86cc7

4 files changed

Lines changed: 199 additions & 9 deletions

File tree

extension/module/module.cpp

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,49 @@ runtime::Error Module::load(const Program::Verification verification) {
203203
runtime::Error Module::load(
204204
const LoadBackendOptionsMap& backend_options,
205205
const Program::Verification verification) {
206-
backend_options_ = &backend_options;
207-
return load_internal(verification);
206+
// load_internal does not read backend options, so run it first; on
207+
// failure we skip the deep-copy work entirely and leave the prior
208+
// installed options (if any) in place.
209+
ET_CHECK_OK_OR_RETURN_ERROR(load_internal(verification));
210+
211+
// Deep-copy the input into local storage so the Module owns the
212+
// BackendOption arrays for the lifetime of any methods loaded with
213+
// these options. Build BOTH the storage and the map in locals so any
214+
// mid-loop failure (or exception from emplace) leaves the prior
215+
// installed state untouched -- the two members are only committed
216+
// together at the end on full success.
217+
//
218+
// local_storage is reserve()'d up front, so emplace_back() never
219+
// reallocates the outer buffer and the inner vectors keep stable
220+
// addresses while we build local_map. The final move of
221+
// local_storage into backend_options_storage_ uses std::vector's
222+
// O(1) buffer transfer, so the heap buffers that local_map's spans
223+
// point into remain valid after the move; the static_assert documents
224+
// the inner-vector property we rely on for that span stability.
225+
static_assert(
226+
std::is_nothrow_move_constructible_v<std::vector<runtime::BackendOption>>,
227+
"Moving local_storage must not move-construct the inner vectors; "
228+
"local_map's spans reference their heap buffers.");
229+
230+
std::vector<std::vector<runtime::BackendOption>> local_storage;
231+
local_storage.reserve(backend_options.size());
232+
LoadBackendOptionsMap local_map;
233+
for (size_t i = 0; i < backend_options.size(); ++i) {
234+
const auto entry = backend_options.entry_at(i);
235+
local_storage.emplace_back(entry.options.begin(), entry.options.end());
236+
auto& owned = local_storage.back();
237+
// The input map was already valid, so set_options should not fail
238+
// here; assert it loudly rather than leaving partial state behind.
239+
ET_CHECK_OK_OR_RETURN_ERROR(local_map.set_options(
240+
entry.backend_id,
241+
runtime::Span<runtime::BackendOption>(owned.data(), owned.size())));
242+
}
243+
244+
// Single commit point: both members updated together.
245+
backend_options_storage_ = std::move(local_storage);
246+
backend_options_map_ = std::move(local_map);
247+
248+
return runtime::Error::Ok;
208249
}
209250

210251
runtime::Error Module::load_internal(const Program::Verification verification) {
@@ -369,9 +410,12 @@ runtime::Error Module::load_method(
369410
if (!is_method_loaded(method_name)) {
370411
ET_CHECK_OK_OR_RETURN_ERROR(load());
371412

372-
// Use passed backend_options, or fall back to stored one from load()
373-
const LoadBackendOptionsMap* effective_backend_options =
374-
backend_options ? backend_options : backend_options_;
413+
// Use passed backend_options, or fall back to stored ones from load().
414+
// An empty stored map behaves identically to nullptr downstream, so we
415+
// only forward the stored map when it actually has entries.
416+
const LoadBackendOptionsMap* effective_backend_options = backend_options
417+
? backend_options
418+
: (backend_options_map_.size() > 0 ? &backend_options_map_ : nullptr);
375419

376420
MethodHolder method_holder;
377421

extension/module/module.h

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
#include <unordered_set>
1515
#include <vector>
1616

17+
#include <executorch/runtime/backend/backend_options_map.h>
18+
#include <executorch/runtime/backend/options.h>
1719
#include <executorch/runtime/executor/program.h>
1820

1921
#ifdef USE_ATEN_LIB
@@ -187,9 +189,18 @@ class Module {
187189
/**
188190
* Loads the program with per-delegate runtime options.
189191
*
190-
* @param[in] backend_options A LoadBackendOptionsMap containing per-delegate
191-
* load-time configuration options. The caller must ensure this object
192-
* outlives any methods loaded with these options.
192+
* The Module deep-copies `backend_options` into internal storage, so the
193+
* caller may release the input (and any backing BackendOption arrays its
194+
* Spans referenced) immediately after this call returns. Future lazy
195+
* `load_method` calls (e.g. triggered by `forward`) consume the
196+
* Module-owned copy.
197+
*
198+
* Transactional: on failure, the previously-installed backend options
199+
* (if any) are left in place; the input is not committed.
200+
*
201+
* @param[in] backend_options A LoadBackendOptionsMap containing
202+
* per-delegate load-time configuration options. Deep-copied into the
203+
* Module on success; not retained on failure.
193204
* @param[in] verification The type of verification to do before returning
194205
* success.
195206
*
@@ -200,6 +211,21 @@ class Module {
200211
const Program::Verification verification =
201212
Program::Verification::Minimal);
202213

214+
/**
215+
* Returns the deep-copied LoadBackendOptionsMap most recently installed
216+
* via `load(LoadBackendOptionsMap, ...)`. The returned reference is owned
217+
* by the Module and remains valid until the next call to
218+
* `load(LoadBackendOptionsMap, ...)` or until the Module is destroyed.
219+
*
220+
* If `load(LoadBackendOptionsMap, ...)` has never been called, returns a
221+
* default-constructed (empty, `size() == 0`) map.
222+
*
223+
* @returns Const reference to the Module-owned LoadBackendOptionsMap.
224+
*/
225+
inline const LoadBackendOptionsMap& backend_options() const {
226+
return backend_options_map_;
227+
}
228+
203229
/**
204230
* Checks if the program is loaded.
205231
*
@@ -713,7 +739,14 @@ class Module {
713739
std::unique_ptr<NamedDataMap> merged_data_map_;
714740
std::vector<std::vector<uint8_t>> shared_arenas_;
715741
ET_DEPRECATED std::vector<uint8_t> debug_buffer_;
716-
const LoadBackendOptionsMap* backend_options_ = nullptr;
742+
// Module-owned deep-copy of the backend options most recently installed
743+
// via load(LoadBackendOptionsMap, ...). `backend_options_storage_` owns
744+
// the per-backend BackendOption arrays; `backend_options_map_` is a
745+
// LoadBackendOptionsMap whose Spans reference those owned arrays. An
746+
// empty map (`size() == 0`) is observationally indistinguishable from
747+
// "never set" by downstream consumers, so we don't track that bit.
748+
std::vector<std::vector<runtime::BackendOption>> backend_options_storage_;
749+
LoadBackendOptionsMap backend_options_map_;
717750
bool share_memory_arenas_;
718751

719752
ET_NODISCARD runtime::Error load_internal(

extension/module/targets.bzl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ def define_common_targets():
2727
"//executorch/extension/named_data_map:merged_data_map" + aten_suffix,
2828
],
2929
exported_deps = [
30+
"//executorch/runtime/backend:backend_options",
31+
"//executorch/runtime/backend:backend_options_map",
3032
"//executorch/runtime/executor:program_no_prim_ops" + aten_suffix,
3133
],
3234
)

extension/module/test/module_test.cpp

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
#include <executorch/extension/module/module.h>
1010

1111
#include <array>
12+
#include <cstring>
1213
#include <thread>
14+
#include <variant>
1315

1416
#include <gtest/gtest.h>
1517

@@ -663,6 +665,115 @@ TEST_F(ModuleTest, TestLoadWithEmptyLoadBackendOptionsMap) {
663665
EXPECT_TRUE(module.is_loaded());
664666
}
665667

668+
TEST_F(ModuleTest, TestLoadWithBackendOptionsRollbackOnFailure) {
669+
// Module pointed at a non-existent file so `load_internal` will fail.
670+
Module module("/this/path/should/not/exist.pte");
671+
672+
{
673+
// `bo1` lives only in this scope. The Module deep-copies the input,
674+
// so dropping `bo1` is always safe regardless of whether the load
675+
// succeeded, but on the failure path we additionally verify the
676+
// Module did NOT install the input options (transactional contract).
677+
LoadBackendOptionsMap bo1;
678+
BackendOptions<2> opts;
679+
opts.set_option("rollback_test", true);
680+
ASSERT_EQ(bo1.set_options("RollbackBackend", opts.view()), Error::Ok);
681+
682+
const auto load_error = module.load(bo1);
683+
EXPECT_NE(load_error, Error::Ok);
684+
EXPECT_FALSE(module.is_loaded());
685+
}
686+
// `bo1` is destroyed. Module must remain in a usable state and a
687+
// subsequent `load_method` should fail with the same load-time error
688+
// (file not found) rather than crashing.
689+
EXPECT_FALSE(module.is_loaded());
690+
const auto method_error = module.load_method("forward");
691+
EXPECT_NE(method_error, Error::Ok);
692+
EXPECT_FALSE(module.is_method_loaded("forward"));
693+
}
694+
695+
TEST_F(ModuleTest, TestLoadDeepCopiesBackendOptionsInputCanBeReleased) {
696+
// Pin the deep-copy contract: the caller may release the input
697+
// LoadBackendOptionsMap (and the BackendOption arrays its Spans
698+
// referenced) immediately after `load()` returns. A subsequent
699+
// `load_method` must use the Module-owned copy via the fallback path,
700+
// not dereference the released input.
701+
Module module(model_path_);
702+
703+
{
704+
LoadBackendOptionsMap bo;
705+
BackendOptions<2> opts;
706+
opts.set_option("persist_test", true);
707+
ASSERT_EQ(bo.set_options("PersistBackend", opts.view()), Error::Ok);
708+
709+
ASSERT_EQ(module.load(bo), Error::Ok);
710+
// `bo` and `opts` go out of scope here; their storage is freed.
711+
}
712+
713+
// load_method without explicit backend_options falls back to the
714+
// Module's stored copy. With the old borrowed-pointer design this
715+
// would have been a use-after-free; with deep-copy it is safe.
716+
EXPECT_EQ(module.load_method("forward"), Error::Ok);
717+
EXPECT_TRUE(module.is_method_loaded("forward"));
718+
719+
// Forward should still execute correctly using the Module-owned
720+
// backend options.
721+
auto tensor = make_tensor_ptr({2, 2}, {1.f, 2.f, 3.f, 4.f});
722+
const auto result = module.execute("forward", {tensor, tensor, 1.0});
723+
EXPECT_EQ(result.error(), Error::Ok);
724+
}
725+
726+
TEST_F(ModuleTest, TestLoadStoresBackendOptionsForReadback) {
727+
// Verify that Module deep-copies the input LoadBackendOptionsMap into
728+
// its own storage so callers can both (a) release the input
729+
// immediately and (b) read back exactly what was stored via the
730+
// public `backend_options()` accessor.
731+
Module module(model_path_);
732+
733+
// Default-constructed: no options stored yet.
734+
EXPECT_EQ(module.backend_options().size(), 0u);
735+
736+
{
737+
LoadBackendOptionsMap bo;
738+
BackendOptions<2> opts;
739+
opts.set_option("num_threads", 8);
740+
opts.set_option("enable_profiling", true);
741+
ASSERT_EQ(bo.set_options("MyBackend", opts.view()), Error::Ok);
742+
743+
ASSERT_EQ(module.load(bo), Error::Ok);
744+
// `bo` and `opts` go out of scope here; their backing storage is
745+
// freed. Anything we read back from `module.backend_options()` must
746+
// therefore live in Module-owned storage.
747+
}
748+
749+
const auto& stored = module.backend_options();
750+
ASSERT_EQ(stored.size(), 1u);
751+
752+
const auto entry = stored.entry_at(0);
753+
EXPECT_STREQ(entry.backend_id, "MyBackend");
754+
ASSERT_EQ(entry.options.size(), 2u);
755+
756+
// Look up each option by key so the value assertions are direct and
757+
// independent of insertion order.
758+
const BackendOption* num_threads_opt = nullptr;
759+
const BackendOption* enable_profiling_opt = nullptr;
760+
for (const auto& opt : entry.options) {
761+
if (std::strcmp(opt.key, "num_threads") == 0) {
762+
num_threads_opt = &opt;
763+
} else if (std::strcmp(opt.key, "enable_profiling") == 0) {
764+
enable_profiling_opt = &opt;
765+
}
766+
}
767+
768+
ASSERT_NE(num_threads_opt, nullptr);
769+
ASSERT_TRUE(std::holds_alternative<int>(num_threads_opt->value));
770+
EXPECT_EQ(std::get<int>(num_threads_opt->value), 8);
771+
772+
ASSERT_NE(enable_profiling_opt, nullptr);
773+
ASSERT_TRUE(std::holds_alternative<bool>(enable_profiling_opt->value));
774+
EXPECT_TRUE(std::get<bool>(enable_profiling_opt->value));
775+
}
776+
666777
TEST_F(ModuleTest, TestLoadBackendOptionsMapPersistedAcrossLoadMethod) {
667778
Module module(model_path_);
668779

0 commit comments

Comments
 (0)