diff --git a/CMakeLists.txt b/CMakeLists.txt index f180bd6f8c1..9abc10ff8bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -854,6 +854,7 @@ endif() if(EXECUTORCH_BUILD_COREAI) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/apple/coreai) + list(APPEND _executorch_backends coreaidelegate) endif() if(EXECUTORCH_BUILD_MLX) diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 5f42d880a79..979f7fd6c64 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -27,14 +27,26 @@ endforeach() enable_language(OBJCXX) +# The consolidated shared runtime target is created after this directory. +if(EXECUTORCH_BUILD_SHARED) + set(_coreai_library_type SHARED) + set(_coreai_runtime executorch_shared) +else() + set(_coreai_library_type STATIC) + set(_coreai_runtime executorch) +endif() + set(_coreai_runtime_sources + runtime/coreai_backend.mm runtime/coreai_bookmarks.mm runtime/coreai_cache_key.mm runtime/coreai_cache_root.mm + runtime/coreai_error.mm runtime/coreai_fault_injection.mm runtime/coreai_load_coordinator.mm runtime/coreai_manifest.mm runtime/coreai_source_bundle.mm + runtime/coreai_tensor_io.mm runtime/coreai_validation.mm runtime/coreai_storage.mm ) @@ -70,6 +82,8 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") runtime/test/coreai_fake_loader.mm runtime/test/coreai_acquisition_test.mm runtime/test/coreai_acquisition_fixture.mm + runtime/test/coreai_delegate_fixture.mm + runtime/test/coreai_delegate_test.mm runtime/ETCoreAITensor.mm ${_coreai_runtime_sources} ) @@ -77,7 +91,7 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") target_compile_definitions(coreai_host_test PRIVATE COREAI_ASSETS_TESTING=1) find_library(COREAI_FOUNDATION_FRAMEWORK Foundation REQUIRED) target_link_libraries( - coreai_host_test PRIVATE executorch GTest::gtest + coreai_host_test PRIVATE ${_coreai_runtime} GTest::gtest ${COREAI_FOUNDATION_FRAMEWORK} ) add_test(NAME coreai_host_test COMMAND coreai_host_test) @@ -346,24 +360,21 @@ if(EXECUTORCH_BUILD_TESTS PROPERTIES TIMEOUT 600 ENVIRONMENT "COREAI_BRIDGE_FIXTURE_DIR=${_coreai_fixture_dir}" ) - add_executable( - coreai_sdk_source_lifetime_test - runtime/test/SDKSourceLifetimeTest.swift runtime/ETCoreAIModel.swift - runtime/ETCoreAITensor.mm - ) - set_target_properties( - coreai_sdk_source_lifetime_test PROPERTIES Swift_LANGUAGE_VERSION 6 - ) - coreai_configure_objc_target(coreai_sdk_source_lifetime_test) - coreai_require_os27(coreai_sdk_source_lifetime_test) - target_compile_options( - coreai_sdk_source_lifetime_test - PRIVATE - "$<$:-parse-as-library;-import-objc-header;${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h>" - ) - target_link_libraries( - coreai_sdk_source_lifetime_test PRIVATE executorch::coreai_dependencies - ) + endif() + add_executable( + coreai_sdk_source_lifetime_test + runtime/test/SDKSourceLifetimeTest.swift runtime/ETCoreAIModel.swift + runtime/ETCoreAITensor.mm + ) + coreai_configure_objc_target(coreai_sdk_source_lifetime_test) + coreai_configure_swift_target( + coreai_sdk_source_lifetime_test coreai_sdk_source_lifetime_test + ) + target_compile_options( + coreai_sdk_source_lifetime_test + PRIVATE "$<$:-parse-as-library>" + ) + if(_coreai_run_sdk_tests) add_test( NAME coreai_sdk_source_lifetime_test COMMAND @@ -375,8 +386,45 @@ if(EXECUTORCH_BUILD_TESTS endif() endif() +add_library( + coreaidelegate ${_coreai_library_type} ${_coreai_runtime_sources} + $ +) +coreai_configure_objc_target(coreaidelegate) +# The delegate contains no Swift sources; its archive is assembled by C++. +set_target_properties(coreaidelegate PROPERTIES LINKER_LANGUAGE CXX) +coreai_require_os27(coreaidelegate) +target_include_directories(coreaidelegate PUBLIC ${_common_include_directories}) +add_dependencies(coreaidelegate coreai_swift) +target_link_libraries( + coreaidelegate PRIVATE coreai_swift ${_coreai_runtime} + executorch::coreai_dependencies +) +executorch_target_link_options_shared_lib(coreaidelegate) +if(EXECUTORCH_BUILD_SHARED) + set_target_properties( + coreaidelegate PROPERTIES OUTPUT_NAME executorch_backend_coreai + ) + executorch_target_soname_policy(coreaidelegate) + executorch_target_shipped_runtime_path(coreaidelegate) +endif() + +# A consumer is necessary to check Swift linkage and registration retention. +add_executable(coreai_runtime_smoke runtime/test/runtime_smoke.cpp) +if(_coreai_run_sdk_tests) + add_test(NAME coreai_runtime_smoke COMMAND coreai_runtime_smoke) +endif() +if(NOT EXECUTORCH_BUILD_TESTS) + set_target_properties(coreai_runtime_smoke PROPERTIES EXCLUDE_FROM_ALL TRUE) +endif() +coreai_require_os27(coreai_runtime_smoke) +set_target_properties(coreai_runtime_smoke PROPERTIES LINKER_LANGUAGE CXX) +target_link_libraries( + coreai_runtime_smoke PRIVATE coreaidelegate ${_coreai_runtime} +) + install( - TARGETS coreai_swift + TARGETS coreaidelegate coreai_swift EXPORT ExecuTorchTargets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index e4bdc0368d4..0d2aa3b7bbc 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -42,6 +42,36 @@ with one bundle's path, digest, and files. Neither type can be constructed without validation; filesystem materialization and SDK host probing remain outside the manifest parser. +The experimental `CoreAIBackend` runtime supports stateless FP32/FP16 tensor +models in both formats. ExecuTorch tensors must be contiguous. Output shapes +must fit the executor's declared resize and capacity constraints. Stateful +functions, image values, other dtypes, and interleaved layouts are not supported. +Calls on one session must be serialized by the caller. + +Supported inputs are borrowed directly from ExecuTorch storage through Core AI +raw views. The bridge copies shape metadata, not tensor bytes, and does not +allocate intermediate input `NSData`, `Data`, or `NDArray` buffers. Each call +wraps `const_data_ptr()` and the current shape's `nbytes()`, not the allocation's +maximum capacity. Outputs use the SDK's actual returned shape; the backend +resizes them within ExecuTorch's declared bounds before copying only the logical +result bytes. Unused capacity is neither wrapped as input nor written as output. +The backend waits for inference and all input access to finish before returning +from `execute`, including on errors. This uses ExecuTorch's normal input-lifetime +contract; callers must not concurrently mutate, release, or rebind the input +storage. Output copiers retain the SDK arrays and copy directly into validated +ExecuTorch destinations, without an intermediate `Data` allocation or payload +copy. If any destination overlaps an input or another destination, all outputs +are staged before writing destinations in order. This preserves input/output +aliasing and overlapping-output semantics. Staging is not zero-initialized. +This does not guarantee that Core AI itself avoids device transfers or internal +copies. + +After an execution error, do not use the outputs as inference results. Output +shapes may already have changed, but no result bytes are copied unless every +output passes validation and resizing. Malformed SDK outputs return `Internal`. +Output dtype/rank mismatches and incompatible shapes or capacity return +`InvalidArgument`. + ### Calling from Swift When using the Core AI backend, run ExecuTorch model loading, inference and @@ -62,13 +92,26 @@ architectures=["h17p"])` requests that compiler architecture; omitting the list lets the compiler emit its supported architectures for the target platform. These are Core AI architecture names, not CPU names such as `arm64`. -Both formats can require device specialization. The acquisition coordinator -first attempts SDK bookmark restoration. A missing bookmark or SDK-confirmed -cache miss falls back to source materialization and persistent specialization. -It returns the acquired model; function binding is the caller's responsibility. +At load time, the runtime queries `AIModel.deviceArchitectureName` and selects +the exact matching entry in the manifest's `archs` map. No runtime architecture +option is required, and there is no fallback to another architecture. Platform, +deployment floor, and architecture checks happen before asset reads or storage +creation. Missing-architecture errors identify the device architecture, list +available architectures, and request a matching export. Missing selected files +are reported separately from an absent architecture entry. + +Both formats can require device specialization. Every load first attempts SDK +bookmark restoration. A missing bookmark or SDK-confirmed cache miss falls back +to source materialization and persistent specialization, then binds the requested +function and ordered I/O on that same acquired model. ### Asset storage +Backend initialization reads the string runtime spec `coreai_assets_dir`. If +absent, it sets the directory to `executorch_coreai` beneath user-domain +`NSCachesDirectory`. The coordinator and storage helpers always receive that +explicit directory. It contains raw bookmarks, staged bundles and lock files. + The assets root must be an absolute path. The application chooses it, should reserve it for this backend, and is trusted not to rename, replace or modify its contents while the backend uses it; the backend does not defend against other @@ -133,6 +176,17 @@ and enumeration use explicit policies, with a missing child distinguishable from an error. Fault injection is a separate, thread-local test interface and compiles away in production. +Delegate construction and registration do not create storage directories or load +models. Per-model initialization performs SDK acquisition and function loading, +validating source assets only when recovery requires them. There is no synthetic +zero-input inference at initialization. SDK specialization and function resource +loading can still be substantial. + +The runtime always uses `AIModelCache.default` with persistent retention and +stores an opaque bookmark for direct restoration. `coreai_assets_dir` does not +choose the SDK artifact directory. The backend does not inspect SDK-private +files, alter their backup flags, or use an App Group cache. + Each selected bundle has a model key derived from its export digest, bundle name, and source/AOT mode. A readable variant key combines that model key with the platform and Core AI device architecture, plus a hash of a versioned @@ -175,6 +229,28 @@ clearing or after process exit, so waiters and future openers keep using the same inode. Removing the assets root externally requires all loads, sessions and maintenance to stop. +### Runtime options + +Pass options through the public `Module` API before loading. In an error-returning +loader using the `executorch::runtime` namespace: + +```cpp +BackendOptions<1> options; +ET_CHECK_OK_OR_RETURN_ERROR(options.set_option("coreai_assets_dir", assets_dir)); +LoadBackendOptionsMap map; +ET_CHECK_OK_OR_RETURN_ERROR(map.set_options("CoreAIBackend", options.view())); +ET_CHECK_OK_OR_RETURN_ERROR(module.load(map)); +``` + +No options are required when using the default assets root. `coreai_assets_dir` +selects an application-chosen cache directory reserved for this backend. The +supplied path is validated during preflight, but warm bookmark hits need no +source. + +Use separate option maps to choose different storage roots for different models. +The path is a root directory, not the bundle itself. ExecuTorch option strings +have a 255-byte limit. + ## SDK Bridge The private Swift module `CoreAIBridge` (`runtime/ETCoreAIModel.swift`) wraps @@ -241,15 +317,40 @@ artifact. The durable-save message confirms local bookmark publication, including its final directory flush, not independent verification of SDK-private artifact storage. Failed operations do not emit their success messages. +The delegate does not require `EXECUTORCH_BUILD_EXTENSION_DATA_LOADER`. +Enable it for consumers such as `Module` that load program data. +Link the CMake target `coreaidelegate`, not just its archive filename. Its +transitive dependencies supply Swift and Foundation/CoreAI linkage, and its +link interface retains static backend registration. With +`EXECUTORCH_BUILD_SHARED=ON`, the delegate is a shared library linked to the +single consolidated `executorch_shared` runtime. Otherwise it is static. +Consumers of the shared delegate must use that same shared runtime. +Installed CMake targets resolve framework and Swift runtime dependencies using +the consumer's selected SDK and toolchain, not the producer's Xcode paths. +Select the Apple SDK/toolchain when configuring the consumer project. +SwiftPM and XCFramework distribution are not integrated yet. + +The `coreai_runtime_smoke` target checks final linkage of a C++ consumer, +including Swift dependencies and backend registration retention. With +`EXECUTORCH_BUILD_TESTS=ON` it is built; otherwise it is excluded from the +default build. SDK-linked tests are registered with CTest only for native macOS +builds when the host meets the target's deployment minimum (at least 27.0). +Cross-builds and older hosts can build these tests without registering them. +Running the smoke test checks registration and availability; it does not perform +model inference. Do not execute SDK27 binaries on an older host. + ## Host Tests On macOS, `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the standalone `coreai_host_test` executable with CTest. It tests manifest parsing, AOT asset selection, cache identity, bookmark I/O, acquisition -coordination, lock contention, and filesystem storage using Foundation, without -Swift or CoreAI SDK calls. Acquisition contention tests run two threads inside a -bounded child process; bookmark lock tests separately cover contention between -processes. The host test target is omitted for Apple cross-builds. +coordination, lock contention, filesystem storage, and delegate initialization +and execution against a fake bridge. It uses Foundation without Swift or CoreAI +SDK calls. Acquisition contention tests run two threads inside a bounded child +process; bookmark lock tests separately cover contention between processes. The +host test target is omitted for Apple cross-builds. On supported macOS hosts, +CTest also runs the Swift bridge tests, source-lifetime probes, and +`coreai_runtime_smoke`, which link Swift and the Core AI framework. CI builds and runs these tests on macOS 27. To use the same configuration from the repository root: @@ -262,3 +363,9 @@ cmake -S . -B -G Ninja \ cmake --build --target backends/apple/coreai/all ctest --test-dir /backends/apple/coreai --output-on-failure --no-tests=error ``` + +For a compile-only production smoke build, use a fresh build directory without +`EXECUTORCH_BUILD_TESTS`, set `CMAKE_OSX_ARCHITECTURES=arm64`, and build +`coreai_runtime_smoke`. For iOS also set `CMAKE_SYSTEM_NAME=iOS` and +`CMAKE_OSX_SYSROOT=iphoneos`. Do not run the iOS binary on the host or install +a simulator as part of this build. diff --git a/backends/apple/coreai/runtime/coreai_backend.mm b/backends/apple/coreai/runtime/coreai_backend.mm new file mode 100644 index 00000000000..e25be0a62e8 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_backend.mm @@ -0,0 +1,160 @@ +/* + * 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 "coreai_cache_root.h" +#import "coreai_error.h" +#import "coreai_load_coordinator.h" +#import "coreai_manifest.h" +#import "coreai_tensor_io.h" + +#include +#include +#include + +namespace executorch::backends::coreai { +namespace { +using namespace executorch::runtime; + +struct Handle final { + size_t input_count = 0; + size_t output_count = 0; + id session = nil; +}; + +Result path_option(BackendInitContext& context, const char* key) { + auto option = context.get_runtime_spec(key); + if (!option.ok()) { + if (option.error() == Error::NotFound) { + return static_cast(nil); + } + return option.error(); + } + NSString* path = option.get() == nullptr + ? nil + : [NSString stringWithUTF8String:option.get()]; + ET_CHECK_OR_RETURN_ERROR( + path != nil && path.isAbsolutePath, + InvalidArgument, + "Core AI %s must be an absolute UTF-8 path", + key); + return path; +} + +Result> bind_session( + id model, + const SelectedBundle& bundle) { + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + __block id session = nil; + __block NSError* error = nil; + [model loadFunctionNamed:bundle.function + inputNames:bundle.inputs + outputNames:bundle.outputs + completion:^(id result, NSError* failure) { + session = result; + error = failure; + dispatch_semaphore_signal(ready); + }]; + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + id bound = session; + NSError* failure = error; + // The SDK may retain the completion block after invoking it. + session = nil; + error = nil; + if (failure != nil) { + return execution_error(failure); + } + ET_CHECK_OR_RETURN_ERROR( + bound != nil, Internal, "Core AI returned no session"); + return bound; +} + +class CoreAIBackend final : public BackendInterface { + public: + bool is_available() const override { + return ETCoreAIIsAvailable(); + } + + Result init( + BackendInitContext& context, + FreeableBuffer* processed, + ArrayRef) const override { + @autoreleasepool { + ET_CHECK_OR_RETURN_ERROR( + processed && processed->data() && processed->size() > 0, + InvalidProgram, + "Missing Core AI manifest"); + auto manifest = parse_manifest(processed->data(), processed->size()); + if (!manifest.ok()) { + return manifest.error(); + } + auto assets = select_runtime_assets(manifest.get()); + if (!assets.ok()) { + return assets.error(); + } + auto assets_option = path_option(context, "coreai_assets_dir"); + if (!assets_option.ok()) { + return assets_option.error(); + } + NSString* root = assets_option.get(); + if (root == nil) { + auto default_root = default_coreai_assets_root(); + if (!default_root.ok()) { + return default_root.error(); + } + root = default_root.get(); + } + id loader = ETCoreAICreateModelLoader(); + ET_CHECK_OR_RETURN_ERROR( + loader != nil, Internal, "Cannot create Core AI loader"); + auto prepared = acquire_bookmark_model( + assets.get(), context.get_named_data_map(), root, loader); + if (!prepared.ok()) { + return prepared.error(); + } + auto session = bind_session(prepared.get(), assets->bundle); + if (!session.ok()) { + return session.error(); + } + auto handle = std::unique_ptr(new (std::nothrow) Handle()); + ET_CHECK_OR_RETURN_ERROR( + handle != nullptr, + MemoryAllocationFailed, + "Cannot allocate Core AI handle"); + handle->input_count = assets->bundle.inputs.count; + handle->output_count = assets->bundle.outputs.count; + handle->session = session.get(); + processed->Free(); + return static_cast(handle.release()); + } + } + + Error execute( + BackendExecutionContext&, + DelegateHandle* opaque, + Span args) const override { + @autoreleasepool { + ET_CHECK_OR_RETURN_ERROR( + opaque != nullptr, DelegateInvalidHandle, "Null Core AI handle"); + auto& handle = *static_cast(opaque); + return execute_session( + handle.session, args, handle.input_count, handle.output_count); + } + } + + void destroy(DelegateHandle* handle) const override { + @autoreleasepool { + delete static_cast(handle); + } + } +}; + +CoreAIBackend backend; +[[maybe_unused]] const auto registration = + register_backend({"CoreAIBackend", &backend}); +} // namespace +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_error.h b/backends/apple/coreai/runtime/coreai_error.h new file mode 100644 index 00000000000..09c276bb43b --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_error.h @@ -0,0 +1,20 @@ +/* + * 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. + */ +#pragma once + +#import +#include + +namespace executorch::backends::coreai { + +// Binding and execution preserve bridge errors that callers can act on. +runtime::Error execution_error(NSError* error); +// Acquisition failures describe SDK/cache internals, not caller arguments. +runtime::Error acquisition_error(NSError* error); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_error.mm b/backends/apple/coreai/runtime/coreai_error.mm new file mode 100644 index 00000000000..e63e66fd477 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_error.mm @@ -0,0 +1,48 @@ +/* + * 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_error.h" + +#import "ETCoreAIBridge.h" + +#include + +namespace executorch::backends::coreai { + +runtime::Error execution_error(NSError* error) { + ET_LOG( + Error, + "Core AI execution error (%s, %ld): %s", + error.domain.UTF8String, + static_cast(error.code), + error.description.UTF8String); + if ([error.domain isEqualToString:ETCoreAIErrorDomain]) { + switch (error.code) { + case ETCoreAIErrorInvalidModel: + return runtime::Error::InvalidProgram; + case ETCoreAIErrorUnsupported: + return runtime::Error::NotSupported; + case ETCoreAIErrorInvalidArgument: + return runtime::Error::InvalidArgument; + default: + break; + } + } + return runtime::Error::Internal; +} + +runtime::Error acquisition_error(NSError* error) { + ET_LOG( + Error, + "Core AI acquisition error (%s, %ld): %s", + error.domain.UTF8String, + static_cast(error.code), + error.description.UTF8String); + return runtime::Error::Internal; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm index 5903a2393d9..8d7e8987498 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -9,6 +9,7 @@ #import "coreai_bookmarks.h" #import "coreai_cache_key.h" +#import "coreai_error.h" #import "coreai_fault_injection.h" #import "coreai_source_bundle.h" @@ -20,16 +21,6 @@ using runtime::Error; using runtime::Result; -Error acquisition_error(NSError* error) { - ET_LOG( - Error, - "Core AI acquisition error (%s, %ld): %s", - error.domain.UTF8String, - static_cast(error.code), - error.description.UTF8String); - return Error::Internal; -} - Result> wait_for_acquisition( void (^operation)(ETCoreAIAcquisitionCompletion)) { ET_CHECK_OR_RETURN_ERROR( @@ -49,7 +40,7 @@ Error acquisition_error(NSError* error) { error = failure; dispatch_semaphore_signal(ready); }); - // The SDK may still be reading caller-owned source after a bounded timeout. + // Wait without a timeout; the SDK may still be reading the staged source. dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); id acquired = model; NSError* failure = error; diff --git a/backends/apple/coreai/runtime/coreai_tensor_io.h b/backends/apple/coreai/runtime/coreai_tensor_io.h new file mode 100644 index 00000000000..1a49564face --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_tensor_io.h @@ -0,0 +1,22 @@ +/* + * 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. + */ +#pragma once + +#include +#include +#import "ETCoreAIBridge.h" + +namespace executorch::backends::coreai { + +runtime::Error execute_session( + id session, + runtime::Span args, + size_t input_count, + size_t output_count); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_tensor_io.mm b/backends/apple/coreai/runtime/coreai_tensor_io.mm new file mode 100644 index 00000000000..d7971c8fc2d --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_tensor_io.mm @@ -0,0 +1,403 @@ +/* + * 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_tensor_io.h" + +#import "coreai_error.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace executorch::backends::coreai { +namespace { +using namespace executorch::runtime; +using executorch::aten::ScalarType; +using executorch::aten::SizesType; +using executorch::aten::Tensor; + +struct TensorMetadata final { + ETCoreAIScalarType scalar_type; + size_t bytes; + size_t rank; +}; + +struct ValidatedArguments final { + std::vector tensors; +}; + +struct OutputPlan final { + std::vector shape; + size_t bytes = 0; + void* destination = nullptr; +}; + +Result scalar_type(ScalarType type) { + switch (type) { + case ScalarType::Half: + return ETCoreAIScalarTypeFloat16; + case ScalarType::Float: + return ETCoreAIScalarTypeFloat32; + default: + ET_LOG(Error, "Core AI runtime supports FP16 and FP32 tensors only"); + return Error::NotSupported; + } +} + +Result bridge_scalar_width(ETCoreAIScalarType type) { + switch (type) { + case ETCoreAIScalarTypeFloat16: + return size_t(2); + case ETCoreAIScalarTypeFloat32: + return size_t(4); + default: + return Error::Internal; + } +} + +Result validate_tensor(const Tensor& tensor) { + auto type = scalar_type(tensor.scalar_type()); + if (!type.ok()) { + return type.error(); + } + ET_CHECK_OR_RETURN_ERROR( + tensor.device_type() == executorch::aten::DeviceType::CPU, + NotSupported, + "Core AI requires CPU-accessible tensor storage"); + ET_CHECK_OR_RETURN_ERROR( + tensor.dim() >= 0 && + static_cast(tensor.dim()) <= kTensorDimensionLimit, + InvalidArgument, + "Invalid Core AI tensor rank"); + ET_CHECK_OR_RETURN_ERROR( + tensor_is_default_dim_order(tensor), + NotSupported, + "Core AI requires default tensor dimension order"); + size_t elements = 1; + size_t expected_stride = 1; + for (size_t i = tensor.sizes().size(); i > 0; --i) { + const auto size = tensor.sizes()[i - 1]; + ET_CHECK_OR_RETURN_ERROR( + size >= 0, InvalidArgument, "Negative Core AI tensor dimension"); + ET_CHECK_OR_RETURN_ERROR( + tensor.strides()[i - 1] >= 0 && + static_cast(tensor.strides()[i - 1]) == expected_stride, + NotSupported, + "Core AI requires contiguous tensor storage"); + ET_CHECK_OR_RETURN_ERROR( + !c10::mul_overflows(elements, static_cast(size), &elements) && + !c10::mul_overflows( + expected_stride, + std::max(static_cast(size), size_t(1)), + &expected_stride), + InvalidArgument, + "Core AI tensor size overflow"); + } + size_t bytes = 0; + ET_CHECK_OR_RETURN_ERROR( + !c10::mul_overflows( + elements, static_cast(tensor.element_size()), &bytes) && + bytes <= static_cast(std::numeric_limits::max()) && + bytes == tensor.nbytes(), + InvalidArgument, + "Invalid Core AI tensor byte count"); + return TensorMetadata{type.get(), bytes, tensor.sizes().size()}; +} + +Result validate_arguments( + Span args, + size_t input_count, + size_t output_count) { + ET_CHECK_OR_RETURN_ERROR( + args.size() >= input_count && args.size() - input_count == output_count, + InvalidArgument, + "Core AI argument count mismatch"); + ValidatedArguments validated; + validated.tensors.reserve(args.size()); + for (EValue* arg : args) { + ET_CHECK_OR_RETURN_ERROR( + arg && arg->isTensor(), + InvalidArgument, + "Core AI arguments must be tensors"); + auto metadata = validate_tensor(arg->toTensor()); + if (!metadata.ok()) { + return metadata.error(); + } + validated.tensors.push_back(metadata.get()); + } + return validated; +} + +Result*> borrow_inputs( + Span args, + const ValidatedArguments& validated, + size_t input_count) { + NSMutableArray* inputs = + [NSMutableArray arrayWithCapacity:input_count]; + for (size_t i = 0; i < input_count; ++i) { + const auto& tensor = args[i]->toTensor(); + const auto& metadata = validated.tensors[i]; + ET_CHECK_OR_RETURN_ERROR( + metadata.bytes == 0 || tensor.const_data_ptr() != nullptr, + InvalidArgument, + "Core AI input has no storage"); + NSMutableArray* shape = + [NSMutableArray arrayWithCapacity:tensor.dim()]; + for (auto size : tensor.sizes()) { + [shape addObject:@(size)]; + } + [inputs addObject:[[ETCoreAIInputTensor alloc] + initWithBytes:tensor.const_data_ptr() + byteCount:metadata.bytes + shape:shape + scalarType:metadata.scalar_type]]; + } + return inputs; +} + +Result*> wait_for_outputs( + id session, + NSArray* inputs) { + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + __block NSArray* outputs = nil; + __block NSError* error = nil; + [session executeInputs:inputs + completion:^(NSArray* result, NSError* failure) { + outputs = result; + error = failure; + dispatch_semaphore_signal(ready); + }]; + // Returning earlier would let the SDK outlive borrowed input storage. + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + NSArray* completed = outputs; + NSError* failure = error; + // The SDK may retain the completion block after invoking it. + outputs = nil; + error = nil; + if (failure != nil) { + return execution_error(failure); + } + return completed; +} + +Result> validate_bridge_outputs( + NSArray* outputs, + const ValidatedArguments& validated, + size_t input_count, + size_t output_count) { + ET_CHECK_OR_RETURN_ERROR( + outputs != nil && outputs.count == output_count, + Internal, + "Core AI output count mismatch"); + std::vector plans(output_count); + for (size_t i = 0; i < output_count; ++i) { + ETCoreAITensor* output = outputs[i]; + ET_CHECK_OR_RETURN_ERROR( + [output isKindOfClass:ETCoreAITensor.class], + Internal, + "Invalid Core AI output tensor"); + auto width = bridge_scalar_width(output.scalarType); + if (!width.ok()) { + return width.error(); + } + const auto& expected = validated.tensors[input_count + i]; + ET_CHECK_OR_RETURN_ERROR( + output.scalarType == expected.scalar_type && + output.shape.count == expected.rank, + InvalidArgument, + "Core AI output type or rank mismatch"); + OutputPlan& plan = plans[i]; + plan.bytes = width.get(); + plan.shape.reserve(output.shape.count); + for (id size in output.shape) { + ET_CHECK_OR_RETURN_ERROR( + [size isKindOfClass:NSNumber.class] && [size longLongValue] >= 0 && + [size longLongValue] <= std::numeric_limits::max() && + [size isEqualToNumber:@([size longLongValue])], + Internal, + "Invalid Core AI output dimension"); + const auto dimension = static_cast([size longLongValue]); + plan.shape.push_back(dimension); + ET_CHECK_OR_RETURN_ERROR( + !c10::mul_overflows( + plan.bytes, static_cast(dimension), &plan.bytes), + Internal, + "Core AI output size overflow"); + } + ET_CHECK_OR_RETURN_ERROR( + plan.bytes == output.byteCount, + Internal, + "Core AI output byte count mismatch"); + } + return plans; +} + +Error resize_outputs( + std::vector& plans, + Span args, + size_t input_count) { + for (size_t i = 0; i < plans.size(); ++i) { + auto& tensor = args[input_count + i]->toTensor(); + auto& shape = plans[i].shape; + const Error resized = + resize_tensor(tensor, ArrayRef(shape.data(), shape.size())); + if (resized != Error::Ok) { + return resized == Error::NotSupported ? Error::InvalidArgument : resized; + } + } + return Error::Ok; +} + +Error validate_resized_outputs( + std::vector& plans, + Span args, + const ValidatedArguments& validated, + size_t input_count) { + for (size_t i = 0; i < plans.size(); ++i) { + auto& tensor = args[input_count + i]->toTensor(); + auto& plan = plans[i]; + ET_CHECK_OR_RETURN_ERROR( + tensor.dim() >= 0 && + static_cast(tensor.dim()) == plan.shape.size() && + std::equal( + plan.shape.begin(), plan.shape.end(), tensor.sizes().begin()), + InvalidArgument, + "Core AI output shape changed during resize"); + auto metadata = validate_tensor(tensor); + if (!metadata.ok()) { + return metadata.error(); + } + ET_CHECK_OR_RETURN_ERROR( + metadata->scalar_type == + validated.tensors[input_count + i].scalar_type && + metadata->bytes == plan.bytes && + (plan.bytes == 0 || tensor.mutable_data_ptr() != nullptr), + InvalidArgument, + "Core AI output has insufficient storage"); + plan.destination = tensor.mutable_data_ptr(); + } + return Error::Ok; +} + +bool storage_overlaps( + const void* left, + size_t left_size, + const void* right, + size_t right_size) { + if (left_size == 0 || right_size == 0) { + return false; + } + const auto a = reinterpret_cast(left); + const auto b = reinterpret_cast(right); + return a <= b ? b - a < left_size : a - b < right_size; +} + +bool outputs_need_staging( + const std::vector& plans, + NSArray* inputs) { + for (size_t i = 0; i < plans.size(); ++i) { + const auto& plan = plans[i]; + for (ETCoreAIInputTensor* input in inputs) { + if (storage_overlaps( + plan.destination, plan.bytes, input.bytes, input.byteCount)) { + return true; + } + } + for (size_t j = 0; j < i; ++j) { + if (storage_overlaps( + plan.destination, + plan.bytes, + plans[j].destination, + plans[j].bytes)) { + return true; + } + } + } + return false; +} + +Error copy_direct( + NSArray* outputs, + const std::vector& plans) { + for (size_t i = 0; i < plans.size(); ++i) { + [outputs[i] copyBytesTo:plans[i].destination]; + } + return Error::Ok; +} + +Error copy_staged( + NSArray* outputs, + const std::vector& plans) { + size_t total_bytes = 0; + for (const auto& plan : plans) { + ET_CHECK_OR_RETURN_ERROR( + plan.bytes <= std::numeric_limits::max() - total_bytes, + MemoryAllocationFailed, + "Core AI output staging size overflow"); + total_bytes += plan.bytes; + } + std::unique_ptr staging(new (std::nothrow) uint8_t[total_bytes]); + ET_CHECK_OR_RETURN_ERROR( + staging != nullptr, + MemoryAllocationFailed, + "Cannot stage Core AI outputs"); + size_t offset = 0; + for (size_t i = 0; i < plans.size(); ++i) { + [outputs[i] copyBytesTo:staging.get() + offset]; + offset += plans[i].bytes; + } + offset = 0; + for (const auto& plan : plans) { + if (plan.bytes > 0) { + memcpy(plan.destination, staging.get() + offset, plan.bytes); + } + offset += plan.bytes; + } + return Error::Ok; +} + +} // namespace + +Error execute_session( + id session, + Span args, + size_t input_count, + size_t output_count) { + ET_CHECK_OR_RETURN_ERROR( + session != nil, DelegateInvalidHandle, "Missing Core AI session"); + auto validated = validate_arguments(args, input_count, output_count); + if (!validated.ok()) { + return validated.error(); + } + auto inputs = borrow_inputs(args, validated.get(), input_count); + if (!inputs.ok()) { + return inputs.error(); + } + auto outputs = wait_for_outputs(session, inputs.get()); + if (!outputs.ok()) { + return outputs.error(); + } + auto plans = validate_bridge_outputs( + outputs.get(), validated.get(), input_count, output_count); + if (!plans.ok()) { + return plans.error(); + } + ET_CHECK_OK_OR_RETURN_ERROR(resize_outputs(plans.get(), args, input_count)); + // Finish every resize before copying so a late failure cannot write results. + ET_CHECK_OK_OR_RETURN_ERROR(validate_resized_outputs( + plans.get(), args, validated.get(), input_count)); + return outputs_need_staging(plans.get(), inputs.get()) + ? copy_staged(outputs.get(), plans.get()) + : copy_direct(outputs.get(), plans.get()); +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm index 2d951f0c168..935b377bea1 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm @@ -268,12 +268,14 @@ ParallelAcquisitionObservation parallel_acquisitions( loader = nil; } auto& state = bridge.state(); - const bool none_live = state.prepared_models.load() == 0 && - state.loaders.load() == 0 && state.missing_bundles.load() == 0; + const bool none_live = state.sessions.load() == 0 && + state.prepared_models.load() == 0 && state.loaders.load() == 0 && + state.binding_mismatches.load() == 0 && + state.missing_bundles.load() == 0 && + state.input_wait_timeouts.load() == 0; if (!send_child_byte(fd, static_cast(none_live))) _exit(12); - // No GTest-bearing destructors run before InitGoogleTest, even on a failed - // child. + // Avoid GTest-bearing destructors before InitGoogleTest. _exit(close(fd) == 0 ? 0 : 13); } } // namespace diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm index e2673f38e70..d331194a2f3 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -44,6 +44,7 @@ - (void)specializeModelAtURL:(NSURL*)url namespace { TEST(CoreAIRuntimeAssetsTest, ProbesHostAndSelectsImmutableBundle) { + ScopedFakeBridgeState bridge; auto parsed = parse_manifest(encode(aot_manifest_dict())); ASSERT_TRUE(parsed.ok()); auto assets = select_runtime_assets(parsed.get()); diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h new file mode 100644 index 00000000000..7d9a9b64fa1 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h @@ -0,0 +1,123 @@ +/* + * 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. + */ +#pragma once + +#include +#include +#include +#include + +#import "coreai_fake_loader.h" +#include "coreai_source_fixture.h" + +namespace executorch::backends::coreai::testing { + +class DelegateFixture final { + public: + DelegateFixture( + const runtime::BackendInterface* backend, + ScopedFakeBridgeState& bridge, + NSDictionary* manifest = manifest_dict()); + ~DelegateFixture(); + DelegateFixture(const DelegateFixture&) = delete; + DelegateFixture& operator=(const DelegateFixture&) = delete; + + runtime::Error init(NSString* root = nil); + runtime::Error init_with_options( + runtime::Span options, + runtime::MemoryAllocator* allocator = nullptr); + void reset(); + runtime::Error execute(runtime::Span args); + + TestData data; + TestDirectory storage; + int freed = 0; + NSData* json; + runtime::FreeableBuffer processed; + runtime::DelegateHandle* handle = nullptr; + + private: + const runtime::BackendInterface* backend_; + ScopedFakeBridgeState& bridge_; +}; + +struct FloatTensors final { + float input[2] = {3, 7}; + float output[2] = {-1, -1}; + executorch::aten::TensorImpl::SizesType input_size[1] = {2}; + executorch::aten::TensorImpl::SizesType output_size[1] = {2}; + executorch::aten::TensorImpl::DimOrderType order[1] = {0}; + executorch::aten::TensorImpl::StridesType input_stride[1] = {1}; + executorch::aten::TensorImpl::StridesType output_stride[1] = {1}; + executorch::aten::TensorImpl input_impl{ + executorch::aten::ScalarType::Float, + 1, + input_size, + input, + order, + input_stride}; + executorch::aten::TensorImpl output_impl{ + executorch::aten::ScalarType::Float, + 1, + output_size, + output, + order, + output_stride, + runtime::TensorShapeDynamism::DYNAMIC_BOUND}; + runtime::EValue in{executorch::aten::Tensor(&input_impl)}; + runtime::EValue out{executorch::aten::Tensor(&output_impl)}; + runtime::EValue* args[2] = {&in, &out}; +}; + +NSMutableDictionary* multiple_io_manifest(); + +template +struct RangeTensors final { + static constexpr executorch::aten::ScalarType dtype = std::is_same_v + ? executorch::aten::ScalarType::Float + : executorch::aten::ScalarType::Half; + T input[12] = {}; + T output[12] = {}; + executorch::aten::TensorImpl::SizesType input_shape[2] = {4, 2}; + executorch::aten::TensorImpl::SizesType output_shape[2] = {4, 2}; + executorch::aten::TensorImpl::DimOrderType order[2] = {0, 1}; + executorch::aten::TensorImpl::StridesType input_strides[2] = {2, 1}; + executorch::aten::TensorImpl::StridesType output_strides[2] = {2, 1}; + executorch::aten::TensorImpl input_impl{ + dtype, + 2, + input_shape, + input + 2, + order, + input_strides, + runtime::TensorShapeDynamism::DYNAMIC_BOUND}; + executorch::aten::TensorImpl output_impl{ + dtype, + 2, + output_shape, + output + 2, + order, + output_strides, + runtime::TensorShapeDynamism::DYNAMIC_BOUND}; + runtime::EValue in{executorch::aten::Tensor(&input_impl)}; + runtime::EValue out{executorch::aten::Tensor(&output_impl)}; + runtime::EValue* args[2] = {&in, &out}; + + RangeTensors() { + for (size_t i = 0; i < 12; ++i) { + input[i] = static_cast(i + 1); + } + fill_output(); + } + + void fill_output() { + std::fill_n(output, 12, static_cast(99)); + } +}; + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm new file mode 100644 index 00000000000..6cac71e0c4f --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm @@ -0,0 +1,88 @@ +/* + * 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_delegate_fixture.h" + +namespace executorch::backends::coreai::testing { +using namespace executorch::runtime; + +DelegateFixture::DelegateFixture( + const BackendInterface* backend, + ScopedFakeBridgeState& bridge, + NSDictionary* manifest) + : json(encode(manifest)), + processed( + json.bytes, + json.length, + [](void* context, void*, size_t) { ++*static_cast(context); }, + &freed), + backend_(backend), + bridge_(bridge) {} + +DelegateFixture::~DelegateFixture() { + reset(); + EXPECT_TRUE(bridge_.wait_for_callbacks( + dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC))); + processed.Free(); + if (json != nil) { + EXPECT_EQ(freed, 1); + } + EXPECT_EQ(data.requests.load(), data.releases.load()); +} + +Error DelegateFixture::init(NSString* root) { + if (storage.url == nil || json == nil) { + return Error::Internal; + } + BackendOptions<1> options; + const Error error = options.set_option( + "coreai_assets_dir", (root ?: storage.url.path).UTF8String); + if (error != Error::Ok) { + return error; + } + return init_with_options({options.view().data(), options.view().size()}); +} + +Error DelegateFixture::init_with_options( + Span options, + MemoryAllocator* allocator) { + reset(); + BackendInitContext context(allocator, nullptr, "forward", &data, options); + auto result = backend_->init(context, &processed, {}); + if (result.ok()) { + handle = result.get(); + } + auto drained = bridge_.wait_for_callbacks( + dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + if (!drained) { + ADD_FAILURE() << drained.message(); + reset(); + return Error::Internal; + } + return result.ok() ? Error::Ok : result.error(); +} + +void DelegateFixture::reset() { + if (handle != nullptr) { + backend_->destroy(handle); + } + handle = nullptr; +} + +Error DelegateFixture::execute(Span args) { + BackendExecutionContext context; + return backend_->execute(context, handle, args); +} + +NSMutableDictionary* multiple_io_manifest() { + NSMutableDictionary* manifest = manifest_dict(); + manifest[@"input_names"] = @[ @"input_1", @"input_0" ]; + manifest[@"output_names"] = @[ @"output_1", @"output_0" ]; + return manifest; +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_test.mm b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm new file mode 100644 index 00000000000..8dcebeeafc7 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm @@ -0,0 +1,889 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include +#include + +#include "coreai_bookmark_fixture.h" +#import "coreai_delegate_fixture.h" + +namespace executorch::backends::coreai::testing { +namespace { +using namespace executorch::runtime; +using executorch::aten::ScalarType; +using executorch::aten::Tensor; +using executorch::aten::TensorImpl; + +class CoreAIDelegateTest : public ::testing::Test { + protected: + void SetUp() override { + backend = get_backend_class("CoreAIBackend"); + ASSERT_NE(backend, nullptr); + } + ScopedFakeBridgeState bridge; + const BackendInterface* backend = nullptr; +}; + +// All preflight rows must fail before reading payloads or creating storage. +void expect_preflight_rejection( + const BackendInterface* backend, + ScopedFakeBridgeState& bridge, + NSDictionary* manifest, + Error expected) { + DelegateFixture fixture(backend, bridge, manifest); + ASSERT_NE(fixture.storage.url, nil); + NSURL* absent = + [fixture.storage.url URLByAppendingPathComponent:@"uncreated/models"]; + NSString* previous_bundle = bridge.state().last_bundle; + EXPECT_EQ(fixture.init(absent.path), expected); + EXPECT_EQ(fixture.data.attempts.load(), 0); + EXPECT_EQ(fixture.data.metadata_requests.load(), 0); + EXPECT_EQ(fixture.freed, 0); + EXPECT_EQ(bridge.state().last_bundle, previous_bundle); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:absent.path]); + NSError* error = nil; + NSArray* children = [NSFileManager.defaultManager + contentsOfDirectoryAtPath:fixture.storage.url.path + error:&error]; + EXPECT_NE(children, nil); + EXPECT_EQ(error, nil); + EXPECT_EQ(children.count, 0u); +} + +TEST_F(CoreAIDelegateTest, AvailabilityRejectsWithoutTakingManifestOwnership) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + bridge.state().available = false; + EXPECT_FALSE(backend->is_available()); + EXPECT_EQ(fixture.init(), Error::NotSupported); + EXPECT_EQ(fixture.freed, 0); + EXPECT_EQ(fixture.data.attempts.load(), 0); + EXPECT_EQ(bridge.state().sessions.load(), 0); + bridge.state().available = true; + EXPECT_TRUE(backend->is_available()); + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(fixture.freed, 1); + EXPECT_EQ(bridge.state().sessions.load(), 1); + } +} + +TEST_F( + CoreAIDelegateTest, + LoaderFactoryFailureLeavesManifestAndStorageUntouched) { + @autoreleasepool { + bridge.state().fail_loader_factory = true; + expect_preflight_rejection( + backend, bridge, manifest_dict(), Error::Internal); + EXPECT_EQ(bridge.state().loaders.load(), 0); + EXPECT_EQ(bridge.state().sessions.load(), 0); + } +} + +TEST_F( + CoreAIDelegateTest, + PreflightRejectsBeforePayloadReadsOrStorageCreation) { + @autoreleasepool { + auto missing_function = aot_manifest_dict(); + [missing_function removeObjectForKey:@"function"]; + auto numeric_platform = aot_manifest_dict(); + numeric_platform[@"platform"] = @1; + auto escaping_arch = aot_manifest_dict(); + NSMutableDictionary* archs = [escaping_arch[@"archs"] mutableCopy]; + archs[@"arch_a"] = @"../model.arch_a.aimodelc"; + escaping_arch[@"archs"] = archs; + auto escaping_file = aot_manifest_dict(); + escaping_file[@"files"] = @{@"model.arch_a.aimodelc/../escape" : @1}; + auto ios = aot_manifest_dict(); + ios[@"platform"] = @"iOS"; + const struct { + const char* name; + NSMutableDictionary* manifest; + NSString* device_architecture; + Error expected; + } rows[] = { + {"missing function", + missing_function, + @"arch_b", + Error::InvalidProgram}, + {"non-string platform", + numeric_platform, + @"arch_b", + Error::InvalidProgram}, + {"escaping architecture path", + escaping_arch, + @"arch_b", + Error::InvalidProgram}, + {"escaping file entry", + escaping_file, + @"arch_b", + Error::InvalidProgram}, + {"incompatible platform", + ios, + @"arch_b", + Error::DelegateInvalidCompatibility}, + {"missing device architecture", + aot_manifest_dict(), + nil, + Error::DelegateInvalidCompatibility}, + }; + for (const auto& row : rows) { + SCOPED_TRACE(row.name); + bridge.state().device_architecture = row.device_architecture; + ASSERT_NO_FATAL_FAILURE(expect_preflight_rejection( + backend, bridge, row.manifest, row.expected)); + } + bridge.state().device_architecture = @"arch_b"; + } +} + +TEST_F(CoreAIDelegateTest, PreflightRejectsFutureDeploymentForSourceAndAot) { + @autoreleasepool { + for (bool aot : {false, true}) { + SCOPED_TRACE(aot ? "AOT" : "source"); + auto dict = aot ? aot_manifest_dict() : manifest_dict(); + dict[@"min_deployment_version"] = @"999999.0"; + expect_preflight_rejection( + backend, bridge, dict, Error::DelegateInvalidCompatibility); + } + } +} + +TEST_F(CoreAIDelegateTest, AotInitPassesOnlySelectedArchitectureBundleToSdk) { + @autoreleasepool { + NSString* arch = @"arch_a"; + DelegateFixture fixture(backend, bridge, aot_manifest_dict()); + aot_data(fixture.data, arch); + bridge.state().device_architecture = arch; + auto parsed = parse_manifest(fixture.json); + ASSERT_TRUE(parsed.ok()); + auto selected = select_assets(parsed.get(), arch, @"macOS"); + ASSERT_TRUE(selected.ok()); + ASSERT_EQ(fixture.init(), Error::Ok); + auto key = cache_variant_key(selected.get(), @"macOS", arch, @"default"); + ASSERT_TRUE(key.ok()); + NSString* expected = [fixture.storage.url.path + stringByAppendingPathComponent: + [NSString stringWithFormat:@"staging/%@/%@", + key.get(), + selected->path.lastPathComponent]]; + EXPECT_TRUE([bridge.state().last_bundle isEqualToString:expected]); + fixture.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_EQ(fixture.data.attempts.load(), 2); + EXPECT_EQ(fixture.data.releases.load(), fixture.data.requests.load()); + } +} + +TEST_F( + CoreAIDelegateTest, + InitBindingFailurePreservesManifestAndAllowsWarmRetry) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + bridge.state().fail_load = true; + EXPECT_EQ(fixture.init(), Error::InvalidProgram); + EXPECT_EQ(fixture.freed, 0); + EXPECT_EQ(bridge.state().sessions.load(), 0); + ASSERT_NE(bridge.state().last_bundle, nil); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:bridge.state().last_bundle]); + const int attempts = fixture.data.attempts.load(); + bridge.state().fail_load = false; + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(fixture.freed, 1); + EXPECT_EQ(bridge.state().sessions.load(), 1); + EXPECT_EQ(fixture.data.attempts.load(), attempts); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:bridge.state().last_bundle]); + EXPECT_TRUE([bridge.state().last_bundle + hasPrefix:[fixture.storage.url.path stringByAppendingString:@"/"]]); + } + EXPECT_EQ(bridge.state().sessions.load(), 0); +} + +TEST_F(CoreAIDelegateTest, RetainedBindingCompletionDoesNotPinSession) { + @autoreleasepool { + bridge.state().retain_binding_completion = true; + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(bridge.state().sessions.load(), 1); + fixture.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_EQ(bridge.state().prepared_models.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, OrderedIoBindingUsesManifestFunctionAndNames) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(bridge.state().binding_mismatches.load(), 0); + FloatTensors tensors; + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(tensors.output[0], 3); + EXPECT_EQ(tensors.output[1], 7); + EXPECT_EQ(bridge.state().last_input_bytes.load(), tensors.input); + EXPECT_EQ(bridge.state().executions.load(), 1); + EXPECT_EQ(bridge.state().output_copies.load(), 1); + EXPECT_EQ(bridge.state().last_output_destination.load(), tensors.output); + } +} + +TEST_F(CoreAIDelegateTest, AdjacentInputAndOutputStorageCopiesDirectly) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + float storage[] = {3, 7, -1, -1}; + tensors.input_impl.set_data(storage); + tensors.output_impl.set_data(storage + 2); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(storage[2], 3); + EXPECT_EQ(storage[3], 7); + EXPECT_EQ(bridge.state().output_copies.load(), 1); + EXPECT_EQ(bridge.state().last_output_destination.load(), storage + 2); + } +} + +TEST_F(CoreAIDelegateTest, PartialInputOutputOverlapStagesBeforeWriting) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + for (bool destination_first : {false, true}) { + SCOPED_TRACE(destination_first); + FloatTensors tensors; + float storage[] = {3, 7, 11}; + float* source = storage + (destination_first ? 1 : 0); + float* destination = storage + (destination_first ? 0 : 1); + const float expected[] = {source[0], source[1]}; + tensors.input_impl.set_data(source); + tensors.output_impl.set_data(destination); + const int copies = bridge.state().output_copies.load(); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(destination[0], expected[0]); + EXPECT_EQ(destination[1], expected[1]); + EXPECT_EQ(bridge.state().output_copies.load(), copies + 1); + EXPECT_NE(bridge.state().last_output_destination.load(), destination); + } + } +} + +TEST_F(CoreAIDelegateTest, CrossOutputInputAliasesPreserveEverySource) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + first.output_impl.set_data(second.input); + second.output_impl.set_data(first.input); + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(first.input[0], 11); + EXPECT_EQ(first.input[1], 13); + EXPECT_EQ(second.input[0], 3); + EXPECT_EQ(second.input[1], 7); + EXPECT_EQ(bridge.state().output_copies.load(), 2); + EXPECT_NE(bridge.state().last_output_destination.load(), first.input); + } +} + +TEST_F(CoreAIDelegateTest, OverlappingOutputDestinationsPreserveWriteOrder) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + float storage[] = {-1, -1, -1}; + first.output_impl.set_data(storage); + second.output_impl.set_data(storage + 1); + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(storage[0], 3); + EXPECT_EQ(storage[1], 11); + EXPECT_EQ(storage[2], 13); + EXPECT_EQ(bridge.state().output_copies.load(), 2); + EXPECT_NE(bridge.state().last_output_destination.load(), storage + 1); + } +} + +TEST_F(CoreAIDelegateTest, MultipleInputsAndOutputsPreserveManifestOrder) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(first.output[0], 3); + EXPECT_EQ(first.output[1], 7); + EXPECT_EQ(second.output[0], 11); + EXPECT_EQ(second.output[1], 13); + EXPECT_EQ(bridge.state().binding_mismatches.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, LaterOutputResizeFailureLeavesResultBytesUntouched) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + float larger[] = {11, 13, 17}; + TensorImpl::SizesType one[] = {1}, three[] = {3}; + TensorImpl first_input( + ScalarType::Float, + 1, + one, + first.input, + first.order, + first.input_stride); + TensorImpl second_input( + ScalarType::Float, 1, three, larger, second.order, second.input_stride); + EValue first_in{Tensor(&first_input)}, second_in{Tensor(&second_input)}; + EValue* args[] = {&first_in, &second_in, &first.out, &second.out}; + EXPECT_EQ(fixture.execute({args, 4}), Error::InvalidArgument); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + EXPECT_EQ(first.out.toTensor().size(0), 1); + EXPECT_EQ(second.out.toTensor().size(0), 2); + for (size_t i = 0; i < 2; ++i) { + EXPECT_EQ(first.output[i], -1); + EXPECT_EQ(second.output[i], -1); + } + } +} + +TEST_F(CoreAIDelegateTest, ConflictingSharedOutputShapesDoNotCopyBytes) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + const struct { + const char* name; + TensorImpl::SizesType shape[2]; + } cases[] = { + {"different byte counts", {2, 2}}, + {"equal byte counts", {2, 1}}, + }; + for (const auto& row : cases) { + SCOPED_TRACE(row.name); + for (bool separate_value : {false, true}) { + SCOPED_TRACE(separate_value); + RangeTensors first, second; + const TensorImpl::SizesType first_shape[] = {1, 2}; + ASSERT_EQ(resize_tensor(first.in.toTensor(), first_shape), Error::Ok); + ASSERT_EQ(resize_tensor(second.in.toTensor(), row.shape), Error::Ok); + EValue alias{Tensor(&first.output_impl)}; + EValue* args[] = { + &first.in, + &second.in, + &first.out, + separate_value ? &alias : &first.out}; + EXPECT_EQ(fixture.execute({args, 4}), Error::InvalidArgument); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + for (float value : first.output) { + EXPECT_EQ(value, 99); + } + } + } + } +} + +TEST_F(CoreAIDelegateTest, MatchingSharedOutputShapesPreserveCopyOrder) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + EValue alias{Tensor(&first.output_impl)}; + EValue* args[] = {&first.in, &second.in, &first.out, &alias}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(bridge.state().output_copies.load(), 2); + EXPECT_EQ(first.out.toTensor().size(0), 2); + EXPECT_EQ(first.output[0], 11); + EXPECT_EQ(first.output[1], 13); + } +} + +TEST_F(CoreAIDelegateTest, WrongOutputCountDoesNotWriteCallerStorage) { + @autoreleasepool { + bridge.state().multiple_io = true; + bridge.state().bad_output = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + EXPECT_EQ(fixture.execute({args, 4}), Error::Internal); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + EXPECT_EQ(first.out.toTensor().size(0), 2); + EXPECT_EQ(second.out.toTensor().size(0), 2); + for (size_t i = 0; i < 2; ++i) { + EXPECT_EQ(first.output[i], -1); + EXPECT_EQ(second.output[i], -1); + } + } +} + +TEST_F(CoreAIDelegateTest, ArgumentValidationRejectsBeforeSdkExecution) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + EXPECT_EQ(fixture.execute({tensors.args, 1}), Error::InvalidArgument); + EValue scalar(int64_t(3)); + EValue* args[] = {&scalar, &tensors.out}; + EXPECT_EQ(fixture.execute({args, 2}), Error::InvalidArgument); + EXPECT_EQ(bridge.state().executions.load(), 0); + } +} + +// No assertion may leave a caller thread blocked behind its input-read gate. +class BorrowedInputWorker final { + public: + BorrowedInputWorker( + DelegateFixture& fixture, + FloatTensors& tensors, + FakeBridgeState& state) + : fixture_(fixture), tensors_(tensors), state_(state) { + state_.input_ready = dispatch_semaphore_create(0); + state_.allow_input_read = dispatch_semaphore_create(0); + } + ~BorrowedInputWorker() { + release_and_join(); + state_.input_ready = nil; + state_.allow_input_read = nil; + } + BorrowedInputWorker(const BorrowedInputWorker&) = delete; + BorrowedInputWorker& operator=(const BorrowedInputWorker&) = delete; + int start() { + const int error = pthread_create(&thread_, nullptr, run, this); + started_ = error == 0; + return error; + } + long wait_ready() { + return dispatch_semaphore_wait( + state_.input_ready, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + } + long wait_finished(int64_t timeout) { + return dispatch_semaphore_wait( + finished_, dispatch_time(DISPATCH_TIME_NOW, timeout)); + } + void release() { + dispatch_semaphore_signal(state_.allow_input_read); + } + void release_and_join() { + release(); + if (started_) { + const long returned = dispatch_semaphore_wait( + returned_, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + if (returned != 0) { + ADD_FAILURE() + << "Delegate worker did not return after releasing its input gate"; + // A stuck worker still borrows this stack; unwinding would be unsafe. + std::_Exit(EXIT_FAILURE); + } + EXPECT_EQ(pthread_join(thread_, nullptr), 0); + started_ = false; + } + } + Error status = Error::Internal; + + private: + static void* run(void* context) { + auto& worker = *static_cast(context); + @autoreleasepool { + worker.status = worker.fixture_.execute({worker.tensors_.args, 2}); + } + dispatch_semaphore_signal(worker.finished_); + dispatch_semaphore_signal(worker.returned_); + return nullptr; + } + DelegateFixture& fixture_; + FloatTensors& tensors_; + FakeBridgeState& state_; + dispatch_semaphore_t finished_ = dispatch_semaphore_create(0); + dispatch_semaphore_t returned_ = dispatch_semaphore_create(0); + pthread_t thread_{}; + bool started_ = false; +}; + +TEST_F(CoreAIDelegateTest, BorrowedInputStorageStaysLiveUntilSuccessOrError) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + for (bool failure : {false, true}) { + SCOPED_TRACE(failure ? "runtime error" : "success"); + bridge.state().fail_execute = failure; + bridge.state().last_input_bytes = nullptr; + const auto first_byte = + reinterpret_cast(tensors.input)[0]; + bridge.state().last_input_first_byte = + static_cast(first_byte ^ 0xff); + const int executions = bridge.state().executions.load(); + BorrowedInputWorker worker(fixture, tensors, bridge.state()); + const int started = worker.start(); + if (started != 0) { + worker.release_and_join(); + FAIL() << "pthread_create failed: " << started; + } + const long ready = worker.wait_ready(); + const void* observed = bridge.state().last_input_bytes.load(); + const long premature = worker.wait_finished(50 * NSEC_PER_MSEC); + worker.release(); + const long finished = worker.wait_finished(5 * NSEC_PER_SEC); + worker.release_and_join(); + ASSERT_TRUE(bridge.wait_for_callbacks( + dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC))); + EXPECT_EQ(ready, 0); + EXPECT_EQ(observed, tensors.input); + EXPECT_NE(premature, 0); + EXPECT_EQ(finished, 0); + EXPECT_EQ(worker.status, failure ? Error::Internal : Error::Ok); + EXPECT_EQ(bridge.state().executions.load(), executions + 1); + EXPECT_EQ( + bridge.state().last_input_first_byte.load(), + reinterpret_cast(tensors.input)[0]); + } + } +} + +TEST_F(CoreAIDelegateTest, RetainedExecutionCompletionReleasesResults) { + @autoreleasepool { + bridge.state().retain_execution_completion = true; + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(bridge.state().output_owners.load(), 0); + bridge.state().fail_execute = true; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::Internal); + EXPECT_EQ(bridge.state().output_owners.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, ExecuteMapsRuntimeErrors) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + bridge.state().fail_execute = true; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::Internal); + } +} + +TEST_F(CoreAIDelegateTest, MalformedOutputDoesNotWriteCallerStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + bridge.state().bad_output = true; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::Internal); + EXPECT_EQ(tensors.output[0], -1); + EXPECT_EQ(tensors.output[1], -1); + } +} + +TEST_F(CoreAIDelegateTest, NoncontiguousInputIsNotSupported) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + tensors.input_stride[0] = 2; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::NotSupported); + EXPECT_EQ(bridge.state().executions.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, NonemptyOutputRequiresStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + tensors.output_impl.set_data(nullptr); + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::InvalidArgument); + } +} + +TEST_F(CoreAIDelegateTest, OutputResizesDownAndBackWithinCapacity) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl::SizesType one[] = {1}; + TensorImpl smaller( + ScalarType::Float, + 1, + one, + tensors.input, + tensors.order, + tensors.input_stride); + EValue small{Tensor(&smaller)}; + EValue* args[] = {&small, &tensors.out}; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(tensors.out.toTensor().size(0), 1); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(tensors.out.toTensor().size(0), 2); + } +} + +TEST_F(CoreAIDelegateTest, OutputResizeFailurePreservesStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl::SizesType capacity[] = {1}; + TensorImpl output( + ScalarType::Float, + 1, + capacity, + tensors.output, + tensors.order, + tensors.output_stride, + TensorShapeDynamism::DYNAMIC_BOUND); + EValue too_small{Tensor(&output)}; + EValue* args[] = {&tensors.in, &too_small}; + tensors.output[0] = -2; + EXPECT_EQ(fixture.execute({args, 2}), Error::InvalidArgument); + EXPECT_EQ(tensors.output[0], -2); + } +} + +TEST_F(CoreAIDelegateTest, StaticOutputRejectsShapeChangeWithinCapacity) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl::SizesType shape[] = {1}; + TensorImpl input( + ScalarType::Float, + 1, + shape, + tensors.input, + tensors.order, + tensors.input_stride); + TensorImpl output( + ScalarType::Float, + 1, + tensors.output_size, + tensors.output, + tensors.order, + tensors.output_stride, + TensorShapeDynamism::STATIC); + EValue in{Tensor(&input)}, out{Tensor(&output)}; + EValue* args[] = {&in, &out}; + EXPECT_EQ(fixture.execute({args, 2}), Error::InvalidArgument); + EXPECT_EQ(out.toTensor().size(0), 2); + EXPECT_EQ(tensors.output[0], -1); + EXPECT_EQ(tensors.output[1], -1); + } +} + +TEST_F(CoreAIDelegateTest, HalfDtypePreservesBitsAndRejectsMixedOutputDtype) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + uint16_t input[] = {0x3c00, 0x4000}, output[] = {0, 0}; + TensorImpl input_impl( + ScalarType::Half, + 1, + tensors.input_size, + input, + tensors.order, + tensors.input_stride); + TensorImpl output_impl( + ScalarType::Half, + 1, + tensors.output_size, + output, + tensors.order, + tensors.output_stride); + EValue in{Tensor(&input_impl)}, out{Tensor(&output_impl)}; + EValue* args[] = {&in, &out}; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(output[0], input[0]); + EXPECT_EQ(output[1], input[1]); + EXPECT_EQ(bridge.state().last_input_bytes.load(), input); + EValue* mixed[] = {&in, &tensors.out}; + EXPECT_EQ(fixture.execute({mixed, 2}), Error::InvalidArgument); + } +} + +TEST_F(CoreAIDelegateTest, ScalarTensorsExecuteAndRejectOutputRankMismatch) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl input(ScalarType::Float, 0, nullptr, tensors.input); + TensorImpl output(ScalarType::Float, 0, nullptr, tensors.output); + EValue in{Tensor(&input)}, out{Tensor(&output)}; + EValue* args[] = {&in, &out}; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(tensors.output[0], tensors.input[0]); + EXPECT_EQ(bridge.state().last_input_bytes.load(), tensors.input); + EValue* wrong_rank[] = {&tensors.in, &out}; + EXPECT_EQ(fixture.execute({wrong_rank, 2}), Error::InvalidArgument); + } +} + +TEST_F(CoreAIDelegateTest, EmptyVectorAndMatrixBorrowNullStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + for (int rank : {1, 2}) { + SCOPED_TRACE(rank); + TensorImpl::SizesType shape[] = {2, 0}; + TensorImpl::DimOrderType order[] = {0, 1}; + TensorImpl::StridesType strides[] = {1, 1}; + auto* sizes = rank == 1 ? shape + 1 : shape; + TensorImpl input(ScalarType::Float, rank, sizes, nullptr, order, strides); + TensorImpl output( + ScalarType::Float, rank, sizes, nullptr, order, strides); + EValue in{Tensor(&input)}, out{Tensor(&output)}; + EValue* args[] = {&in, &out}; + const int executions = bridge.state().executions.load(); + bridge.state().last_input_bytes = &input; + bridge.state().last_input_byte_count = 1; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(bridge.state().executions.load(), executions + 1); + EXPECT_EQ(bridge.state().last_input_bytes.load(), nullptr); + EXPECT_EQ(bridge.state().last_input_byte_count.load(), 0); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + } + } +} + +TEST_F(CoreAIDelegateTest, BorrowedStorageMayAliasOutputAfterExecution) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + tensors.output_impl.set_data(tensors.input); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(tensors.input[0], 3); + EXPECT_EQ(tensors.input[1], 7); + } +} + +TEST_F(CoreAIDelegateTest, BorrowedStorageUsesCurrentInputPointer) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + float next_input[] = {11, 13}; + tensors.input_impl.set_data(next_input); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(bridge.state().last_input_bytes.load(), next_input); + EXPECT_EQ(tensors.output[0], 11); + EXPECT_EQ(tensors.output[1], 13); + } +} + +TEST_F(CoreAIDelegateTest, DynamicShapesBorrowCurrentSubrange) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + RangeTensors tensors; + for (TensorImpl::SizesType rows : {3, 0}) { + SCOPED_TRACE(rows); + TensorImpl::SizesType shape[] = {rows, 2}; + ASSERT_EQ(resize_tensor(tensors.in.toTensor(), {shape, 2}), Error::Ok); + tensors.fill_output(); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + const size_t elements = static_cast(rows) * 2; + EXPECT_EQ(bridge.state().last_input_bytes.load(), tensors.input + 2); + EXPECT_EQ( + bridge.state().last_input_byte_count.load(), + elements * sizeof(float)); + EXPECT_EQ(tensors.out.toTensor().size(0), rows); + EXPECT_EQ(tensors.out.toTensor().const_data_ptr(), tensors.output + 2); + for (size_t i = 0; i < 12; ++i) { + SCOPED_TRACE(i); + EXPECT_EQ( + tensors.output[i], + i >= 2 && i < 2 + elements ? tensors.input[i] : 99.0f); + } + } + } +} + +TEST_F(CoreAIDelegateTest, StorageOptionsRejectWrongTypeAndRelativePaths) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + { + SCOPED_TRACE("integer assets directory"); + BackendOption wrong_type{"coreai_assets_dir", 1}; + EXPECT_EQ( + fixture.init_with_options({&wrong_type, 1}), Error::InvalidArgument); + } + { + SCOPED_TRACE("relative assets directory"); + BackendOptions<1> relative; + ASSERT_EQ( + relative.set_option("coreai_assets_dir", "relative"), Error::Ok); + EXPECT_EQ( + fixture.init_with_options( + {relative.view().data(), relative.view().size()}), + Error::InvalidArgument); + } + EXPECT_EQ(fixture.freed, 0); + } +} + +TEST_F( + CoreAIDelegateTest, + LifetimeDestroyAndWarmReloadIsolateRootsWithoutSource) { + @autoreleasepool { + DelegateFixture first(backend, bridge); + ASSERT_EQ(first.init(), Error::Ok); + EXPECT_EQ(first.freed, 1); + EXPECT_EQ(bridge.state().sessions.load(), 1); + NSString* first_bundle = bridge.state().last_bundle; + ASSERT_NE(first_bundle, nil); + first.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:first_bundle]); + backend->destroy(nullptr); + + DelegateFixture reloaded(backend, bridge); + ASSERT_EQ(reloaded.init(first.storage.url.path), Error::Ok); + EXPECT_EQ(reloaded.data.attempts.load(), 0); + EXPECT_TRUE([bridge.state().last_bundle isEqualToString:first_bundle]); + DelegateFixture other(backend, bridge); + NSURL* nested = + [other.storage.url URLByAppendingPathComponent:@"nested/models"]; + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:nested.path]); + ASSERT_EQ(other.init(nested.path), Error::Ok); + EXPECT_EQ(bridge.state().sessions.load(), 2); + EXPECT_TRUE([bridge.state().last_bundle + hasPrefix:[other.storage.url.path stringByAppendingString:@"/"]]); + EXPECT_FALSE([bridge.state().last_bundle isEqualToString:first_bundle]); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:first_bundle]); + other.reset(); + reloaded.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + } + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_EQ(bridge.state().prepared_models.load(), 0); + EXPECT_EQ(bridge.state().loaders.load(), 0); +} + +} // namespace +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.h b/backends/apple/coreai/runtime/test/coreai_fake_loader.h index cf8f70a6a6b..b5194100429 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.h +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.h @@ -20,11 +20,34 @@ namespace executorch::backends::coreai::testing { // Configure on the test thread before starting workers; join before changing // it. struct FakeBridgeState { + bool available = true; + bool fail_loader_factory = false; + NSString* device_architecture = @"arch_b"; id bookmark_loader = nil; + bool fail_load = false; + bool fail_execute = false; + bool bad_output = false; + bool multiple_io = false; + bool retain_binding_completion = false; + bool retain_execution_completion = false; + NSString* last_bundle = nil; + dispatch_semaphore_t input_ready = nil; + dispatch_semaphore_t allow_input_read = nil; + std::atomic architecture_queries{0}; + std::atomic executions{0}; + std::atomic output_copies{0}; + std::atomic output_owners{0}; + std::atomic last_output_destination{nullptr}; + std::atomic sessions{0}; std::atomic prepared_models{0}; std::atomic loaders{0}; + std::atomic last_input_bytes{nullptr}; + std::atomic last_input_byte_count{0}; + std::atomic last_input_first_byte{0}; // Worker-side contract failures are checked by the scope on the test thread. + std::atomic binding_mismatches{0}; std::atomic missing_bundles{0}; + std::atomic input_wait_timeouts{0}; }; // Process-visible, not thread-local. Scopes and configuration changes are @@ -34,7 +57,7 @@ class ScopedFakeBridgeState final { ScopedFakeBridgeState(); ~ScopedFakeBridgeState(); FakeBridgeState& state(); - // Join caller threads before draining callbacks. + // Release input gates and join caller threads before draining callbacks. ::testing::AssertionResult wait_for_callbacks(dispatch_time_t deadline); ScopedFakeBridgeState(const ScopedFakeBridgeState&) = delete; ScopedFakeBridgeState& operator=(const ScopedFakeBridgeState&) = delete; @@ -46,6 +69,17 @@ class ScopedFakeBridgeState final { } // namespace executorch::backends::coreai::testing +@interface FakeSession : NSObject +@property(nonatomic, strong) id preparedPin; +@end + +@interface FakePreparedModel : NSObject +@property(nonatomic, copy) NSData* bookmark; +@end + +@interface FakeLoader : NSObject +@end + @interface BookmarkFakeModel : NSObject @property(nonatomic, weak) BookmarkFakeLoader* owner; @property(nonatomic, copy) NSData* bookmark; @@ -55,16 +89,19 @@ class ScopedFakeBridgeState final { @public std::atomic restores; std::atomic specializations; + std::atomic bindings; std::atomic evictions; } @property(nonatomic, strong) NSMutableDictionary* models; @property(nonatomic, strong) NSError* restoreError; @property(nonatomic, strong) NSError* specializeError; +@property(nonatomic, strong) NSError* bindError; @property(nonatomic) BOOL restoreMiss; @property(nonatomic) BOOL emptyBookmark; @property(nonatomic, copy) void (^onSpecialize)(NSURL*); @property(nonatomic, strong) BookmarkFakeModel* lastAcquired; +@property(nonatomic, strong) BookmarkFakeModel* lastBound; @property(nonatomic, copy) NSString* lastSpecializeURL; // Call only after workers/callbacks finish and caller-owned handles are // released. diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm index e708d5dbd92..019552e978d 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -7,6 +7,7 @@ */ #import "coreai_fake_loader.h" +#include using executorch::backends::coreai::testing::FakeBridgeState; @@ -16,6 +17,10 @@ @interface CoreAIFakeBridgeContext : NSObject { FakeBridgeState state; } @property(nonatomic, strong) dispatch_group_t callbacks; +@property(nonatomic, copy) void (^retainedBindingCompletion) + (id, NSError*); +@property(nonatomic, copy) void (^retainedExecutionCompletion) + (NSArray*, NSError*); @end @implementation CoreAIFakeBridgeContext @@ -34,6 +39,27 @@ - (instancetype)init { return [NSError errorWithDomain:ETCoreAIErrorDomain code:code userInfo:nil]; } +bool valid_binding( + CoreAIFakeBridgeContext* context, + NSString* function, + NSArray* inputs, + NSArray* outputs) { + // Reversed names make accidental sorting observable in multi-I/O tests. + NSArray* expected_inputs = context->state.multiple_io + ? @[ @"input_1", @"input_0" ] + : @[ @"input_0" ]; + NSArray* expected_outputs = context->state.multiple_io + ? @[ @"output_1", @"output_0" ] + : @[ @"output_0" ]; + const bool valid = [function isEqualToString:@"main"] && + [inputs isEqualToArray:expected_inputs] && + [outputs isEqualToArray:expected_outputs]; + if (!valid) { + ++context->state.binding_mismatches; + } + return valid; +} + // Release callback captures before publishing that all fake work has drained. void run_async(CoreAIFakeBridgeContext* context, void (^work)()) { dispatch_group_t group = context.callbacks; @@ -62,6 +88,218 @@ dispatch_time_t cleanup_deadline() { } } // namespace +@interface FakeOutputOwner : NSObject +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; +@end + +@implementation FakeOutputOwner +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context { + if ((self = [super init])) { + self.context = context; + ++context->state.output_owners; + } + return self; +} +- (void)dealloc { + --_context->state.output_owners; +} +@end + +@interface FakeSession () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; +@end + +@implementation FakeSession +- (instancetype)init { + return [self initWithContext:current_context]; +} +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context { + if (context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = context; + ++context->state.sessions; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.sessions; + } +} +- (void)executeInputs:(NSArray*)inputs + completion: + (void (^)(NSArray*, NSError*))completion { + CoreAIFakeBridgeContext* context = self.context; + ++context->state.executions; + const bool should_fail = context->state.fail_execute; + const bool malformed = context->state.bad_output; + dispatch_semaphore_t ready = context->state.input_ready; + dispatch_semaphore_t proceed = context->state.allow_input_read; + if (context->state.retain_execution_completion) { + context.retainedExecutionCompletion = completion; + } + run_async(context, ^{ + ETCoreAIInputTensor* input = inputs[0]; + context->state.last_input_bytes = input.bytes; + context->state.last_input_byte_count = input.byteCount; + if (ready != nil) { + dispatch_semaphore_signal(ready); + if (proceed == nil || + dispatch_semaphore_wait(proceed, cleanup_deadline()) != 0) { + ++context->state.input_wait_timeouts; + completion(nil, fake_error(ETCoreAIErrorRuntime)); + return; + } + } + if (input.byteCount > 0) { + context->state.last_input_first_byte = + static_cast(input.bytes)[0]; + } + if (should_fail) { + completion(nil, fake_error(ETCoreAIErrorRuntime)); + return; + } + if (malformed) { + completion( + @[ [[ETCoreAITensor alloc] initWithData:[NSData data] + shape:@[ @2 ] + scalarType:ETCoreAIScalarTypeFloat32] ], + nil); + return; + } + NSMutableArray* outputs = + [NSMutableArray arrayWithCapacity:inputs.count]; + for (ETCoreAIInputTensor* value in inputs) { + FakeOutputOwner* owner = + [[FakeOutputOwner alloc] initWithContext:context]; + [outputs addObject:[[ETCoreAITensor alloc] + initWithByteCount:value.byteCount + shape:value.shape + scalarType:value.scalarType + copyBytes:^(void* destination) { + (void)owner; + ++context->state.output_copies; + context->state.last_output_destination = + destination; + std::memcpy( + destination, + value.bytes, + value.byteCount); + }]]; + } + completion(outputs, nil); + }); +} +@end + +@interface FakePreparedModel () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; +@end + +@implementation FakePreparedModel +- (instancetype)init { + return [self initWithContext:current_context]; +} +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context { + if (context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = context; + ++context->state.prepared_models; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.prepared_models; + } +} +- (NSData*)copyBookmarkData { + return self.bookmark; +} +- (void)loadFunctionNamed:(NSString*)functionName + inputNames:(NSArray*)inputNames + outputNames:(NSArray*)outputNames + completion:(void (^)(id, NSError*))completion { + CoreAIFakeBridgeContext* context = self.context; + const bool valid = + valid_binding(context, functionName, inputNames, outputNames); + const bool should_fail = context->state.fail_load; + if (context->state.retain_binding_completion) { + context.retainedBindingCompletion = completion; + } + run_async(context, ^{ + if (!valid || should_fail) { + completion(nil, fake_error(ETCoreAIErrorInvalidModel)); + return; + } + FakeSession* session = [[FakeSession alloc] initWithContext:context]; + session.preparedPin = self; + completion(session, nil); + }); +} +@end + +@interface FakeLoader () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +@end + +@implementation FakeLoader +- (instancetype)init { + if (current_context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = current_context; + ++_context->state.loaders; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.loaders; + } +} +- (NSString*)specializationIdentifier { + return @"default"; +} +- (void)restoreModelFromBookmark:(NSData*)bookmark + completion:(ETCoreAIAcquisitionCompletion)completion { + FakePreparedModel* model = + [[FakePreparedModel alloc] initWithContext:self.context]; + model.bookmark = bookmark; + _context->state.last_bundle = + [[NSString alloc] initWithData:bookmark encoding:NSUTF8StringEncoding]; + completion(ETCoreAIAcquisitionStatusHit, model, nil); +} +- (void)specializeModelAtURL:(NSURL*)url + completion:(ETCoreAIAcquisitionCompletion)completion { + if (![NSFileManager.defaultManager fileExistsAtPath:url.path]) { + ++_context->state.missing_bundles; + completion( + ETCoreAIAcquisitionStatusError, + nil, + fake_error(ETCoreAIErrorInvalidModel)); + return; + } + _context->state.last_bundle = url.path; + FakePreparedModel* model = + [[FakePreparedModel alloc] initWithContext:self.context]; + model.bookmark = [url.path dataUsingEncoding:NSUTF8StringEncoding]; + completion(ETCoreAIAcquisitionStatusHit, model, nil); +} +- (void)evictModelWithBookmark:(NSData*)bookmark + completion:(void (^)(NSError*))completion { + completion(nil); +} +@end + @interface BookmarkFakeModel () @property(nonatomic, strong) CoreAIFakeBridgeContext* context; - (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; @@ -97,8 +335,24 @@ - (void)loadFunctionNamed:(NSString*)functionName inputNames:(NSArray*)inputNames outputNames:(NSArray*)outputNames completion:(void (^)(id, NSError*))completion { - // Acquisition tests never bind functions. - completion(nil, fake_error(ETCoreAIErrorUnsupported)); + CoreAIFakeBridgeContext* context = self.context; + BookmarkFakeLoader* owner = self.owner; + if (!valid_binding(context, functionName, inputNames, outputNames) || + owner == nil) { + completion(nil, fake_error(ETCoreAIErrorInvalidModel)); + return; + } + ++owner->bindings; + owner.lastBound = self; + run_async(context, ^{ + if (owner.bindError != nil) { + completion(nil, owner.bindError); + return; + } + FakeSession* session = [[FakeSession alloc] initWithContext:context]; + session.preparedPin = self; + completion(session, nil); + }); } @end @@ -112,6 +366,7 @@ - (instancetype)init { self.models = [NSMutableDictionary dictionary]; restores = 0; specializations = 0; + bindings = 0; evictions = 0; ++_context->state.loaders; } @@ -186,22 +441,11 @@ - (void)evictModelWithBookmark:(NSData*)bookmark - (void)clearRetainedState { self.onSpecialize = nil; self.lastAcquired = nil; + self.lastBound = nil; [self.models removeAllObjects]; } @end -BOOL ETCoreAIIsAvailable(void) { - return YES; -} - -NSString* ETCoreAIDeviceArchitectureName(void) { - return @"arch_b"; -} - -id ETCoreAICreateModelLoader(void) { - return current_context == nil ? nil : current_context->state.bookmark_loader; -} - namespace executorch::backends::coreai::testing { ScopedFakeBridgeState::ScopedFakeBridgeState() @@ -220,9 +464,17 @@ BOOL ETCoreAIIsAvailable(void) { } context_->state.bookmark_loader = nil; loader = nil; + context_.retainedBindingCompletion = nil; + context_.retainedExecutionCompletion = nil; + context_->state.input_ready = nil; + context_->state.allow_input_read = nil; + EXPECT_EQ(context_->state.sessions.load(), 0); EXPECT_EQ(context_->state.prepared_models.load(), 0); EXPECT_EQ(context_->state.loaders.load(), 0); + EXPECT_EQ(context_->state.output_owners.load(), 0); + EXPECT_EQ(context_->state.binding_mismatches.load(), 0); EXPECT_EQ(context_->state.missing_bundles.load(), 0); + EXPECT_EQ(context_->state.input_wait_timeouts.load(), 0); } EXPECT_EQ(current_context, context_); current_context = previous_; @@ -263,3 +515,22 @@ BOOL ETCoreAIIsAvailable(void) { } } // namespace executorch::backends::coreai::testing + +BOOL ETCoreAIIsAvailable(void) { + return current_context != nil && current_context->state.available; +} + +NSString* ETCoreAIDeviceArchitectureName(void) { + if (current_context == nil) { + return nil; + } + ++current_context->state.architecture_queries; + return current_context->state.device_architecture; +} + +id ETCoreAICreateModelLoader(void) { + if (current_context == nil || current_context->state.fail_loader_factory) { + return nil; + } + return current_context->state.bookmark_loader ?: [[FakeLoader alloc] init]; +} diff --git a/backends/apple/coreai/runtime/test/runtime_smoke.cpp b/backends/apple/coreai/runtime/test/runtime_smoke.cpp new file mode 100644 index 00000000000..3d91a760b4b --- /dev/null +++ b/backends/apple/coreai/runtime/test/runtime_smoke.cpp @@ -0,0 +1,27 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include + +#include + +int main() { + executorch::runtime::runtime_init(); + const auto* backend = executorch::runtime::get_backend_class("CoreAIBackend"); + if (backend == nullptr) { + std::fputs("CoreAIBackend was not retained by the linker\n", stderr); + return 1; + } + if (!backend->is_available()) { + std::fputs("CoreAIBackend requires OS 27 or newer\n", stderr); + return 1; + } + std::puts("CoreAIBackend is registered and available"); + return 0; +} diff --git a/tools/cmake/executorch-config.cmake b/tools/cmake/executorch-config.cmake index 3d3b650f33e..6e468b1da0c 100644 --- a/tools/cmake/executorch-config.cmake +++ b/tools/cmake/executorch-config.cmake @@ -105,6 +105,7 @@ set(optional_lib_list coreml_util coreml_inmemoryfs coremldelegate + coreaidelegate mlxdelegate mlx metal_backend