From 756e9064edff6677bd645e1355a49c0f9bda72a6 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Sun, 4 Oct 2026 19:49:52 -0700 Subject: [PATCH] [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. --- .github/workflows/coreai.yml | 1 + CMakeLists.txt | 11 + backends/apple/coreai/CMakeLists.txt | 228 ++++++++- 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 +- 12 files changed, 1318 insertions(+), 2 deletions(-) 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/.github/workflows/coreai.yml b/.github/workflows/coreai.yml index e3b9a8542f6..c835846d488 100644 --- a/.github/workflows/coreai.yml +++ b/.github/workflows/coreai.yml @@ -11,6 +11,7 @@ on: paths: - .github/workflows/coreai.yml - backends/apple/coreai/** + - tools/cmake/ExecuTorchCoreAIDependencies.cmake workflow_dispatch: concurrency: diff --git a/CMakeLists.txt b/CMakeLists.txt index 95c765518bb..d64d972042f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -825,6 +825,17 @@ elseif(EXECUTORCH_BUILD_CUDA) "find_dependency(CUDAToolkit)\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 baaae9a50a9..85d6d9b323f 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -5,15 +5,27 @@ # LICENSE file in the root directory of this source tree. if(NOT APPLE) - message(FATAL_ERROR "Core AI host tests require Apple platforms") + message(FATAL_ERROR "Core AI runtime and host tests require 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 @@ -53,3 +65,217 @@ if(EXECUTORCH_BUILD_TESTS) 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 a53e6eb1709..f5565e7f57c 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -94,6 +94,38 @@ acquiring a lock does not flush its file or directory. Lock files are not removed by the backend, including after process exit. 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 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 `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the 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..