From 32ffdb14f59f0c0134adf994c485161abff25f95 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Sun, 4 Oct 2026 19:49:52 -0700 Subject: [PATCH 1/5] [CoreAI] Add the Core AI SDK Swift and Objective-C bridge Summary: Add the private bridge between ExecuTorch's Objective-C++ code and the Swift `CoreAI` framework, and package it for CMake consumers. No ExecuTorch delegate is added yet; a later commit wires this bridge into `CoreAIBackend`. - `runtime/ETCoreAIBridge.h`: Objective-C protocols for model acquisition, prepared models, sessions and tensor transfer, plus availability, device architecture and loader factory entry points. - `runtime/ETCoreAIModel.swift` (module `CoreAIBridge`): restore models from bookmarks, specialize source bundles with the persistent default SDK cache, bind named functions with ordered inputs/outputs and evict cache entries. Asynchronous SDK calls run in detached tasks and report through completion blocks; function binding completes synchronously. Only an SDK nil result is a cache miss; thrown errors keep their underlying cause. - Inputs are borrowed from caller storage through Core AI raw views; only shape metadata is copied. Outputs use one `memcpy` for dense row-major arrays and fall back to strided copies otherwise. - `runtime/ETCoreAITensor.mm`, `runtime/coreai_bridge.mm`: tensor wrappers and the C entry points. They build as the private `coreai_bridge_obj` object library so the delegate can absorb them and host tests can substitute a fake bridge. - Build: `backends/apple/coreai/CMakeLists.txt` requires CMake 3.31 and the Ninja or Xcode generator, keeps ExecuTorch's numeric preprocessor definitions away from swiftc, applies an OS 27 minimum to Core AI targets only, and builds `coreai_swift`. `cmake/PublishSwiftHeader.cmake` publishes the generated Objective-C header for Xcode. - Tests: `runtime/test/SwiftBridgeTests.swift` builds with the bridge sources into the `coreai_swift_bridge_test` XCTest bundle (Ninja, macOS), which CTest runs under `xcrun xctest`. - README: "SDK Bridge" and the build requirements in "Building". Changes outside `backends/apple/coreai` are CMake and CI only and only take effect when `EXECUTORCH_BUILD_COREAI=ON`: - `tools/cmake/ExecuTorchCoreAIDependencies.cmake` (new): resolves Foundation, CoreAI and Swift runtime link paths from the consumer's SDK, so installed packages do not embed the producer's Xcode paths. It fails configuration for SDKs older than 27. - `CMakeLists.txt`: install `ExecuTorchCoreAIDependencies.cmake` into the package config. - `tools/cmake/preset/default.cmake`: note the SDK 27 requirement in the option description. - `.github/workflows/coreai.yml`: also trigger on changes to `ExecuTorchCoreAIDependencies.cmake`. Test Plan: ``` # Local: configure and build only cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_DEPLOYMENT_TARGET=27.0 -DEXECUTORCH_BUILD_COREAI=ON \ -DEXECUTORCH_BUILD_TESTS=ON -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON cmake --build build --target backends/apple/coreai/all # CI (macOS 27 runner, .github/workflows/coreai.yml) ctest --test-dir build/backends/apple/coreai --output-on-failure --no-tests=error ``` CTest runs `coreai_host_test` and `coreai_swift_bridge_test`. The XCTest bundle links CoreAI, so it only runs on the macOS 27 runner; locally it is built but not run. --- CMakeLists.txt | 11 + backends/apple/coreai/CMakeLists.txt | 226 +++++++++ backends/apple/coreai/README.md | 32 ++ .../coreai/cmake/PublishSwiftHeader.cmake | 17 + .../apple/coreai/runtime/ETCoreAIBridge.h | 108 +++++ .../apple/coreai/runtime/ETCoreAIModel.swift | 435 ++++++++++++++++++ .../apple/coreai/runtime/ETCoreAITensor.mm | 41 ++ .../apple/coreai/runtime/coreai_bridge.mm | 29 ++ .../runtime/test/SwiftBridgeTests.swift | 298 ++++++++++++ .../cmake/ExecuTorchCoreAIDependencies.cmake | 118 +++++ tools/cmake/preset/default.cmake | 2 +- 11 files changed, 1316 insertions(+), 1 deletion(-) create mode 100644 backends/apple/coreai/cmake/PublishSwiftHeader.cmake create mode 100644 backends/apple/coreai/runtime/ETCoreAIBridge.h create mode 100644 backends/apple/coreai/runtime/ETCoreAIModel.swift create mode 100644 backends/apple/coreai/runtime/ETCoreAITensor.mm create mode 100644 backends/apple/coreai/runtime/coreai_bridge.mm create mode 100644 backends/apple/coreai/runtime/test/SwiftBridgeTests.swift create mode 100644 tools/cmake/ExecuTorchCoreAIDependencies.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 0620bd5ad27..d9c4c073956 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -800,6 +800,17 @@ 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}/ExecuTorchCoreAIDependencies.cmake\")\n" + "executorch_coreai_dependencies()\n" + ) + install(FILES tools/cmake/ExecuTorchCoreAIDependencies.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 435e684a57a..e7549698dd4 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -8,12 +8,24 @@ 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() + +# ExecuTorch's numeric preprocessor definitions are not Swift conditions. +get_directory_property(_coreai_inherited_definitions COMPILE_DEFINITIONS) +set_property(DIRECTORY PROPERTY COMPILE_DEFINITIONS "") +foreach(definition IN LISTS _coreai_inherited_definitions) + add_compile_definitions("$<$>:${definition}>") +endforeach() + enable_language(OBJCXX) set(_coreai_runtime_sources runtime/coreai_assets.mm runtime/coreai_storage.mm runtime/coreai_bookmarks.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 @@ -50,3 +62,217 @@ 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(${PROJECT_SOURCE_DIR}/tools/cmake/ExecuTorchCoreAIDependencies.cmake) +executorch_coreai_dependencies() +# 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") + 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-ios27.0${_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 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(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() + +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) +set_target_properties( + coreai_swift + PROPERTIES Swift_MODULE_NAME CoreAIBridge + Swift_MODULE_DIRECTORY "${_coreai_generated_dir}" + Swift_LANGUAGE_VERSION 6 + POSITION_INDEPENDENT_CODE ON +) +if(CMAKE_GENERATOR STREQUAL "Xcode") + set_target_properties( + coreai_swift + PROPERTIES XCODE_ATTRIBUTE_PRODUCT_MODULE_NAME CoreAIBridge + XCODE_ATTRIBUTE_SWIFT_MODULE_NAME CoreAIBridge + XCODE_ATTRIBUTE_SWIFT_OBJC_BRIDGING_HEADER + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h" + 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() + set(_coreai_swift_options + -import-objc-header + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h" -emit-objc-header + -emit-objc-header-path "${_coreai_swift_header}" + ) + target_compile_options( + coreai_swift + PRIVATE "$<$:${_coreai_swift_options}>" + ) + 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" +) +coreai_require_os27(coreai_swift) +target_link_libraries(coreai_swift PRIVATE executorch::coreai_dependencies) + +# The Objective-C half of the bridge. Host tests replace coreai_bridge.mm with a +# fake, so these objects are only absorbed by production targets. +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}" +) + +# 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 + Swift_MODULE_NAME CoreAIBridgeTests + Swift_LANGUAGE_VERSION 6 + ) + coreai_configure_objc_target(coreai_swift_bridge_test) + coreai_require_os27(coreai_swift_bridge_test) + target_compile_options( + coreai_swift_bridge_test + PRIVATE + "$<$:-import-objc-header;${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h;-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 + executorch::coreai_dependencies + ) + add_test(NAME coreai_swift_bridge_test + COMMAND xcrun xctest "$" + ) + set_tests_properties(coreai_swift_bridge_test PROPERTIES TIMEOUT 120) +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 0e45335dfff..7d083bca924 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -135,6 +135,38 @@ left untouched; they can be removed separately during a fully quiescent offline cleanup. The 256-file bound applies to the new stripe pool, not pre-existing legacy files. Bookmarks and staged bundles need no migration. +## 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 with the persistent default SDK cache and binds named functions with +ordered inputs and outputs. 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. Dense row-major outputs are copied with one `memcpy`, and +padded or transposed outputs fall back to strided copies. Only FP16 and FP32 +NDArrays are supported. + +The Objective-C half of the bridge builds as the private `coreai_bridge_obj` +object library, which production delegate targets absorb. Host tests substitute +a fake bridge instead. + +`coreai_swift_bridge_test` runs the bridge's XCTests under `xctest` with +`EXECUTORCH_BUILD_TESTS=ON`. It links CoreAI, so it runs only on macOS 27. + +## 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/runtime/ETCoreAIBridge.h b/backends/apple/coreai/runtime/ETCoreAIBridge.h new file mode 100644 index 00000000000..55c63d9609d --- /dev/null +++ b/backends/apple/coreai/runtime/ETCoreAIBridge.h @@ -0,0 +1,108 @@ +/* + * 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 + +typedef NS_ENUM(NSInteger, ETCoreAIScalarType) { + ETCoreAIScalarTypeFloat16 = 0, + ETCoreAIScalarTypeFloat32 = 1, +}; + +typedef NS_ENUM(NSInteger, ETCoreAIErrorCode) { + ETCoreAIErrorInvalidModel = 1, + ETCoreAIErrorUnsupported = 2, + ETCoreAIErrorInvalidArgument = 3, + ETCoreAIErrorRuntime = 4, +}; + +FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; + +// Input storage is borrowed until session completion, 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)initWithBytes:(const void* _Nullable)bytes + byteCount:(NSUInteger)byteCount + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType; +@end + +// Outputs own densely packed bytes, independent of the executor's storage. +@interface ETCoreAITensor : NSObject +@property(nonatomic, readonly, copy) NSData* data; +@property(nonatomic, readonly, copy) NSArray* shape; +@property(nonatomic, readonly) ETCoreAIScalarType scalarType; +- (instancetype)initWithData:(NSData*)data + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType; +@end + +// The caller keeps inputs valid and unmodified until completion. All session +// input access must finish before completion is invoked, including on failure. +@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 +- (void)restoreModelFromBookmark:(NSData*)bookmark + completion:(ETCoreAIAcquisitionCompletion)completion; +// Uses only the SDK default cache and persistent policy. 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..af43e6d741f --- /dev/null +++ b/backends/apple/coreai/runtime/ETCoreAIModel.swift @@ -0,0 +1,435 @@ +// 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: (Value?, Error?) -> Void + + init(_ call: @escaping (Value?, Error?) -> Void) { + self.call = call + } +} + +private final class AcquisitionCompletion: @unchecked Sendable { + let call: ETCoreAIAcquisitionCompletion + + init(_ call: @escaping ETCoreAIAcquisitionCompletion) { + 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 = AcquisitionCompletion(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: .invalidModel, context: context)) + } + } +} + +// ExecuTorch waits synchronously and keeps storage valid and unchanged until completion. +// This snapshot transfers only metadata and a pointer to the task; it does not own storage. +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) throws -> Int { + let width: Int + switch scalarType { + case .float16: width = 2 + case .float32: width = 4 + default: throw bridgeError(.unsupported, "Only FP16 and FP32 tensors are supported") + } + guard shape.allSatisfy({ $0 >= 0 }) else { + throw bridgeError(.invalidArgument, "Negative tensor dimension") + } + // Check suffix products too: SDK dense-stride construction must not overflow on empty tensors. + var count = width + for dimension in shape.reversed() { + let (next, overflow) = count.multipliedReportingOverflow(by: max(dimension, 1)) + guard !overflow else { + throw bridgeError(.invalidArgument, "Tensor byte count overflows Int") + } + count = next + } + return shape.contains(0) ? 0 : count + } +} + +@available(macOS 27.0, iOS 27.0, *) +private func bridgeScalarType(_ type: NDArray.ScalarType) throws -> ETCoreAIScalarType { + switch type { + case .float16: return .float16 + case .float32: return .float32 + default: throw bridgeError(.unsupported, "Only FP16 and FP32 NDArrays are supported") + } +} + +@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 bridgeScalarType(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) throws { + guard shape.count == descriptor.shape.count, + zip(shape, descriptor.shape).allSatisfy({ actual, expected in + actual >= 0 && (expected == -1 || actual == expected) + }) + else { + throw bridgeError(.invalidArgument, "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(.invalidArgument, "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(.invalidArgument, "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 copyOutput(_ array: NDArray, as: T.Type) throws -> Data { + let type = try bridgeScalarType(array.scalarType) + guard (T.self == Float.self && type == .float32) || (T.self == Float16.self && type == .float16) + else { + throw bridgeError(.invalidArgument, "Output copy dtype mismatch") + } + let byteCount = try CoreAITensorPayload.byteCount(shape: array.shape, scalarType: type) + guard array.interleaveLayout == nil else { + throw bridgeError(.unsupported, "Interleaved output storage is not supported") + } + let layout = try CoreAITensorLayout( + shape: array.shape, strides: array.strides, width: MemoryLayout.stride, + storageByteCount: array.rawView().bytes.byteCount) + var data = Data(count: byteCount) + let view = array.view(as: T.self) + try view.withUnsafePointer { source, shape, strides in + guard 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") + } + } + try data.withUnsafeMutableBytes { destination in + guard layout.count > 0 else { return } + guard let base = destination.baseAddress else { + throw bridgeError(.runtime, "Nonempty output has null data") + } + if layout.isDense { + base.copyMemory(from: source, byteCount: layout.count * MemoryLayout.stride) + return + } + for index in 0...stride).copyMemory( + from: source.advanced(by: layout.offset(for: index)), byteCount: MemoryLayout.stride) + } + } + } + return data +} + +@available(macOS 27.0, iOS 27.0, *) +private final class CoreAISwiftSession: NSObject, ETCoreAISession, Sendable { + let model: AIModel + let function: InferenceFunction + let inputNames: [String] + let outputNames: [String] + let inputDescriptors: [NDArrayDescriptor] + let outputDescriptors: [NDArrayDescriptor] + + init(model: AIModel, function: InferenceFunction, inputNames: [String], outputNames: [String]) + throws + { + let descriptor = function.descriptor + guard descriptor.stateNames.isEmpty else { + throw bridgeError(.unsupported, "Stateful functions are not supported") + } + guard inputNames.count == descriptor.inputCount, + outputNames.count == descriptor.outputCount, + Set(inputNames).count == inputNames.count, Set(outputNames).count == outputNames.count, + descriptor.inputNames.count == inputNames.count, + descriptor.outputNames.count == outputNames.count, + Set(inputNames) == Set(descriptor.inputNames), Set(outputNames) == Set(descriptor.outputNames) + else { + throw bridgeError(.invalidModel, "Ordered bindings do not match the function descriptor") + } + self.model = model + self.function = function + self.inputNames = inputNames + self.outputNames = outputNames + inputDescriptors = try inputNames.map { + try tensorDescriptor(descriptor.inputDescriptor(of: $0)) + } + outputDescriptors = try outputNames.map { + try tensorDescriptor(descriptor.outputDescriptor(of: $0)) + } + super.init() + } + + // All nonescapable input views end here on both return and throw, before completion. + private func runBorrowing(_ payloads: [CoreAITensorPayload]) async throws + -> 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) + guard payload.scalarType == (try bridgeScalarType(descriptor.scalarType)) 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) + guard array.scalarType == descriptor.scalarType else { + throw bridgeError(.runtime, "Output dtype does not match the function descriptor") + } + let type = try bridgeScalarType(array.scalarType) + let data = + try type == .float16 + ? copyOutput(array, as: Float16.self) : copyOutput(array, as: Float.self) + tensors.append( + ETCoreAITensor( + data: data, shape: array.shape.map(NSNumber.init(value:)), scalarType: type)) + } + 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: .invalidModel, context: "Core AI function binding failed")) + } + } +} + +@available(macOS 27.0, iOS 27.0, *) +@objc(ETCoreAISwiftModelLoader) +public final class ETCoreAISwiftModelLoader: NSObject, ETCoreAIModelLoading { + @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) { + 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: .default, cache: .default, cachePolicy: .persistent) + return CoreAISwiftPreparedModel(model: model) + } + } + + public func evictModel( + withBookmark bookmark: Data, completion: @escaping (Error?) -> Void + ) { + let callback = Completion { _, error in completion(error) } + Task.detached { + do { + try AIModelCache.deleteEntry(referencedBy: bookmark) + callback.call((), nil) + } catch { + callback.call( + nil, 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..6f48b94553e --- /dev/null +++ b/backends/apple/coreai/runtime/ETCoreAITensor.mm @@ -0,0 +1,41 @@ +/* + * 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" + +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 +- (instancetype)initWithData:(NSData*)data + shape:(NSArray*)shape + scalarType:(ETCoreAIScalarType)scalarType { + self = [super init]; + if (self) { + _data = [data copy]; + _shape = [shape copy]; + _scalarType = scalarType; + } + return self; +} +@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..d54f7f3c5ad --- /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..78fc0be30f0 --- /dev/null +++ b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift @@ -0,0 +1,298 @@ +// 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 XCTest + +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])) + model?.loadFunctionNamed("missing", inputNames: [], outputNames: []) { session, error in + XCTAssertNil(session) + XCTAssertEqual((error as NSError?)?.code, ETCoreAIErrorCode.invalidModel.rawValue) + } + completed.fulfill() + } operation: { + ProtocolPreparedModel(released: released) + } + wait(for: [completed, released], timeout: 5) + } + + 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.invalidModel.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 SwiftBridgeTests: XCTestCase { + func testInvalidSpecializationURLCompletesOffMainThread() { + let completed = expectation(description: "invalid specialization URL") + completed.assertForOverFulfill = true + ETCoreAISwiftModelLoader().specializeModel(at: URL(string: "coreai-test:model")!) { + status, model, error in + XCTAssertFalse(Thread.isMainThread) + XCTAssertEqual(status, .error) + XCTAssertNil(model) + XCTAssertEqual((error as NSError?)?.domain, ETCoreAIErrorDomain) + XCTAssertEqual((error as NSError?)?.code, ETCoreAIErrorCode.invalidArgument.rawValue) + completed.fulfill() + } + wait(for: [completed], timeout: 5) + } + + func testByteCounts() throws { + XCTAssertEqual(try CoreAITensorPayload.byteCount(shape: [], scalarType: .float32), 4) + XCTAssertEqual(try CoreAITensorPayload.byteCount(shape: [2, 3], scalarType: .float16), 12) + XCTAssertEqual(try CoreAITensorPayload.byteCount(shape: [2, 0, 3], scalarType: .float32), 0) + for shape in [[-1], [Int.max / 4, 8]] { + XCTAssertThrowsError(try CoreAITensorPayload.byteCount(shape: shape, scalarType: .float32)) { + XCTAssertEqual(($0 as NSError).domain, ETCoreAIErrorDomain) + XCTAssertEqual(($0 as NSError).code, ETCoreAIErrorCode.invalidArgument.rawValue) + } + } + } + + func testInvalidPayloads() { + var value: Float = 1 + withUnsafeBytes(of: &value) { bytes in + assertInvalidInput(bytes: bytes.baseAddress, byteCount: 4, shape: [-1]) + assertInvalidInput(bytes: bytes.baseAddress, byteCount: 4, shape: [0, NSNumber(value: Int.max)]) + assertInvalidInput(bytes: bytes.baseAddress, byteCount: 3, shape: [1]) + assertInvalidInput(bytes: nil, byteCount: 4, shape: [1]) + } + } + + private func assertInvalidInput( + bytes: UnsafeRawPointer?, byteCount: UInt, shape: [NSNumber], + file: StaticString = #filePath, line: UInt = #line + ) { + XCTAssertThrowsError( + try CoreAITensorPayload( + ETCoreAIInputTensor( + bytes: bytes, byteCount: byteCount, shape: shape, scalarType: .float32)), + file: file, line: line + ) { + XCTAssertEqual(($0 as NSError).domain, ETCoreAIErrorDomain, file: file, line: line) + XCTAssertEqual( + ($0 as NSError).code, ETCoreAIErrorCode.invalidArgument.rawValue, file: file, line: line) + } + } + + func testInputsBorrowStorage() throws { + try assertBorrows(Array((0..<12).map(Float.init)), scalarType: .float32) + try assertBorrows(Array((0..<12).map(Float16.init)), scalarType: .float16) + } + + private func assertBorrows(_ 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: NDArray.ScalarType = scalarType == .float16 ? .float16 : .float32 + 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 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.. Date: Mon, 26 Oct 2026 16:13:35 -0700 Subject: [PATCH 2/5] [CoreAI] Cover SDK sessions and document Swift caller requirements Summary: Make the synchronous calling contract explicit and protect real model execution against binding and validation regressions. Consolidate callback handling without changing scheduling or retry behavior. Test Plan: CoreAI native and Swift CTest targets passed on macOS 27. All 17 Swift tests passed, including five new real-SDK session tests; five repeated suite runs also passed. The Python backend suite passed all 113 tests in the pinned CoreAI environment. A bounded probe using the production acquisition helper reproduced cooperative-pool starvation with 16 synchronously blocking Swift-task callers under the default runtime. GCD and async-continuation controls completed successfully. This was a scheduling-only probe, not real inference. --- backends/apple/coreai/CMakeLists.txt | 26 ++- backends/apple/coreai/README.md | 16 ++ .../apple/coreai/runtime/ETCoreAIBridge.h | 6 + .../apple/coreai/runtime/ETCoreAIModel.swift | 22 +- .../runtime/test/SwiftBridgeTests.swift | 188 ++++++++++++++++++ .../runtime/test/export_bridge_fixture.py | 39 ++++ 6 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 backends/apple/coreai/runtime/test/export_bridge_fixture.py diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index e7549698dd4..36a704250f5 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -263,10 +263,34 @@ if(EXECUTORCH_BUILD_TESTS coreai_swift_bridge_test PRIVATE "-framework XCTest" XCTestSwiftSupport executorch::coreai_dependencies ) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + set(_coreai_fixture_dir "${CMAKE_CURRENT_BINARY_DIR}/bridge_fixture") + add_custom_command( + OUTPUT "${_coreai_fixture_dir}/manifest.json" + BYPRODUCTS "${_coreai_fixture_dir}/model.aimodel/main.hash" + "${_coreai_fixture_dir}/model.aimodel/main.mlirb" + "${_coreai_fixture_dir}/model.aimodel/metadata.json" + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/test/export_bridge_fixture.py" + "${_coreai_fixture_dir}" + DEPENDS runtime/test/export_bridge_fixture.py compiler/preprocess.py + 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" + ) + add_dependencies(coreai_swift_bridge_test coreai_bridge_fixture) add_test(NAME coreai_swift_bridge_test COMMAND xcrun xctest "$" ) - set_tests_properties(coreai_swift_bridge_test PROPERTIES TIMEOUT 120) + set_tests_properties( + coreai_swift_bridge_test + PROPERTIES TIMEOUT 120 ENVIRONMENT + "COREAI_BRIDGE_FIXTURE_DIR=${_coreai_fixture_dir}" + ) endif() install( diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 7d083bca924..79603904854 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -36,6 +36,19 @@ export-time SHA-256 digest. Asset paths, including bundle and directory names, must remain distinct under case-insensitive, canonically normalized Unicode comparison. Accepted names are preserved, not rewritten. +### 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 until the ExecuTorch call returns, even if the waiting task is +cancelled. + ### AOT architecture selection Configure AOT export with `AOTCompileConfig`, including the target `platform` and @@ -155,6 +168,9 @@ a fake bridge instead. `coreai_swift_bridge_test` runs the bridge's XCTests under `xctest` with `EXECUTORCH_BUILD_TESTS=ON`. It links CoreAI, so it runs only on macOS 27. +The test build exports a small portable model for real session tests. CMake's +`Python3_EXECUTABLE` must use the Python environment with the pinned Core AI +requirements above. CTest supplies the generated fixture location to XCTest. ## Building diff --git a/backends/apple/coreai/runtime/ETCoreAIBridge.h b/backends/apple/coreai/runtime/ETCoreAIBridge.h index 55c63d9609d..f14b67c598b 100644 --- a/backends/apple/coreai/runtime/ETCoreAIBridge.h +++ b/backends/apple/coreai/runtime/ETCoreAIBridge.h @@ -10,6 +10,10 @@ 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, @@ -48,6 +52,8 @@ FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; // The caller keeps inputs valid and unmodified until completion. All session // input access must finish before completion is invoked, including on failure. +// 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 (^)( diff --git a/backends/apple/coreai/runtime/ETCoreAIModel.swift b/backends/apple/coreai/runtime/ETCoreAIModel.swift index af43e6d741f..0feb8c3d33e 100644 --- a/backends/apple/coreai/runtime/ETCoreAIModel.swift +++ b/backends/apple/coreai/runtime/ETCoreAIModel.swift @@ -22,18 +22,10 @@ private func mappedError(_ error: Error, code: ETCoreAIErrorCode, context: Strin } // Objective-C blocks have no Sendable annotation. Each box is invoked by one detached task only. -private final class Completion: @unchecked Sendable { - let call: (Value?, Error?) -> Void +private final class Completion: @unchecked Sendable { + let call: Callback - init(_ call: @escaping (Value?, Error?) -> Void) { - self.call = call - } -} - -private final class AcquisitionCompletion: @unchecked Sendable { - let call: ETCoreAIAcquisitionCompletion - - init(_ call: @escaping ETCoreAIAcquisitionCompletion) { + init(_ call: Callback) { self.call = call } } @@ -43,7 +35,7 @@ func acquireCoreAIModel( context: String, completion: @escaping ETCoreAIAcquisitionCompletion, operation: @escaping @Sendable () async throws -> (any ETCoreAIPreparedModel)? ) { - let callback = AcquisitionCompletion(completion) + let callback = Completion(completion) Task.detached { do { if let model = try await operation() { @@ -421,14 +413,14 @@ public final class ETCoreAISwiftModelLoader: NSObject, ETCoreAIModelLoading { public func evictModel( withBookmark bookmark: Data, completion: @escaping (Error?) -> Void ) { - let callback = Completion { _, error in completion(error) } + let callback = Completion(completion) Task.detached { do { try AIModelCache.deleteEntry(referencedBy: bookmark) - callback.call((), nil) + callback.call(nil) } catch { callback.call( - nil, mappedError(error, code: .runtime, context: "Core AI cache eviction failed")) + mappedError(error, code: .runtime, context: "Core AI cache eviction failed")) } } } diff --git a/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift index 78fc0be30f0..59caf30be06 100644 --- a/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift +++ b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift @@ -6,6 +6,7 @@ import CoreAI import Foundation +import Synchronization import XCTest private final class ProtocolPreparedModel: NSObject, ETCoreAIPreparedModel, Sendable { @@ -96,6 +97,193 @@ final class SwiftAcquisitionTests: XCTestCase { } } +@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 let loader = ETCoreAISwiftModelLoader() + private var manifest: Manifest! + private var model: (any ETCoreAIPreparedModel)? + private var session: (any ETCoreAISession)? + private var bookmark: Data? + + override func setUpWithError() throws { + let root = try XCTUnwrap(ProcessInfo.processInfo.environment["COREAI_BRIDGE_FIXTURE_DIR"]) + let directory = URL(fileURLWithPath: root, isDirectory: true) + manifest = try JSONDecoder().decode( + Manifest.self, from: Data(contentsOf: directory.appendingPathComponent("manifest.json"))) + XCTAssertEqual(manifest.inputNames.count, 2) + XCTAssertEqual(manifest.outputNames.count, 2) + let completed = expectation(description: "specialized session fixture") + completed.assertForOverFulfill = true + var failure: Error? + loader.specializeModel(at: directory.appendingPathComponent("model.aimodel")) { + status, model, error in + XCTAssertEqual(status, .hit) + self.model = model + failure = error + completed.fulfill() + } + wait(for: [completed], timeout: 30) + if let failure { throw failure } + bookmark = try XCTUnwrap(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 { + 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 + result.withLock { $0 = (tensors, 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(output.data, values.withUnsafeBytes { Data($0) }) + } + } + } + + func testRejectsDuplicateAndMismatchedBindings() throws { + let inputs = manifest.inputNames + let outputs = manifest.outputNames + for (inputNames, outputNames) in [ + ([inputs[0], inputs[0]], outputs), + (inputs, [outputs[0], outputs[0]]), + ([inputs[0], "missing"], outputs), + (inputs, [outputs[0], "missing"]), + ] { + XCTAssertThrowsError(try bind(inputNames: inputNames, outputNames: outputNames)) { + XCTAssertEqual(($0 as NSError).domain, ETCoreAIErrorDomain) + XCTAssertEqual(($0 as NSError).code, ETCoreAIErrorCode.invalidModel.rawValue) + } + } + } + + func testInvalidInputCountCompletesSynchronouslyOnce() throws { + try bind() + let calls = Mutex(0) + let caller = ObjectIdentifier(Thread.current) + try XCTUnwrap(session).executeInputs([]) { outputs, error in + calls.withLock { $0 += 1 } + XCTAssertEqual(ObjectIdentifier(Thread.current), caller) + XCTAssertNil(outputs) + XCTAssertEqual((error as NSError?)?.domain, ETCoreAIErrorDomain) + XCTAssertEqual((error as NSError?)?.code, ETCoreAIErrorCode.invalidArgument.rawValue) + } + XCTAssertEqual(calls.withLock { $0 }, 1) + } + + func testInvalidPayloadCompletesSynchronouslyOnce() throws { + try bind() + let invalid = ETCoreAIInputTensor( + bytes: nil, byteCount: 8, shape: [2], scalarType: .float32) + let calls = Mutex(0) + let caller = ObjectIdentifier(Thread.current) + try XCTUnwrap(session).executeInputs([invalid, invalid]) { outputs, error in + calls.withLock { $0 += 1 } + XCTAssertEqual(ObjectIdentifier(Thread.current), caller) + XCTAssertNil(outputs) + XCTAssertEqual((error as NSError?)?.code, ETCoreAIErrorCode.invalidArgument.rawValue) + } + XCTAssertEqual(calls.withLock { $0 }, 1) + } + + func testRejectsInputDescriptorDtypeAndShapeMismatches() throws { + try bind() + for input in [ + Input(Data(count: 4), shape: [2], type: .float16), + Input(Data(count: 8), shape: [1, 2]), + Input(Data(count: 12), shape: [3]), + ] { + XCTAssertThrowsError(try execute([input, Input([1, 2])])) { + XCTAssertEqual(($0 as NSError).domain, ETCoreAIErrorDomain) + XCTAssertEqual(($0 as NSError).code, ETCoreAIErrorCode.invalidArgument.rawValue) + } + } + } +} + @available(macOS 27.0, iOS 27.0, *) final class SwiftBridgeTests: XCTestCase { func testInvalidSpecializationURLCompletesOffMainThread() { diff --git a/backends/apple/coreai/runtime/test/export_bridge_fixture.py b/backends/apple/coreai/runtime/test/export_bridge_fixture.py new file mode 100644 index 00000000000..f48b884bbc8 --- /dev/null +++ b/backends/apple/coreai/runtime/test/export_bridge_fixture.py @@ -0,0 +1,39 @@ +# 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 argparse +import json +from pathlib import Path + +import torch + +from executorch.backends.apple.coreai.compiler.preprocess import CoreAIBackend +from executorch.exir import to_edge + + +class Arithmetic(torch.nn.Module): + def forward(self, left, right): + return left + right, left - right + + +def export_fixture(output_dir: Path) -> None: + exported = torch.export.export(Arithmetic().eval(), (torch.ones(2), torch.ones(2))) + result = CoreAIBackend.preprocess(to_edge(exported).exported_program(), []) + manifest = json.loads(result.processed_bytes) + store = result.data_store_output + assert store is not None + 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(result.processed_bytes) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("output_dir", type=Path) + export_fixture(parser.parse_args().output_dir) From 4f984848ef31bd8f190be4d9410dc0268c1a98a9 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Mon, 26 Oct 2026 16:16:06 -0700 Subject: [PATCH 3/5] up --- .../runtime/test/export_bridge_fixture.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/backends/apple/coreai/runtime/test/export_bridge_fixture.py b/backends/apple/coreai/runtime/test/export_bridge_fixture.py index f48b884bbc8..0a671f77ee5 100644 --- a/backends/apple/coreai/runtime/test/export_bridge_fixture.py +++ b/backends/apple/coreai/runtime/test/export_bridge_fixture.py @@ -10,8 +10,13 @@ import torch -from executorch.backends.apple.coreai.compiler.preprocess import CoreAIBackend -from executorch.exir import to_edge +from executorch.backends.apple.coreai import ( + CoreAIPartitioner, + get_default_compile_config, + get_default_passes, +) +from executorch.exir import to_edge_transform_and_lower +from executorch.exir.lowered_backend_module import get_lowered_backend_modules class Arithmetic(torch.nn.Module): @@ -21,16 +26,24 @@ def forward(self, left, right): def export_fixture(output_dir: Path) -> None: exported = torch.export.export(Arithmetic().eval(), (torch.ones(2), torch.ones(2))) - result = CoreAIBackend.preprocess(to_edge(exported).exported_program(), []) - manifest = json.loads(result.processed_bytes) - store = result.data_store_output + 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 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(result.processed_bytes) + (output_dir / "manifest.json").write_bytes(manifest_bytes) if __name__ == "__main__": From e8e355a7b3554c84e7829dfd8fefa7329adb675b Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Tue, 27 Oct 2026 13:23:37 -0700 Subject: [PATCH 4/5] [CoreAI] Harden SDK bridge contracts and fixture validation Fix fixture dependencies and interpreter selection, isolate Swift from inherited Clang flags, and honor the iOS deployment target. Distinguish SDK failures from invalid inputs and require initialized tensor metadata. Strengthen callback lifetime and dynamic-descriptor tests. Test Plan: 96 native, 18 Swift, and 115 Python tests passed. Verified macOS27.0 and iOS27.1 Ninja builds, fixture dependency graph, unavailable initializers, and direct XCTest skip behavior. Formatting and lint passed. --- backends/apple/coreai/CMakeLists.txt | 53 +++++++---- backends/apple/coreai/README.md | 13 ++- .../apple/coreai/runtime/ETCoreAIBridge.h | 15 ++- .../apple/coreai/runtime/ETCoreAIModel.swift | 31 ++++--- .../runtime/test/SwiftBridgeTests.swift | 93 +++++++++++++++---- .../runtime/test/export_bridge_fixture.py | 24 ++++- .../test/test_export_bridge_fixture.py | 63 +++++++++++++ 7 files changed, 233 insertions(+), 59 deletions(-) create mode 100644 backends/apple/coreai/runtime/test/test_export_bridge_fixture.py diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 36a704250f5..fc5d5f6117d 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -12,11 +12,17 @@ if(CMAKE_VERSION VERSION_LESS 3.31) message(FATAL_ERROR "Core AI requires CMake 3.31 or newer for Swift support") endif() -# ExecuTorch's numeric preprocessor definitions are not Swift conditions. -get_directory_property(_coreai_inherited_definitions COMPILE_DEFINITIONS) -set_property(DIRECTORY PROPERTY COMPILE_DEFINITIONS "") -foreach(definition IN LISTS _coreai_inherited_definitions) - add_compile_definitions("$<$>:${definition}>") +# 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) @@ -72,8 +78,15 @@ endif() include(${PROJECT_SOURCE_DIR}/tools/cmake/ExecuTorchCoreAIDependencies.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") +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() @@ -84,17 +97,14 @@ if(CMAKE_SYSTEM_NAME STREQUAL "iOS" AND NOT CMAKE_GENERATOR STREQUAL "Xcode") set(_coreai_probe_environment "-simulator") endif() set(CMAKE_Swift_COMPILER_TARGET - "${_coreai_probe_arch}-apple-ios27.0${_coreai_probe_environment}" + "${_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 27.0) - if(CMAKE_OSX_DEPLOYMENT_TARGET VERSION_GREATER deployment) - set(deployment "${CMAKE_OSX_DEPLOYMENT_TARGET}") - endif() + set(deployment "${_coreai_deployment}") set(target_environment "") if(CMAKE_SYSTEM_NAME STREQUAL "iOS") set(platform ios) @@ -204,8 +214,7 @@ set_property( coreai_require_os27(coreai_swift) target_link_libraries(coreai_swift PRIVATE executorch::coreai_dependencies) -# The Objective-C half of the bridge. Host tests replace coreai_bridge.mm with a -# fake, so these objects are only absorbed by production targets. +# 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 ) @@ -263,24 +272,32 @@ if(EXECUTORCH_BUILD_TESTS coreai_swift_bridge_test PRIVATE "-framework XCTest" XCTestSwiftSupport executorch::coreai_dependencies ) - find_package(Python3 REQUIRED COMPONENTS Interpreter) + 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 - "${Python3_EXECUTABLE}" + "${PYTHON_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/runtime/test/export_bridge_fixture.py" "${_coreai_fixture_dir}" - DEPENDS runtime/test/export_bridge_fixture.py compiler/preprocess.py + 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_bridge_fixture + DEPENDS "${_coreai_fixture_dir}/manifest.json" + "${_coreai_fixture_dir}/dynamic/manifest.json" ) add_dependencies(coreai_swift_bridge_test coreai_bridge_fixture) add_test(NAME coreai_swift_bridge_test @@ -288,7 +305,7 @@ if(EXECUTORCH_BUILD_TESTS ) set_tests_properties( coreai_swift_bridge_test - PROPERTIES TIMEOUT 120 ENVIRONMENT + PROPERTIES TIMEOUT 600 ENVIRONMENT "COREAI_BRIDGE_FIXTURE_DIR=${_coreai_fixture_dir}" ) endif() diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 79603904854..7b45b877410 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -163,14 +163,17 @@ padded or transposed outputs fall back to strided copies. Only FP16 and FP32 NDArrays are supported. The Objective-C half of the bridge builds as the private `coreai_bridge_obj` -object library, which production delegate targets absorb. Host tests substitute -a fake bridge instead. +object library for integration with the production delegate. The separate entry +points allow delegate tests to substitute a fake bridge. `coreai_swift_bridge_test` runs the bridge's XCTests under `xctest` with `EXECUTORCH_BUILD_TESTS=ON`. It links CoreAI, so it runs only on macOS 27. -The test build exports a small portable model for real session tests. CMake's -`Python3_EXECUTABLE` must use the Python environment with the pinned Core AI -requirements above. CTest supplies the generated fixture location to XCTest. +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 diff --git a/backends/apple/coreai/runtime/ETCoreAIBridge.h b/backends/apple/coreai/runtime/ETCoreAIBridge.h index f14b67c598b..3415f4b0ba7 100644 --- a/backends/apple/coreai/runtime/ETCoreAIBridge.h +++ b/backends/apple/coreai/runtime/ETCoreAIBridge.h @@ -20,9 +20,14 @@ typedef NS_ENUM(NSInteger, ETCoreAIScalarType) { }; 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, }; @@ -34,10 +39,13 @@ FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; @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; + scalarType:(ETCoreAIScalarType)scalarType + NS_DESIGNATED_INITIALIZER; @end // Outputs own densely packed bytes, independent of the executor's storage. @@ -45,9 +53,12 @@ FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; @property(nonatomic, readonly, copy) NSData* data; @property(nonatomic, readonly, copy) NSArray* shape; @property(nonatomic, readonly) ETCoreAIScalarType scalarType; +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; - (instancetype)initWithData:(NSData*)data shape:(NSArray*)shape - scalarType:(ETCoreAIScalarType)scalarType; + scalarType:(ETCoreAIScalarType)scalarType + NS_DESIGNATED_INITIALIZER; @end // The caller keeps inputs valid and unmodified until completion. All session diff --git a/backends/apple/coreai/runtime/ETCoreAIModel.swift b/backends/apple/coreai/runtime/ETCoreAIModel.swift index 0feb8c3d33e..3f37d5a00a4 100644 --- a/backends/apple/coreai/runtime/ETCoreAIModel.swift +++ b/backends/apple/coreai/runtime/ETCoreAIModel.swift @@ -44,7 +44,7 @@ func acquireCoreAIModel( callback.call(.miss, nil, nil) } } catch { - callback.call(.error, nil, mappedError(error, code: .invalidModel, context: context)) + callback.call(.error, nil, mappedError(error, code: .runtime, context: context)) } } } @@ -76,7 +76,10 @@ struct CoreAITensorPayload: @unchecked Sendable { } } - static func byteCount(shape: [Int], scalarType: ETCoreAIScalarType) throws -> Int { + static func byteCount( + shape: [Int], scalarType: ETCoreAIScalarType, + errorCode: ETCoreAIErrorCode = .invalidArgument + ) throws -> Int { let width: Int switch scalarType { case .float16: width = 2 @@ -84,14 +87,14 @@ struct CoreAITensorPayload: @unchecked Sendable { default: throw bridgeError(.unsupported, "Only FP16 and FP32 tensors are supported") } guard shape.allSatisfy({ $0 >= 0 }) else { - throw bridgeError(.invalidArgument, "Negative tensor dimension") + throw bridgeError(errorCode, "Negative tensor dimension") } // Check suffix products too: SDK dense-stride construction must not overflow on empty tensors. var count = width for dimension in shape.reversed() { let (next, overflow) = count.multipliedReportingOverflow(by: max(dimension, 1)) guard !overflow else { - throw bridgeError(.invalidArgument, "Tensor byte count overflows Int") + throw bridgeError(errorCode, "Tensor byte count overflows Int") } count = next } @@ -125,13 +128,16 @@ private func tensorDescriptor(_ value: InferenceValue.Descriptor?) throws -> NDA } @available(macOS 27.0, iOS 27.0, *) -private func validateShape(_ shape: [Int], descriptor: NDArrayDescriptor) throws { +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(.invalidArgument, "Tensor shape does not match the function descriptor") + throw bridgeError(errorCode, "Tensor shape does not match the function descriptor") } } @@ -156,7 +162,7 @@ struct CoreAITensorLayout { let (extent, extentOverflow) = max(dimension - 1, 0).multipliedReportingOverflow(by: stride) let (offset, offsetOverflow) = maximumOffset.addingReportingOverflow(extent) guard !countOverflow, !extentOverflow, !offsetOverflow else { - throw bridgeError(.invalidArgument, "NDArray layout overflows Int") + throw bridgeError(.runtime, "NDArray layout overflows Int") } count = next maximumOffset = offset @@ -173,7 +179,7 @@ struct CoreAITensorLayout { let (span, spanOverflow) = maximumOffset.addingReportingOverflow(1) let (bytes, byteOverflow) = span.multipliedReportingOverflow(by: width) guard !spanOverflow, !byteOverflow, bytes <= storageByteCount else { - throw bridgeError(.invalidArgument, "NDArray layout exceeds its storage") + throw bridgeError(.runtime, "NDArray layout exceeds its storage") } } } @@ -194,9 +200,10 @@ func copyOutput(_ array: NDArray, as: T.Type) throws -> Data let type = try bridgeScalarType(array.scalarType) guard (T.self == Float.self && type == .float32) || (T.self == Float16.self && type == .float16) else { - throw bridgeError(.invalidArgument, "Output copy dtype mismatch") + throw bridgeError(.runtime, "Output copy dtype mismatch") } - let byteCount = try CoreAITensorPayload.byteCount(shape: array.shape, scalarType: type) + let byteCount = try CoreAITensorPayload.byteCount( + shape: array.shape, scalarType: type, errorCode: .runtime) guard array.interleaveLayout == nil else { throw bridgeError(.unsupported, "Interleaved output storage is not supported") } @@ -322,7 +329,7 @@ private final class CoreAISwiftSession: NSObject, ETCoreAISession, Sendable { throw bridgeError(.unsupported, "Inference returned an image output") } let descriptor = outputDescriptors[index] - try validateShape(array.shape, descriptor: descriptor) + 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") } @@ -381,7 +388,7 @@ private final class CoreAISwiftPreparedModel: NSObject, ETCoreAIPreparedModel, S outputNames: outputNames), nil) } catch { completion( - nil, mappedError(error, code: .invalidModel, context: "Core AI function binding failed")) + nil, mappedError(error, code: .runtime, context: "Core AI function binding failed")) } } } diff --git a/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift index 59caf30be06..9a39f6a4681 100644 --- a/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift +++ b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift @@ -42,15 +42,11 @@ final class SwiftAcquisitionTests: XCTestCase { XCTAssertEqual(status, .hit) XCTAssertNil(error) XCTAssertEqual(model?.copyBookmarkData(), Data([1, 2, 3])) - model?.loadFunctionNamed("missing", inputNames: [], outputNames: []) { session, error in - XCTAssertNil(session) - XCTAssertEqual((error as NSError?)?.code, ETCoreAIErrorCode.invalidModel.rawValue) - } completed.fulfill() } operation: { ProtocolPreparedModel(released: released) } - wait(for: [completed, released], timeout: 5) + wait(for: [completed, released], timeout: 5, enforceOrder: true) } func testOnlyNilIsMiss() { @@ -84,7 +80,7 @@ final class SwiftAcquisitionTests: XCTestCase { XCTAssertTrue(error === bridge) } else { XCTAssertEqual(error?.domain, ETCoreAIErrorDomain) - XCTAssertEqual(error?.code, ETCoreAIErrorCode.invalidModel.rawValue) + XCTAssertEqual(error?.code, ETCoreAIErrorCode.runtime.rawValue) XCTAssertTrue(error?.localizedDescription.hasPrefix("test restoration:") == true) XCTAssertTrue((error?.userInfo[NSUnderlyingErrorKey] as? NSError) === sdk) } @@ -129,30 +125,46 @@ final class SwiftSessionTests: XCTestCase { private let 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 { - let root = try XCTUnwrap(ProcessInfo.processInfo.environment["COREAI_BRIDGE_FIXTURE_DIR"]) - let directory = URL(fileURLWithPath: root, isDirectory: true) + 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) + } + + private func prepareModel() throws { + guard model == nil else { return } let completed = expectation(description: "specialized session fixture") completed.assertForOverFulfill = true - var failure: Error? - loader.specializeModel(at: directory.appendingPathComponent("model.aimodel")) { + let result = Mutex<(model: (any ETCoreAIPreparedModel)?, error: Error?)>((nil, nil)) + loader.specializeModel(at: fixtureDirectory.appendingPathComponent("model.aimodel")) { status, model, error in XCTAssertEqual(status, .hit) - self.model = model - failure = error + result.withLock { $0 = (model, error) } completed.fulfill() } wait(for: [completed], timeout: 30) - if let failure { throw failure } - bookmark = try XCTUnwrap(model).copyBookmarkData() + 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 { @@ -171,6 +183,7 @@ final class SwiftSessionTests: XCTestCase { } 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, @@ -224,6 +237,30 @@ final class SwiftSessionTests: XCTestCase { } } + 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.. None: - exported = torch.export.export(Arithmetic().eval(), (torch.ones(2), torch.ones(2))) +def export_fixture(output_dir: Path, *, dynamic: bool = False) -> 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(), @@ -38,6 +48,8 @@ def export_fixture(output_dir: Path) -> None: 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 @@ -48,5 +60,9 @@ def export_fixture(output_dir: Path) -> None: if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("output_dir", type=Path) - export_fixture(parser.parse_args().output_dir) + 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 From 88c52331bb1674a9af96df3d383ab59fe9b7f7e8 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Tue, 27 Oct 2026 17:30:22 -0700 Subject: [PATCH 5/5] [CoreAI] Fix SDK bridge formatting before runtime CI Apply clang-format 18.1.3 to the device-architecture function at the layer introducing it. GitHub Core AI CI stopped in its formatting step before running tests on PR #23391; the same formatting had previously only been corrected in a later acquisition commit. Test Plan: et-extra lintrunner -a passed on this PR. The CI-equivalent clang-format --dry-run --Werror check passed for every CoreAI Objective-C++ source at this layer. Other eight published PRs had successful CoreAI Python/runtime test runs; no gating change required. --- backends/apple/coreai/runtime/coreai_bridge.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends/apple/coreai/runtime/coreai_bridge.mm b/backends/apple/coreai/runtime/coreai_bridge.mm index d54f7f3c5ad..539bdb408cb 100644 --- a/backends/apple/coreai/runtime/coreai_bridge.mm +++ b/backends/apple/coreai/runtime/coreai_bridge.mm @@ -17,7 +17,7 @@ BOOL ETCoreAIIsAvailable(void) { return NO; } -NSString *ETCoreAIDeviceArchitectureName(void) { +NSString* ETCoreAIDeviceArchitectureName(void) { if (@available(macOS 27.0, iOS 27.0, *)) { return ETCoreAISwiftModelLoader.deviceArchitectureName; }