Skip to content

Commit 10c8958

Browse files
authored
Drop _loadedBackendOptions ivar from Apple bindings (pytorch#19680)
Differential Revision: D105100368 Pull Request resolved: pytorch#19680
1 parent 5358bcf commit 10c8958

3 files changed

Lines changed: 77 additions & 75 deletions

File tree

extension/apple/ExecuTorch/Exported/ExecuTorchModule.mm

Lines changed: 8 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -252,27 +252,6 @@ @implementation ExecuTorchModule {
252252
std::unique_ptr<Module> _module;
253253
NSMutableDictionary<NSString *, NSMutableArray<ExecuTorchValue *> *> *_inputs;
254254
NSMutableDictionary<NSString *, NSMutableArray<ExecuTorchValue *> *> *_outputs;
255-
// Strong reference to the most recently passed BackendOptionsMap. The
256-
// C++ Module borrows a pointer into the map's underlying C++ storage and
257-
// dereferences it during lazy load_method calls (triggered by forward),
258-
// so the ObjC wrapper must keep it alive. ARC handles the lifetime.
259-
//
260-
// INVARIANT: this ivar is only ever overwritten with another non-nil
261-
// BackendOptionsMap, and never reset to nil while `_module` is alive.
262-
// Resetting to nil would release the C++ map while `_module` still holds
263-
// a borrowed pointer into it.
264-
//
265-
// THREAD SAFETY: like the rest of `ExecuTorchModule`, write access here
266-
// is not thread-safe. The ARC retain/release on assignment is non-atomic
267-
// for direct ivars; serialize `loadWithOptions:` calls externally if you
268-
// share a `Module` across threads.
269-
//
270-
// TODO: remove this ivar once the C++ Module owns its LoadBackendOptionsMap
271-
// by value (today it borrows a raw pointer). With owned options the ObjC
272-
// wrapper has nothing to retain, the thread-safety caveat above goes
273-
// away, and -loadMethod:options: / -loadWithOptions: stop needing a
274-
// custom lifetime contract between the bindings and the C++ layer.
275-
ExecuTorchBackendOptionsMap *_loadedBackendOptions;
276255
}
277256

278257
- (instancetype)initWithFilePath:(NSString *)filePath
@@ -354,25 +333,10 @@ - (BOOL)loadWithOptions:(ExecuTorchBackendOptionsMap *)options
354333
verification:(ExecuTorchVerification)verification
355334
error:(NSError **)error {
356335
NSParameterAssert(options);
357-
// Retain the options object so the C++ borrowed pointer it contains stays
358-
// valid for the lifetime of any methods loaded with these options.
359-
// (Methods load lazily during forward(), so the borrow may outlive this
360-
// call.) See ExecuTorchBackendOptionsMap.h for the lifetime contract.
361-
//
362-
// No rollback on failure: Module::load updates its backend_options_ raw
363-
// pointer BEFORE attempting load_internal, so after a failed call the
364-
// C++ side already references `options`. The ObjC retain therefore
365-
// always matches what C++ points at, even on the failure path — a
366-
// two-phase commit here would instead leave C++ pointing at a map the
367-
// wrapper no longer retains. See:
368-
// https://github.com/pytorch/executorch/blob/6412f55a54dd3ce1f4ed220a3e96ee19b8f37967/extension/module/module.cpp#L192-L197
369-
//
370-
// TODO: once Module::load is made transactional (i.e. it only commits
371-
// `backend_options_` after load_internal succeeds), replace the
372-
// unconditional assignment below with a proper two-phase commit that
373-
// only overwrites _loadedBackendOptions on success. This removes the
374-
// "match C++'s unconditional write" workaround documented above.
375-
_loadedBackendOptions = options;
336+
// Module deep-copies the LoadBackendOptionsMap on the C++ side, so we
337+
// do not need to retain `options` past this call. ARC releases the
338+
// wrapper when the parameter goes out of scope and the Module's owned
339+
// copy keeps lazy load_method paths working.
376340
const auto errorCode = _module->load(*[options cppMap],
377341
static_cast<Program::Verification>(verification));
378342
if (errorCode != Error::Ok) {
@@ -395,22 +359,10 @@ - (BOOL)loadMethod:(NSString *)methodName
395359
options:(ExecuTorchBackendOptionsMap *)options
396360
error:(NSError **)error {
397361
NSParameterAssert(options);
398-
// Do NOT assign to _loadedBackendOptions here. Module::load_method
399-
// consumes `backend_options` synchronously within this call — it is
400-
// passed through to program_->load_method and is not cached on the
401-
// Module. Only Module::load(backend_options, ...) stores the pointer
402-
// (via backend_options_). ARC keeps `options` alive for the call
403-
// duration via the parameter, so no ivar retention is needed here.
404-
// See:
405-
// load_method: https://github.com/pytorch/executorch/blob/6412f55a54dd3ce1f4ed220a3e96ee19b8f37967/extension/module/module.cpp#L353-L409
406-
// load (stores backend_options_): https://github.com/pytorch/executorch/blob/6412f55a54dd3ce1f4ed220a3e96ee19b8f37967/extension/module/module.cpp#L195
407-
//
408-
// Overwriting _loadedBackendOptions would release any map previously
409-
// installed by -loadWithOptions:, but the C++ Module's backend_options_
410-
// raw pointer would still reference that released map's storage — a
411-
// use-after-free on the next lazy load_method. The XCTest
412-
// testMixedLoadWithOptionsAndLoadMethodWithOptionsOnMultiMethodModel
413-
// pins this invariant via a weak reference.
362+
// load_method consumes `options` synchronously: the cppMap pointer is
363+
// passed through to program_->load_method and is not cached on the C++
364+
// Module. ARC keeps `options` alive for the duration of this call via
365+
// the parameter, so no extra retention is needed here.
414366
const auto errorCode = _module->load_method(methodName.UTF8String,
415367
/*planned_memory=*/nullptr,
416368
/*event_tracer=*/nullptr,

extension/apple/ExecuTorch/__tests__/ModuleTest.swift

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -525,22 +525,23 @@ class ModuleTest: XCTestCase {
525525
}
526526

527527
// Mixed sequence on a multi-method delegated model:
528-
// 1. load(optionsA) — installs optionsA; the C++ Module
529-
// stores a raw pointer into optionsA's storage and the ObjC
530-
// wrapper retains optionsA via _loadedBackendOptions.
528+
// 1. load(optionsA) — installs optionsA. The C++ Module
529+
// deep-copies optionsA at load time; the Apple binding does NOT
530+
// retain the input map. After the autoreleasepool drains, the
531+
// caller's optionsA is dropped — the C++ Module's internal copy
532+
// keeps the configuration alive.
531533
// 2. load("mul", options: optionsB) — loads "mul" explicitly with
532-
// optionsB, synchronously. Must NOT release optionsA (doing so
533-
// would leave _module->backend_options_ dangling).
534-
// 3. forward(inputs) — triggers a lazy load_method on
535-
// "forward" which falls back to the stored pointer (into optionsA).
534+
// optionsB; the C++ Module deep-copies optionsB.
535+
// 3. forward(inputs) — triggers a lazy load_method on
536+
// "forward" which consults the deep-copied optionsA stored
537+
// inside the C++ Module.
536538
//
537-
// The XCTAssertNotNil(weakA) after step 2 is the deterministic check:
538-
// a buggy loadMethod:options: that assigns `_loadedBackendOptions =
539-
// options` releases optionsA's last strong ref there, weakA becomes
540-
// nil, and the assertion fails independent of heap layout. With the
541-
// correct implementation weakA stays non-nil. The forward/execute
542-
// assertions additionally verify the positive path end-to-end.
543-
func testMixedLoadWithOptionsAndLoadMethodWithOptionsOnMultiMethodModel() throws {
539+
// The XCTAssertNil(weakA) after step 1 is the deterministic check:
540+
// it proves the Apple binding does not silently retain the input map
541+
// (a regression would re-introduce a strong reference). The forward
542+
// assertions verify the positive path: even though the caller has
543+
// dropped optionsA, the deep-copied configuration is still applied.
544+
func testMultiMethodLoadAndForwardWithBackendOptions() throws {
544545
let modelPath = try requireFixture("add_mul_coreml", ofType: "pte")
545546
let module = Module(filePath: modelPath)
546547

@@ -552,7 +553,9 @@ class ModuleTest: XCTestCase {
552553
weakA = optionsA
553554
try module.load(options: optionsA)
554555
}
555-
XCTAssertNotNil(weakA, "Module must retain optionsA after load(optionsA)")
556+
XCTAssertNil(weakA,
557+
"Apple binding must not retain the input BackendOptionsMap — " +
558+
"the C++ Module deep-copies it at load time")
556559

557560
try autoreleasepool {
558561
let optionsB = try BackendOptionsMap(options: [
@@ -561,11 +564,9 @@ class ModuleTest: XCTestCase {
561564
try module.load("mul", options: optionsB)
562565
}
563566
XCTAssertTrue(module.isLoaded("mul"))
564-
XCTAssertNotNil(weakA,
565-
"load(\"mul\", options: optionsB) must not release optionsA — " +
566-
"_module->backend_options_ still points into its storage")
567567

568-
// Lazy load_method("forward") must still see valid optionsA storage.
568+
// Lazy load_method("forward") must still apply the configuration
569+
// from the now-released optionsA, via the C++ Module's deep copy.
569570
let inputs: [Tensor<Float>] = [Tensor([2]), Tensor([3])]
570571
let addOuts: [Value] = try module.forward(inputs)
571572
XCTAssertEqual(addOuts.first?.tensor(), Tensor([Float(5)]))

extension/apple/ExecuTorch/__tests__/ObjC/ModuleTestObjC.m

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,55 @@ @interface ModuleTestObjC : XCTestCase
1515

1616
@implementation ModuleTestObjC
1717

18+
// Pins the deep-copy contract: -loadWithOptions: must not retain the
19+
// options wrapper. The C++ Module deep-copies the LoadBackendOptionsMap
20+
// (and its BackendOption arrays) into Module-owned storage, so dropping
21+
// the ObjC wrapper after loadWithOptions: returns must be safe and a
22+
// subsequent forward() must continue to work using the Module's owned
23+
// copy. If a future refactor reverts to the borrowed-pointer design and
24+
// reintroduces an ivar to retain the wrapper, this test fails (the
25+
// weak reference stays non-nil).
26+
- (void)testLoadWithOptionsDoesNotRetainOptions {
27+
NSString *modelPath = [self requireFixture:@"add_coreml" ofType:@"pte"];
28+
if (!modelPath) return;
29+
NSError *error = nil;
30+
ExecuTorchModule *module = [[ExecuTorchModule alloc] initWithFilePath:modelPath];
31+
32+
__weak ExecuTorchBackendOptionsMap *weakOptions = nil;
33+
@autoreleasepool {
34+
ExecuTorchBackendOptionsMap *options = [ExecuTorchBackendOptionsMap mapWithOptions:@{
35+
@"CoreMLBackend": @[
36+
[ExecuTorchBackendOption optionWithKey:@"compute_unit" stringValue:@"cpu_only"],
37+
],
38+
} error:&error];
39+
XCTAssertNotNil(options, @"%@", error);
40+
weakOptions = options;
41+
XCTAssertTrue([module loadWithOptions:options error:&error], @"%@", error);
42+
}
43+
// Local + autoreleased refs have drained. With deep-copy, the wrapper
44+
// can be reclaimed and the C++ Module keeps its own copy of the
45+
// backend options for any future lazy load_method calls.
46+
XCTAssertNil(weakOptions,
47+
@"loadWithOptions: must not retain the map (Module deep-copies). "
48+
@"See module.cpp Module::load(LoadBackendOptionsMap, ...) deep-copy.");
49+
50+
// Forward must still execute against the Module-owned options copy.
51+
ExecuTorchTensor *one =
52+
[[ExecuTorchTensor alloc] initWithScalars:@[@1.0f] dataType:ExecuTorchDataTypeFloat];
53+
NSArray<ExecuTorchValue *> *outputs =
54+
[module forwardWithTensors:@[one, one] error:&error];
55+
XCTAssertNotNil(outputs, @"%@", error);
56+
57+
__block float result = NAN;
58+
[outputs.firstObject.tensorValue
59+
bytesWithHandler:^(const void *bytes, NSInteger count, ExecuTorchDataType dt) {
60+
if (dt == ExecuTorchDataTypeFloat && count >= 1) {
61+
result = ((const float *)bytes)[0];
62+
}
63+
}];
64+
XCTAssertEqual(result, 2.0f);
65+
}
66+
1867
- (NSBundle *)resourceBundle {
1968
#if SWIFT_PACKAGE
2069
return SWIFTPM_MODULE_BUNDLE;

0 commit comments

Comments
 (0)