diff --git a/CMakeLists.txt b/CMakeLists.txt index 0620bd5ad27..f180bd6f8c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -800,6 +800,15 @@ endif() if(EXECUTORCH_BUILD_VULKAN) string(APPEND _executorch_backend_dependencies "find_dependency(Threads)\n") endif() +if(EXECUTORCH_BUILD_COREAI) + string(APPEND _executorch_backend_dependencies + "include(\"\${CMAKE_CURRENT_LIST_DIR}/coreai-dependencies.cmake\")\n" + "executorch_coreai_dependencies()\n" + ) + install(FILES backends/apple/coreai/coreai-dependencies.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ExecuTorch + ) +endif() configure_file( tools/cmake/executorch-backend-dependencies.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/executorch-backend-dependencies.cmake @ONLY diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 1f90f40d915..2c1714a5847 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -8,6 +8,23 @@ if(NOT APPLE) message(FATAL_ERROR "Core AI requires Apple platforms") endif() +if(CMAKE_VERSION VERSION_LESS 3.31) + message(FATAL_ERROR "Core AI requires CMake 3.31 or newer for Swift support") +endif() + +# Inherited Clang flags and numeric definitions are not valid Swift options. +foreach(property IN ITEMS COMPILE_DEFINITIONS COMPILE_OPTIONS) + get_directory_property(_coreai_inherited ${property}) + set_property(DIRECTORY PROPERTY ${property} "") + foreach(value IN LISTS _coreai_inherited) + set_property( + DIRECTORY + APPEND + PROPERTY ${property} "$<$>:${value}>" + ) + endforeach() +endforeach() + enable_language(OBJCXX) set(_coreai_runtime_sources @@ -21,6 +38,7 @@ set(_coreai_runtime_sources runtime/coreai_storage.mm ) +# Keep ARC and C++ exception settings away from the private Swift module. function(coreai_configure_objc_target target) target_include_directories( ${target} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/runtime @@ -60,3 +78,276 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_test(NAME coreai_host_test COMMAND coreai_host_test) set_tests_properties(coreai_host_test PROPERTIES TIMEOUT 120) endif() + +if(NOT CMAKE_GENERATOR MATCHES "Ninja|Xcode") + message(FATAL_ERROR "Core AI Swift compilation requires Ninja or Xcode") +endif() +if(NOT CMAKE_SYSTEM_NAME MATCHES "^(Darwin|iOS)$") + message(FATAL_ERROR "Core AI currently supports macOS and iOS only") +endif() + +include(${CMAKE_CURRENT_LIST_DIR}/coreai-dependencies.cmake) +executorch_coreai_dependencies() +set(_coreai_deployment 27.0) +if(CMAKE_OSX_DEPLOYMENT_TARGET VERSION_GREATER _coreai_deployment) + set(_coreai_deployment "${CMAKE_OSX_DEPLOYMENT_TARGET}") +endif() +# CMake 3.31 does not infer an iOS target for its Swift compiler probe. +if(CMAKE_SYSTEM_NAME STREQUAL "iOS" + AND NOT CMAKE_GENERATOR STREQUAL "Xcode" + AND NOT CMAKE_Swift_COMPILER_TARGET +) + if(CMAKE_OSX_ARCHITECTURES) + list(GET CMAKE_OSX_ARCHITECTURES 0 _coreai_probe_arch) + else() + set(_coreai_probe_arch "${CMAKE_SYSTEM_PROCESSOR}") + endif() + set(_coreai_probe_environment "") + if(CMAKE_OSX_SYSROOT MATCHES "[Ss]imulator") + set(_coreai_probe_environment "-simulator") + endif() + set(CMAKE_Swift_COMPILER_TARGET + "${_coreai_probe_arch}-apple-ios${_coreai_deployment}${_coreai_probe_environment}" + ) +endif() +enable_language(Swift) + +# CMake has no per-target OSX_DEPLOYMENT_TARGET property for Ninja. +function(coreai_require_os27 target) + set(deployment "${_coreai_deployment}") + set(target_environment "") + if(CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(platform ios) + if(CMAKE_OSX_SYSROOT MATCHES "[Ss]imulator") + set(minimum_flag "-mios-simulator-version-min=${deployment}") + set(target_environment -simulator) + else() + set(minimum_flag "-miphoneos-version-min=${deployment}") + endif() + else() + set(platform macosx) + set(minimum_flag "-mmacosx-version-min=${deployment}") + endif() + set_target_properties( + ${target} + PROPERTIES XCODE_ATTRIBUTE_MACOSX_DEPLOYMENT_TARGET "${deployment}" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "${deployment}" + ) + if(NOT CMAKE_GENERATOR STREQUAL "Xcode") + set(arch "${CMAKE_OSX_ARCHITECTURES}") + if(NOT arch) + set(arch "${CMAKE_SYSTEM_PROCESSOR}") + endif() + list(LENGTH arch arch_count) + if(NOT arch_count EQUAL 1) + message( + FATAL_ERROR + "Core AI Ninja builds require one CMAKE_OSX_ARCHITECTURES value" + ) + endif() + set(triple "${arch}-apple-${platform}${deployment}${target_environment}") + target_compile_options( + ${target} + PRIVATE "$<$:${minimum_flag}>" + "$<$:-target;${triple}>" + ) + target_link_options( + ${target} PRIVATE "$<$:${minimum_flag}>" + "$<$:-target;${triple}>" + ) + endif() +endfunction() + +function(coreai_configure_swift_target target module_name) + set_target_properties( + ${target} PROPERTIES Swift_MODULE_NAME "${module_name}" + Swift_LANGUAGE_VERSION 6 + ) + coreai_require_os27(${target}) + target_link_libraries(${target} PRIVATE executorch::coreai_dependencies) + if(CMAKE_GENERATOR STREQUAL "Xcode") + set_target_properties( + ${target} + PROPERTIES XCODE_ATTRIBUTE_PRODUCT_MODULE_NAME "${module_name}" + XCODE_ATTRIBUTE_SWIFT_MODULE_NAME "${module_name}" + XCODE_ATTRIBUTE_SWIFT_OBJC_BRIDGING_HEADER + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h" + ) + else() + target_compile_options( + ${target} + PRIVATE + "$<$:-import-objc-header;${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h>" + ) + endif() +endfunction() + +set(_coreai_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated") +if(CMAKE_GENERATOR STREQUAL "Xcode") + string(APPEND _coreai_generated_dir + "/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)" + ) +else() + file(MAKE_DIRECTORY "${_coreai_generated_dir}") +endif() +set(_coreai_swift_header "${_coreai_generated_dir}/CoreAIBridge-Swift.h") +add_library(coreai_swift STATIC runtime/ETCoreAIModel.swift) +coreai_configure_swift_target(coreai_swift CoreAIBridge) +set_target_properties( + coreai_swift PROPERTIES Swift_MODULE_DIRECTORY "${_coreai_generated_dir}" + POSITION_INDEPENDENT_CODE ON +) +if(CMAKE_GENERATOR STREQUAL "Xcode") + set_target_properties( + coreai_swift PROPERTIES XCODE_ATTRIBUTE_SWIFT_OBJC_INTERFACE_HEADER_NAME + CoreAIBridge-Swift.h + ) + # Xcode merges architecture-specific headers into DERIVED_SOURCES_DIR. + add_custom_command( + TARGET coreai_swift + POST_BUILD + COMMAND + ${CMAKE_COMMAND} "-DPUBLISH_ROOT=${CMAKE_CURRENT_BINARY_DIR}/generated" -P + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/PublishSwiftHeader.cmake" + BYPRODUCTS "${_coreai_swift_header}" + COMMENT "Publish the merged Swift Objective-C header" + VERBATIM + ) +else() + target_compile_options( + coreai_swift + PRIVATE + "$<$:-emit-objc-header;-emit-objc-header-path;${_coreai_swift_header}>" + ) + add_custom_command( + TARGET coreai_swift + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E true + BYPRODUCTS "${_coreai_swift_header}" + COMMENT "Track the generated Swift Objective-C header" + ) +endif() +# The bridge header is an input to Swift even when only its declarations change. +set_property( + SOURCE runtime/ETCoreAIModel.swift + APPEND + PROPERTY OBJECT_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h" +) + +# Keep SDK entry points separate so delegate tests can substitute a fake. +add_library( + coreai_bridge_obj OBJECT runtime/ETCoreAITensor.mm runtime/coreai_bridge.mm +) +coreai_configure_objc_target(coreai_bridge_obj) +coreai_require_os27(coreai_bridge_obj) +set_target_properties(coreai_bridge_obj PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_include_directories(coreai_bridge_obj PRIVATE "${_coreai_generated_dir}") +add_dependencies(coreai_bridge_obj coreai_swift) +set_property( + SOURCE runtime/coreai_bridge.mm + APPEND + PROPERTY OBJECT_DEPENDS "${_coreai_swift_header}" +) + +set(_coreai_run_sdk_tests FALSE) +if(EXECUTORCH_BUILD_TESTS + AND CMAKE_SYSTEM_NAME STREQUAL "Darwin" + AND NOT CMAKE_CROSSCOMPILING +) + execute_process( + COMMAND sw_vers -productVersion + OUTPUT_VARIABLE _coreai_host_version + OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY + ) + if(_coreai_host_version VERSION_GREATER_EQUAL _coreai_deployment) + set(_coreai_run_sdk_tests TRUE) + endif() +endif() + +# The Swift tests use the bridge's internal declarations, so they compile into +# one module with it and run under xctest. Ninja on macOS only. +if(EXECUTORCH_BUILD_TESTS + AND CMAKE_SYSTEM_NAME STREQUAL "Darwin" + AND NOT CMAKE_GENERATOR STREQUAL "Xcode" +) + execute_process( + COMMAND xcrun --sdk macosx --show-sdk-platform-path + OUTPUT_VARIABLE _coreai_platform + OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY + ) + set(_coreai_xctest_frameworks + "${_coreai_platform}/Developer/Library/Frameworks" + ) + set(_coreai_xctest_libs "${_coreai_platform}/Developer/usr/lib") + add_library( + coreai_swift_bridge_test MODULE + runtime/ETCoreAIModel.swift runtime/test/SwiftBridgeTests.swift + runtime/ETCoreAITensor.mm + ) + set_target_properties( + coreai_swift_bridge_test PROPERTIES BUNDLE TRUE BUNDLE_EXTENSION xctest + ) + coreai_configure_objc_target(coreai_swift_bridge_test) + coreai_configure_swift_target(coreai_swift_bridge_test CoreAIBridgeTests) + target_compile_options( + coreai_swift_bridge_test + PRIVATE + "$<$:-F;${_coreai_xctest_frameworks};-I;${_coreai_xctest_libs}>" + ) + target_link_options( + coreai_swift_bridge_test PRIVATE "-F${_coreai_xctest_frameworks}" + "-L${_coreai_xctest_libs}" "LINKER:-rpath,${_coreai_xctest_frameworks}" + "LINKER:-rpath,${_coreai_xctest_libs}" + ) + target_link_libraries( + coreai_swift_bridge_test PRIVATE "-framework XCTest" XCTestSwiftSupport + ) + file(GLOB_RECURSE _coreai_fixture_sources CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/*.py" + ) + set(_coreai_fixture_dir "${CMAKE_CURRENT_BINARY_DIR}/bridge_fixture") + add_custom_command( + OUTPUT "${_coreai_fixture_dir}/manifest.json" + "${_coreai_fixture_dir}/dynamic/manifest.json" + BYPRODUCTS "${_coreai_fixture_dir}/model.aimodel/main.hash" + "${_coreai_fixture_dir}/model.aimodel/main.mlirb" + "${_coreai_fixture_dir}/model.aimodel/metadata.json" + "${_coreai_fixture_dir}/dynamic/model.aimodel/main.hash" + "${_coreai_fixture_dir}/dynamic/model.aimodel/main.mlirb" + "${_coreai_fixture_dir}/dynamic/model.aimodel/metadata.json" + COMMAND + "${PYTHON_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/test/export_bridge_fixture.py" + "${_coreai_fixture_dir}" + DEPENDS ${_coreai_fixture_sources} + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + COMMENT "Export the Core AI session test model" + VERBATIM + ) + add_custom_target( + coreai_bridge_fixture + DEPENDS "${_coreai_fixture_dir}/manifest.json" + "${_coreai_fixture_dir}/dynamic/manifest.json" + ) + add_dependencies(coreai_swift_bridge_test coreai_bridge_fixture) + if(_coreai_run_sdk_tests) + add_test(NAME coreai_swift_bridge_test + COMMAND xcrun xctest + "$" + ) + set_tests_properties( + coreai_swift_bridge_test + PROPERTIES TIMEOUT 600 ENVIRONMENT + "COREAI_BRIDGE_FIXTURE_DIR=${_coreai_fixture_dir}" + ) + endif() +endif() + +install( + TARGETS coreai_swift + EXPORT ExecuTorchTargets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 22e67e58524..13d99d85045 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -42,6 +42,18 @@ with one bundle's path, digest, and files. Neither type can be constructed without validation; filesystem materialization and SDK host probing remain outside the manifest parser. +### Calling from Swift + +When using the Core AI backend, run ExecuTorch model loading, inference and +cache operations on a background `DispatchQueue` or a dedicated worker thread. +Do not call these synchronous APIs directly inside Swift `Task` or +`Task.detached`, because doing so can hang your application. + +From async Swift code, submit the entire ExecuTorch call with +`DispatchQueue.async` and await its result using a continuation. Do not block +the Swift task with `DispatchQueue.sync` or a semaphore. Keep input tensors alive +and unchanged through all output copies, even if the waiting task is cancelled. + ### AOT architecture selection Configure AOT export with `AOTCompileConfig`, including the target `platform` and @@ -142,6 +154,52 @@ clearing or after process exit, so waiters and future openers keep using the same inode. Removing the assets root externally requires all loads, sessions and maintenance to stop. +## SDK Bridge + +The private Swift module `CoreAIBridge` (`runtime/ETCoreAIModel.swift`) wraps +the `CoreAI` framework behind the Objective-C protocols in +`runtime/ETCoreAIBridge.h`. It restores models from bookmarks, specializes source +bundles in the default SDK cache with storage-pressure-only purging, and binds +named functions with ordered inputs and outputs. Each loader owns an immutable +snapshot of its actual specialization options and exposes a stable, versioned +identity derived from that snapshot. Asynchronous SDK calls run in detached +tasks and report through completion blocks; function binding completes +synchronously. + +Inputs are borrowed from caller storage through Core AI raw views; only shape +metadata is copied. Outputs retain their SDK storage and expose a copier after +all fallible layout validation finishes. Dense row-major outputs copy with one +`memcpy`, while padded or transposed outputs use strided copies. Only FP16 and +FP32 NDArrays are supported. Callers keep inputs live through output copying and +stage copies before writing destinations that could alias inputs or other output +destinations. + +Bridge-owned Objective-C types live in `runtime/ETCoreAITensor.mm`, separately +from the production SDK entry points in `runtime/coreai_bridge.mm`. Both build in +the private `coreai_bridge_obj` object library, while SDK-free tests can link the +types and substitute fake entry points. + +`coreai_swift_bridge_test` runs the bridge's XCTests under `xctest` with +`EXECUTORCH_BUILD_TESTS=ON`. It links CoreAI and is registered with CTest only +when the macOS host meets the configured Core AI deployment target. +The test build exports static- and dynamic-shape models for real session tests. +CMake's `PYTHON_EXECUTABLE` must use the Python environment with the pinned Core +AI requirements above. Backend Python changes regenerate the dedicated fixture +directory, replacing previous contents. CTest supplies its location to XCTest; +direct XCTest runs without it skip the session tests. Building only +`coreai_host_test` does not export fixtures or build the Swift test bundle. + +## Building + +Core AI builds require an Apple SDK 27 or newer, Swift from Xcode 27, and CMake +3.31 or newer with the Ninja or Xcode generator. Execution requires macOS 27 or +iOS 27. `EXECUTORCH_BUILD_COREAI` defaults to `OFF`. Enabling it builds the +private Swift implementation, module `CoreAIBridge`. Only Core AI targets get an +OS 27 minimum; unrelated runtime targets keep the configured deployment target. +Ninja builds use one architecture per build directory. Current runtime support +covers arm64 macOS and iOS device builds. x86_64 builds are blocked by Swift +`Float16` availability, and the tested iOS simulator SDKs do not contain Core AI. + ## Host Tests On macOS, `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` diff --git a/backends/apple/coreai/cmake/PublishSwiftHeader.cmake b/backends/apple/coreai/cmake/PublishSwiftHeader.cmake new file mode 100644 index 00000000000..19c4006acb4 --- /dev/null +++ b/backends/apple/coreai/cmake/PublishSwiftHeader.cmake @@ -0,0 +1,17 @@ +# 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. + +if("$ENV{DERIVED_SOURCES_DIR}" STREQUAL "" OR "$ENV{CONFIGURATION}" STREQUAL "") + message(FATAL_ERROR "Swift header publication requires Xcode build settings") +endif() +set(_header "$ENV{DERIVED_SOURCES_DIR}/$ENV{SWIFT_OBJC_INTERFACE_HEADER_NAME}") +set(_destination + "${PUBLISH_ROOT}/$ENV{CONFIGURATION}$ENV{EFFECTIVE_PLATFORM_NAME}" +) +file(MAKE_DIRECTORY "${_destination}") +file(COPY_FILE "${_header}" "${_destination}/CoreAIBridge-Swift.h" + ONLY_IF_DIFFERENT +) diff --git a/backends/apple/coreai/coreai-dependencies.cmake b/backends/apple/coreai/coreai-dependencies.cmake new file mode 100644 index 00000000000..98f1c202a79 --- /dev/null +++ b/backends/apple/coreai/coreai-dependencies.cmake @@ -0,0 +1,118 @@ +# 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. + +# Resolve dependencies on the consuming machine, including C++-only projects. +function(executorch_coreai_dependencies) + if(TARGET executorch::coreai_dependencies) + return() + endif() + if(NOT APPLE OR NOT CMAKE_SYSTEM_NAME MATCHES "^(Darwin|iOS)$") + message(FATAL_ERROR "Core AI requires macOS or iOS") + endif() + + set(sdk "${CMAKE_OSX_SYSROOT}") + if(NOT sdk) + if(CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(sdk iphoneos) + else() + set(sdk macosx) + endif() + endif() + execute_process( + COMMAND xcrun --sdk "${sdk}" --show-sdk-version + OUTPUT_VARIABLE sdk_version + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE sdk_result + ERROR_VARIABLE sdk_error + ) + if(NOT sdk_result EQUAL 0 OR sdk_version VERSION_LESS 27.0) + message( + FATAL_ERROR + "Core AI requires an Apple SDK 27 or newer; selected '${sdk}' " + "reports '${sdk_version}'. Select Xcode 27 with DEVELOPER_DIR. " + "${sdk_error}" + ) + endif() + execute_process( + COMMAND xcrun --sdk "${sdk}" --show-sdk-path + OUTPUT_VARIABLE sdk_path + OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY + ) + find_library( + foundation Foundation + PATHS "${sdk_path}/System/Library/Frameworks" + NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH NO_CACHE REQUIRED + ) + find_library( + coreai CoreAI + PATHS "${sdk_path}/System/Library/Frameworks" + NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH NO_CACHE REQUIRED + ) + + # C++ package consumers need Swift's autolink paths, not the Swift language. + set(swift_compiler "${CMAKE_Swift_COMPILER}") + if(NOT swift_compiler) + execute_process( + COMMAND xcrun --sdk "${sdk}" --find swiftc + OUTPUT_VARIABLE swift_compiler + OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY + ) + endif() + set(arch "${CMAKE_OSX_ARCHITECTURES}") + if(NOT arch) + set(arch "${CMAKE_SYSTEM_PROCESSOR}") + endif() + if(NOT arch) + set(arch "${CMAKE_HOST_SYSTEM_PROCESSOR}") + endif() + list(GET arch 0 arch) + set(deployment 27.0) + if(CMAKE_OSX_DEPLOYMENT_TARGET VERSION_GREATER deployment) + set(deployment "${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + set(target_environment "") + if(CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(platform ios) + if(sdk_path MATCHES "[Ss]imulator") + set(target_environment -simulator) + endif() + else() + set(platform macosx) + endif() + execute_process( + COMMAND "${swift_compiler}" -print-target-info -sdk "${sdk_path}" -target + "${arch}-apple-${platform}${deployment}${target_environment}" + OUTPUT_VARIABLE target_info COMMAND_ERROR_IS_FATAL ANY + ) + string(JSON path_count LENGTH "${target_info}" paths runtimeLibraryPaths) + set(runtime_paths "") + if(path_count GREATER 0) + math(EXPR path_last "${path_count} - 1") + foreach(index RANGE ${path_last}) + string( + JSON + runtime_path + GET + "${target_info}" + paths + runtimeLibraryPaths + ${index} + ) + list(APPEND runtime_paths "${runtime_path}") + endforeach() + endif() + + # GLOBAL keeps the target visible to runners outside the backend directory. + add_library(executorch::coreai_dependencies INTERFACE IMPORTED GLOBAL) + set_target_properties( + executorch::coreai_dependencies + PROPERTIES + INTERFACE_LINK_LIBRARIES "${foundation};${coreai}" + INTERFACE_LINK_DIRECTORIES "${runtime_paths}" + INTERFACE_LINK_OPTIONS + "SHELL:-F \"${sdk_path}/System/Library/SubFrameworks\";LINKER:-rpath,/usr/lib/swift" + ) +endfunction() diff --git a/backends/apple/coreai/runtime/ETCoreAIBridge.h b/backends/apple/coreai/runtime/ETCoreAIBridge.h new file mode 100644 index 00000000000..73426ac00c6 --- /dev/null +++ b/backends/apple/coreai/runtime/ETCoreAIBridge.h @@ -0,0 +1,139 @@ +/* + * 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 + +// Bridge completions can require Swift's cooperative thread pool. Callers that +// synchronously wait for them must use ordinary worker threads, not Swift Task +// or Task.detached workers, which can starve the work needed for completion. + +typedef NS_ENUM(NSInteger, ETCoreAIScalarType) { + ETCoreAIScalarTypeFloat16 = 0, + ETCoreAIScalarTypeFloat32 = 1, +}; + +typedef NS_ENUM(NSInteger, ETCoreAIErrorCode) { + // Invalid model descriptors, functions or bindings. + ETCoreAIErrorInvalidModel = 1, + // Unsupported tensor types, layouts or model features. + ETCoreAIErrorUnsupported = 2, + // Invalid caller arguments or input tensor payloads. + ETCoreAIErrorInvalidArgument = 3, + // SDK/system failures or inconsistent SDK outputs, not evidence of + // corruption. + ETCoreAIErrorRuntime = 4, +}; + +FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; + +// Input storage is borrowed through output copying, never retained or freed. +@interface ETCoreAIInputTensor : NSObject +@property(nonatomic, readonly) const void* _Nullable bytes; +@property(nonatomic, readonly) NSUInteger byteCount; +@property(nonatomic, readonly, copy) NSArray* shape; +@property(nonatomic, readonly) ETCoreAIScalarType scalarType; +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)initWithBytes:(const void* _Nullable)bytes + byteCount:(NSUInteger)byteCount + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType + NS_DESIGNATED_INITIALIZER; +@end + +// The copier retains its source storage; SDK pointers never escape their access +// scope. Construct only after validating the source layout. Copying cannot +// fail. +@interface ETCoreAITensor : NSObject +@property(nonatomic, readonly) NSUInteger byteCount; +@property(nonatomic, readonly, copy) NSArray* shape; +@property(nonatomic, readonly) ETCoreAIScalarType scalarType; +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)initWithByteCount:(NSUInteger)byteCount + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType + copyBytes:(void (^)(void* bytes))copyBytes + NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithData:(NSData*)data + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType; +// Caller supplies byteCount writable bytes, disjoint from live source storage. +// A null destination is allowed only for an empty output. +- (void)copyBytesTo:(void* _Nullable)bytes NS_SWIFT_NAME(copyBytes(to:)); +@end + +// The caller keeps inputs valid until all output copies finish, or completion +// reports failure. Outputs may share input storage; stage copies before writing +// any destination that aliases an input or another output destination. +// Completion is called exactly once, possibly before executeInputs returns, +// with no guarantee of a particular thread or queue. +@protocol ETCoreAISession +- (void)executeInputs:(NSArray*)inputs + completion:(void (^)( + NSArray* _Nullable outputs, + NSError* _Nullable error))completion; +@end + +// A prepared model pins the SDK entry. Sessions retain their own pin after +// binding. +@protocol ETCoreAIPreparedModel +- (NSData*)copyBookmarkData; +// Completes once with either a session or an error, never both. May complete +// synchronously on the calling thread. +- (void)loadFunctionNamed:(NSString*)functionName + inputNames:(NSArray*)inputNames + outputNames:(NSArray*)outputNames + completion:(void (^)( + id _Nullable session, + NSError* _Nullable error))completion; +@end + +typedef NS_ENUM(NSInteger, ETCoreAIAcquisitionStatus) { + ETCoreAIAcquisitionStatusHit = 0, + ETCoreAIAcquisitionStatusMiss = 1, + ETCoreAIAcquisitionStatusError = 2, +}; + +// Hit: nonnull model, nil error. Miss: nil model and error, only on SDK nil. +// Error: nil model, nonnull error. Function errors never produce a Miss. +// Acquisition operations call completion exactly once off the main thread. +typedef void (^ETCoreAIAcquisitionCompletion)( + ETCoreAIAcquisitionStatus status, + id _Nullable preparedModel, + NSError* _Nullable error); + +@protocol ETCoreAIModelLoading +// Stable for this loader's lifetime; derived from its actual specialization +// options. +@property(nonatomic, readonly, copy) NSString* specializationIdentifier; +- (void)restoreModelFromBookmark:(NSData*)bookmark + completion:(ETCoreAIAcquisitionCompletion)completion; +// Uses the SDK default cache with storage-pressure-only purging. Never returns +// Miss. +- (void)specializeModelAtURL:(NSURL*)url + completion:(ETCoreAIAcquisitionCompletion)completion; +// Nil error means SDK-confirmed deletion. No busy/absent classification is +// inferred. +- (void)evictModelWithBookmark:(NSData*)bookmark + completion:(void (^)(NSError* _Nullable error))completion; +@end + +#ifdef __cplusplus +extern "C" { +#endif +BOOL ETCoreAIIsAvailable(void); +NSString* _Nullable ETCoreAIDeviceArchitectureName(void); +id ETCoreAICreateModelLoader(void); +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/backends/apple/coreai/runtime/ETCoreAIModel.swift b/backends/apple/coreai/runtime/ETCoreAIModel.swift new file mode 100644 index 00000000000..1e7dc71fffb --- /dev/null +++ b/backends/apple/coreai/runtime/ETCoreAIModel.swift @@ -0,0 +1,508 @@ +// 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 CoreAI +import Foundation + +private func bridgeError( + _ code: ETCoreAIErrorCode, _ message: String, underlying: Error? = nil +) -> NSError { + var info: [String: Any] = [NSLocalizedDescriptionKey: message] + if let underlying { info[NSUnderlyingErrorKey] = underlying } + return NSError(domain: ETCoreAIErrorDomain, code: code.rawValue, userInfo: info) +} + +private func mappedError(_ error: Error, code: ETCoreAIErrorCode, context: String) -> NSError { + let error = error as NSError + return error.domain == ETCoreAIErrorDomain + ? error : bridgeError(code, "\(context): \(error.localizedDescription)", underlying: error) +} + +// Objective-C blocks have no Sendable annotation. Each box is invoked by one detached task only. +private final class Completion: @unchecked Sendable { + let call: Callback + + init(_ call: Callback) { + self.call = call + } +} + +// Only a successful SDK nil is a miss; thrown errors retain their underlying cause. +func acquireCoreAIModel( + context: String, completion: @escaping ETCoreAIAcquisitionCompletion, + operation: @escaping @Sendable () async throws -> (any ETCoreAIPreparedModel)? +) { + let callback = Completion(completion) + Task.detached { + do { + if let model = try await operation() { + callback.call(.hit, model, nil) + } else { + callback.call(.miss, nil, nil) + } + } catch { + callback.call(.error, nil, mappedError(error, code: .runtime, context: context)) + } + } +} + +@available(macOS 27.0, iOS 27.0, *) +struct CoreAIScalarInfo: Sendable { + let sdkType: NDArray.ScalarType + let bridgeType: ETCoreAIScalarType + let byteWidth: Int + + // Only byte-addressable scalar types belong here; packed types need a separate layout policy. + static let supported: [Self] = [ + Self(sdkType: .float16, bridgeType: .float16, byteWidth: 2), + Self(sdkType: .float32, bridgeType: .float32, byteWidth: 4), + ] + + static func from(sdkType: NDArray.ScalarType) throws -> Self { + guard let info = supported.first(where: { $0.sdkType == sdkType }) else { + throw bridgeError(.unsupported, "Unsupported Core AI scalar type: \(sdkType)") + } + return info + } + + static func from(bridgeType: ETCoreAIScalarType) throws -> Self { + guard let info = supported.first(where: { $0.bridgeType == bridgeType }) else { + throw bridgeError(.unsupported, "Unsupported bridge scalar type: \(bridgeType.rawValue)") + } + return info + } + + func byteCount(shape: [Int], errorCode: ETCoreAIErrorCode = .invalidArgument) throws -> Int { + guard shape.allSatisfy({ $0 >= 0 }) else { + throw bridgeError(errorCode, "Negative tensor dimension") + } + // Check suffix products too: SDK dense-stride construction must not overflow on empty tensors. + var count = byteWidth + for dimension in shape.reversed() { + let (next, overflow) = count.multipliedReportingOverflow(by: max(dimension, 1)) + guard !overflow else { + throw bridgeError(errorCode, "Tensor byte count overflows Int") + } + count = next + } + return shape.contains(0) ? 0 : count + } +} + +// ExecuTorch waits synchronously and keeps input storage live through output copying. +// This snapshot transfers only metadata and a pointer to the task; it does not own storage. +@available(macOS 27.0, iOS 27.0, *) +struct CoreAITensorPayload: @unchecked Sendable { + let bytes: UnsafeRawPointer? + let byteCount: Int + let shape: [Int] + let scalarType: ETCoreAIScalarType + + init(_ tensor: ETCoreAIInputTensor) throws { + shape = try tensor.shape.map { number in + guard let dimension = number as? Int, dimension >= 0 else { + throw bridgeError(.invalidArgument, "Tensor dimensions must be nonnegative integers") + } + return dimension + } + scalarType = tensor.scalarType + byteCount = try Self.byteCount(shape: shape, scalarType: scalarType) + guard tensor.byteCount == byteCount else { + throw bridgeError( + .invalidArgument, "Tensor payload byte count does not match shape and dtype") + } + bytes = tensor.bytes + guard byteCount == 0 || bytes != nil else { + throw bridgeError(.invalidArgument, "Nonempty tensor has null data") + } + } + + static func byteCount( + shape: [Int], scalarType: ETCoreAIScalarType, + errorCode: ETCoreAIErrorCode = .invalidArgument + ) throws -> Int { + try CoreAIScalarInfo.from(bridgeType: scalarType).byteCount(shape: shape, errorCode: errorCode) + } +} + +@available(macOS 27.0, iOS 27.0, *) +private func tensorDescriptor(_ value: InferenceValue.Descriptor?) throws -> NDArrayDescriptor { + guard let value else { throw bridgeError(.invalidModel, "Missing tensor descriptor") } + guard case .ndArray(let descriptor) = value else { + throw bridgeError(.unsupported, "Image values are not supported") + } + _ = try CoreAIScalarInfo.from(sdkType: descriptor.scalarType) + guard descriptor.interleaveLayout == nil else { + throw bridgeError(.unsupported, "Interleaved tensor descriptors are not supported") + } + guard descriptor.shape.allSatisfy({ $0 >= -1 }) else { + throw bridgeError(.invalidModel, "Invalid descriptor dimension") + } + return descriptor +} + +@available(macOS 27.0, iOS 27.0, *) +private func validateShape( + _ shape: [Int], descriptor: NDArrayDescriptor, + errorCode: ETCoreAIErrorCode = .invalidArgument +) throws { + guard shape.count == descriptor.shape.count, + zip(shape, descriptor.shape).allSatisfy({ actual, expected in + actual >= 0 && (expected == -1 || actual == expected) + }) + else { + throw bridgeError(errorCode, "Tensor shape does not match the function descriptor") + } +} + +// Offsets are in elements, including padded or transposed SDK layouts. +struct CoreAITensorLayout { + let shape: [Int] + let strides: [Int] + let count: Int + // Row-major contiguous, ignoring strides of size-1 dimensions. + let isDense: Bool + + init(shape: [Int], strides: [Int], width: Int, storageByteCount: Int) throws { + guard shape.count == strides.count, shape.allSatisfy({ $0 >= 0 }), + strides.allSatisfy({ $0 >= 0 }), width > 0, storageByteCount >= 0 + else { throw bridgeError(.unsupported, "Invalid or negative NDArray strides") } + self.shape = shape + self.strides = strides + var count = 1 + var maximumOffset = 0 + for (dimension, stride) in zip(shape, strides) { + let (next, countOverflow) = count.multipliedReportingOverflow(by: dimension) + let (extent, extentOverflow) = max(dimension - 1, 0).multipliedReportingOverflow(by: stride) + let (offset, offsetOverflow) = maximumOffset.addingReportingOverflow(extent) + guard !countOverflow, !extentOverflow, !offsetOverflow else { + throw bridgeError(.runtime, "NDArray layout overflows Int") + } + count = next + maximumOffset = offset + } + self.count = count + var expectedStride = 1 + var isDense = true + for axis in shape.indices.reversed() where shape[axis] != 1 { + isDense = isDense && strides[axis] == expectedStride + expectedStride = expectedStride &* shape[axis] + } + self.isDense = isDense + if count > 0 { + let (span, spanOverflow) = maximumOffset.addingReportingOverflow(1) + let (bytes, byteOverflow) = span.multipliedReportingOverflow(by: width) + guard !spanOverflow, !byteOverflow, bytes <= storageByteCount else { + throw bridgeError(.runtime, "NDArray layout exceeds its storage") + } + } + } + + func offset(for linearIndex: Int) -> Int { + var remainder = linearIndex + var offset = 0 + for axis in shape.indices.reversed() { + offset += (remainder % shape[axis]) * strides[axis] + remainder /= shape[axis] + } + return offset + } +} + +@available(macOS 27.0, iOS 27.0, *) +func prepareOutput(_ array: NDArray) throws -> ETCoreAITensor { + let type = try CoreAIScalarInfo.from(sdkType: array.scalarType) + let byteCount = try type.byteCount(shape: array.shape, errorCode: .runtime) + guard array.interleaveLayout == nil else { + throw bridgeError(.unsupported, "Interleaved output storage is not supported") + } + let view = array.rawView() + let layout = try CoreAITensorLayout( + shape: array.shape, strides: array.strides, width: type.byteWidth, + storageByteCount: view.bytes.byteCount) + let shape = view.shape + let strides = view.strides + guard view.scalarType == type.sdkType, view.interleaveLayout == nil, + shape.count == layout.shape.count, strides.count == layout.strides.count + else { + throw bridgeError(.runtime, "Output view layout changed") + } + for axis in layout.shape.indices { + guard shape[axis] == layout.shape[axis], strides[axis] == layout.strides[axis] else { + throw bridgeError(.runtime, "Output view layout changed") + } + } + // Retain the immutable array, not its scoped pointer. All fallible work is complete. + return ETCoreAITensor( + byteCount: UInt(byteCount), shape: layout.shape.map(NSNumber.init(value:)), + scalarType: type.bridgeType + ) { destination in + let view = array.rawView() + view.bytes.withUnsafeBytes { source in + if layout.isDense { + destination.copyMemory(from: source.baseAddress!, byteCount: byteCount) + return + } + for index in 0.. InferenceFunction.Outputs + { + var arguments = InferenceFunction.Inputs() + for index in payloads.indices { + let payload = payloads[index] + let descriptor = inputDescriptors[index] + try validateShape(payload.shape, descriptor: descriptor) + let expectedType = try CoreAIScalarInfo.from(sdkType: descriptor.scalarType) + guard payload.scalarType == expectedType.bridgeType else { + throw bridgeError(.invalidArgument, "Input dtype does not match the function descriptor") + } + // Unsafe boundary: the ET caller, not this metadata snapshot, owns the storage. + let bytes = RawSpan( + _unsafeBytes: UnsafeRawBufferPointer( + start: payload.bytes, count: payload.byteCount)) + let view = NDArray.RawView( + bytes: bytes, scalarType: descriptor.scalarType, shape: payload.shape) + arguments.insert(view, for: inputNames[index]) + } + return try await function.run(inputs: arguments) + } + + func executeInputs( + _ inputs: [ETCoreAIInputTensor], completion: @escaping ([ETCoreAITensor]?, Error?) -> Void + ) { + let payloads: [CoreAITensorPayload] + do { + guard inputs.count == inputNames.count else { + throw bridgeError(.invalidArgument, "Input count does not match the function") + } + payloads = try inputs.map(CoreAITensorPayload.init) + } catch { + completion(nil, mappedError(error, code: .invalidArgument, context: "Invalid inputs")) + return + } + let callback = Completion(completion) + Task.detached { [self, payloads, callback] in + do { + var results = try await runBorrowing(payloads) + guard results.count == outputNames.count, Set(results.names) == Set(outputNames) else { + throw bridgeError(.runtime, "Inference returned unexpected output names") + } + var tensors: [ETCoreAITensor] = [] + for index in outputNames.indices { + guard let value = results.remove(outputNames[index]) else { + throw bridgeError(.runtime, "Inference output is missing") + } + guard let array = value.ndArray else { + throw bridgeError(.unsupported, "Inference returned an image output") + } + let descriptor = outputDescriptors[index] + try validateShape(array.shape, descriptor: descriptor, errorCode: .runtime) + guard array.scalarType == descriptor.scalarType else { + throw bridgeError(.runtime, "Output dtype does not match the function descriptor") + } + tensors.append(try prepareOutput(array)) + } + callback.call(tensors, nil) + } catch { + callback.call(nil, mappedError(error, code: .runtime, context: "Core AI inference failed")) + } + } + } +} + +@available(macOS 27.0, iOS 27.0, *) +private func bindFunction( + model: AIModel, functionName: String, inputNames: [String], outputNames: [String] +) throws -> CoreAISwiftSession { + guard !functionName.isEmpty else { + throw bridgeError(.invalidArgument, "A nonempty function name is required") + } + guard let function = try model.loadFunction(named: functionName) else { + throw bridgeError(.invalidModel, "Model has no function named '\(functionName)'") + } + return try CoreAISwiftSession( + model: model, function: function, inputNames: inputNames, outputNames: outputNames) +} + +@available(macOS 27.0, iOS 27.0, *) +private final class CoreAISwiftPreparedModel: NSObject, ETCoreAIPreparedModel, Sendable { + let model: AIModel + + init(model: AIModel) { + self.model = model + super.init() + } + + func copyBookmarkData() -> Data { + model.bookmarkData + } + + // Binding never awaits, so it completes on the calling thread. + func loadFunctionNamed( + _ functionName: String, inputNames: [String], outputNames: [String], + completion: @escaping ((any ETCoreAISession)?, Error?) -> Void + ) { + do { + completion( + try bindFunction( + model: model, functionName: functionName, inputNames: inputNames, + outputNames: outputNames), nil) + } catch { + completion( + nil, mappedError(error, code: .runtime, context: "Core AI function binding failed")) + } + } +} + +@available(macOS 27.0, iOS 27.0, *) +struct CoreAISpecialization: Sendable { + let options: SpecializationOptions + let identifier: String + + static let `default` = Self() + + private init() { + options = .default + identifier = "default" + } + + // New SDK option fields require a schema review and versioned identity changes. + private struct Descriptor: Encodable { + let version: Int + let allowedComputeUnits: [String] + let preferredComputeUnit: String? + let expectFrequentReshapes: Bool + } + + init(options: SpecializationOptions) throws { + if options == .default { + self = Self.default + return + } + let descriptor = Descriptor( + version: 1, + allowedComputeUnits: try options.allowedComputeUnitKinds.map(Self.name).sorted(), + preferredComputeUnit: try options.preferredComputeUnitKind.map(Self.name), + expectFrequentReshapes: options.expectFrequentReshapes) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + identifier = String(decoding: try encoder.encode(descriptor), as: UTF8.self) + self.options = options + } + + private static func name(_ kind: ComputeUnitKind) throws -> String { + switch kind { + case .cpu: return "cpu" + case .gpu: return "gpu" + case .neuralEngine: return "neuralEngine" + @unknown default: + throw bridgeError(.unsupported, "Unknown specialization compute unit") + } + } +} + +@available(macOS 27.0, iOS 27.0, *) +@objc(ETCoreAISwiftModelLoader) +public final class ETCoreAISwiftModelLoader: NSObject, ETCoreAIModelLoading { + static let cachePolicy = AIModelCache.Policy(purgeConditions: [.storagePressure]) + private let specialization: CoreAISpecialization + + public override init() { + specialization = .default + super.init() + } + + init(specialization: CoreAISpecialization) { + self.specialization = specialization + super.init() + } + + public var specializationIdentifier: String { specialization.identifier } + + @objc public static var deviceArchitectureName: String { AIModel.deviceArchitectureName } + + public func restoreModel( + fromBookmark bookmark: Data, completion: @escaping ETCoreAIAcquisitionCompletion + ) { + acquireCoreAIModel(context: "Core AI bookmark restoration failed", completion: completion) { + try AIModel(resolvingBookmark: bookmark).map { CoreAISwiftPreparedModel(model: $0) } + } + } + + public func specializeModel(at url: URL, completion: @escaping ETCoreAIAcquisitionCompletion) { + let options = specialization.options + acquireCoreAIModel(context: "Core AI specialization failed", completion: completion) { + guard url.isFileURL else { + throw bridgeError(.invalidArgument, "A file URL is required") + } + let model = try await AIModel.specialize( + contentsOf: url, options: options, cache: .default, cachePolicy: Self.cachePolicy) + return CoreAISwiftPreparedModel(model: model) + } + } + + public func evictModel( + withBookmark bookmark: Data, completion: @escaping (Error?) -> Void + ) { + let callback = Completion(completion) + Task.detached { + do { + try AIModelCache.deleteEntry(referencedBy: bookmark) + callback.call(nil) + } catch { + callback.call( + mappedError(error, code: .runtime, context: "Core AI cache eviction failed")) + } + } + } +} diff --git a/backends/apple/coreai/runtime/ETCoreAITensor.mm b/backends/apple/coreai/runtime/ETCoreAITensor.mm new file mode 100644 index 00000000000..611812e3d39 --- /dev/null +++ b/backends/apple/coreai/runtime/ETCoreAITensor.mm @@ -0,0 +1,63 @@ +/* + * 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. + */ + +#include +#import "ETCoreAIBridge.h" + +NSErrorDomain const ETCoreAIErrorDomain = @"org.pytorch.executorch.coreai"; + +@implementation ETCoreAIInputTensor +- (instancetype)initWithBytes:(const void*)bytes + byteCount:(NSUInteger)byteCount + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType { + self = [super init]; + if (self) { + _bytes = bytes; + _byteCount = byteCount; + _shape = [shape copy]; + _scalarType = scalarType; + } + return self; +} +@end + +@implementation ETCoreAITensor { + void (^_copyBytes)(void*); +} +- (instancetype)initWithByteCount:(NSUInteger)byteCount + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType + copyBytes:(void (^)(void*))copyBytes { + NSParameterAssert(copyBytes != nil); + self = [super init]; + if (self) { + _byteCount = byteCount; + _shape = [shape copy]; + _scalarType = scalarType; + _copyBytes = [copyBytes copy]; + } + return self; +} +- (instancetype)initWithData:(NSData*)data + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType { + NSData* storage = [data copy]; + return [self initWithByteCount:storage.length + shape:shape + scalarType:scalarType + copyBytes:^(void* bytes) { + std::memcpy(bytes, storage.bytes, storage.length); + }]; +} +- (void)copyBytesTo:(void*)bytes { + if (_byteCount > 0) { + _copyBytes(bytes); + } +} +@end diff --git a/backends/apple/coreai/runtime/coreai_bridge.mm b/backends/apple/coreai/runtime/coreai_bridge.mm new file mode 100644 index 00000000000..539bdb408cb --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_bridge.mm @@ -0,0 +1,29 @@ +/* + * 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 "ETCoreAIBridge.h" + +#import "CoreAIBridge-Swift.h" + +BOOL ETCoreAIIsAvailable(void) { + if (@available(macOS 27.0, iOS 27.0, *)) { + return YES; + } + return NO; +} + +NSString* ETCoreAIDeviceArchitectureName(void) { + if (@available(macOS 27.0, iOS 27.0, *)) { + return ETCoreAISwiftModelLoader.deviceArchitectureName; + } + return nil; +} + +id ETCoreAICreateModelLoader(void) { + return [[ETCoreAISwiftModelLoader alloc] init]; +} diff --git a/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift new file mode 100644 index 00000000000..da3dce548a5 --- /dev/null +++ b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift @@ -0,0 +1,754 @@ +// 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 CoreAI +import Foundation +import Synchronization +import XCTest + +private func copiedData(_ output: ETCoreAITensor) -> Data { + var data = Data(count: Int(output.byteCount)) + data.withUnsafeMutableBytes { output.copyBytes(to: $0.baseAddress) } + return data +} + +@available(macOS 27.0, iOS 27.0, *) +private func copyOutput(_ array: NDArray) throws -> Data { + copiedData(try prepareOutput(array)) +} + +private final class ProtocolPreparedModel: NSObject, ETCoreAIPreparedModel, Sendable { + let released: XCTestExpectation + + init(released: XCTestExpectation) { + self.released = released + super.init() + } + + deinit { released.fulfill() } + + func copyBookmarkData() -> Data { Data([1, 2, 3]) } + + func loadFunctionNamed( + _ functionName: String, inputNames: [String], outputNames: [String], + completion: @escaping ((any ETCoreAISession)?, Error?) -> Void + ) { + completion( + nil, NSError(domain: ETCoreAIErrorDomain, code: ETCoreAIErrorCode.invalidModel.rawValue)) + } +} + +// These contract tests never invoke the SDK; the linked test bundle still requires OS 27. +final class SwiftAcquisitionTests: XCTestCase { + func testHitPinsPreparedModelThroughCompletion() { + let completed = expectation(description: "acquired") + let released = expectation(description: "prepared model released") + completed.assertForOverFulfill = true + released.assertForOverFulfill = true + acquireCoreAIModel(context: "test hit") { status, model, error in + XCTAssertFalse(Thread.isMainThread) + XCTAssertEqual(status, .hit) + XCTAssertNil(error) + XCTAssertEqual(model?.copyBookmarkData(), Data([1, 2, 3])) + completed.fulfill() + } operation: { + ProtocolPreparedModel(released: released) + } + wait(for: [completed, released], timeout: 5, enforceOrder: true) + } + + func testOnlyNilIsMiss() { + let completed = expectation(description: "miss") + completed.assertForOverFulfill = true + acquireCoreAIModel(context: "test miss") { status, model, error in + XCTAssertFalse(Thread.isMainThread) + XCTAssertEqual(status, .miss) + XCTAssertNil(model) + XCTAssertNil(error) + completed.fulfill() + } operation: { + nil + } + wait(for: [completed], timeout: 5) + } + + func testErrorsAreWrappedOnceAndPreserveTheUnderlyingError() { + let sdk = NSError(domain: "SDK.test", code: 91, userInfo: ["detail": "opaque"]) + let bridge = NSError( + domain: ETCoreAIErrorDomain, code: ETCoreAIErrorCode.invalidArgument.rawValue) + for underlying in [sdk, bridge] { + let completed = expectation(description: underlying.domain) + completed.assertForOverFulfill = true + acquireCoreAIModel(context: "test restoration") { status, model, error in + XCTAssertFalse(Thread.isMainThread) + XCTAssertEqual(status, .error) + XCTAssertNil(model) + let error = error as NSError? + if underlying === bridge { + XCTAssertTrue(error === bridge) + } else { + XCTAssertEqual(error?.domain, ETCoreAIErrorDomain) + XCTAssertEqual(error?.code, ETCoreAIErrorCode.runtime.rawValue) + XCTAssertTrue(error?.localizedDescription.hasPrefix("test restoration:") == true) + XCTAssertTrue((error?.userInfo[NSUnderlyingErrorKey] as? NSError) === sdk) + } + completed.fulfill() + } operation: { + throw underlying + } + wait(for: [completed], timeout: 5) + } + } +} + +@available(macOS 27.0, iOS 27.0, *) +final class SwiftSpecializationTests: XCTestCase { + func testCachePolicyAllowsOnlyStoragePressure() { + XCTAssertEqual(ETCoreAISwiftModelLoader.cachePolicy.purgeConditions, [.storagePressure]) + } + func testDefaultPreservesIdentifier() throws { + let snapshot = try CoreAISpecialization(options: .default) + XCTAssertEqual(snapshot.options, .default) + XCTAssertEqual(snapshot.identifier, "default") + XCTAssertEqual(ETCoreAISwiftModelLoader().specializationIdentifier, snapshot.identifier) + } + + func testAllOptionFieldsParticipateInIdentity() throws { + let choices: [SpecializationOptions] = [ + .default, .cpuOnly, + SpecializationOptions(preferredComputeUnitKind: .cpu), + SpecializationOptions(preferredComputeUnitKind: .gpu), + SpecializationOptions(preferredComputeUnitKind: .neuralEngine), + ] + let options = + choices + + choices.map { original in + var changed = original + changed.expectFrequentReshapes.toggle() + return changed + } + let snapshots = try options.map { try CoreAISpecialization(options: $0) } + for first in snapshots { + XCTAssertEqual(first.identifier, try CoreAISpecialization(options: first.options).identifier) + XCTAssertEqual( + ETCoreAISwiftModelLoader(specialization: first).specializationIdentifier, first.identifier) + for second in snapshots { + XCTAssertEqual(first.options == second.options, first.identifier == second.identifier) + } + } + } + + func testSnapshotDoesNotFollowSubsequentOptionMutation() throws { + var options = SpecializationOptions.cpuOnly + let original = options + let snapshot = try CoreAISpecialization(options: options) + let loader = ETCoreAISwiftModelLoader(specialization: snapshot) + options.expectFrequentReshapes.toggle() + XCTAssertEqual(snapshot.options, original) + XCTAssertEqual(loader.specializationIdentifier, snapshot.identifier) + XCTAssertNotEqual( + loader.specializationIdentifier, try CoreAISpecialization(options: options).identifier) + } + + func testDescriptorIsVersionedAndUsesStableComputeUnitNames() throws { + var options = SpecializationOptions(preferredComputeUnitKind: .gpu) + options.expectFrequentReshapes = !SpecializationOptions.default.expectFrequentReshapes + let snapshot = try CoreAISpecialization(options: options) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(snapshot.identifier.utf8)) as? [String: Any]) + XCTAssertEqual(json["version"] as? Int, 1) + XCTAssertEqual(json["preferredComputeUnit"] as? String, "gpu") + XCTAssertEqual(json["expectFrequentReshapes"] as? Bool, options.expectFrequentReshapes) + let allowed = try XCTUnwrap(json["allowedComputeUnits"] as? [String]) + XCTAssertEqual(allowed, allowed.sorted()) + XCTAssertTrue(Set(allowed).isSubset(of: ["cpu", "gpu", "neuralEngine"])) + XCTAssertEqual(allowed.count, options.allowedComputeUnitKinds.count) + } +} + +@available(macOS 27.0, iOS 27.0, *) +final class SwiftSessionTests: XCTestCase { + private struct Manifest: Decodable { + let function: String + let inputNames: [String] + let outputNames: [String] + + enum CodingKeys: String, CodingKey { + case function + case inputNames = "input_names" + case outputNames = "output_names" + } + } + + private struct Input: @unchecked Sendable { + let storage: NSData + let tensor: ETCoreAIInputTensor + + init(_ data: Data, shape: [Int], type: ETCoreAIScalarType = .float32) { + storage = data as NSData + tensor = ETCoreAIInputTensor( + bytes: storage.bytes, byteCount: UInt(storage.length), + shape: shape.map(NSNumber.init(value:)), scalarType: type) + } + + init(_ values: [Float]) { + self.init(values.withUnsafeBytes { Data($0) }, shape: [values.count]) + } + } + + private var loader = ETCoreAISwiftModelLoader() + private var manifest: Manifest! + private var fixtureDirectory: URL! + private var model: (any ETCoreAIPreparedModel)? + private var session: (any ETCoreAISession)? + private var bookmark: Data? + + override func setUpWithError() throws { + guard let root = ProcessInfo.processInfo.environment["COREAI_BRIDGE_FIXTURE_DIR"] else { + throw XCTSkip("COREAI_BRIDGE_FIXTURE_DIR is required for SDK session tests") + } + try useFixture(at: URL(fileURLWithPath: root, isDirectory: true)) + } + + private func useFixture(at directory: URL) throws { + fixtureDirectory = directory + manifest = try JSONDecoder().decode( + Manifest.self, from: Data(contentsOf: directory.appendingPathComponent("manifest.json"))) + XCTAssertEqual(manifest.inputNames.count, 2) + XCTAssertEqual(manifest.outputNames.count, 2) + } + + func testSpecializationUsesTheSnapshotOptions() throws { + let snapshot = try CoreAISpecialization(options: .cpuOnly) + loader = ETCoreAISwiftModelLoader(specialization: snapshot) + try prepareModel() + XCTAssertNotNil( + try AIModelCache.default.model( + for: fixtureDirectory.appendingPathComponent("model.aimodel"), options: snapshot.options)) + } + + private func prepareModel() throws { + guard model == nil else { return } + let completed = expectation(description: "specialized session fixture") + completed.assertForOverFulfill = true + let result = Mutex<(model: (any ETCoreAIPreparedModel)?, error: Error?)>((nil, nil)) + loader.specializeModel(at: fixtureDirectory.appendingPathComponent("model.aimodel")) { + status, model, error in + XCTAssertEqual(status, .hit) + result.withLock { $0 = (model, error) } + completed.fulfill() + } + wait(for: [completed], timeout: 30) + let value = result.withLock { value in + let snapshot = value + value = (nil, nil) + return snapshot + } + if let failure = value.error { throw failure } + model = try XCTUnwrap(value.model) + bookmark = model?.copyBookmarkData() + } + + override func tearDownWithError() throws { + session = nil + model = nil + if let bookmark { + let completed = expectation(description: "evicted session fixture") + completed.assertForOverFulfill = true + loader.evictModel(withBookmark: bookmark) { error in + XCTAssertNil(error) + completed.fulfill() + } + wait(for: [completed], timeout: 10) + } + bookmark = nil + } + + private func bind(inputNames: [String]? = nil, outputNames: [String]? = nil) throws { + try prepareModel() + let result = Mutex<(session: (any ETCoreAISession)?, error: Error?, calls: Int)>((nil, nil, 0)) + try XCTUnwrap(model).loadFunctionNamed( + manifest.function, inputNames: inputNames ?? manifest.inputNames, + outputNames: outputNames ?? manifest.outputNames + ) { session, error in + result.withLock { $0 = (session, error, $0.calls + 1) } + } + let value = result.withLock { value in + let snapshot = value + value = (nil, nil, 0) + return snapshot + } + XCTAssertEqual(value.calls, 1) + if let failure = value.error { throw failure } + session = try XCTUnwrap(value.session) + } + + private func execute(_ inputs: [Input]) throws -> [ETCoreAITensor] { + let completed = expectation(description: "executed session fixture") + completed.assertForOverFulfill = true + let result = Mutex<(outputs: [ETCoreAITensor]?, error: Error?)>((nil, nil)) + try XCTUnwrap(session).executeInputs(inputs.map(\.tensor)) { tensors, error in + let owned = tensors?.map { + ETCoreAITensor(data: copiedData($0), shape: $0.shape, scalarType: $0.scalarType) + } + result.withLock { $0 = (owned, error) } + // Retain the borrowed allocations even if the XCTest wait times out. + withExtendedLifetime(inputs) { completed.fulfill() } + } + wait(for: [completed], timeout: 10) + let value = result.withLock { value in + let snapshot = value + value = (nil, nil) + return snapshot + } + if let failure = value.error { throw failure } + return try XCTUnwrap(value.outputs) + } + + func testExecutesWithOrderedAndReversedBindings() throws { + for reversed in [false, true] { + try bind( + inputNames: reversed ? Array(manifest.inputNames.reversed()) : manifest.inputNames, + outputNames: reversed ? Array(manifest.outputNames.reversed()) : manifest.outputNames) + let inputs = [Input([5, 9]), Input([1, 2])] + let outputs = try execute(reversed ? Array(inputs.reversed()) : inputs) + let expected: [[Float]] = reversed ? [[4, 7], [6, 11]] : [[6, 11], [4, 7]] + XCTAssertEqual(outputs.count, expected.count) + for (output, values) in zip(outputs, expected) { + XCTAssertEqual(output.scalarType, .float32) + XCTAssertEqual(output.shape, [2]) + XCTAssertEqual(copiedData(output), values.withUnsafeBytes { Data($0) }) + } + } + } + + func testDynamicDescriptorsTrackRuntimeShapes() throws { + try useFixture(at: fixtureDirectory.appendingPathComponent("dynamic")) + try bind() + let sdkModel = try XCTUnwrap(AIModel(resolvingBookmark: XCTUnwrap(bookmark))) + let function = try XCTUnwrap(sdkModel.loadFunction(named: manifest.function)) + for name in manifest.inputNames { + guard case .ndArray(let descriptor) = function.descriptor.inputDescriptor(of: name) else { + return XCTFail("Expected an NDArray input descriptor") + } + XCTAssertEqual(descriptor.shape, [-1]) + } + for count in [2, 4, 1] { + let left = (0..(_ values: [T], scalarType: ETCoreAIScalarType) throws { + try values.withUnsafeBytes { storage in + try assertBorrowedInput( + UnsafeRawBufferPointer(start: storage.baseAddress, count: 6 * MemoryLayout.stride), + shape: [2, 3], scalarType: scalarType) + try assertBorrowedInput( + UnsafeRawBufferPointer(start: storage.baseAddress, count: MemoryLayout.stride), + shape: [], scalarType: scalarType) + try assertBorrowedInput( + UnsafeRawBufferPointer(start: nil, count: 0), shape: [2, 0, 3], scalarType: scalarType) + // A dynamic-shape subrange starts mid-allocation and tracks the current rows. + for rows in [4, 0] { + try assertBorrowedInput( + UnsafeRawBufferPointer( + start: storage.baseAddress!.advanced(by: 2 * MemoryLayout.stride), + count: rows * 2 * MemoryLayout.stride), + shape: [rows, 2], scalarType: scalarType) + } + } + } + + private func assertBorrowedInput( + _ source: UnsafeRawBufferPointer, shape: [Int], scalarType: ETCoreAIScalarType, + file: StaticString = #filePath, line: UInt = #line + ) throws { + let payload = try CoreAITensorPayload( + ETCoreAIInputTensor( + bytes: source.baseAddress, byteCount: UInt(source.count), + shape: shape.map(NSNumber.init(value:)), scalarType: scalarType)) + XCTAssertEqual(payload.bytes, source.baseAddress, file: file, line: line) + XCTAssertEqual(payload.byteCount, source.count, file: file, line: line) + XCTAssertEqual(payload.shape, shape, file: file, line: line) + XCTAssertEqual(payload.scalarType, scalarType, file: file, line: line) + // The source's withUnsafeBytes scope is still active for every view access. + let bytes = RawSpan( + _unsafeBytes: UnsafeRawBufferPointer( + start: payload.bytes, count: payload.byteCount)) + let expectedType = try CoreAIScalarInfo.from(bridgeType: scalarType).sdkType + let view = NDArray.RawView( + bytes: bytes, scalarType: expectedType, shape: payload.shape) + view.bytes.withUnsafeBytes { borrowed in + XCTAssertEqual(borrowed.baseAddress, source.baseAddress, file: file, line: line) + XCTAssertEqual(borrowed.count, source.count, file: file, line: line) + } + let viewShape = view.shape + XCTAssertEqual(viewShape.count, shape.count, file: file, line: line) + for axis in shape.indices { + XCTAssertEqual(viewShape[axis], shape[axis], file: file, line: line) + } + XCTAssertEqual(view.scalarType, expectedType, file: file, line: line) + } + + func testPreparedOutputsRetainSDKStorageAndCopyIntoCallerSubranges() throws { + try assertOwnedOutput([Float(3), 7], scalarType: .float32) + try assertOwnedOutput([Float16(3), 7], scalarType: .float16) + } + + private func assertOwnedOutput( + _ values: [T], scalarType: NDArray.ScalarType + ) throws { + let output: ETCoreAITensor = try { + var array = NDArray(shape: [values.count], scalarType: scalarType) + do { + var view = array.mutableView(as: T.self) + view.withUnsafeMutablePointer { pointer, _, _ in + for index in values.indices { pointer[index] = values[index] } + } + } + return try prepareOutput(array) + }() + XCTAssertEqual(output.byteCount, UInt(values.count * MemoryLayout.stride)) + for _ in 0..<2 { + var destination = [T](repeating: 99, count: values.count + 2) + destination.withUnsafeMutableBytes { bytes in + output.copyBytes(to: bytes.baseAddress!.advanced(by: MemoryLayout.stride)) + } + XCTAssertEqual(destination, [T(99)] + values + [T(99)]) + } + } + + func testEmptyOutputDoesNotInvokeCopier() { + var calls = 0 + let output = ETCoreAITensor(byteCount: 0, shape: [0], scalarType: .float32) { _ in + calls += 1 + } + output.copyBytes(to: nil) + XCTAssertEqual(calls, 0) + } + + func testOutputCopierIsDeferredUntilCopyAndRetainsItsOwner() { + final class Owner { var calls = 0 } + weak var observed: Owner? + var output: ETCoreAITensor? = { + let owner = Owner() + observed = owner + return ETCoreAITensor(byteCount: 4, shape: [1], scalarType: .float32) { destination in + owner.calls += 1 + var value: Float = 7 + withUnsafeBytes(of: &value) { source in + destination.copyMemory(from: source.baseAddress!, byteCount: source.count) + } + } + }() + XCTAssertEqual(observed?.calls, 0) + XCTAssertEqual(copiedData(output!), [Float(7)].withUnsafeBytes { Data($0) }) + XCTAssertEqual(observed?.calls, 1) + output = nil + XCTAssertNil(observed) + } + + func testOutputCopiesTrackActualShape() throws { + for rows in [2, 0] { + let values = (0..<(rows * 2)).map(Float.init) + var array = NDArray(shape: [rows, 2], scalarType: .float32, strides: [3, 1]) + do { + var view = array.mutableView(as: Float.self) + view.withUnsafeMutablePointer { pointer, _, strides in + for row in 0.. None: + """Export into a dedicated fixture directory, replacing its contents.""" + size = torch.export.Dim("size", min=1, max=8) + exported = torch.export.export( + Arithmetic().eval(), + (torch.ones(2), torch.ones(2)), + dynamic_shapes=({0: size}, {0: size}) if dynamic else None, + ) + lowered = to_edge_transform_and_lower( + exported, + transform_passes=get_default_passes(), + partitioner=[CoreAIPartitioner()], + compile_config=get_default_compile_config(), + ) + delegates = get_lowered_backend_modules(lowered.exported_program().graph_module) + assert len(delegates) == 1, "Expected the fixture to lower to one Core AI delegate" + manifest_bytes = bytes(delegates[0].processed_bytes) + manifest = json.loads(manifest_bytes) + store = delegates[0].named_data_store_output + assert store is not None + if output_dir.exists(): + shutil.rmtree(output_dir) + for relative_path in manifest["files"]: + entry = store.pte_data[f"coreai/{manifest['hash']}/{relative_path}"] + destination = output_dir / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(store.buffers[entry.buffer_index]) + (output_dir / "manifest.json").write_bytes(manifest_bytes) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "output_dir", type=Path, help="Dedicated fixture directory, replaced on export" + ) + output_dir = parser.parse_args().output_dir + export_fixture(output_dir) + export_fixture(output_dir / "dynamic", dynamic=True) diff --git a/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py b/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py new file mode 100644 index 00000000000..1efe401fc3e --- /dev/null +++ b/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py @@ -0,0 +1,63 @@ +# 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 json +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from executorch.backends.apple.coreai.runtime.test import ( + export_bridge_fixture as fixture, +) + + +@pytest.mark.parametrize("dynamic", [False, True]) +def test_export_replaces_stale_fixture_files(tmp_path, dynamic): + """Re-exports must not retain stale assets or freeze dynamic extents.""" + output = tmp_path / "fixture" + bundle = output / "model.aimodel" + bundle.mkdir(parents=True) + (bundle / "obsolete.bin").write_bytes(b"obsolete") + (output / "stale.json").write_text("{}") + manifest = {"hash": "ab", "files": {"model.aimodel/graph.bin": 3}} + manifest_bytes = json.dumps(manifest).encode() + delegate = SimpleNamespace( + processed_bytes=manifest_bytes, + named_data_store_output=SimpleNamespace( + pte_data={ + "coreai/ab/model.aimodel/graph.bin": SimpleNamespace(buffer_index=0) + }, + buffers=[b"abc"], + ), + ) + with ( + patch.object(fixture, "to_edge_transform_and_lower"), + patch.object(fixture, "get_lowered_backend_modules", return_value=[delegate]), + patch.object(fixture, "get_default_passes", return_value=[]), + patch.object(fixture, "get_default_compile_config"), + patch.object(fixture, "CoreAIPartitioner"), + patch.object( + fixture.torch.export, "export", wraps=fixture.torch.export.export + ) as export, + ): + fixture.export_fixture(output, dynamic=dynamic) + + assert (output / "manifest.json").read_bytes() == manifest_bytes + assert (bundle / "graph.bin").read_bytes() == b"abc" + assert sorted( + str(p.relative_to(output)) for p in output.rglob("*") if p.is_file() + ) == [ + "manifest.json", + "model.aimodel/graph.bin", + ] + shapes = export.call_args.kwargs["dynamic_shapes"] + if dynamic: + assert shapes[0][0] is shapes[1][0] + assert shapes[0][0].min == 1 + assert shapes[0][0].max == 8 + else: + assert shapes is None diff --git a/tools/cmake/preset/default.cmake b/tools/cmake/preset/default.cmake index 16e2b574d0e..7b017cf36b9 100644 --- a/tools/cmake/preset/default.cmake +++ b/tools/cmake/preset/default.cmake @@ -24,7 +24,7 @@ define_overridable_option( EXECUTORCH_BUILD_COREML "Build the Core ML backend" BOOL OFF ) define_overridable_option( - EXECUTORCH_BUILD_COREAI "Build the Core AI backend" BOOL OFF + EXECUTORCH_BUILD_COREAI "Build the Core AI backend (Apple SDK 27+)" BOOL OFF ) define_overridable_option( EXECUTORCH_FLATBUFFERS_MAX_ALIGNMENT