diff --git a/.github/workflows/coreai.yml b/.github/workflows/coreai.yml index f09b5972d05..e3b9a8542f6 100644 --- a/.github/workflows/coreai.yml +++ b/.github/workflows/coreai.yml @@ -59,10 +59,21 @@ jobs: echo "::group::Install ExecuTorch and Core AI requirements" ${CONDA_RUN} python install_executorch.py --editable > /dev/null - ${CONDA_RUN} pip install -r backends/apple/coreai/requirements.txt + ${CONDA_RUN} pip install -r backends/apple/coreai/requirements.txt "cmake>=3.31,<4" ninja ${CONDA_RUN} pip list echo "::endgroup::" echo "::group::Run Core AI export tests" ${CONDA_RUN} python -m pytest backends/apple/coreai echo "::endgroup::" + + echo "::group::Build and run Core AI runtime tests" + ${CONDA_RUN} cmake -S . -B cmake-out-coreai -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 + ${CONDA_RUN} cmake --build cmake-out-coreai --target backends/apple/coreai/all -j$(sysctl -n hw.ncpu) + ${CONDA_RUN} ctest --test-dir cmake-out-coreai/backends/apple/coreai --output-on-failure --no-tests=error + echo "::endgroup::" diff --git a/CMakeLists.txt b/CMakeLists.txt index 737133db121..95c765518bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -865,6 +865,10 @@ if(EXECUTORCH_BUILD_COREML) list(APPEND _executorch_backends coremldelegate) endif() +if(EXECUTORCH_BUILD_COREAI) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/apple/coreai) +endif() + if(EXECUTORCH_BUILD_MLX) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/mlx) list(APPEND _executorch_backends mlxdelegate) diff --git a/backends/apple/coreai/.clang-format b/backends/apple/coreai/.clang-format new file mode 100644 index 00000000000..8bd9536477b --- /dev/null +++ b/backends/apple/coreai/.clang-format @@ -0,0 +1,244 @@ +# Root style without its Cpp-only Language key, so Objective-C headers format too. +--- +AccessModifierOffset: -1 +AlignAfterOpenBracket: AlwaysBreak +AlignArrayOfStructures: None +AlignConsecutiveAssignments: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveBitFields: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveDeclarations: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveMacros: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveShortCaseStatements: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCaseColons: false +AlignEscapedNewlines: Left +AlignOperands: DontAlign +AlignTrailingComments: + Kind: Never + OverEmptyLines: 0 +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: false +AllowBreakBeforeNoexceptSpecifier: Never +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortCompoundRequirementOnASingleLine: true +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +AttributeMacros: + - __capability +BinPackArguments: false +BinPackParameters: false +BitFieldColonSpacing: Both +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterExternBlock: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakAdjacentStringLiterals: true +BreakAfterAttributes: Leave +BreakAfterJavaFieldAnnotations: false +BreakArrays: true +BreakBeforeBinaryOperators: None +BreakBeforeConceptDeclarations: Always +BreakBeforeBraces: Attach +BreakBeforeInlineASMColon: OnlyMultiline +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakInheritanceList: BeforeColon +BreakStringLiterals: false +ColumnLimit: 80 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - FOR_EACH + - FOR_EACH_R + - FOR_EACH_RANGE +IfMacros: + - KJ_IF_MAYBE +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^<.*\.h(pp)?>' + Priority: 1 + SortPriority: 0 + CaseSensitive: false + - Regex: '^<.*' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '.*' + Priority: 3 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: '(Test)?$' +IncludeIsMainSourceRegex: '' +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: true +IndentExternBlock: AfterExternBlock +IndentGotoLabels: true +IndentPPDirectives: None +IndentRequiresClause: true +IndentWidth: 2 +IndentWrappedFunctionNames: false +InsertBraces: false +InsertNewlineAtEOF: false +InsertTrailingCommas: None +IntegerLiteralSeparator: + Binary: 0 + BinaryMinDigits: 0 + Decimal: 0 + DecimalMinDigits: 0 + Hex: 0 + HexMinDigits: 0 +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: false +KeepEmptyLinesAtEOF: false +LambdaBodyIndentation: Signature +LineEnding: DeriveLF +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 2 +ObjCBreakBeforeNestedBlockParam: true +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: false +PackConstructorInitializers: NextLine +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakOpenParenthesis: 0 +PenaltyBreakScopeResolution: 500 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyIndentedWhitespace: 0 +PenaltyReturnTypeOnItsOwnLine: 200 +PointerAlignment: Left +PPIndentWidth: -1 +QualifierAlignment: Leave +ReferenceAlignment: Pointer +ReflowComments: true +RemoveBracesLLVM: false +RemoveParentheses: Leave +RemoveSemicolon: false +RequiresClausePosition: OwnLine +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 1 +SkipMacroDefinitionBody: false +SortIncludes: CaseSensitive +SortJavaStaticImport: Before +SortUsingDeclarations: LexicographicNumeric +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatements +SpaceBeforeParensOptions: + AfterControlStatements: true + AfterForeachMacros: true + AfterFunctionDefinitionName: false + AfterFunctionDeclarationName: false + AfterIfMacros: true + AfterOverloadedOperator: false + AfterPlacementOperator: true + AfterRequiresInClause: false + AfterRequiresInExpression: false + BeforeNonEmptyParentheses: false +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: Never +SpacesInContainerLiterals: true +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +SpacesInParens: Never +SpacesInParensOptions: + InCStyleCasts: false + InConditionalStatements: false + InEmptyParentheses: false + Other: false +SpacesInSquareBrackets: false +Standard: Latest +StatementAttributeLikeMacros: + - Q_EMIT +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 8 +UseTab: Never +VerilogBreakBetweenInstancePorts: true +WhitespaceSensitiveMacros: + - BOOST_PP_STRINGIZE + - CF_SWIFT_NAME + - NS_SWIFT_NAME + - PP_STRINGIZE + - STRINGIZE +... diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt new file mode 100644 index 00000000000..312f30c9d0c --- /dev/null +++ b/backends/apple/coreai/CMakeLists.txt @@ -0,0 +1,46 @@ +# 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(NOT APPLE) + message(FATAL_ERROR "Core AI host tests require Apple platforms") +endif() + +enable_language(OBJCXX) + +set(_coreai_runtime_sources runtime/coreai_assets.mm) + +function(coreai_configure_objc_target target) + target_include_directories( + ${target} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/runtime + ) + set_target_properties( + ${target} PROPERTIES OBJCXX_STANDARD 17 OBJCXX_STANDARD_REQUIRED YES + ) + target_compile_options( + ${target} + PRIVATE "$<$:-fobjc-arc;-fno-exceptions;-fno-rtti>" + ) +endfunction() + +if(EXECUTORCH_BUILD_TESTS) + if(NOT CMAKE_SYSTEM_NAME STREQUAL "Darwin") + message(FATAL_ERROR "Core AI host tests require a macOS build") + endif() + add_executable( + coreai_host_test + runtime/test/coreai_host_test.mm runtime/test/coreai_manifest_test.mm + ${_coreai_runtime_sources} + ) + coreai_configure_objc_target(coreai_host_test) + 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_FOUNDATION_FRAMEWORK} + ) + add_test(NAME coreai_host_test COMMAND coreai_host_test) + set_tests_properties(coreai_host_test PROPERTIES TIMEOUT 120) +endif() diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 2e6ce4d61b4..99ca602bbdd 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -23,3 +23,41 @@ python -m pytest backends/apple/coreai `CoreAIAOTCompileTest` needs `xcrun coreai-build` from the Metal Toolchain (`xcodebuild -downloadComponent MetalToolchain`) and skips without it. + +## Runtime + +The delegate manifest describes `.aimodel` source bundles, which require +`inline` packaging, and AOT-compiled `.aimodelc` bundles, which require +`aot_compiled_inline`. Both formats require a `function` name, and ordered +`input_names` and `output_names` matching the converter's bindings. Dictionary +iteration order is not a binding contract. The `files` object maps relative +filenames to byte sizes; `bundle_digests` maps each bundle basename to its +export-time SHA-256 digest. + +### AOT architecture selection + +Configure AOT export with `AOTCompileConfig`, including the target `platform` and +optional `architectures` list. For example, `AOTCompileConfig(platform="iOS", +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`. + +## Host Tests + +`EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the +standalone `coreai_host_test` executable with CTest. It compiles the runtime +sources without Swift or CoreAI, routing any SDK calls through a fake bridge, so +it also runs on macOS 26. Host tests do not establish real SDK behavior. + +From the repository root: + +```bash +cmake -S . -B -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=26.0 \ + -DEXECUTORCH_BUILD_COREAI=ON -DEXECUTORCH_BUILD_TESTS=ON \ + -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \ + -DEXECUTORCH_ENABLE_PROGRAM_VERIFICATION=ON \ + -DEXECUTORCH_BUILD_EXECUTOR_RUNNER=OFF +cmake --build --target coreai_host_test +ctest --test-dir -R '^coreai_host_test$' --output-on-failure +``` diff --git a/backends/apple/coreai/runtime/coreai_assets.h b/backends/apple/coreai/runtime/coreai_assets.h new file mode 100644 index 00000000000..ebf84a3bf01 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_assets.h @@ -0,0 +1,37 @@ +/* + * 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 { + +struct Manifest { + NSString* hash = nil; + NSString* path = nil; + NSString* function = nil; + NSArray* inputs = nil; + NSArray* outputs = nil; + NSDictionary* files = nil; + NSDictionary* bundle_digests = nil; + bool aot_compiled = false; + NSString* platform = nil; + NSDictionary* archs = nil; + NSOperatingSystemVersion minimum_version{}; +}; + +runtime::Result parse_manifest(NSData* data); +// Resolve an already validated manifest before reading or materializing assets. +runtime::Result select_assets( + const Manifest& manifest, + NSString* device_architecture, + NSString* platform); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_assets.mm b/backends/apple/coreai/runtime/coreai_assets.mm new file mode 100644 index 00000000000..d28cb8e1d55 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_assets.mm @@ -0,0 +1,296 @@ +/* + * 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_assets.h" +#include +#include + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; + +bool nonempty_string(id value) { + return [value isKindOfClass:NSString.class] && [value length] > 0 && + [value rangeOfString:@"\0"].location == NSNotFound; +} + +bool names_array(id value) { + if (![value isKindOfClass:NSArray.class]) { + return false; + } + NSMutableSet* seen = [NSMutableSet set]; + for (id name in value) { + if (!nonempty_string(name) || [seen containsObject:name]) { + return false; + } + [seen addObject:name]; + } + return true; +} + +bool relative_path(NSString* path) { + if (!nonempty_string(path) || path.isAbsolutePath || + [path containsString:@"\\"]) { + return false; + } + for (NSString* component in [path componentsSeparatedByString:@"/"]) { + if (component.length == 0 || [component isEqualToString:@"."] || + [component isEqualToString:@".."]) { + return false; + } + } + return true; +} + +bool file_size(id value) { + return [value isKindOfClass:NSNumber.class] && + CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID() && + !CFNumberIsFloatType((__bridge CFNumberRef)value) && + [value compare:@0] != NSOrderedAscending && + [value compare:@(std::numeric_limits::max())] != + NSOrderedDescending && + [value isEqualToNumber:@([value unsignedLongLongValue])]; +} + +Error validate_files( + NSDictionary* filenames, + NSSet* bundles) { + ET_CHECK_OR_RETURN_ERROR( + [filenames isKindOfClass:NSDictionary.class] && filenames.count > 0, + InvalidProgram, + "Core AI manifest requires asset file sizes"); + NSSet* files = [NSSet setWithArray:filenames.allKeys]; + NSMutableSet* populated = [NSMutableSet set]; + for (NSString* file in filenames) { + ET_CHECK_OR_RETURN_ERROR( + relative_path(file) && file_size(filenames[file]) && + [bundles containsObject:[file componentsSeparatedByString:@"/"] + .firstObject] && + [file containsString:@"/"] && + [file canBeConvertedToEncoding:NSUTF8StringEncoding], + InvalidProgram, + "Invalid Core AI asset path or size"); + [populated addObject:[file componentsSeparatedByString:@"/"].firstObject]; + NSString* parent = file.stringByDeletingLastPathComponent; + while (parent.length > 0) { + ET_CHECK_OR_RETURN_ERROR( + ![files containsObject:parent], + InvalidProgram, + "Conflicting Core AI asset paths"); + parent = parent.stringByDeletingLastPathComponent; + } + } + ET_CHECK_OR_RETURN_ERROR( + [populated isEqualToSet:bundles], + InvalidProgram, + "Core AI manifest requires files for every declared bundle"); + return Error::Ok; +} + +bool parse_version(id value, NSOperatingSystemVersion& version) { + // A null floor means the exporter used the SDK's save_asset default. + if (value == NSNull.null) { + version = {}; + return true; + } + if (!nonempty_string(value)) { + return false; + } + NSArray* parts = [value componentsSeparatedByString:@"."]; + if (parts.count > 3) { + return false; + } + NSInteger numbers[3] = {0, 0, 0}; + NSCharacterSet* invalid = [[NSCharacterSet + characterSetWithCharactersInString:@"0123456789"] invertedSet]; + for (NSUInteger i = 0; i < parts.count; ++i) { + NSString* part = parts[i]; + if (part.length == 0 || part.length > 6 || + [part rangeOfCharacterFromSet:invalid].location != NSNotFound) { + return false; + } + numbers[i] = part.integerValue; + } + version = {numbers[0], numbers[1], numbers[2]}; + return numbers[0] > 0; +} + +} // namespace + +Result parse_manifest(NSData* data) { + NSError* error = nil; + id object = [NSJSONSerialization JSONObjectWithData:data + options:0 + error:&error]; + ET_CHECK_OR_RETURN_ERROR( + [object isKindOfClass:NSDictionary.class], + InvalidProgram, + "Invalid Core AI manifest JSON"); + NSDictionary* dict = object; + NSString* packaging = dict[@"packaging"]; + ET_CHECK_OR_RETURN_ERROR( + [packaging isKindOfClass:NSString.class], + InvalidProgram, + "Missing Core AI packaging"); + ET_CHECK_OR_RETURN_ERROR( + [packaging isEqualToString:@"inline"] || + [packaging isEqualToString:@"aot_compiled_inline"], + NotSupported, + "Unsupported Core AI packaging; expected .aimodel or AOT .aimodelc"); + Manifest manifest; + manifest.aot_compiled = [packaging hasPrefix:@"aot_compiled_"]; + manifest.hash = dict[@"hash"]; + manifest.path = dict[@"path"]; + manifest.function = dict[@"function"]; + manifest.inputs = dict[@"input_names"]; + manifest.outputs = dict[@"output_names"]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(manifest.hash) && + [manifest.hash + rangeOfCharacterFromSet:[[NSCharacterSet + characterSetWithCharactersInString: + @"0123456789abcdef"] invertedSet]] + .location == NSNotFound, + InvalidProgram, + "Invalid Core AI model hash"); + NSMutableSet* bundles = [NSMutableSet set]; + if (manifest.aot_compiled) { + manifest.platform = dict[@"platform"]; + manifest.archs = dict[@"archs"]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(manifest.platform), + InvalidProgram, + "Core AI AOT manifest requires a target platform; re-export the model"); + ET_CHECK_OR_RETURN_ERROR( + [manifest.archs isKindOfClass:NSDictionary.class] && + manifest.archs.count > 0, + InvalidProgram, + "Core AI AOT manifest requires a nonempty architecture map; " + "re-export the model"); + for (id arch in manifest.archs) { + ET_CHECK_OR_RETURN_ERROR( + relative_path(arch) && ![arch containsString:@"/"] && + [arch canBeConvertedToEncoding:NSUTF8StringEncoding], + InvalidProgram, + "Invalid Core AI AOT architecture name"); + NSString* bundle = [NSString stringWithFormat:@"model.%@.aimodelc", arch]; + id path = manifest.archs[arch]; + ET_CHECK_OR_RETURN_ERROR( + relative_path(path) && + [path isEqualToString:[manifest.hash + stringByAppendingPathComponent:bundle]], + InvalidProgram, + "Invalid Core AI AOT bundle path for architecture %s", + [arch UTF8String]); + [bundles addObject:bundle]; + } + manifest.path = nil; + } else { + ET_CHECK_OR_RETURN_ERROR( + relative_path(manifest.path) && + [manifest.path + isEqualToString:[manifest.hash + stringByAppendingString:@"/model.aimodel"]], + InvalidProgram, + "Invalid Core AI source bundle path"); + [bundles addObject:@"model.aimodel"]; + } + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(manifest.function) && names_array(manifest.inputs) && + names_array(manifest.outputs), + InvalidProgram, + "Core AI manifest requires ordered input/output names; re-export the " + "model"); + ET_CHECK_OR_RETURN_ERROR( + parse_version(dict[@"min_deployment_version"], manifest.minimum_version), + InvalidProgram, + "Invalid Core AI minimum deployment version"); + manifest.files = dict[@"files"]; + ET_CHECK_OK_OR_RETURN_ERROR(validate_files(manifest.files, bundles)); + manifest.bundle_digests = dict[@"bundle_digests"]; + ET_CHECK_OR_RETURN_ERROR( + [manifest.bundle_digests isKindOfClass:NSDictionary.class] && + [[NSSet setWithArray:manifest.bundle_digests.allKeys] + isEqualToSet:bundles], + InvalidProgram, + "Core AI bundle digests must exactly cover declared bundles"); + for (NSString* bundle in bundles) { + id digest = manifest.bundle_digests[bundle]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(digest) && [digest length] == 64 && + [digest + rangeOfCharacterFromSet: + [[NSCharacterSet + characterSetWithCharactersInString:@"0123456789abcdef"] + invertedSet]] + .location == NSNotFound, + InvalidProgram, + "Invalid Core AI exported bundle digest"); + } + return manifest; +} + +Result select_assets( + const Manifest& manifest, + NSString* device_architecture, + NSString* platform) { + if (!manifest.aot_compiled) { + return manifest; + } + ET_CHECK_OR_RETURN_ERROR( + [manifest.platform isEqualToString:platform], + DelegateInvalidCompatibility, + "Core AI AOT target platform %s does not match runtime platform %s; " + "re-export the model for this platform", + manifest.platform.UTF8String, + platform.UTF8String); + NSArray* architectures = [manifest.archs.allKeys + sortedArrayUsingComparator:^NSComparisonResult(NSString* a, NSString* b) { + return [a compare:b options:NSLiteralSearch]; + }]; + NSString* available = [architectures componentsJoinedByString:@", "]; + ET_CHECK_OR_RETURN_ERROR( + nonempty_string(device_architecture), + DelegateInvalidCompatibility, + "Core AI SDK device architecture is unavailable (%s); available " + "architectures: [%s]; re-export the model as .aimodel or for this device", + device_architecture == nil ? "nil" : device_architecture.UTF8String, + available.UTF8String); + NSString* path = manifest.archs[device_architecture]; + ET_CHECK_OR_RETURN_ERROR( + path != nil, + DelegateInvalidCompatibility, + "Core AI AOT has no bundle for device architecture %s; available " + "architectures: [%s]; re-export the model for this device architecture", + device_architecture.UTF8String, + available.UTF8String); + Manifest selected = manifest; + selected.path = path; + NSString* prefix = [path.lastPathComponent stringByAppendingString:@"/"]; + NSMutableDictionary* files = + [NSMutableDictionary dictionary]; + for (NSString* file in manifest.files) { + if ([file hasPrefix:prefix]) { + files[file] = manifest.files[file]; + } + } + ET_CHECK_OR_RETURN_ERROR( + files.count > 0, + InvalidProgram, + "Core AI manifest has no files for selected device architecture %s; " + "re-export the model", + device_architecture.UTF8String); + selected.files = files; + selected.bundle_digests = @{ + path.lastPathComponent : manifest.bundle_digests[path.lastPathComponent] + }; + return selected; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/coreai_host_test.mm b/backends/apple/coreai/runtime/test/coreai_host_test.mm new file mode 100644 index 00000000000..5c857dfddf4 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_host_test.mm @@ -0,0 +1,19 @@ +/* + * 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 +#include +#include + +int main(int argc, char** argv) { + executorch::runtime::runtime_init(); + @autoreleasepool { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); + } +} diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h new file mode 100644 index 00000000000..5155ed8dd77 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h @@ -0,0 +1,63 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#import "coreai_assets.h" + +namespace executorch::backends::coreai::testing { + +// Synthetic identities keep host tests independent of the exporter hash. +inline NSString* fixture_identity(char digit) { + EXPECT_TRUE((digit >= '0' && digit <= '9') || (digit >= 'a' && digit <= 'f')); + return [NSString stringWithUTF8String:std::string(64, digit).c_str()]; +} + +inline NSMutableDictionary* manifest_dict() { + return [@{ + @"packaging" : @"inline", + @"hash" : @"ab", + @"path" : @"ab/model.aimodel", + @"function" : @"main", + @"input_names" : @[ @"input_0" ], + @"output_names" : @[ @"output_0" ], + @"files" : @{@"model.aimodel/graph.bin" : @11}, + @"bundle_digests" : @{@"model.aimodel" : fixture_identity('1')}, + @"min_deployment_version" : @"26.0" + } mutableCopy]; +} + +inline NSMutableDictionary* aot_manifest_dict() { + auto dict = manifest_dict(); + [dict removeObjectForKey:@"path"]; + dict[@"packaging"] = @"aot_compiled_inline"; + dict[@"platform"] = @"macOS"; + dict[@"archs"] = @{ + @"arch_b" : @"ab/model.arch_b.aimodelc", + @"arch_a" : @"ab/model.arch_a.aimodelc" + }; + dict[@"files"] = @{ + @"model.arch_b.aimodelc/nested/weights.bin" : @7, + @"model.arch_a.aimodelc/graph.bin" : @14, + @"model.arch_b.aimodelc/graph.bin" : @14, + @"model.arch_a.aimodelc/nested/weights.bin" : @7 + }; + dict[@"bundle_digests"] = @{ + @"model.arch_a.aimodelc" : fixture_identity('a'), + @"model.arch_b.aimodelc" : fixture_identity('b') + }; + return dict; +} + +inline NSData* encode(NSDictionary* dict) { + return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm new file mode 100644 index 00000000000..337a939c4d8 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm @@ -0,0 +1,187 @@ +/* + * 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 "coreai_manifest_fixture.h" +#include + +using namespace executorch::runtime; +using namespace executorch::backends::coreai; +using namespace executorch::backends::coreai::testing; + +TEST(CoreAIManifestTest, AcceptsInlineAndRejectsInvalidJson) { + EXPECT_TRUE(parse_manifest(encode(manifest_dict())).ok()); + EXPECT_FALSE(parse_manifest([@"[]" dataUsingEncoding:NSUTF8StringEncoding]).ok()); + EXPECT_FALSE(parse_manifest([@"garbage" dataUsingEncoding:NSUTF8StringEncoding]).ok()); +} + +TEST(CoreAIManifestTest, RejectsUnknownPackaging) { + auto dict = manifest_dict(); + dict[@"packaging"] = @"unknown"; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::NotSupported); +} + +TEST(CoreAIManifestTest, RejectsUnsafeSourcePaths) { + for (NSString* path in @[ + @"../model.aimodel", @"/ab/model.aimodel", @"ab/../model.aimodel", + @"xx/model.aimodel" + ]) { + SCOPED_TRACE(path.UTF8String); + auto dict = manifest_dict(); + dict[@"path"] = path; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, RejectsInvalidFileMaps) { + for (id files in @[ + @[], @{}, NSNull.null, @"files", + @{@"model.aimodel/../escape" : @1}, + @{@"model.aimodel/bad\\name" : @1}, + @{@"/tmp/file" : @1}, + @{@"model.aimodel/one" : @1, @"model.aimodel/one/two" : @2} + ]) { + SCOPED_TRACE([files description].UTF8String); + auto dict = manifest_dict(); + dict[@"files"] = files; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, RejectsInvalidInputNames) { + for (id names in @[ @[ @"same", @"same" ], @[ @1 ], @[ @"" ], @"input", NSNull.null ]) { + SCOPED_TRACE([names description].UTF8String); + auto dict = manifest_dict(); + dict[@"input_names"] = names; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, ValidatesDeploymentFloor) { + for (NSString* version in @[ @"", @"27.x", @"27.0.0.0" ]) { + SCOPED_TRACE(version.UTF8String); + auto dict = manifest_dict(); + dict[@"min_deployment_version"] = version; + EXPECT_FALSE(parse_manifest(encode(dict)).ok()); + } + auto dict = manifest_dict(); + dict[@"min_deployment_version"] = NSNull.null; + EXPECT_TRUE(parse_manifest(encode(dict)).ok()); +} + +TEST(CoreAIManifestTest, RequiresMetadataMaps) { + auto dict = manifest_dict(); + ASSERT_TRUE(parse_manifest(encode(dict)).ok()); + for (NSString* key in @[ @"files", @"bundle_digests" ]) { + SCOPED_TRACE(key.UTF8String); + NSMutableDictionary* invalid = [dict mutableCopy]; + [invalid removeObjectForKey:key]; + EXPECT_EQ(parse_manifest(encode(invalid)).error(), Error::InvalidProgram); + invalid[key] = @[]; + EXPECT_EQ(parse_manifest(encode(invalid)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, RejectsInvalidFileSizes) { + for (id size in @[ @(-1), @0.5, @(std::numeric_limits::max()) ]) { + SCOPED_TRACE([size description].UTF8String); + auto dict = manifest_dict(); + dict[@"files"] = @{@"model.aimodel/graph.bin" : size}; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, AcceptsIntegralFileSizes) { + for (NSNumber* size in @[ @0, @11, @(std::numeric_limits::max()) ]) { + SCOPED_TRACE(size.description.UTF8String); + auto dict = manifest_dict(); + dict[@"files"] = @{@"model.aimodel/graph.bin" : size}; + EXPECT_TRUE(parse_manifest(encode(dict)).ok()); + } +} + +TEST(CoreAIManifestTest, RequiresExactLowercaseDigests) { + for (NSString* digest in @[ + [fixture_identity('a') substringFromIndex:1], fixture_identity('a').uppercaseString, + [@"g" stringByPaddingToLength:64 withString:@"g" startingAtIndex:0] + ]) { + SCOPED_TRACE(digest.UTF8String); + auto dict = manifest_dict(); + dict[@"bundle_digests"] = @{@"model.aimodel" : digest}; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +class CoreAIManifestMetadataTest : public ::testing::TestWithParam { + protected: + NSMutableDictionary* manifest() { + return GetParam() ? aot_manifest_dict() : manifest_dict(); + } + NSString* file() { + return GetParam() ? @"model.arch_b.aimodelc/graph.bin" : @"model.aimodel/graph.bin"; + } +}; + +TEST_P(CoreAIManifestMetadataTest, RequiresExactBundleCoverage) { + for (bool extra : {false, true}) { + SCOPED_TRACE(extra ? "extra digest" : "missing digest"); + auto dict = manifest(); + NSMutableDictionary* digests = [dict[@"bundle_digests"] mutableCopy]; + if (extra) { + digests[@"unexpected.aimodel"] = fixture_identity('e'); + } else { + [digests removeObjectForKey:file().stringByDeletingLastPathComponent]; + } + dict[@"bundle_digests"] = digests; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +INSTANTIATE_TEST_SUITE_P( + Delivery, CoreAIManifestMetadataTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "Aot" : "Source"; + }); + +TEST(CoreAIManifestTest, SelectsAotArchitectureWithoutMutatingManifest) { + auto dict = aot_manifest_dict(); + auto parsed = parse_manifest(encode(dict)); + ASSERT_TRUE(parsed.ok()); + ASSERT_TRUE(parsed->aot_compiled); + EXPECT_EQ(parsed->path, nil); + for (NSString* arch in @[ @"arch_a", @"arch_b" ]) { + SCOPED_TRACE(arch.UTF8String); + auto selected = select_assets(parsed.get(), arch, @"macOS"); + ASSERT_TRUE(selected.ok()); + EXPECT_TRUE([selected->path isEqualToString:dict[@"archs"][arch]]); + EXPECT_EQ(selected->files.count, 2); + NSString* bundle = selected->path.lastPathComponent; + EXPECT_TRUE(([selected->bundle_digests isEqual:@{bundle : dict[@"bundle_digests"][bundle]}])); + EXPECT_TRUE(([selected->files isEqual:@{ + [bundle stringByAppendingPathComponent:@"graph.bin"] : @14, + [bundle stringByAppendingPathComponent:@"nested/weights.bin"] : @7 + }])); + EXPECT_EQ(parsed->path, nil); + } +} + +TEST(CoreAIManifestTest, RejectsMismatchedAotPlatformAndArchitecture) { + auto ios = aot_manifest_dict(); + ios[@"platform"] = @"iOS"; + auto parsed_ios = parse_manifest(encode(ios)); + ASSERT_TRUE(parsed_ios.ok()); + EXPECT_TRUE(select_assets(parsed_ios.get(), @"arch_b", @"iOS").ok()); + auto parsed = parse_manifest(encode(aot_manifest_dict())); + ASSERT_TRUE(parsed.ok()); + EXPECT_EQ(select_assets(parsed.get(), @"arch_b", @"iOS").error(), + Error::DelegateInvalidCompatibility); + for (id arch in @[ @"missing", @"ARCH_B", @"", NSNull.null ]) { + SCOPED_TRACE([arch description].UTF8String); + EXPECT_EQ(select_assets(parsed.get(), arch == NSNull.null ? nil : arch, @"macOS").error(), + Error::DelegateInvalidCompatibility); + } +} diff --git a/tools/cmake/preset/default.cmake b/tools/cmake/preset/default.cmake index 89e679aec7c..e458647fd07 100644 --- a/tools/cmake/preset/default.cmake +++ b/tools/cmake/preset/default.cmake @@ -23,6 +23,9 @@ define_overridable_option( define_overridable_option( EXECUTORCH_BUILD_COREML "Build the Core ML backend" BOOL OFF ) +define_overridable_option( + EXECUTORCH_BUILD_COREAI "Build the Core AI backend" BOOL OFF +) define_overridable_option( EXECUTORCH_FLATBUFFERS_MAX_ALIGNMENT "Exir lets users set the alignment of tensor data embedded in the flatbuffer, and some users need an alignment larger than the default, which is typically 32."