diff --git a/Package.swift b/Package.swift index 3186284f5f6..a310e9ff31d 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ // https://pytorch.org/executorch/main/using-executorch-ios import PackageDescription +import Foundation let debug_suffix = "_debug" let dependencies_suffix = "_with_dependencies" @@ -126,6 +127,43 @@ for (key, value) in products { packageTargets.append(target) } +// Test fixtures. add_coreml.pte and add_mul_coreml.pte are generated at CI +// time by extension/apple/ExecuTorch/__tests__/resources/generate_coreml_test_models.py +// (invoked by scripts/build_apple_frameworks.sh before `swift test`). They +// are gitignored, so include them in test resources only when present so +// that `swift test` runs on dev machines without CoreML python deps don't +// fail at the SwiftPM resolve stage. +let testResourcesDir = "extension/apple/ExecuTorch/__tests__/resources" +var testResources: [Resource] = [.copy("resources/add.pte")] +if FileManager.default.fileExists(atPath: "\(testResourcesDir)/add_coreml.pte") { + testResources.append(.copy("resources/add_coreml.pte")) +} +if FileManager.default.fileExists(atPath: "\(testResourcesDir)/add_mul_coreml.pte") { + testResources.append(.copy("resources/add_mul_coreml.pte")) +} + +// SwiftPM resources must live under the target's path, so the ObjC test +// target uses symlinks to the canonical resources directory. The symlinks +// themselves are gitignored and (re)created by scripts/build_apple_frameworks.sh. +let objcTestsDir = "extension/apple/ExecuTorch/__tests__/ObjC" +var objcTestResources: [Resource] = [] +if FileManager.default.fileExists(atPath: "\(objcTestsDir)/add.pte") { + objcTestResources.append(.copy("add.pte")) +} +if FileManager.default.fileExists(atPath: "\(objcTestsDir)/add_coreml.pte") { + objcTestResources.append(.copy("add_coreml.pte")) +} +if FileManager.default.fileExists(atPath: "\(objcTestsDir)/add_mul_coreml.pte") { + objcTestResources.append(.copy("add_mul_coreml.pte")) +} + +let testLinkerSettings: [LinkerSetting] = [ + .unsafeFlags([ + "-Xlinker", "-force_load", + "-Xlinker", "cmake-out/kernels_optimized.xcframework/macos-arm64/libkernels_optimized_macos.a", + ]) +] + let package = Package( name: "executorch", platforms: [ @@ -141,15 +179,20 @@ let package = Package( .target(name: "kernels_optimized\(dependencies_suffix)"), ], path: "extension/apple/ExecuTorch/__tests__", - resources: [ - .copy("resources/add.pte"), + exclude: ["ObjC", "resources/generate_coreml_test_models.py", "resources/.gitignore"], + resources: testResources, + linkerSettings: testLinkerSettings + ), + .testTarget( + name: "objc_tests", + dependencies: [ + .target(name: "executorch\(debug_suffix)"), + .target(name: "kernels_optimized\(dependencies_suffix)"), ], - linkerSettings: [ - .unsafeFlags([ - "-Xlinker", "-force_load", - "-Xlinker", "cmake-out/kernels_optimized.xcframework/macos-arm64/libkernels_optimized_macos.a", - ]) - ] + path: "extension/apple/ExecuTorch/__tests__/ObjC", + exclude: [".gitignore"], + resources: objcTestResources, + linkerSettings: testLinkerSettings ) ] ) diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorch+Module.swift b/extension/apple/ExecuTorch/Exported/ExecuTorch+Module.swift index 86e9f7d3cc9..125a4a518da 100644 --- a/extension/apple/ExecuTorch/Exported/ExecuTorch+Module.swift +++ b/extension/apple/ExecuTorch/Exported/ExecuTorch+Module.swift @@ -47,6 +47,40 @@ public extension MethodMetadata { } } +public extension Module { + /// Loads the module's program with per-delegate backend options. + /// + /// The receiver retains `options` for as long as the underlying program + /// references it (lifetime tracked via ARC). + /// + /// - Parameters: + /// - options: A `BackendOptionsMap` built once from a dict of + /// per-backend `BackendOption`s, e.g. + /// `try BackendOptionsMap(["CoreMLBackend": [BackendOption("compute_unit", "cpu_and_gpu")]])`. + /// - verification: The verification level to apply when loading the program. + /// - Throws: An error if loading fails. + func load( + options: BackendOptionsMap, + verification: ModuleVerification = .minimal + ) throws { + try __load(withOptions: options, verification: verification) + } + + /// Loads a specific method from the program with per-delegate backend options. + /// + /// - Parameters: + /// - method: The name of the method to load. + /// - options: A `BackendOptionsMap` built once from a dict of + /// per-backend `BackendOption`s. + /// - Throws: An error if loading fails. + func load( + _ method: String, + options: BackendOptionsMap + ) throws { + try __loadMethod(method, options: options) + } +} + public extension Module { /// Executes a specific method with the provided input values. /// The method is loaded on demand if not already loaded. diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorch.h b/extension/apple/ExecuTorch/Exported/ExecuTorch.h index 3a12a5ddbae..d0ad6c2840a 100644 --- a/extension/apple/ExecuTorch/Exported/ExecuTorch.h +++ b/extension/apple/ExecuTorch/Exported/ExecuTorch.h @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +#import "ExecuTorchBackendOption.h" +#import "ExecuTorchBackendOptionsMap.h" #import "ExecuTorchError.h" #import "ExecuTorchLog.h" #import "ExecuTorchModule.h" diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOption.h b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOption.h new file mode 100644 index 00000000000..9509f6fce8a --- /dev/null +++ b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOption.h @@ -0,0 +1,89 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Enum to define the type of a backend option value. + */ +typedef NS_ENUM(NSInteger, ExecuTorchBackendOptionType) { + ExecuTorchBackendOptionTypeBoolean, + ExecuTorchBackendOptionTypeInteger, + ExecuTorchBackendOptionTypeString, +} NS_SWIFT_NAME(BackendOptionType); + +/** + * Represents a single key-value configuration option for a backend. + * + * Backend options are used to pass per-delegate configuration (e.g., compute + * unit, thread count, cache directory) when loading a module. Each option has + * a string key and a typed value (boolean, integer, or string). + */ +NS_SWIFT_NAME(BackendOption) +__attribute__((objc_subclassing_restricted)) +@interface ExecuTorchBackendOption : NSObject + +/** The option key name (e.g. "compute_unit", "num_threads"). */ +@property (nonatomic, readonly) NSString *key; + +/** The type of the option value. */ +@property (nonatomic, readonly) ExecuTorchBackendOptionType type; + +/** The boolean value. Only valid when type is Boolean. */ +@property (nonatomic, readonly) BOOL boolValue; + +/** The integer value. Only valid when type is Integer. */ +@property (nonatomic, readonly) NSInteger intValue; + +/** The string value. Only valid when type is String. */ +@property (nullable, nonatomic, readonly) NSString *stringValue; + +/** + * Creates a backend option with a boolean value. + * + * @param key The option key. + * @param value The boolean value. + * @return A new ExecuTorchBackendOption instance. + */ ++ (instancetype)optionWithKey:(NSString *)key + booleanValue:(BOOL)value + NS_SWIFT_NAME(init(_:_:)) + NS_RETURNS_RETAINED; + +/** + * Creates a backend option with an integer value. + * + * @param key The option key. + * @param value The integer value. + * @return A new ExecuTorchBackendOption instance. + */ ++ (instancetype)optionWithKey:(NSString *)key + integerValue:(NSInteger)value + NS_SWIFT_NAME(init(_:_:)) + NS_RETURNS_RETAINED; + +/** + * Creates a backend option with a string value. + * + * @param key The option key. + * @param value The string value. + * @return A new ExecuTorchBackendOption instance. + */ ++ (instancetype)optionWithKey:(NSString *)key + stringValue:(NSString *)value + NS_SWIFT_NAME(init(_:_:)) + NS_RETURNS_RETAINED; + ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOption.mm b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOption.mm new file mode 100644 index 00000000000..c8737ffeb3d --- /dev/null +++ b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOption.mm @@ -0,0 +1,137 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "ExecuTorchBackendOption.h" + +@implementation ExecuTorchBackendOption { + NSString *_key; + ExecuTorchBackendOptionType _type; + BOOL _boolValue; + NSInteger _intValue; + NSString *_stringValue; +} + +- (instancetype)initWithKey:(NSString *)key + booleanValue:(BOOL)value { + self = [super init]; + if (self) { + _key = [key copy]; + _type = ExecuTorchBackendOptionTypeBoolean; + _boolValue = value; + } + return self; +} + +- (instancetype)initWithKey:(NSString *)key + integerValue:(NSInteger)value { + self = [super init]; + if (self) { + _key = [key copy]; + _type = ExecuTorchBackendOptionTypeInteger; + _intValue = value; + } + return self; +} + +- (instancetype)initWithKey:(NSString *)key + stringValue:(NSString *)value { + self = [super init]; + if (self) { + _key = [key copy]; + _type = ExecuTorchBackendOptionTypeString; + _stringValue = [value copy]; + } + return self; +} + ++ (instancetype)optionWithKey:(NSString *)key + booleanValue:(BOOL)value { + return [[self alloc] initWithKey:key booleanValue:value]; +} + ++ (instancetype)optionWithKey:(NSString *)key + integerValue:(NSInteger)value { + return [[self alloc] initWithKey:key integerValue:value]; +} + ++ (instancetype)optionWithKey:(NSString *)key + stringValue:(NSString *)value { + return [[self alloc] initWithKey:key stringValue:value]; +} + +#pragma mark - NSObject + +- (NSString *)description { + switch (_type) { + case ExecuTorchBackendOptionTypeBoolean: + return [NSString stringWithFormat:@"<%@ %@=%@ (bool)>", + NSStringFromClass([self class]), _key, _boolValue ? @"true" : @"false"]; + case ExecuTorchBackendOptionTypeInteger: + return [NSString stringWithFormat:@"<%@ %@=%ld (int)>", + NSStringFromClass([self class]), _key, (long)_intValue]; + case ExecuTorchBackendOptionTypeString: + return [NSString stringWithFormat:@"<%@ %@=%@ (string)>", + NSStringFromClass([self class]), _key, + _stringValue ? [NSString stringWithFormat:@"\"%@\"", _stringValue] : @"(null)"]; + } + return [super description]; +} + +- (NSString *)debugDescription { + return [self description]; +} + +- (BOOL)isEqual:(id)object { + if (self == object) { + return YES; + } + if (![object isKindOfClass:[ExecuTorchBackendOption class]]) { + return NO; + } + ExecuTorchBackendOption *other = (ExecuTorchBackendOption *)object; + if (_type != other.type || ![_key isEqualToString:other.key]) { + return NO; + } + switch (_type) { + case ExecuTorchBackendOptionTypeBoolean: + return _boolValue == other.boolValue; + case ExecuTorchBackendOptionTypeInteger: + return _intValue == other.intValue; + case ExecuTorchBackendOptionTypeString: { + // Both are non-null when type is String (init enforces it), but be + // defensive in case of subclass/manual misuse. + NSString *otherString = other.stringValue; + if (_stringValue == otherString) { + return YES; + } + if (_stringValue == nil || otherString == nil) { + return NO; + } + return [_stringValue isEqualToString:otherString]; + } + } + return NO; +} + +- (NSUInteger)hash { + NSUInteger h = _key.hash ^ (NSUInteger)_type; + switch (_type) { + case ExecuTorchBackendOptionTypeBoolean: + h ^= (NSUInteger)(_boolValue ? 1 : 0); + break; + case ExecuTorchBackendOptionTypeInteger: + h ^= (NSUInteger)_intValue; + break; + case ExecuTorchBackendOptionTypeString: + h ^= _stringValue.hash; + break; + } + return h; +} + +@end diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOptionsMap.h b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOptionsMap.h new file mode 100644 index 00000000000..c192c5a4220 --- /dev/null +++ b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOptionsMap.h @@ -0,0 +1,77 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "ExecuTorchBackendOption.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * An immutable, opaque container for per-delegate load-time configuration, + * built from a dictionary mapping backend identifiers to arrays of + * `ExecuTorchBackendOption` objects. + * + * # Lifetime + * + * Once a `BackendOptionsMap` is passed to a `Module` load call, the `Module` + * **retains** it for as long as the underlying program references it. The + * caller does not need to manage this lifetime manually — ARC handles it. + * + * # Reuse + * + * The same `BackendOptionsMap` instance can be reused across multiple `Module`s + * and across multiple load calls. Build it once, pass it many times. + * + * # Validation + * + * Validation (option-key length, string-value length, integer 32-bit range) + * happens at construction time. If the input dictionary contains an invalid + * entry, the initializer returns `nil` and populates the out-error. + * + * @note The current C++ runtime stores integer option values as 32-bit `int`. + * Passing an integer outside `[INT32_MIN, INT32_MAX]` will cause the + * initializer to fail with `Error::InvalidArgument`. + */ +NS_SWIFT_NAME(BackendOptionsMap) +__attribute__((objc_subclassing_restricted)) +@interface ExecuTorchBackendOptionsMap : NSObject + +/** + * Creates a backend options map from a dictionary of per-backend options. + * + * @param options A dictionary mapping backend identifiers (e.g. "CoreMLBackend") + * to arrays of `ExecuTorchBackendOption` objects configuring that backend. + * @param error On failure, populated with an `NSError` describing the validation + * problem (e.g. invalid integer range). + * @return A new instance, or `nil` if validation fails. + */ +- (nullable instancetype)initWithOptions:(NSDictionary *> *)options + error:(NSError **)error + NS_DESIGNATED_INITIALIZER NS_SWIFT_NAME(init(options:)); + +/** + * Convenience class factory mirroring `-initWithOptions:error:`. + */ ++ (nullable instancetype)mapWithOptions:(NSDictionary *> *)options + error:(NSError **)error + NS_RETURNS_RETAINED; + +/** + * The options the receiver was constructed with, exposed as a deep-immutable + * snapshot dictionary captured at construction time. Useful for debugging and + * round-tripping. + */ +@property (nonatomic, readonly) NSDictionary *> *options; + ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOptionsMap.mm b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOptionsMap.mm new file mode 100644 index 00000000000..bf854e9fd83 --- /dev/null +++ b/extension/apple/ExecuTorch/Exported/ExecuTorchBackendOptionsMap.mm @@ -0,0 +1,257 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "ExecuTorchBackendOptionsMap.h" +#import "ExecuTorchBackendOptionsMap+Internal.h" + +#import "ExecuTorchError.h" + +#import + +#import +#import +#import + +using executorch::runtime::BackendOption; +using executorch::runtime::Error; +using executorch::runtime::LoadBackendOptionsMap; +using executorch::runtime::Span; +using executorch::runtime::kMaxOptionKeyLength; +using executorch::runtime::kMaxOptionValueLength; + +namespace { + +// Translate an ObjC dictionary into the C++ map + the backing storage whose +// Spans the map references. On failure, returns a non-OK Error, writes a +// human-readable explanation to `*outReason`, and leaves the outputs in a +// partial (but destructible) state. Callers should construct the +// storage/map locally and only commit them to ivars on success. +// +// Lifetime note on `storage`: each `Span` stored inside `map` +// points at the heap buffer owned by one of the inner `std::vector`s. Even +// if the OUTER `std::vector` reallocates, those inner vectors are +// move-constructed to their new home, which preserves their data() pointer +// (the heap buffer is moved, not copied). So the Spans remain valid across +// outer-vector growth. The `reserve(options.count)` below is belt-and- +// suspenders: we never need to grow beyond the reservation because the +// loop iterates exactly `options.count` times. +Error buildBackendOptionsMap( + NSDictionary *> *options, + std::vector> &storage, + LoadBackendOptionsMap &map, + NSString * __autoreleasing *outReason) { + storage.reserve(options.count); + for (NSString *backendId in options) { + const char *backendIdCStr = backendId.UTF8String; + if (backendIdCStr == nullptr) { + *outReason = @"backend id is not valid UTF-8"; + return Error::InvalidArgument; + } + // Reject empty backend ids early so the error message names the caller + // bug precisely; the C++ set_options below also rejects empty ids, but + // via a generic InvalidArgument. + if (backendIdCStr[0] == '\0') { + *outReason = @"backend id is empty"; + return Error::InvalidArgument; + } + NSArray *backendOptions = options[backendId]; + std::vector opts; + opts.reserve(backendOptions.count); + // Reject duplicate keys within the same backend. The C++ runtime's + // set_option path would otherwise silently resolve to last-write-wins + // (or undefined depending on the backend), which is almost never what + // the caller intended. + std::unordered_set seenKeys; + seenKeys.reserve(backendOptions.count); + for (ExecuTorchBackendOption *opt in backendOptions) { + BackendOption bo; + const char *keyCStr = opt.key.UTF8String; + if (keyCStr == nullptr) { + *outReason = [NSString stringWithFormat: + @"option key for backend '%@' is not valid UTF-8", backendId]; + return Error::InvalidArgument; + } + if (keyCStr[0] == '\0') { + *outReason = [NSString stringWithFormat: + @"option key for backend '%@' is empty", backendId]; + return Error::InvalidArgument; + } + // The C++ runtime stores option keys in a fixed-size buffer of + // kMaxOptionKeyLength (including the null terminator). Reject inputs + // that would silently truncate. + const size_t keyLen = strlen(keyCStr); + if (keyLen >= kMaxOptionKeyLength) { + *outReason = [NSString stringWithFormat: + @"option key '%@' for backend '%@' is %zu bytes; limit is %zu", + opt.key, backendId, keyLen, (size_t)(kMaxOptionKeyLength - 1)]; + return Error::InvalidArgument; + } + if (!seenKeys.insert(std::string(keyCStr)).second) { + *outReason = [NSString stringWithFormat: + @"duplicate option key '%@' for backend '%@'", + opt.key, backendId]; + return Error::InvalidArgument; + } + strncpy(bo.key, keyCStr, kMaxOptionKeyLength - 1); + bo.key[kMaxOptionKeyLength - 1] = '\0'; + switch (opt.type) { + case ExecuTorchBackendOptionTypeBoolean: + bo.value = (bool)opt.boolValue; + break; + case ExecuTorchBackendOptionTypeInteger: + // The C++ runtime stores integer option values as 32-bit `int`. + // Reject anything that would silently narrow. On Apple's current + // 64-bit-only targets NSInteger is int64_t, so this check is + // meaningful; on a hypothetical 32-bit build NSInteger would be + // int32_t and the comparison would be tautological (still correct, + // just never trips). + if (opt.intValue < INT_MIN || opt.intValue > INT_MAX) { + *outReason = [NSString stringWithFormat: + @"option '%@' for backend '%@' is %ld; out of 32-bit int range", + opt.key, backendId, (long)opt.intValue]; + return Error::InvalidArgument; + } + bo.value = (int)opt.intValue; + break; + case ExecuTorchBackendOptionTypeString: { + const char *valCStr = opt.stringValue.UTF8String; + if (valCStr == nullptr) { + *outReason = [NSString stringWithFormat: + @"option '%@' value for backend '%@' is not valid UTF-8", + opt.key, backendId]; + return Error::InvalidArgument; + } + // Same fixed-buffer constraint as the key. + const size_t valLen = strlen(valCStr); + if (valLen >= kMaxOptionValueLength) { + *outReason = [NSString stringWithFormat: + @"option '%@' value for backend '%@' is %zu bytes; limit is %zu", + opt.key, backendId, valLen, (size_t)(kMaxOptionValueLength - 1)]; + return Error::InvalidArgument; + } + std::array arr{}; + strncpy(arr.data(), valCStr, kMaxOptionValueLength - 1); + arr[kMaxOptionValueLength - 1] = '\0'; + bo.value = arr; + break; + } + } + opts.push_back(bo); + } + storage.push_back(std::move(opts)); + auto &backOpts = storage.back(); + // C++ set_options enforces backend-id length (kMaxBackendIdLength = 64). + // We pass through its Error code unchanged but surface a targeted reason. + const auto err = map.set_options( + backendIdCStr, + Span(backOpts.data(), backOpts.size())); + if (err != Error::Ok) { + *outReason = [NSString stringWithFormat: + @"failed to install options for backend '%@' (C++ Error %d; backend id may exceed 63 bytes or map is full)", + backendId, (int)err]; + return err; + } + } + return Error::Ok; +} + +} // namespace + +@implementation ExecuTorchBackendOptionsMap { + // Backing storage for the Spans referenced by _map. Must outlive _map. + std::vector> _storage; + LoadBackendOptionsMap _map; + // Cached snapshot of the original input, for the public -options accessor. + // Built once at init; immutable. + NSDictionary *> *_snapshot; +} + +- (nullable instancetype)initWithOptions:(NSDictionary *> *)options + error:(NSError **)error { + self = [super init]; + if (!self) { + return nil; + } + // Build into local temporaries so a partial failure leaves the ivars in a + // pristine (default-constructed) state. Commit only on full success. + std::vector> storage; + LoadBackendOptionsMap map; + NSString *reason = nil; + const auto buildError = buildBackendOptionsMap(options, storage, map, &reason); + if (buildError != Error::Ok) { + if (error) { + *error = ExecuTorchErrorWithCodeAndDescription( + (ExecuTorchErrorCode)buildError, reason); + } + return nil; + } + _storage = std::move(storage); + // Move-assignment order matters: `_storage` is moved first so the Spans + // inside `map` (which point at each inner vector's heap buffer) survive + // into `_storage`. std::vector's move preserves data() pointers, so the + // Spans remain valid. This relies on `LoadBackendOptionsMap`'s move + // being a shallow member-wise move that does not recompute span + // pointers; if that ever changes, the move order here would need to be + // revisited. The end-to-end CoreML-delegated test exercises this path. + _map = std::move(map); + // Snapshot the input as a shallow-immutable dictionary, then also copy + // each value array immutably. Combined with ExecuTorchBackendOption + // itself being immutable, this guarantees the public -options accessor + // returns a consistent view even if the caller passes mutable container + // subclasses and mutates them later. + NSMutableDictionary *snapshot = [NSMutableDictionary dictionaryWithCapacity:options.count]; + for (NSString *key in options) { + snapshot[key] = [options[key] copy]; + } + _snapshot = [snapshot copy]; + return self; +} + ++ (nullable instancetype)mapWithOptions:(NSDictionary *> *)options + error:(NSError **)error { + return [[self alloc] initWithOptions:options error:error]; +} + +- (NSDictionary *> *)options { + return _snapshot; +} + +- (const LoadBackendOptionsMap *)cppMap { + return &_map; +} + +#pragma mark - NSObject + +- (NSString *)description { + // Compact one-line format. The default NSDictionary formatter is multi- + // line and hard to read in `po`. Backends are listed in the dict's + // enumeration order (insertion order is not guaranteed by NSDictionary, + // but in practice this is good enough for debugging). + if (_snapshot.count == 0) { + return [NSString stringWithFormat:@"<%@ (empty)>", + NSStringFromClass([self class])]; + } + NSMutableArray *backendStrings = + [NSMutableArray arrayWithCapacity:_snapshot.count]; + for (NSString *backendId in _snapshot) { + NSArray *opts = _snapshot[backendId]; + NSMutableArray *optStrings = + [NSMutableArray arrayWithCapacity:opts.count]; + for (ExecuTorchBackendOption *opt in opts) { + [optStrings addObject:opt.description]; + } + [backendStrings addObject: + [NSString stringWithFormat:@"%@=[%@]", backendId, + [optStrings componentsJoinedByString:@", "]]]; + } + return [NSString stringWithFormat:@"<%@ %@>", + NSStringFromClass([self class]), + [backendStrings componentsJoinedByString:@", "]]; +} + +@end diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorchModule.h b/extension/apple/ExecuTorch/Exported/ExecuTorchModule.h index 9b8400d739f..9f2633dfb9a 100644 --- a/extension/apple/ExecuTorch/Exported/ExecuTorchModule.h +++ b/extension/apple/ExecuTorch/Exported/ExecuTorchModule.h @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +#import "ExecuTorchBackendOption.h" +#import "ExecuTorchBackendOptionsMap.h" #import "ExecuTorchValue.h" NS_ASSUME_NONNULL_BEGIN @@ -186,6 +188,34 @@ NS_SWIFT_NAME(Module) */ - (BOOL)load:(NSError **)error; +/** + * Loads the module's program with per-delegate backend options. + * + * The receiver retains @c options for as long as the underlying program + * references it (lifetime tracked via ARC). + * + * @param options A `ExecuTorchBackendOptionsMap` containing per-delegate + * load-time configuration, built once via + * `[ExecuTorchBackendOptionsMap mapWithOptions:error:]`. + * @param verification The verification level to apply when loading the program. + * @param error A pointer to an NSError pointer that will be set if an error occurs. + * @return YES if the program was successfully loaded; otherwise, NO. + */ +- (BOOL)loadWithOptions:(ExecuTorchBackendOptionsMap *)options + verification:(ExecuTorchVerification)verification + error:(NSError **)error NS_REFINED_FOR_SWIFT; + +/** + * Loads the module's program with per-delegate backend options using minimal verification. + * + * @param options A `ExecuTorchBackendOptionsMap` containing per-delegate + * load-time configuration. + * @param error A pointer to an NSError pointer that will be set if an error occurs. + * @return YES if the program was successfully loaded; otherwise, NO. + */ +- (BOOL)loadWithOptions:(ExecuTorchBackendOptionsMap *)options + error:(NSError **)error NS_REFINED_FOR_SWIFT; + /** * Checks if the module is loaded. * @@ -203,6 +233,19 @@ NS_SWIFT_NAME(Module) - (BOOL)loadMethod:(NSString *)methodName error:(NSError **)error NS_SWIFT_NAME(load(_:)); +/** + * Loads a specific method from the program with per-delegate backend options. + * + * @param methodName A string representing the name of the method to load. + * @param options A `ExecuTorchBackendOptionsMap` containing per-delegate + * load-time configuration. + * @param error A pointer to an NSError pointer that is set if an error occurs. + * @return YES if the method was successfully loaded; otherwise, NO. + */ +- (BOOL)loadMethod:(NSString *)methodName + options:(ExecuTorchBackendOptionsMap *)options + error:(NSError **)error NS_REFINED_FOR_SWIFT; + /** * Checks if a specific method is loaded. * diff --git a/extension/apple/ExecuTorch/Exported/ExecuTorchModule.mm b/extension/apple/ExecuTorch/Exported/ExecuTorchModule.mm index 69bb59c860e..53b7d997c6a 100644 --- a/extension/apple/ExecuTorch/Exported/ExecuTorchModule.mm +++ b/extension/apple/ExecuTorch/Exported/ExecuTorchModule.mm @@ -8,11 +8,16 @@ #import "ExecuTorchModule.h" +#import "ExecuTorchBackendOption.h" +#import "ExecuTorchBackendOptionsMap.h" +#import "ExecuTorchBackendOptionsMap+Internal.h" #import "ExecuTorchError.h" #import "ExecuTorchUtils.h" #import #import +#import +#import using namespace executorch::extension; using namespace executorch::runtime; @@ -247,6 +252,27 @@ @implementation ExecuTorchModule { std::unique_ptr _module; NSMutableDictionary *> *_inputs; NSMutableDictionary *> *_outputs; + // Strong reference to the most recently passed BackendOptionsMap. The + // C++ Module borrows a pointer into the map's underlying C++ storage and + // dereferences it during lazy load_method calls (triggered by forward), + // so the ObjC wrapper must keep it alive. ARC handles the lifetime. + // + // INVARIANT: this ivar is only ever overwritten with another non-nil + // BackendOptionsMap, and never reset to nil while `_module` is alive. + // Resetting to nil would release the C++ map while `_module` still holds + // a borrowed pointer into it. + // + // THREAD SAFETY: like the rest of `ExecuTorchModule`, write access here + // is not thread-safe. The ARC retain/release on assignment is non-atomic + // for direct ivars; serialize `loadWithOptions:` calls externally if you + // share a `Module` across threads. + // + // TODO: remove this ivar once the C++ Module owns its LoadBackendOptionsMap + // by value (today it borrows a raw pointer). With owned options the ObjC + // wrapper has nothing to retain, the thread-safety caveat above goes + // away, and -loadMethod:options: / -loadWithOptions: stop needing a + // custom lifetime contract between the bindings and the C++ layer. + ExecuTorchBackendOptionsMap *_loadedBackendOptions; } - (instancetype)initWithFilePath:(NSString *)filePath @@ -324,6 +350,80 @@ - (BOOL)loadMethod:(NSString *)methodName return YES; } +- (BOOL)loadWithOptions:(ExecuTorchBackendOptionsMap *)options + verification:(ExecuTorchVerification)verification + error:(NSError **)error { + NSParameterAssert(options); + // Retain the options object so the C++ borrowed pointer it contains stays + // valid for the lifetime of any methods loaded with these options. + // (Methods load lazily during forward(), so the borrow may outlive this + // call.) See ExecuTorchBackendOptionsMap.h for the lifetime contract. + // + // No rollback on failure: Module::load updates its backend_options_ raw + // pointer BEFORE attempting load_internal, so after a failed call the + // C++ side already references `options`. The ObjC retain therefore + // always matches what C++ points at, even on the failure path — a + // two-phase commit here would instead leave C++ pointing at a map the + // wrapper no longer retains. See: + // https://github.com/pytorch/executorch/blob/6412f55a54dd3ce1f4ed220a3e96ee19b8f37967/extension/module/module.cpp#L192-L197 + // + // TODO: once Module::load is made transactional (i.e. it only commits + // `backend_options_` after load_internal succeeds), replace the + // unconditional assignment below with a proper two-phase commit that + // only overwrites _loadedBackendOptions on success. This removes the + // "match C++'s unconditional write" workaround documented above. + _loadedBackendOptions = options; + const auto errorCode = _module->load(*[options cppMap], + static_cast(verification)); + if (errorCode != Error::Ok) { + if (error) { + *error = ExecuTorchErrorWithCode((ExecuTorchErrorCode)errorCode); + } + return NO; + } + return YES; +} + +- (BOOL)loadWithOptions:(ExecuTorchBackendOptionsMap *)options + error:(NSError **)error { + return [self loadWithOptions:options + verification:ExecuTorchVerificationMinimal + error:error]; +} + +- (BOOL)loadMethod:(NSString *)methodName + options:(ExecuTorchBackendOptionsMap *)options + error:(NSError **)error { + NSParameterAssert(options); + // Do NOT assign to _loadedBackendOptions here. Module::load_method + // consumes `backend_options` synchronously within this call — it is + // passed through to program_->load_method and is not cached on the + // Module. Only Module::load(backend_options, ...) stores the pointer + // (via backend_options_). ARC keeps `options` alive for the call + // duration via the parameter, so no ivar retention is needed here. + // See: + // load_method: https://github.com/pytorch/executorch/blob/6412f55a54dd3ce1f4ed220a3e96ee19b8f37967/extension/module/module.cpp#L353-L409 + // load (stores backend_options_): https://github.com/pytorch/executorch/blob/6412f55a54dd3ce1f4ed220a3e96ee19b8f37967/extension/module/module.cpp#L195 + // + // Overwriting _loadedBackendOptions would release any map previously + // installed by -loadWithOptions:, but the C++ Module's backend_options_ + // raw pointer would still reference that released map's storage — a + // use-after-free on the next lazy load_method. The XCTest + // testMixedLoadWithOptionsAndLoadMethodWithOptionsOnMultiMethodModel + // pins this invariant via a weak reference. + const auto errorCode = _module->load_method(methodName.UTF8String, + /*planned_memory=*/nullptr, + /*event_tracer=*/nullptr, + [options cppMap]); + if (errorCode != Error::Ok) { + if (error) { + *error = ExecuTorchErrorWithCode((ExecuTorchErrorCode)errorCode); + } + return NO; + } + return YES; +} + - (BOOL)isMethodLoaded:(NSString *)methodName { return _module->is_method_loaded(methodName.UTF8String); } diff --git a/extension/apple/ExecuTorch/Internal/ExecuTorchBackendOptionsMap+Internal.h b/extension/apple/ExecuTorch/Internal/ExecuTorchBackendOptionsMap+Internal.h new file mode 100644 index 00000000000..9ec704fdbdb --- /dev/null +++ b/extension/apple/ExecuTorch/Internal/ExecuTorchBackendOptionsMap+Internal.h @@ -0,0 +1,35 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +// Internal extension header exposing the underlying C++ map to other ObjC++ +// translation units in this module (e.g. ExecuTorchModule.mm). Not part of the +// public umbrella header. The C++ types in the method signatures mean this +// header is ObjC++-only — guarded against accidental import from a `.m` file. + +#import "ExecuTorchBackendOptionsMap.h" + +#ifdef __cplusplus + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ExecuTorchBackendOptionsMap (Internal) + +/** + * Pointer to the underlying C++ `LoadBackendOptionsMap`. The map is owned by + * the receiver; callers must not retain or destroy it directly. Lifetime is + * tied to the lifetime of this `ExecuTorchBackendOptionsMap` instance. + */ +- (const executorch::runtime::LoadBackendOptionsMap *)cppMap; + +@end + +NS_ASSUME_NONNULL_END + +#endif // __cplusplus diff --git a/extension/apple/ExecuTorch/__tests__/ModuleTest.swift b/extension/apple/ExecuTorch/__tests__/ModuleTest.swift index 1cc4a31c4a3..db75345f071 100644 --- a/extension/apple/ExecuTorch/__tests__/ModuleTest.swift +++ b/extension/apple/ExecuTorch/__tests__/ModuleTest.swift @@ -18,6 +18,30 @@ class ModuleTest: XCTestCase { #endif } + /// Resolves a fixture by name. In CI (the `CI` env var is set, regardless + /// of value — matches the convention used by GitHub Actions / Sandcastle / + /// most CI systems), absence is a hard failure (a thrown non-`XCTSkip` + /// error → the test is reported as failed, not skipped). Locally, absence + /// is a soft skip — convenient on dev machines without the CoreML python + /// deps. + private func requireFixture(_ name: String, ofType type: String) throws -> String { + if let path = resourceBundle.path(forResource: name, ofType: type) { + return path + } + let message = "\(name).\(type) not bundled." + if ProcessInfo.processInfo.environment["CI"] != nil { + // Throw a plain Error (NOT XCTSkip) so the test is reported as failed + // rather than skipped. The thrown error's localizedDescription is the + // single failure artifact recorded for the test. + throw NSError( + domain: "ModuleTest.FixtureMissing", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "[CI] \(message)"] + ) + } + throw XCTSkip(message) + } + func testLoad() { guard let modelPath = resourceBundle.path(forResource: "add", ofType: "pte") else { XCTFail("Couldn't find the model file") @@ -193,4 +217,361 @@ class ModuleTest: XCTestCase { XCTAssertNoThrow(try module.setInputs(Tensor([2]), Tensor([3]))) XCTAssertEqual(try module.forward(), Tensor([5])) } + + func testBackendOptionCreation() { + let boolOption = BackendOption("use_cache", true) + XCTAssertEqual(boolOption.key, "use_cache") + XCTAssertEqual(boolOption.type, .boolean) + XCTAssertTrue(boolOption.boolValue) + + let intOption = BackendOption("num_threads", 4) + XCTAssertEqual(intOption.key, "num_threads") + XCTAssertEqual(intOption.type, .integer) + XCTAssertEqual(intOption.intValue, 4) + + let stringOption = BackendOption("compute_unit", "cpu_and_gpu") + XCTAssertEqual(stringOption.key, "compute_unit") + XCTAssertEqual(stringOption.type, .string) + XCTAssertEqual(stringOption.stringValue, "cpu_and_gpu") + } + + func testBackendOptionEqualityHashAndDescription() { + // Equality and hash agree on equal contents, differ on any field. + XCTAssertEqual(BackendOption("k", true), BackendOption("k", true)) + XCTAssertEqual(BackendOption("k", 4), BackendOption("k", 4)) + XCTAssertEqual(BackendOption("k", "v"), BackendOption("k", "v")) + + XCTAssertNotEqual(BackendOption("k", true), BackendOption("k", false)) + XCTAssertNotEqual(BackendOption("k", 4), BackendOption("k", 5)) + XCTAssertNotEqual(BackendOption("k", "v"), BackendOption("k", "w")) + XCTAssertNotEqual(BackendOption("k1", 4), BackendOption("k2", 4)) + // Different types with same key are not equal. + XCTAssertNotEqual(BackendOption("k", 1), BackendOption("k", true)) + + XCTAssertEqual( + BackendOption("k", true).hashValue, + BackendOption("k", true).hashValue + ) + // Set membership works. + let set: Set = [BackendOption("k", 1), BackendOption("k", 1)] + XCTAssertEqual(set.count, 1) + + // Description is human-readable, not a pointer. + let desc = BackendOption("compute_unit", "cpu_and_gpu").description + XCTAssertTrue(desc.contains("compute_unit")) + XCTAssertTrue(desc.contains("cpu_and_gpu")) + XCTAssertFalse(desc.contains("0x"), "description should not include a pointer: \(desc)") + } + + // Exercises the full feature end-to-end against a delegated model: + // load(options) installs backend options, then forward() triggers lazy + // load_method which consumes them. A delegated fixture is required so + // the per-delegate option lookup actually runs. + func testLoadWithBackendOptionsThenExecuteOnCoreMLDelegatedModel() throws { + let modelPath = try requireFixture("add_coreml", ofType: "pte") + let module = Module(filePath: modelPath) + let options = try BackendOptionsMap(options: [ + "CoreMLBackend": [ + BackendOption("compute_unit", "cpu_and_gpu"), + BackendOption("_use_new_cache", true), + ] + ]) + XCTAssertNoThrow(try module.load(options: options)) + // No explicit load("forward") here — exercise the lazy load_method path + // that consumes the retained LoadBackendOptionsMap. + let inputs: [Tensor] = [Tensor([1]), Tensor([1])] + var outputs: [Value]? + XCTAssertNoThrow(outputs = try module.forward(inputs)) + XCTAssertEqual(outputs?.first?.tensor(), Tensor([Float(2)])) + } + + // Calling load(_:BackendOptionsMap) repeatedly replaces the installed + // options. The Module retains the most recently passed map via ARC; the + // previous one is released only after the new one is installed, so the + // C++ pointer it stored is always valid. + func testRepeatedLoadWithBackendOptionsThenExecuteOnCoreMLDelegatedModel() throws { + let modelPath = try requireFixture("add_coreml", ofType: "pte") + let module = Module(filePath: modelPath) + + let firstOptions = try BackendOptionsMap(options: [ + "CoreMLBackend": [ + BackendOption("compute_unit", "cpu_only"), + ] + ]) + XCTAssertNoThrow(try module.load(options: firstOptions)) + + let secondOptions = try BackendOptionsMap(options: [ + "CoreMLBackend": [ + BackendOption("compute_unit", "cpu_and_gpu"), + BackendOption("_use_new_cache", true), + ] + ]) + XCTAssertNoThrow(try module.load(options: secondOptions)) + + // Lazy load_method via forward() should now see the second options. + let inputs: [Tensor] = [Tensor([1]), Tensor([1])] + var outputs: [Value]? + XCTAssertNoThrow(outputs = try module.forward(inputs)) + XCTAssertEqual(outputs?.first?.tensor(), Tensor([Float(2)])) + } + + // Validation happens at BackendOptionsMap construction time. The current + // C++ runtime stores integer option values as 32-bit `int` and stores + // string keys/values in fixed-size buffers, so values outside the + // representable range or strings exceeding the buffer must surface as a + // thrown error rather than silently truncating. + func testBackendOptionsMapDescription() throws { + // Empty map renders without a trailing space artifact. + let empty = try BackendOptionsMap(options: [:]) + XCTAssertEqual(empty.description, "") + + // Populated map renders compactly and includes both backend id and option keys. + let populated = try BackendOptionsMap(options: [ + "CoreMLBackend": [BackendOption("compute_unit", "cpu_only")] + ]) + let desc = populated.description + XCTAssertTrue(desc.contains("CoreMLBackend"), desc) + XCTAssertTrue(desc.contains("compute_unit"), desc) + XCTAssertTrue(desc.contains("cpu_only"), desc) + XCTAssertFalse(desc.contains("\n"), "description should be a single line: \(desc)") + } + + func testBackendOptionsMapValidation() { + // Helper that asserts throwing with a specific ExecuTorch error code + // and a non-empty localizedDescription (enriched reason from the + // wrapper, not just the generic enum name). + func assertInvalidArgument( + _ build: () throws -> BackendOptionsMap, + reasonContains expected: String, + file: StaticString = #file, line: UInt = #line + ) { + XCTAssertThrowsError(try build(), file: file, line: line) { error in + let ns = error as NSError + XCTAssertEqual(ns.domain, ErrorDomain, file: file, line: line) + XCTAssertEqual( + ns.code, ErrorCode.invalidArgument.rawValue, + "unexpected code \(ns.code)", file: file, line: line) + XCTAssertTrue( + ns.localizedDescription.contains(expected), + "localizedDescription missing '\(expected)': \(ns.localizedDescription)", + file: file, line: line) + } + } + + // Integer overflow — both bounds. + assertInvalidArgument({ + try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption("too_big", Int(Int32.max) + 1)] + ]) + }, reasonContains: "too_big") + assertInvalidArgument({ + try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption("too_small", Int(Int32.min) - 1)] + ]) + }, reasonContains: "too_small") + + // Oversized key / value — well over any plausible limit. + let longKey = String(repeating: "k", count: 256) + assertInvalidArgument({ + try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption(longKey, 1)] + ]) + }, reasonContains: "limit is") + let longValue = String(repeating: "v", count: 4096) + assertInvalidArgument({ + try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption("compute_unit", longValue)] + ]) + }, reasonContains: "limit is") + + // Empty option key. + assertInvalidArgument({ + try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption("", 1)] + ]) + }, reasonContains: "empty") + + // Empty backend id. + assertInvalidArgument({ + try BackendOptionsMap(options: [ + "": [BackendOption("k", 1)] + ]) + }, reasonContains: "empty") + + // Duplicate keys within the same backend. + assertInvalidArgument({ + try BackendOptionsMap(options: [ + "AnyBackend": [ + BackendOption("dup", 1), + BackendOption("dup", 2), + ] + ]) + }, reasonContains: "duplicate") + } + + // Boundary lengths. kMaxOptionKeyLength (64) is the fixed-buffer size + // including the NUL terminator, so the largest valid key is 63 bytes. + // kMaxOptionValueLength (256) → largest valid value 255. Pins the + // off-by-one math in the C++-buffer length check at + // ExecuTorchBackendOptionsMap.mm so a future refactor cannot silently + // allow truncation. + func testBackendOptionsMapBoundaryLengths() throws { + // Max valid key: 63 bytes. + let maxKey = String(repeating: "k", count: 63) + XCTAssertNoThrow(try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption(maxKey, 1)] + ])) + // One over: 64 bytes should fail (no room for the NUL). + let tooLongKey = String(repeating: "k", count: 64) + XCTAssertThrowsError(try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption(tooLongKey, 1)] + ])) + + // Max valid value: 255 bytes. + let maxValue = String(repeating: "v", count: 255) + XCTAssertNoThrow(try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption("compute_unit", maxValue)] + ])) + // One over: 256 bytes should fail. + let tooLongValue = String(repeating: "v", count: 256) + XCTAssertThrowsError(try BackendOptionsMap(options: [ + "AnyBackend": [BackendOption("compute_unit", tooLongValue)] + ])) + } + + // The .options accessor must return a deep-immutable snapshot of the + // construction input. Mutating the original containers afterwards must + // not leak through. + func testBackendOptionsMapOptionsSnapshotIsDeepImmutable() throws { + let mutableOpts = NSMutableArray(array: [ + BackendOption("compute_unit", "cpu_only"), + ]) + let mutableDict = NSMutableDictionary(dictionary: [ + "CoreMLBackend": mutableOpts, + ]) + // Force the Swift bridge to the concrete Foundation types. + let map = try BackendOptionsMap( + options: mutableDict as! [String: [BackendOption]]) + + // Mutate the inputs after construction. + mutableOpts.add(BackendOption("_use_new_cache", true)) + mutableDict["XNNPACK"] = [BackendOption("num_threads", 4)] + + // The snapshot must be unchanged. + let snapshot = map.options + XCTAssertEqual(snapshot.count, 1) + XCTAssertEqual(snapshot["CoreMLBackend"]?.count, 1) + XCTAssertEqual(snapshot["CoreMLBackend"]?.first?.key, "compute_unit") + XCTAssertNil(snapshot["XNNPACK"]) + } + + // A single map may configure multiple backends. Exercises the outer + // backend-iteration loop in BackendOptionsMap's builder. + func testBackendOptionsMapMultipleBackends() throws { + let map = try BackendOptionsMap(options: [ + "CoreMLBackend": [ + BackendOption("compute_unit", "cpu_and_gpu"), + BackendOption("_use_new_cache", true), + ], + "XNNPACK": [ + BackendOption("num_threads", 4), + ], + ]) + let snapshot = map.options + XCTAssertEqual(snapshot.count, 2) + XCTAssertEqual(snapshot["CoreMLBackend"]?.count, 2) + XCTAssertEqual(snapshot["XNNPACK"]?.count, 1) + XCTAssertEqual(snapshot["XNNPACK"]?.first?.intValue, 4) + + let desc = map.description + XCTAssertTrue(desc.contains("CoreMLBackend"), desc) + XCTAssertTrue(desc.contains("XNNPACK"), desc) + } + + // A single BackendOptionsMap can be reused across multiple Module + // instances without copying. Each Module retains it independently via ARC. + func testBackendOptionsMapReusedAcrossModules() throws { + let modelPath = try requireFixture("add_coreml", ofType: "pte") + let options = try BackendOptionsMap(options: [ + "CoreMLBackend": [BackendOption("compute_unit", "cpu_only")] + ]) + + let inputs: [Tensor] = [Tensor([1]), Tensor([1])] + for _ in 0..<2 { + let module = Module(filePath: modelPath) + try module.load(options: options) + let outs: [Value] = try module.forward(inputs) + XCTAssertEqual(outs.first?.tensor(), Tensor([Float(2)])) + } + } + + // Covers the Module.load(_:options:) Swift wrapper over the ObjC + // loadMethod:options: bridge. Loads a specific method with per-delegate + // backend options, then executes it. + func testLoadMethodWithBackendOptionsThenExecuteOnCoreMLDelegatedModel() throws { + let modelPath = try requireFixture("add_coreml", ofType: "pte") + let module = Module(filePath: modelPath) + let options = try BackendOptionsMap(options: [ + "CoreMLBackend": [ + BackendOption("compute_unit", "cpu_and_gpu"), + ] + ]) + try module.load("forward", options: options) + XCTAssertTrue(module.isLoaded("forward")) + + let inputs: [Tensor] = [Tensor([1]), Tensor([1])] + let outputs: [Value] = try module.forward(inputs) + XCTAssertEqual(outputs.first?.tensor(), Tensor([Float(2)])) + } + + // Mixed sequence on a multi-method delegated model: + // 1. load(optionsA) — installs optionsA; the C++ Module + // stores a raw pointer into optionsA's storage and the ObjC + // wrapper retains optionsA via _loadedBackendOptions. + // 2. load("mul", options: optionsB) — loads "mul" explicitly with + // optionsB, synchronously. Must NOT release optionsA (doing so + // would leave _module->backend_options_ dangling). + // 3. forward(inputs) — triggers a lazy load_method on + // "forward" which falls back to the stored pointer (into optionsA). + // + // The XCTAssertNotNil(weakA) after step 2 is the deterministic check: + // a buggy loadMethod:options: that assigns `_loadedBackendOptions = + // options` releases optionsA's last strong ref there, weakA becomes + // nil, and the assertion fails independent of heap layout. With the + // correct implementation weakA stays non-nil. The forward/execute + // assertions additionally verify the positive path end-to-end. + func testMixedLoadWithOptionsAndLoadMethodWithOptionsOnMultiMethodModel() throws { + let modelPath = try requireFixture("add_mul_coreml", ofType: "pte") + let module = Module(filePath: modelPath) + + weak var weakA: BackendOptionsMap? + try autoreleasepool { + let optionsA = try BackendOptionsMap(options: [ + "CoreMLBackend": [BackendOption("compute_unit", "cpu_only")] + ]) + weakA = optionsA + try module.load(options: optionsA) + } + XCTAssertNotNil(weakA, "Module must retain optionsA after load(optionsA)") + + try autoreleasepool { + let optionsB = try BackendOptionsMap(options: [ + "CoreMLBackend": [BackendOption("compute_unit", "cpu_and_gpu")] + ]) + try module.load("mul", options: optionsB) + } + XCTAssertTrue(module.isLoaded("mul")) + XCTAssertNotNil(weakA, + "load(\"mul\", options: optionsB) must not release optionsA — " + + "_module->backend_options_ still points into its storage") + + // Lazy load_method("forward") must still see valid optionsA storage. + let inputs: [Tensor] = [Tensor([2]), Tensor([3])] + let addOuts: [Value] = try module.forward(inputs) + XCTAssertEqual(addOuts.first?.tensor(), Tensor([Float(5)])) + + // "mul" was loaded explicitly with optionsB and should compute 2 * 3. + let mulOuts: [Value] = try module.execute("mul", inputs) + XCTAssertEqual(mulOuts.first?.tensor(), Tensor([Float(6)])) + } } diff --git a/extension/apple/ExecuTorch/__tests__/ObjC/.gitignore b/extension/apple/ExecuTorch/__tests__/ObjC/.gitignore new file mode 100644 index 00000000000..c025289a0fd --- /dev/null +++ b/extension/apple/ExecuTorch/__tests__/ObjC/.gitignore @@ -0,0 +1,6 @@ +# Resource symlinks created at build time by scripts/build_apple_frameworks.sh. +# Pointing at ../resources/*.pte so the ObjC test target's .copy(...) can +# find them under its own path per SwiftPM rules. +add.pte +add_coreml.pte +add_mul_coreml.pte diff --git a/extension/apple/ExecuTorch/__tests__/ObjC/ModuleTestObjC.m b/extension/apple/ExecuTorch/__tests__/ObjC/ModuleTestObjC.m new file mode 100644 index 00000000000..69efaa25304 --- /dev/null +++ b/extension/apple/ExecuTorch/__tests__/ObjC/ModuleTestObjC.m @@ -0,0 +1,210 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +@interface ModuleTestObjC : XCTestCase +@end + +@implementation ModuleTestObjC + +- (NSBundle *)resourceBundle { +#if SWIFT_PACKAGE + return SWIFTPM_MODULE_BUNDLE; +#else + return [NSBundle bundleForClass:[self class]]; +#endif +} + +// Resolves a fixture by name. In CI (the `CI` env var is set, regardless +// of value — matches the convention used by GitHub Actions / Sandcastle / +// most CI systems), absence is a hard failure (`XCTFail`). Locally, absence +// is a soft skip — convenient on dev machines without the CoreML python +// deps. Returns nil when the test should not proceed; the caller should +// `return` immediately on nil. +- (nullable NSString *)requireFixture:(NSString *)name ofType:(NSString *)type { + NSString *path = [[self resourceBundle] pathForResource:name ofType:type]; + if (path) return path; + NSString *message = [NSString stringWithFormat:@"%@.%@ not bundled.", name, type]; + if (NSProcessInfo.processInfo.environment[@"CI"] != nil) { + // CI: hard fail. XCTFail records the failure; we then return nil so the + // caller exits early. Single failure artifact. + XCTFail(@"[CI] %@", message); + } else { + // Local: skip. XCTSkip throws an exception that XCTest catches and + // marks the test as skipped — the `return nil` below is unreachable. + XCTSkip(@"%@", message); + } + return nil; +} + +// Mirrors the Swift testLoadWithBackendOptionsThenExecuteOnCoreMLDelegatedModel, +// exercising the ObjC API surface directly so coverage does not depend on +// the Swift overlays. +- (void)testLoadWithOptionsThenExecuteOnCoreMLDelegatedModel { + NSString *modelPath = [self requireFixture:@"add_coreml" ofType:@"pte"]; + if (!modelPath) return; + NSError *error = nil; + ExecuTorchBackendOptionsMap *options = [ExecuTorchBackendOptionsMap mapWithOptions:@{ + @"CoreMLBackend": @[ + [ExecuTorchBackendOption optionWithKey:@"compute_unit" stringValue:@"cpu_and_gpu"], + [ExecuTorchBackendOption optionWithKey:@"_use_new_cache" booleanValue:YES], + ] + } error:&error]; + XCTAssertNotNil(options, @"%@", error); + + ExecuTorchModule *module = [[ExecuTorchModule alloc] initWithFilePath:modelPath]; + XCTAssertTrue([module loadWithOptions:options error:&error], @"%@", error); + + // No explicit -loadMethod: — exercise the lazy load_method path that + // consumes the retained backend options map. + ExecuTorchTensor *one = + [[ExecuTorchTensor alloc] initWithScalars:@[@1.0f] dataType:ExecuTorchDataTypeFloat]; + NSArray *outputs = + [module forwardWithTensors:@[one, one] error:&error]; + XCTAssertNotNil(outputs, @"%@", error); + XCTAssertEqual(outputs.count, 1u); + + __block float result = NAN; + [outputs.firstObject.tensorValue + bytesWithHandler:^(const void *bytes, NSInteger count, ExecuTorchDataType dt) { + if (dt == ExecuTorchDataTypeFloat && count >= 1) { + result = ((const float *)bytes)[0]; + } + }]; + XCTAssertEqual(result, 2.0f); +} + +// Validation: oversized integer / oversized key / oversized string value +// must each surface as nil + populated NSError, never silently truncate. +- (void)testBackendOptionsMapValidation { + NSError *error = nil; + // Oversized integer. + // NOTE: dict/array literals are extracted to locals because the C + // preprocessor only treats `()` as nesting — commas inside `@{...}` and + // `@[...]` would otherwise split the XCTAssertNil(...) macro argument. + long long oversized = (long long)INT32_MAX + 1; + ExecuTorchBackendOptionsMap *oversizedIntMap = + [ExecuTorchBackendOptionsMap mapWithOptions:@{ + @"AnyBackend": @[ + [ExecuTorchBackendOption optionWithKey:@"too_big" integerValue:(NSInteger)oversized], + ] + } error:&error]; + XCTAssertNil(oversizedIntMap); + XCTAssertNotNil(error); + + // Oversized key. + error = nil; + NSString *longKey = [@"" stringByPaddingToLength:256 withString:@"k" startingAtIndex:0]; + ExecuTorchBackendOptionsMap *oversizedKeyMap = + [ExecuTorchBackendOptionsMap mapWithOptions:@{ + @"AnyBackend": @[ + [ExecuTorchBackendOption optionWithKey:longKey integerValue:1], + ] + } error:&error]; + XCTAssertNil(oversizedKeyMap); + XCTAssertNotNil(error); + + // Oversized string value. + error = nil; + NSString *longValue = [@"" stringByPaddingToLength:4096 withString:@"v" startingAtIndex:0]; + ExecuTorchBackendOptionsMap *oversizedValueMap = + [ExecuTorchBackendOptionsMap mapWithOptions:@{ + @"AnyBackend": @[ + [ExecuTorchBackendOption optionWithKey:@"compute_unit" stringValue:longValue], + ] + } error:&error]; + XCTAssertNil(oversizedValueMap); + XCTAssertNotNil(error); +} + +// A single map can be reused across multiple Module instances. Each Module +// retains the options independently via ARC. +- (void)testBackendOptionsMapReusedAcrossModules { + NSString *modelPath = [self requireFixture:@"add_coreml" ofType:@"pte"]; + if (!modelPath) return; + NSError *error = nil; + ExecuTorchBackendOptionsMap *options = [ExecuTorchBackendOptionsMap mapWithOptions:@{ + @"CoreMLBackend": @[ + [ExecuTorchBackendOption optionWithKey:@"compute_unit" stringValue:@"cpu_only"], + ] + } error:&error]; + XCTAssertNotNil(options, @"%@", error); + + ExecuTorchTensor *one = + [[ExecuTorchTensor alloc] initWithScalars:@[@1.0f] dataType:ExecuTorchDataTypeFloat]; + + for (NSInteger i = 0; i < 2; ++i) { + ExecuTorchModule *module = [[ExecuTorchModule alloc] initWithFilePath:modelPath]; + XCTAssertTrue([module loadWithOptions:options error:&error], @"%@", error); + NSArray *outputs = + [module forwardWithTensors:@[one, one] error:&error]; + XCTAssertNotNil(outputs, @"%@", error); + __block float result = NAN; + [outputs.firstObject.tensorValue + bytesWithHandler:^(const void *bytes, NSInteger count, ExecuTorchDataType dt) { + if (dt == ExecuTorchDataTypeFloat && count >= 1) { + result = ((const float *)bytes)[0]; + } + }]; + XCTAssertEqual(result, 2.0f); + } +} + +// Covers -[ExecuTorchModule loadMethod:options:error:] and locks the +// "load_method consumes the borrow synchronously and does not cache it" +// invariant that the no-retain decision in the wrapper rests on. +// See the citation on ExecuTorchModule.mm's loadMethod:options:error: +// (module.cpp#L353-L409). If a future refactor caches `backend_options` +// on the Module, this test fails (the weak reference stays non-nil). +- (void)testLoadMethodWithOptionsDoesNotRetainOptions { + NSString *modelPath = [self requireFixture:@"add_coreml" ofType:@"pte"]; + if (!modelPath) return; + NSError *error = nil; + ExecuTorchModule *module = [[ExecuTorchModule alloc] initWithFilePath:modelPath]; + + __weak ExecuTorchBackendOptionsMap *weakOptions = nil; + @autoreleasepool { + ExecuTorchBackendOptionsMap *options = [ExecuTorchBackendOptionsMap mapWithOptions:@{ + @"CoreMLBackend": @[ + [ExecuTorchBackendOption optionWithKey:@"compute_unit" stringValue:@"cpu_and_gpu"], + ], + } error:&error]; + XCTAssertNotNil(options, @"%@", error); + weakOptions = options; + XCTAssertTrue([module loadMethod:@"forward" options:options error:&error], + @"%@", error); + XCTAssertTrue([module isMethodLoaded:@"forward"]); + } + // The local + any autoreleased refs have drained. If loadMethod:options: + // silently retained the map, weakOptions would still be live here. + XCTAssertNil(weakOptions, + @"loadMethod:options: must not retain the map (load_method consumes " + @"it synchronously). See module.cpp load_method borrow contract."); + + // The loaded method must still run — it reads from _module->methods_ + // (populated during loadMethod:options:), not from the options map. + ExecuTorchTensor *one = + [[ExecuTorchTensor alloc] initWithScalars:@[@1.0f] dataType:ExecuTorchDataTypeFloat]; + NSArray *outputs = + [module forwardWithTensors:@[one, one] error:&error]; + XCTAssertNotNil(outputs, @"%@", error); + + __block float result = NAN; + [outputs.firstObject.tensorValue + bytesWithHandler:^(const void *bytes, NSInteger count, ExecuTorchDataType dt) { + if (dt == ExecuTorchDataTypeFloat && count >= 1) { + result = ((const float *)bytes)[0]; + } + }]; + XCTAssertEqual(result, 2.0f); +} + +@end diff --git a/extension/apple/ExecuTorch/__tests__/resources/.gitignore b/extension/apple/ExecuTorch/__tests__/resources/.gitignore new file mode 100644 index 00000000000..85a87634596 --- /dev/null +++ b/extension/apple/ExecuTorch/__tests__/resources/.gitignore @@ -0,0 +1,3 @@ +# Generated by generate_coreml_test_models.py at CI time, not committed. +add_coreml.pte +add_mul_coreml.pte diff --git a/extension/apple/ExecuTorch/__tests__/resources/generate_coreml_test_models.py b/extension/apple/ExecuTorch/__tests__/resources/generate_coreml_test_models.py new file mode 100644 index 00000000000..9b9098b2dbe --- /dev/null +++ b/extension/apple/ExecuTorch/__tests__/resources/generate_coreml_test_models.py @@ -0,0 +1,70 @@ +"""Generate CoreML-delegated test fixtures for the Swift/ObjC bindings. + +Currently produces: + - add_coreml.pte: a single-method CoreML-delegated tensor-add model whose + forward(x, y) returns x + y. + - add_mul_coreml.pte: a two-method CoreML-delegated model exposing + forward(x, y) = x + y and mul(x, y) = x * y. Used to exercise mixed + load(options:) / load(_:options:) sequences where one method is loaded + explicitly with its own options and another is loaded lazily, so the + C++ Module's stored backend_options_ is consulted during the lazy path. + A non-delegated or single-method fixture does not reach that code path. + +Usage: + python extension/apple/ExecuTorch/__tests__/resources/generate_coreml_test_models.py + +This script is invoked by scripts/build_apple_frameworks.sh in CI before +`swift test` so the fixtures are always present in CI runs. The output .pte +files are gitignored; local developers who want to run the CoreML-dependent +tests should run this script once. +""" + +import os + +import torch + +from executorch.backends.apple.coreml.partition import CoreMLPartitioner +from executorch.exir import to_edge_transform_and_lower +from torch import nn + + +class AddModule(nn.Module): + def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + return x + y + + +class MulModule(nn.Module): + def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + return x * y + + +def _write_pte(exec_program, filename: str) -> None: + out_path = os.path.join(os.path.dirname(__file__), filename) + with open(out_path, "wb") as f: + exec_program.write_to_file(f) + print(f"Wrote {out_path} ({os.path.getsize(out_path)} bytes)") + + +def main() -> None: + example_inputs = (torch.tensor([1.0]), torch.tensor([1.0])) + + # Single-method add model. + ep_add = torch.export.export(AddModule().eval(), example_inputs) + add_only = to_edge_transform_and_lower( + ep_add, + partitioner=[CoreMLPartitioner()], + ).to_executorch() + _write_pte(add_only, "add_coreml.pte") + + # Two-method model: forward (add) and mul. Both are CoreML-delegated so + # each has its own per-delegate option set to query at load time. + ep_mul = torch.export.export(MulModule().eval(), example_inputs) + add_mul = to_edge_transform_and_lower( + {"forward": ep_add, "mul": ep_mul}, + partitioner=[CoreMLPartitioner()], + ).to_executorch() + _write_pte(add_mul, "add_mul_coreml.pte") + + +if __name__ == "__main__": + main() diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index a229e518ecb..68b1b088033 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -335,6 +335,17 @@ done rm -rf "$FRAMEWORK_EXECUTORCH_HEADERS_PATH" rm -rf "$FRAMEWORK_EXECUTORCH_LLM_HEADERS_PATH" +echo "Generating Swift test fixtures (requires CoreML python deps)" + +cd "$SOURCE_ROOT_DIR" +python3 extension/apple/ExecuTorch/__tests__/resources/generate_coreml_test_models.py + +# SwiftPM requires resources to live under the test target's path. The ObjC +# test target shares fixtures with the Swift one via symlinks. +ln -sfn ../resources/add.pte extension/apple/ExecuTorch/__tests__/ObjC/add.pte +ln -sfn ../resources/add_coreml.pte extension/apple/ExecuTorch/__tests__/ObjC/add_coreml.pte +ln -sfn ../resources/add_mul_coreml.pte extension/apple/ExecuTorch/__tests__/ObjC/add_mul_coreml.pte + echo "Running tests" cd "$SOURCE_ROOT_DIR"