From c777b926e574ccc18ea6a91e02d87cbd999206d1 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 26 Aug 2026 21:21:52 -0700 Subject: [PATCH 01/28] Add the MLX backend to the Apple frameworks and SwiftPM package This makes the MLX backend, which runs models on the Apple GPU through Metal, available to Swift and C++ apps through the SwiftPM package, the same way the Core ML and XNNPACK backends already are. Before this, MLX could only be reached from the pip wheel. Three things were needed. The package minimum is raised to macOS 14. MLX requires a macOS 14 or iOS 17 deployment target and does not build below it, so a package that ships MLX has to declare at least that. iOS was already at 17. This drops macOS 12 and 13 for the whole package. The Metal kernel library (metallib) is delivered per platform slice. MLX loads a metallib at runtime, and a metallib built for one slice does not load on another, so one is built for iOS device, iOS simulator, and macOS and shipped together in a single SwiftPM resource bundle. Each slice's MLX binary asks for its own file. Two small patches to the vendored MLX build make this work: one selects the Metal SDK by platform instead of always using macOS, and one lets the runtime look up the per-slice file name. The backend is otherwise used like the others: linking the framework registers it, and it runs through the existing Module API with no new Swift class. Test Plan: Configured and built the MLX delegate for the macOS slice and confirmed it builds against the macOS 14 deployment target with the backend enabled. Confirmed the SwiftPM manifest parses with the backend_mlx product and the shared backend_mlx_resources bundle target, that both the release and debug delegates depend on the one bundle, and that the per-slice metallib resources are included when present and omitted cleanly when absent so a fresh checkout still resolves. Verified both MLX build patches apply cleanly and are idempotent. Full device and simulator runs are exercised by the Apple CI jobs this change adds the framework to. --- .Package.swift/backend_mlx/dummy.swift | 0 .Package.swift/backend_mlx_debug/dummy.swift | 0 .../backend_mlx_resources/dummy.swift | 0 .github/workflows/apple.yml | 20 ++++++ .gitignore | 1 + CMakePresets.json | 2 +- Package.swift | 43 +++++++++++- backends/mlx/CMakeLists.txt | 25 +++++++ .../patches/mlx_metal_sdk_per_platform.patch | 70 +++++++++++++++++++ .../patches/mlx_swiftpm_metallib_name.patch | 35 ++++++++++ scripts/build_apple_frameworks.sh | 42 ++++++++++- tools/cmake/preset/apple_common.cmake | 4 ++ 12 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 .Package.swift/backend_mlx/dummy.swift create mode 100644 .Package.swift/backend_mlx_debug/dummy.swift create mode 100644 .Package.swift/backend_mlx_resources/dummy.swift create mode 100644 backends/mlx/patches/mlx_metal_sdk_per_platform.patch create mode 100644 backends/mlx/patches/mlx_swiftpm_metallib_name.patch diff --git a/.Package.swift/backend_mlx/dummy.swift b/.Package.swift/backend_mlx/dummy.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.Package.swift/backend_mlx_debug/dummy.swift b/.Package.swift/backend_mlx_debug/dummy.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.Package.swift/backend_mlx_resources/dummy.swift b/.Package.swift/backend_mlx_resources/dummy.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index c234fc0ce15..c75cf888c1c 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -193,6 +193,7 @@ jobs: "executorch_llm" "backend_coreml" "backend_xnnpack" + "backend_mlx" "kernels_llm" "kernels_optimized" "kernels_quantized" @@ -220,6 +221,15 @@ jobs: zip -r "${RUNNER_TEMP}/artifacts/${FRAMEWORK}_debug-${VERSION}.zip" "${FRAMEWORK}_debug.xcframework" ) done + # The MLX Metal kernel libraries are data files, not part of any + # xcframework, so carry them in the artifact for the SwiftPM update job to + # commit onto the package branch beside the manifest. + if [ -d .Package.swift/backend_mlx_resources ]; then + mkdir -p "${RUNNER_TEMP}/artifacts/backend_mlx_resources" + cp .Package.swift/backend_mlx_resources/*.metallib \ + "${RUNNER_TEMP}/artifacts/backend_mlx_resources/" 2>/dev/null || true + fi + upload-frameworks-ios: # NB: Don't run this on fork PRs because they won't have access to the secret and would fail anyway if: ${{ !github.event.pull_request.head.repo.fork }} @@ -331,6 +341,16 @@ jobs: git config --global user.name "PyTorch Bot" git config --global user.email "pytorchbot@users.noreply.github.com" + # Carry the MLX Metal kernel libraries onto the package branch beside + # the manifest. They are data files the MLX product loads at runtime, + # not part of any xcframework, so they travel with the package source + # rather than an S3 zip. + if [ -d "${RUNNER_TEMP}/frameworks-ios/backend_mlx_resources" ]; then + mkdir -p .Package.swift/backend_mlx_resources + cp "${RUNNER_TEMP}"/frameworks-ios/backend_mlx_resources/*.metallib \ + .Package.swift/backend_mlx_resources/ + git add -f .Package.swift/backend_mlx_resources/*.metallib + fi git add Package.swift git commit -am "${VERSION}" git push -f origin "${BRANCH}" diff --git a/.gitignore b/.gitignore index 83ec08f5472..74676532260 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,4 @@ zephyr_dev_root.backup.*/ # Agents .claude/*.local.* extension/pybindings/mlx.metallib +.Package.swift/backend_mlx_resources/*.metallib diff --git a/CMakePresets.json b/CMakePresets.json index 09685ffe65a..fe74bc437b7 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -45,7 +45,7 @@ "CMAKE_TOOLCHAIN_FILE": "${sourceDir}/third-party/ios-cmake/ios.toolchain.cmake", "EXECUTORCH_BUILD_PRESET_FILE": "${sourceDir}/tools/cmake/preset/macos.cmake", "PLATFORM": "MAC_ARM64", - "DEPLOYMENT_TARGET": "12.0", + "DEPLOYMENT_TARGET": "14.0", "CMAKE_MACOSX_BUNDLE": "OFF" }, "condition": { diff --git a/Package.swift b/Package.swift index 5561d48978f..2a0f5a092b5 100644 --- a/Package.swift +++ b/Package.swift @@ -53,6 +53,13 @@ let products = deliverables([ "sqlite3", ], ], + "backend_mlx": [ + "frameworks": [ + "Metal", + "Foundation", + "QuartzCore", + ], + ], "backend_xnnpack": [ "targets": [ "threadpool", @@ -113,6 +120,12 @@ for (key, value) in products { key.hasSuffix(debug_suffix) ? $0 + debug_suffix : $0 }).map { .target(name: $0) }, path: ".Package.swift/\(key)", + resources: (value["resources"] as? [String] ?? []).filter { + // Generated by scripts/build_apple_frameworks.sh and gitignored, so + // include it only when present, the way the test fixtures below do, or a + // consumer's resolve fails before the build script has produced it. + FileManager.default.fileExists(atPath: ".Package.swift/\(key)/\($0)") + }.map { .copy($0) }, linkerSettings: (value["frameworks"] as? [String] ?? []).map { .linkedFramework($0) } + (value["libraries"] as? [String] ?? []).map { .linkedLibrary($0) } @@ -120,6 +133,34 @@ for (key, value) in products { packageTargets.append(target) } +// The MLX Metal kernel libraries, one per platform slice, shipped as a single +// resource bundle both MLX products share. Kept out of the generic loop above so +// there is one bundle (executorch_backend_mlx_resources.bundle) rather than a +// separate debug copy, and so the release and debug delegates resolve the same +// name. Each slice's MLX binary asks for its own mlx-.metallib. The files +// are produced by scripts/build_apple_frameworks.sh and gitignored, so each is +// included only when present, or a consumer's resolve fails before the build has +// produced them. +let mlxMetallibSlices = ["mlx-ios", "mlx-ios-simulator", "mlx-macos"] +let mlxResourcesDir = ".Package.swift/backend_mlx_resources" +if products.keys.contains("backend_mlx") { + packageTargets.append(.target( + name: "backend_mlx_resources", + path: mlxResourcesDir, + resources: mlxMetallibSlices.compactMap { slice in + FileManager.default.fileExists(atPath: "\(mlxResourcesDir)/\(slice).metallib") + ? .copy("\(slice).metallib") : nil + } + )) + for suffix in ["", debug_suffix] { + if let index = packageTargets.firstIndex(where: { + $0.name == "backend_mlx\(suffix)\(dependencies_suffix)" + }) { + packageTargets[index].dependencies.append(.target(name: "backend_mlx_resources")) + } + } +} + // Test fixtures. add_coreml.pte and add_mul_coreml.pte are generated at CI // time by extension/apple/ExecuTorch/__tests__/resources/generate_coreml_test_models.py // (invoked by scripts/build_apple_frameworks.sh before `swift test`). They @@ -166,7 +207,7 @@ let package = Package( name: "executorch", platforms: [ .iOS(.v17), - .macOS(.v12), + .macOS(.v14), ], products: packageProducts, targets: packageTargets + [ diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index eec7d88e696..abe34903b8b 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -188,7 +188,31 @@ set(_mlx_patches ${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_nax_jit_sdk_gate.patch ${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_qmm_splitk_bk_align.patch ${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_gather_mm_rhs_lda.patch + ${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_metal_sdk_per_platform.patch + ${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_swiftpm_metallib_name.patch ) +# In a framework build the delegate is linked statically into the consumer, so +# MLX cannot find its metallib next to a shared library the way it does in the +# wheel. MLX instead searches loaded bundles for one named by SWIFTPM_BUNDLE, +# which the SwiftPM package ships as a resource bundle. That bundle holds a +# metallib per platform slice, because a metallib built for one slice does not +# load on another, so each slice's binary asks for its own file by name. Compile +# both names in for Apple framework builds, identified by PLATFORM being set by +# ios.toolchain. PLATFORM is absent for the wheel build, which keeps the +# colocated path. +set(_mlx_extra_cxx_flags "") +if(PLATFORM) + if(PLATFORM STREQUAL "OS64") + set(_mlx_metallib_slice "ios") + elseif(PLATFORM STREQUAL "SIMULATORARM64") + set(_mlx_metallib_slice "ios-simulator") + else() + set(_mlx_metallib_slice "macos") + endif() + set(_mlx_extra_cxx_flags + "-DSWIFTPM_BUNDLE=\\\"executorch_backend_mlx_resources\\\" -DMLX_SWIFTPM_METALLIB_NAME=\\\"mlx-${_mlx_metallib_slice}\\\"" + ) +endif() ExternalProject_Add( mlx_external SOURCE_DIR ${MLX_SOURCE_DIR} @@ -197,6 +221,7 @@ ExternalProject_Add( ${MLX_SOURCE_DIR} ${_mlx_patches} CMAKE_ARGS -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD} + "-DCMAKE_CXX_FLAGS=${_mlx_extra_cxx_flags}" -DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} -DPLATFORM=${PLATFORM} diff --git a/backends/mlx/patches/mlx_metal_sdk_per_platform.patch b/backends/mlx/patches/mlx_metal_sdk_per_platform.patch new file mode 100644 index 00000000000..e8983d61844 --- /dev/null +++ b/backends/mlx/patches/mlx_metal_sdk_per_platform.patch @@ -0,0 +1,70 @@ +Select the Metal SDK per target platform. + +MLX compiles and links its Metal shader library with a hardcoded +`xcrun -sdk macosx metal` and a hardcoded `-mmacosx-version-min` flag. ExecuTorch +builds MLX for iOS device, iOS simulator, and macOS from one source tree, so the +hardcoded macOS SDK produces a metallib built for the wrong platform on the iOS +and simulator slices. + +Derive the Metal SDK and the deployment-version flag from PLATFORM (which +ExecuTorch already passes to this build): iphoneos for OS64, iphonesimulator for +SIMULATORARM64, macosx otherwise. This mirrors how the rest of the Apple build +selects its SDK. + +Upstream candidate; carried locally until MLX selects the Metal SDK by platform. + +diff --git a/mlx/backend/metal/kernels/CMakeLists.txt b/mlx/backend/metal/kernels/CMakeLists.txt +index edc169ee..94154f80 100644 +--- a/mlx/backend/metal/kernels/CMakeLists.txt ++++ b/mlx/backend/metal/kernels/CMakeLists.txt +@@ -9,6 +9,21 @@ set(BASE_HEADERS + logging.h + utils.h) + ++# The Metal SDK and deployment flag must follow the target platform. ExecuTorch ++# builds this for iOS device, iOS simulator, and macOS from one source tree and ++# passes PLATFORM in for each. Without this the shaders are always built against ++# the macOS SDK, so the iOS and simulator metallibs are wrong for their slice. ++if(PLATFORM STREQUAL "OS64") ++ set(MLX_METAL_SDK iphoneos) ++ set(MLX_METAL_VERSION_MIN_FLAG "-mios-version-min") ++elseif(PLATFORM STREQUAL "SIMULATORARM64") ++ set(MLX_METAL_SDK iphonesimulator) ++ set(MLX_METAL_VERSION_MIN_FLAG "-mios-simulator-version-min") ++else() ++ set(MLX_METAL_SDK macosx) ++ set(MLX_METAL_VERSION_MIN_FLAG "-mmacosx-version-min") ++endif() ++ + function(build_kernel_base TARGET SRCFILE DEPS) + set(METAL_FLAGS + -x +@@ -26,10 +41,10 @@ function(build_kernel_base TARGET SRCFILE DEPS) + endif() + if(NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL "") + set(METAL_FLAGS ${METAL_FLAGS} +- "-mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}") ++ "${MLX_METAL_VERSION_MIN_FLAG}=${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + add_custom_command( +- COMMAND xcrun -sdk macosx metal ${METAL_FLAGS} -c ${SRCFILE} ++ COMMAND xcrun -sdk ${MLX_METAL_SDK} metal ${METAL_FLAGS} -c ${SRCFILE} + -I${PROJECT_SOURCE_DIR} -o ${TARGET}.air + DEPENDS ${SRCFILE} ${DEPS} ${BASE_HEADERS} + OUTPUT ${TARGET}.air +@@ -180,12 +195,12 @@ endif() + + set(METAL_LINK_FLAGS) + if(NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL "") +- set(METAL_LINK_FLAGS "-mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}") ++ set(METAL_LINK_FLAGS ++ "${MLX_METAL_VERSION_MIN_FLAG}=${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() +- + add_custom_command( + OUTPUT ${MLX_METAL_PATH}/mlx.metallib +- COMMAND xcrun -sdk macosx metal ${METAL_LINK_FLAGS} ${KERNEL_AIR} -o ++ COMMAND xcrun -sdk ${MLX_METAL_SDK} metal ${METAL_LINK_FLAGS} ${KERNEL_AIR} -o + ${MLX_METAL_PATH}/mlx.metallib + DEPENDS ${KERNEL_AIR} + COMMENT "Building mlx.metallib" diff --git a/backends/mlx/patches/mlx_swiftpm_metallib_name.patch b/backends/mlx/patches/mlx_swiftpm_metallib_name.patch new file mode 100644 index 00000000000..6f08cb6578c --- /dev/null +++ b/backends/mlx/patches/mlx_swiftpm_metallib_name.patch @@ -0,0 +1,35 @@ +Make the SwiftPM metallib name a build-time define. + +MLX loads its Metal library from a SwiftPM resource bundle by a fixed name, +"default". ExecuTorch ships one resource bundle that holds a separate metallib for +each Apple platform slice (device, simulator, macOS), because a metallib built for +one slice does not load on another. Each slice's binary therefore has to ask for +its own file. + +Read the name from MLX_SWIFTPM_METALLIB_NAME when defined, falling back to +"default" so a plain MLX build is unchanged. + +Upstream candidate; carried locally until MLX supports a per-slice bundle name. + +diff --git a/mlx/backend/metal/device.cpp b/mlx/backend/metal/device.cpp +index 29eecf55..61a1180f 100644 +--- a/mlx/backend/metal/device.cpp ++++ b/mlx/backend/metal/device.cpp +@@ -217,8 +217,15 @@ MTL::Library* load_default_library(MTL::Device* device) { + return lib; + } + +- // Then try default.metallib in a SwiftPM bundle if we have one +- std::tie(lib, error[2]) = load_swiftpm_library(device, "default"); ++ // Then try the metallib in a SwiftPM bundle if we have one. The name is a ++ // build-time define because ExecuTorch ships one bundle holding a metallib per ++ // platform slice, so each slice's binary must ask for its own file rather than a ++ // single shared "default". Falls back to "default" for a plain MLX build. ++#ifndef MLX_SWIFTPM_METALLIB_NAME ++#define MLX_SWIFTPM_METALLIB_NAME "default" ++#endif ++ std::tie(lib, error[2]) = ++ load_swiftpm_library(device, MLX_SWIFTPM_METALLIB_NAME); + if (lib) { + return lib; + } diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index 329dd97dc74..086ebaed6f0 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -108,6 +108,16 @@ libxnnpack_backend.a,\ libxnnpack-microkernels-prod.a,\ :" +# The MLX backend. libmlx.a is the MLX runtime the delegate binds to, and +# libextension_llm_cache.a carries the off-graph KV-cache registry the backend +# uses; neither ships in any other framework. The Metal kernels are a separate +# mlx.metallib resource handled below, not a static archive. +FRAMEWORK_BACKEND_MLX="backend_mlx:\ +libmlxdelegate.a,\ +libmlx.a,\ +libextension_llm_cache.a,\ +:" + FRAMEWORK_KERNELS_LLM="kernels_llm:\ libcustom_ops.a,\ :" @@ -138,6 +148,7 @@ usage() { echo " --Release Build Release version." echo " --coreml Only build the Core ML backend." echo " --llm Only build the LLM custom kernels." + echo " --mlx Only build the MLX backend." echo " --optimized Only build the Optimized kernels." echo " --quantized Only build the Quantized kernels." echo " --torchao Only build the TorchAO kernels." @@ -158,6 +169,7 @@ set_cmake_options_override() { "-DEXECUTORCH_BUILD_KERNELS_OPTIMIZED=OFF" "-DEXECUTORCH_BUILD_KERNELS_QUANTIZED=OFF" "-DEXECUTORCH_BUILD_KERNELS_TORCHAO=OFF" + "-DEXECUTORCH_BUILD_MLX=OFF" "-DEXECUTORCH_BUILD_XNNPACK=OFF" ) fi @@ -185,6 +197,7 @@ for arg in "$@"; do ;; --coreml) set_cmake_options_override "EXECUTORCH_BUILD_COREML";; --llm) set_cmake_options_override "EXECUTORCH_BUILD_KERNELS_LLM" ;; + --mlx) set_cmake_options_override "EXECUTORCH_BUILD_MLX" ;; --optimized) set_cmake_options_override "EXECUTORCH_BUILD_KERNELS_OPTIMIZED" ;; --quantized) set_cmake_options_override "EXECUTORCH_BUILD_KERNELS_QUANTIZED" ;; --torchao) set_cmake_options_override "EXECUTORCH_BUILD_KERNELS_TORCHAO" ;; @@ -310,15 +323,40 @@ for mode in "${MODES[@]}"; do append_framework_flag "" "$FRAMEWORK_THREADPOOL" "$mode" append_framework_flag "EXECUTORCH_BUILD_COREML" "$FRAMEWORK_BACKEND_COREML" "$mode" append_framework_flag "EXECUTORCH_BUILD_XNNPACK" "$FRAMEWORK_BACKEND_XNNPACK" "$mode" + append_framework_flag "EXECUTORCH_BUILD_MLX" "$FRAMEWORK_BACKEND_MLX" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_LLM" "$FRAMEWORK_KERNELS_LLM" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_OPTIMIZED" "$FRAMEWORK_KERNELS_OPTIMIZED" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_QUANTIZED" "$FRAMEWORK_KERNELS_QUANTIZED" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_TORCHAO" "$FRAMEWORK_KERNELS_TORCHAO" "$mode" - cd "${OUTPUT_DIR}" - "$SOURCE_ROOT_DIR"/scripts/create_frameworks.sh "${FRAMEWORK_FLAGS[@]}" + cd "${OUTPUT_DIR}" + "$SOURCE_ROOT_DIR"/scripts/create_frameworks.sh "${FRAMEWORK_FLAGS[@]}" done +# Ship the MLX Metal kernels as a SwiftPM resource. Unlike the static archives +# merged into the xcframework, the metallib is a data file MLX loads at runtime by +# bundle name. A metallib is platform specific, so one is copied per slice into the +# shared backend_mlx_resources target, where SwiftPM turns them into +# executorch_backend_mlx_resources.bundle. Each slice's MLX binary was compiled to +# ask for its own mlx-.metallib, so all three ship and each binary picks its +# own. +if [[ ! " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then + mlx_resources_dir="$SOURCE_ROOT_DIR/.Package.swift/backend_mlx_resources" + mkdir -p "${mlx_resources_dir}" + for preset_out_dir in "${PRESETS_RELATIVE_OUT_DIR[@]}"; do + mlx_metallib="${OUTPUT_DIR}/${preset_out_dir}/backends/mlx/mlx/mlx/backend/metal/kernels/mlx.metallib" + if [[ -f "${mlx_metallib}" ]]; then + # The metallib name compiled into each slice (see backends/mlx/CMakeLists.txt): + # the simulator preset dir is "simulator" but the slice name is "ios-simulator". + case "${preset_out_dir}" in + simulator) slice="ios-simulator" ;; + *) slice="${preset_out_dir}" ;; + esac + cp "${mlx_metallib}" "${mlx_resources_dir}/mlx-${slice}.metallib" + fi + done +fi + echo "Cleaning up" for preset_out_dir in "${PRESETS_RELATIVE_OUT_DIR[@]}"; do diff --git a/tools/cmake/preset/apple_common.cmake b/tools/cmake/preset/apple_common.cmake index 3e80e3666f8..e720c1910f3 100644 --- a/tools/cmake/preset/apple_common.cmake +++ b/tools/cmake/preset/apple_common.cmake @@ -18,6 +18,10 @@ add_compile_options( set_overridable_option(BUILD_TESTING OFF) set_overridable_option(EXECUTORCH_BUILD_XNNPACK ON) set_overridable_option(EXECUTORCH_BUILD_COREML ON) +# The MLX backend runs models on the Apple GPU through Metal. It builds only for +# Apple Silicon and requires a deployment target of macOS 14 or iOS 17, which +# the Apple presets set. +set_overridable_option(EXECUTORCH_BUILD_MLX ON) set_overridable_option(EXECUTORCH_XNNPACK_ENABLE_WEIGHT_CACHE ON) set_overridable_option(EXECUTORCH_XNNPACK_SHARED_WORKSPACE ON) set_overridable_option(EXECUTORCH_BUILD_EXTENSION_APPLE ON) From ed94c3ba812a84a8aa3c45b5c3882d5129137b9c Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 26 Aug 2026 21:52:43 -0700 Subject: [PATCH 02/28] Suppress the shorten-64-to-32 warning for the MLX delegate Building the MLX delegate for the Apple frameworks compiles it under Xcode's default -Wshorten-64-to-32 with -Werror. The delegate includes the core tensor headers, which carry pre-existing narrowing conversions that trip that pair, so the Apple framework build failed to compile it. The wheel build does not use that warning and was unaffected. Suppress the warning for this target, the same way the XNNPACK and abseil third-party builds already do for the same warning. This is scoped to the delegate and changes no core header. --- backends/mlx/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index abe34903b8b..010d611941f 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -358,6 +358,12 @@ endif() executorch_target_link_options_shared_lib(mlxdelegate) target_compile_options(mlxdelegate PRIVATE ${_common_compile_options}) +# The Apple frameworks build compiles this delegate with Xcode's default +# -Wshorten-64-to-32 and -Werror. It includes the core tensor headers, which +# carry pre-existing narrowing conversions that trip that pair, so suppress it +# here as the XNNPACK and abseil third-party builds already do for the same +# warning. Only meaningful under the Apple toolchain; a no-op elsewhere. +target_compile_options(mlxdelegate PRIVATE -Wno-shorten-64-to-32) if(EXECUTORCH_MLX_ENABLE_SANITIZERS) target_link_options(mlxdelegate PRIVATE ${_mlx_sanitizer_link_options}) endif() From 0046ddcdf63e6a7613b5a772ce78533057a9afcc Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 26 Aug 2026 22:42:10 -0700 Subject: [PATCH 03/28] Build the MLX static library at a config-agnostic path The Apple frameworks build uses the Xcode generator, which is multi-config and placed the MLX static library the delegate links at mlx/Debug/libmlx.a. Everything that consumes it, the imported target, the install, and the framework archive list, expects it flat at mlx/libmlx.a, so linking a binary that pulls in MLX failed with "no such file or directory" for libmlx.a. Force the MLX sub-build to a single-config generator so it emits the archive at the flat path on every parent generator, which is also where the wheel build (a single-config generator) already produces it. Test Plan: Reproduced the failure with a clean Xcode-generator build of the macOS preset: the final executable link failed on the missing flat libmlx.a. With this change the same clean build places libmlx.a at the flat path and the executable links and builds. --- backends/mlx/CMakeLists.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index 010d611941f..fd75466e6de 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -217,6 +217,13 @@ ExternalProject_Add( mlx_external SOURCE_DIR ${MLX_SOURCE_DIR} BINARY_DIR ${_mlx_binary_dir} + # Force a single-config generator for the sub-build. The parent Apple build + # uses the Xcode generator, which is multi-config and would place libmlx.a in + # a per-config subdirectory (mlx/Debug/libmlx.a). The paths below that consume + # the archive expect it flat at ${_mlx_binary_dir}/libmlx.a, the way a + # single-config generator emits it, which is also how the wheel build already + # produces it. + CMAKE_GENERATOR "Unix Makefiles" PATCH_COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/patches/apply.sh ${MLX_SOURCE_DIR} ${_mlx_patches} CMAKE_ARGS -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} From ad2cc22ced18da0348b85152a2ece2dcd53d8b78 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 26 Aug 2026 22:59:03 -0700 Subject: [PATCH 04/28] Fix MLX review findings: iOS deploy target, metallib guard, docs - Build MLX for the correct per-slice deployment target. The iOS toolchain leaves CMAKE_OSX_DEPLOYMENT_TARGET at 12.0 on the iOS slices while the preset's DEPLOYMENT_TARGET holds the real minimum (iOS 17, macOS 14). The shader-flag patch reads the former, so it stamped the metallib with the wrong minimum. Feed the preset value into the sub-build as CMAKE_OSX_DEPLOYMENT_TARGET. - Fail the framework build if a metallib slice is missing. The copy loop skipped a missing slice silently, which would ship a package that resolves and then throws at first device init on that platform only. Assert each enabled slice produced its metallib, matching the framework-set guard. - Update the macOS floor in the docs starter snippet to 14, matching the package bump, so a consumer copying it still resolves. - Clarify the SWIFTPM_BUNDLE / MLX_SWIFTPM_METALLIB_NAME comment: they are a bundle name and a per-slice file name that MLX concatenates, not a fallback chain. Test Plan: Configured the macOS preset with the Xcode generator and confirmed configure and generate complete with these changes. --- backends/mlx/CMakeLists.txt | 26 ++++++++++++++++++++++++-- docs/source/using-executorch-ios.md | 2 +- scripts/build_apple_frameworks.sh | 21 +++++++++++++-------- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index fd75466e6de..6a69d1d6f77 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -199,7 +199,13 @@ set(_mlx_patches # load on another, so each slice's binary asks for its own file by name. Compile # both names in for Apple framework builds, identified by PLATFORM being set by # ios.toolchain. PLATFORM is absent for the wheel build, which keeps the -# colocated path. +# colocated path. For an Apple framework build, compile the delegate to find its +# Metal kernels in the SwiftPM resource bundle. SWIFTPM_BUNDLE is the bundle's +# name and MLX_SWIFTPM_METALLIB_NAME is the per-slice file name inside it; MLX +# concatenates the two, they are not a fallback chain. PLATFORM is set only for +# the Apple presets, so a plain build passes an empty value and uses the +# colocated metallib. The sub-build is a fresh configure that inherits no CXX +# flags, so replacing them is safe. set(_mlx_extra_cxx_flags "") if(PLATFORM) if(PLATFORM STREQUAL "OS64") @@ -213,6 +219,16 @@ if(PLATFORM) "-DSWIFTPM_BUNDLE=\\\"executorch_backend_mlx_resources\\\" -DMLX_SWIFTPM_METALLIB_NAME=\\\"mlx-${_mlx_metallib_slice}\\\"" ) endif() + +# The deployment target to build MLX for. Prefer the preset's DEPLOYMENT_TARGET, +# which holds the correct per-slice minimum, and fall back to the toolchain's +# CMAKE_OSX_DEPLOYMENT_TARGET for a plain build that sets no preset value. +if(DEPLOYMENT_TARGET) + set(_mlx_osx_deployment_target ${DEPLOYMENT_TARGET}) +else() + set(_mlx_osx_deployment_target ${CMAKE_OSX_DEPLOYMENT_TARGET}) +endif() + ExternalProject_Add( mlx_external SOURCE_DIR ${MLX_SOURCE_DIR} @@ -229,7 +245,13 @@ ExternalProject_Add( CMAKE_ARGS -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD} "-DCMAKE_CXX_FLAGS=${_mlx_extra_cxx_flags}" - -DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} + # The preset's DEPLOYMENT_TARGET carries the correct per-slice + # minimum (iOS 17, macOS 14); the ios.toolchain leaves + # CMAKE_OSX_DEPLOYMENT_TARGET at 12.0 on the iOS slices and only + # syncs it for MAC. The shader-flag patch reads + # CMAKE_OSX_DEPLOYMENT_TARGET, so feed the preset value in as that, + # or the iOS metallib is stamped -mios-version-min=12.0. + -DCMAKE_OSX_DEPLOYMENT_TARGET=${_mlx_osx_deployment_target} -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} -DPLATFORM=${PLATFORM} -DDEPLOYMENT_TARGET=${DEPLOYMENT_TARGET} diff --git a/docs/source/using-executorch-ios.md b/docs/source/using-executorch-ios.md index e9d5ae20968..79844e49f64 100644 --- a/docs/source/using-executorch-ios.md +++ b/docs/source/using-executorch-ios.md @@ -53,7 +53,7 @@ let package = Package( name: "YourPackageName", platforms: [ .iOS(.v17), - .macOS(.v12), + .macOS(.v14), ], products: [ .library(name: "YourPackageName", targets: ["YourTargetName"]), diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index 086ebaed6f0..d6280decd3b 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -345,15 +345,20 @@ if [[ ! " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; t mkdir -p "${mlx_resources_dir}" for preset_out_dir in "${PRESETS_RELATIVE_OUT_DIR[@]}"; do mlx_metallib="${OUTPUT_DIR}/${preset_out_dir}/backends/mlx/mlx/mlx/backend/metal/kernels/mlx.metallib" - if [[ -f "${mlx_metallib}" ]]; then - # The metallib name compiled into each slice (see backends/mlx/CMakeLists.txt): - # the simulator preset dir is "simulator" but the slice name is "ios-simulator". - case "${preset_out_dir}" in - simulator) slice="ios-simulator" ;; - *) slice="${preset_out_dir}" ;; - esac - cp "${mlx_metallib}" "${mlx_resources_dir}/mlx-${slice}.metallib" + # The metallib name compiled into each slice (see backends/mlx/CMakeLists.txt): + # the simulator preset dir is "simulator" but the slice name is "ios-simulator". + case "${preset_out_dir}" in + simulator) slice="ios-simulator" ;; + *) slice="${preset_out_dir}" ;; + esac + # A missing metallib means the delegate ships but throws at first device init on + # that platform only, so fail the build here rather than ship a half-populated + # bundle. This is the metallib counterpart of the framework-set guard below. + if [[ ! -f "${mlx_metallib}" ]]; then + echo "error: MLX is enabled but ${mlx_metallib} was not produced for the ${slice} slice" >&2 + exit 1 fi + cp "${mlx_metallib}" "${mlx_resources_dir}/mlx-${slice}.metallib" done fi From 5117b491711f1e8549a6222be17e6509be2198d6 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 26 Aug 2026 23:02:04 -0700 Subject: [PATCH 05/28] Find the MLX metallib bundle from a consumer checkout The resource guard for the MLX Metal kernel bundle checked the files with a path relative to the process working directory. When the package is used as a dependency, the manifest runs with the working directory set to the consumer's root, so the check was false for every slice and the bundle shipped empty, which made MLX fail to find its kernels at first device init. Anchor the check to this manifest's own directory with #filePath, so it holds whether the package is the root or a dependency. Test Plan: Ran swift package dump-package from a different working directory with the metallibs present and confirmed all three slice resources are included, where the relative check returned none. --- Package.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 2a0f5a092b5..49246d99ddd 100644 --- a/Package.swift +++ b/Package.swift @@ -142,11 +142,18 @@ for (key, value) in products { // included only when present, or a consumer's resolve fails before the build has // produced them. let mlxMetallibSlices = ["mlx-ios", "mlx-ios-simulator", "mlx-macos"] -let mlxResourcesDir = ".Package.swift/backend_mlx_resources" +// Anchored to this manifest's own directory, not the process working directory. +// When the package is a dependency, the manifest runs with the cwd set to the +// consumer's root, so a relative check would be false for every slice and ship an +// empty bundle. #filePath is always this file, so the check holds either way. +let mlxResourcesRelDir = ".Package.swift/backend_mlx_resources" +let mlxResourcesDir = + URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent(mlxResourcesRelDir).path if products.keys.contains("backend_mlx") { packageTargets.append(.target( name: "backend_mlx_resources", - path: mlxResourcesDir, + path: mlxResourcesRelDir, resources: mlxMetallibSlices.compactMap { slice in FileManager.default.fileExists(atPath: "\(mlxResourcesDir)/\(slice).metallib") ? .copy("\(slice).metallib") : nil From 3cc24044e3ba3c6007a13068ed10bef4b7b883a4 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 02:09:55 -0700 Subject: [PATCH 06/28] Place libmlx.a where the Apple framework packaging reads it The Apple framework build merges each static library straight from the target output directory without an install step. The MLX runtime archive is produced by the MLX sub-build in its own binary directory, not beside the delegate, so the packaging failed with "File ios/Release/libmlx.a does not exist" while merging the backend_mlx framework. Copy the archive next to the delegate archive after the delegate builds, into the per-config target output directory the packaging reads. The wheel build is unaffected because it installs the archive explicitly. Test Plan: Built the delegate with the Xcode generator and an archive output directory set the way the framework build sets it, and confirmed libmlx.a lands beside libmlxdelegate.a in the per-config directory the packaging merges from. --- backends/mlx/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index 6a69d1d6f77..a9681e3f6f3 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -337,6 +337,21 @@ add_dependencies(mlxdelegate mlx_schema) # Depend on mlx_external directly so libmlx.a exists before mlxdelegate links. add_dependencies(mlxdelegate mlx_external) +# The Apple framework build reads each static library straight from the target +# output directory (it merges them without an install step), but libmlx.a is +# produced by the MLX sub-build in its own binary dir, not next to the delegate. +# Copy it beside libmlxdelegate.a after the delegate builds so the framework +# packaging finds it where it expects. TARGET_FILE_DIR resolves to the +# per-config directory the packaging reads, so this is correct under every +# generator. +add_custom_command( + TARGET mlxdelegate + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_mlx_static_lib} + $/libmlx.a + VERBATIM +) + # Add logging flag if enabled if(ET_MLX_ENABLE_OP_LOGGING) target_compile_definitions(mlxdelegate PRIVATE ET_MLX_ENABLE_OP_LOGGING=1) From 765b7740c5a4da1ae3b5337d6dacb9112fdd4590 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 03:05:16 -0700 Subject: [PATCH 07/28] Split the target initializer so the manifest type-checks The resource filter this change added to the per-product target initializer pushed that single chained expression past the Swift manifest compiler's type-check budget, so the Apple framework build failed to compile Package.swift. Bind the dependency, resource, and linker lists to typed locals first, then build the target from them. Test Plan: swift package dump-package now completes in a few seconds and resolves all 18 products, where the compiler previously reported it could not type-check the expression in reasonable time. --- Package.swift | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/Package.swift b/Package.swift index 49246d99ddd..ead2be1c144 100644 --- a/Package.swift +++ b/Package.swift @@ -114,23 +114,29 @@ for (key, value) in products { name: key, path: "cmake-out/\(key).xcframework" )) - let target: Target = .target( + // Broken into typed sub-expressions because the single chained initializer + // exceeds the Swift manifest compiler's type-check budget. + let targetNames: [String] = [key] + (value["targets"] as? [String] ?? []).map { + key.hasSuffix(debug_suffix) ? $0 + debug_suffix : $0 + } + let dependencies: [Target.Dependency] = targetNames.map { .target(name: $0) } + let resources: [Resource] = (value["resources"] as? [String] ?? []).filter { + // Generated by scripts/build_apple_frameworks.sh and gitignored, so include + // it only when present, the way the test fixtures below do, or a consumer's + // resolve fails before the build script has produced it. + FileManager.default.fileExists(atPath: ".Package.swift/\(key)/\($0)") + }.map { .copy($0) } + let frameworks: [LinkerSetting] = + (value["frameworks"] as? [String] ?? []).map { .linkedFramework($0) } + let libraries: [LinkerSetting] = + (value["libraries"] as? [String] ?? []).map { .linkedLibrary($0) } + packageTargets.append(.target( name: "\(key)\(dependencies_suffix)", - dependencies: ([key] + (value["targets"] as? [String] ?? []).map { - key.hasSuffix(debug_suffix) ? $0 + debug_suffix : $0 - }).map { .target(name: $0) }, + dependencies: dependencies, path: ".Package.swift/\(key)", - resources: (value["resources"] as? [String] ?? []).filter { - // Generated by scripts/build_apple_frameworks.sh and gitignored, so - // include it only when present, the way the test fixtures below do, or a - // consumer's resolve fails before the build script has produced it. - FileManager.default.fileExists(atPath: ".Package.swift/\(key)/\($0)") - }.map { .copy($0) }, - linkerSettings: - (value["frameworks"] as? [String] ?? []).map { .linkedFramework($0) } + - (value["libraries"] as? [String] ?? []).map { .linkedLibrary($0) } - ) - packageTargets.append(target) + resources: resources, + linkerSettings: frameworks + libraries + )) } // The MLX Metal kernel libraries, one per platform slice, shipped as a single From da23b0d8e6de7285c555c25c991851507390a6c4 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 08:07:42 -0700 Subject: [PATCH 08/28] Link libc++ into the test bundles The Swift test bundles force-load C++ static archives, so they need libc++. They never declared it, and below macOS 13 a Swift back-deployment shim pulled it in by accident. Raising the package floor to macOS 14 drops that shim, so the test link failed with many undefined C++ standard library symbols. Link libc++ explicitly in the shared test linker settings, which covers every test target. A product's libraries do not reach a test target, since a test bundle is its own linked image. Test Plan: Reproduced with a test target that force-loads a C++ archive: it links at macOS 12 and fails at macOS 13 and above, and linking libc++ makes it pass again. --- Package.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Package.swift b/Package.swift index ead2be1c144..43fb0d154fc 100644 --- a/Package.swift +++ b/Package.swift @@ -205,6 +205,12 @@ if FileManager.default.fileExists(atPath: "\(objcTestsDir)/add_mul_coreml.pte") } let testLinkerSettings: [LinkerSetting] = [ + // The test bundles force-load C++ static archives, so they must link libc++. + // The executorch product declares this, but a test target is its own linked + // image and does not inherit a product's linker settings. Below macOS 13 a + // Swift back-deployment shim pulled libc++ in by accident; the macOS 14 floor + // this package now sets drops that shim, so link it explicitly. + .linkedLibrary("c++"), .unsafeFlags([ "-Xlinker", "-force_load", "-Xlinker", "cmake-out/kernels_optimized.xcframework/macos-arm64/libkernels_optimized_macos.a", From ed7bf6651817154c18f8613f6587a7a3e15c817b Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 27 Aug 2026 09:51:20 -0700 Subject: [PATCH 09/28] Address review: sort backend_mlx in the framework lists, trim comments, drop redundant Foundation Sort backend_mlx between backend_coreml and backend_xnnpack in the apple.yml FRAMEWORKS list and in both the definition and append order in build_apple_frameworks.sh, matching the rest of the backend group. Drop the Foundation linked framework from the backend_mlx product. It links by default on Apple platforms and no other product lists it. Trim the added comments to match the surrounding style: the sibling framework entries carry none, and the longer blocks repeated what the code already shows. --- .github/workflows/apple.yml | 11 +++------ Package.swift | 31 +++++++----------------- backends/mlx/CMakeLists.txt | 32 +++++++------------------ scripts/build_apple_frameworks.sh | 34 +++++++++------------------ tools/cmake/preset/apple_common.cmake | 3 --- 5 files changed, 31 insertions(+), 80 deletions(-) diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index c75cf888c1c..1788098f765 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -192,8 +192,8 @@ jobs: "executorch" "executorch_llm" "backend_coreml" - "backend_xnnpack" "backend_mlx" + "backend_xnnpack" "kernels_llm" "kernels_optimized" "kernels_quantized" @@ -221,9 +221,7 @@ jobs: zip -r "${RUNNER_TEMP}/artifacts/${FRAMEWORK}_debug-${VERSION}.zip" "${FRAMEWORK}_debug.xcframework" ) done - # The MLX Metal kernel libraries are data files, not part of any - # xcframework, so carry them in the artifact for the SwiftPM update job to - # commit onto the package branch beside the manifest. + # MLX metallibs are data files, so carry them in the artifact for the SwiftPM job to commit. if [ -d .Package.swift/backend_mlx_resources ]; then mkdir -p "${RUNNER_TEMP}/artifacts/backend_mlx_resources" cp .Package.swift/backend_mlx_resources/*.metallib \ @@ -341,10 +339,7 @@ jobs: git config --global user.name "PyTorch Bot" git config --global user.email "pytorchbot@users.noreply.github.com" - # Carry the MLX Metal kernel libraries onto the package branch beside - # the manifest. They are data files the MLX product loads at runtime, - # not part of any xcframework, so they travel with the package source - # rather than an S3 zip. + # Commit the MLX metallibs onto the package branch beside the manifest. if [ -d "${RUNNER_TEMP}/frameworks-ios/backend_mlx_resources" ]; then mkdir -p .Package.swift/backend_mlx_resources cp "${RUNNER_TEMP}"/frameworks-ios/backend_mlx_resources/*.metallib \ diff --git a/Package.swift b/Package.swift index 43fb0d154fc..e0e813ce555 100644 --- a/Package.swift +++ b/Package.swift @@ -56,7 +56,6 @@ let products = deliverables([ "backend_mlx": [ "frameworks": [ "Metal", - "Foundation", "QuartzCore", ], ], @@ -114,16 +113,13 @@ for (key, value) in products { name: key, path: "cmake-out/\(key).xcframework" )) - // Broken into typed sub-expressions because the single chained initializer - // exceeds the Swift manifest compiler's type-check budget. + // Split into typed locals to stay under the manifest type-check budget. let targetNames: [String] = [key] + (value["targets"] as? [String] ?? []).map { key.hasSuffix(debug_suffix) ? $0 + debug_suffix : $0 } let dependencies: [Target.Dependency] = targetNames.map { .target(name: $0) } let resources: [Resource] = (value["resources"] as? [String] ?? []).filter { - // Generated by scripts/build_apple_frameworks.sh and gitignored, so include - // it only when present, the way the test fixtures below do, or a consumer's - // resolve fails before the build script has produced it. + // Gitignored until the build script produces them; skip a missing one so resolve does not fail. FileManager.default.fileExists(atPath: ".Package.swift/\(key)/\($0)") }.map { .copy($0) } let frameworks: [LinkerSetting] = @@ -139,19 +135,12 @@ for (key, value) in products { )) } -// The MLX Metal kernel libraries, one per platform slice, shipped as a single -// resource bundle both MLX products share. Kept out of the generic loop above so -// there is one bundle (executorch_backend_mlx_resources.bundle) rather than a -// separate debug copy, and so the release and debug delegates resolve the same -// name. Each slice's MLX binary asks for its own mlx-.metallib. The files -// are produced by scripts/build_apple_frameworks.sh and gitignored, so each is -// included only when present, or a consumer's resolve fails before the build has -// produced them. +// One resource bundle shared by both MLX products (release and debug) so they +// resolve the same bundle name, with a per-slice metallib since one slice's does +// not load on another. Kept out of the loop above to avoid a per-debug copy. let mlxMetallibSlices = ["mlx-ios", "mlx-ios-simulator", "mlx-macos"] -// Anchored to this manifest's own directory, not the process working directory. -// When the package is a dependency, the manifest runs with the cwd set to the -// consumer's root, so a relative check would be false for every slice and ship an -// empty bundle. #filePath is always this file, so the check holds either way. +// Anchor to this file's own directory: as a dependency the manifest runs with the +// consumer's cwd, where a relative path would miss every slice and ship an empty bundle. let mlxResourcesRelDir = ".Package.swift/backend_mlx_resources" let mlxResourcesDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() @@ -205,11 +194,7 @@ if FileManager.default.fileExists(atPath: "\(objcTestsDir)/add_mul_coreml.pte") } let testLinkerSettings: [LinkerSetting] = [ - // The test bundles force-load C++ static archives, so they must link libc++. - // The executorch product declares this, but a test target is its own linked - // image and does not inherit a product's linker settings. Below macOS 13 a - // Swift back-deployment shim pulled libc++ in by accident; the macOS 14 floor - // this package now sets drops that shim, so link it explicitly. + // Test targets do not inherit the executorch product's libc++ link, and the macOS 14 floor drops the shim that used to supply it. .linkedLibrary("c++"), .unsafeFlags([ "-Xlinker", "-force_load", diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index a9681e3f6f3..c139b2c225f 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -191,21 +191,12 @@ set(_mlx_patches ${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_metal_sdk_per_platform.patch ${CMAKE_CURRENT_SOURCE_DIR}/patches/mlx_swiftpm_metallib_name.patch ) -# In a framework build the delegate is linked statically into the consumer, so -# MLX cannot find its metallib next to a shared library the way it does in the -# wheel. MLX instead searches loaded bundles for one named by SWIFTPM_BUNDLE, -# which the SwiftPM package ships as a resource bundle. That bundle holds a -# metallib per platform slice, because a metallib built for one slice does not -# load on another, so each slice's binary asks for its own file by name. Compile -# both names in for Apple framework builds, identified by PLATFORM being set by -# ios.toolchain. PLATFORM is absent for the wheel build, which keeps the -# colocated path. For an Apple framework build, compile the delegate to find its -# Metal kernels in the SwiftPM resource bundle. SWIFTPM_BUNDLE is the bundle's -# name and MLX_SWIFTPM_METALLIB_NAME is the per-slice file name inside it; MLX -# concatenates the two, they are not a fallback chain. PLATFORM is set only for -# the Apple presets, so a plain build passes an empty value and uses the -# colocated metallib. The sub-build is a fresh configure that inherits no CXX -# flags, so replacing them is safe. +# In a framework build the delegate is static, so MLX cannot find a colocated +# metallib and instead loads one from a SwiftPM resource bundle. SWIFTPM_BUNDLE +# is the bundle name and MLX_SWIFTPM_METALLIB_NAME the per-slice file inside it +# (one per slice, since a slice's metallib does not load on another). PLATFORM is +# set only for the Apple presets; a plain wheel build leaves it empty and keeps +# the colocated path. set(_mlx_extra_cxx_flags "") if(PLATFORM) if(PLATFORM STREQUAL "OS64") @@ -220,9 +211,7 @@ if(PLATFORM) ) endif() -# The deployment target to build MLX for. Prefer the preset's DEPLOYMENT_TARGET, -# which holds the correct per-slice minimum, and fall back to the toolchain's -# CMAKE_OSX_DEPLOYMENT_TARGET for a plain build that sets no preset value. +# Prefer the preset's per-slice DEPLOYMENT_TARGET; fall back to the toolchain value. if(DEPLOYMENT_TARGET) set(_mlx_osx_deployment_target ${DEPLOYMENT_TARGET}) else() @@ -402,11 +391,8 @@ endif() executorch_target_link_options_shared_lib(mlxdelegate) target_compile_options(mlxdelegate PRIVATE ${_common_compile_options}) -# The Apple frameworks build compiles this delegate with Xcode's default -# -Wshorten-64-to-32 and -Werror. It includes the core tensor headers, which -# carry pre-existing narrowing conversions that trip that pair, so suppress it -# here as the XNNPACK and abseil third-party builds already do for the same -# warning. Only meaningful under the Apple toolchain; a no-op elsewhere. +# Core tensor headers carry pre-existing narrowing conversions that trip Xcode's +# -Wshorten-64-to-32 -Werror; suppress it here as XNNPACK and abseil already do. target_compile_options(mlxdelegate PRIVATE -Wno-shorten-64-to-32) if(EXECUTORCH_MLX_ENABLE_SANITIZERS) target_link_options(mlxdelegate PRIVATE ${_mlx_sanitizer_link_options}) diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index d6280decd3b..f192cb56593 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -101,6 +101,12 @@ libcoreml_inmemoryfs.a,\ libcoremldelegate.a,\ :" +FRAMEWORK_BACKEND_MLX="backend_mlx:\ +libmlxdelegate.a,\ +libmlx.a,\ +libextension_llm_cache.a,\ +:" + FRAMEWORK_BACKEND_XNNPACK="backend_xnnpack:\ libXNNPACK.a,\ libkleidiai.a,\ @@ -108,16 +114,6 @@ libxnnpack_backend.a,\ libxnnpack-microkernels-prod.a,\ :" -# The MLX backend. libmlx.a is the MLX runtime the delegate binds to, and -# libextension_llm_cache.a carries the off-graph KV-cache registry the backend -# uses; neither ships in any other framework. The Metal kernels are a separate -# mlx.metallib resource handled below, not a static archive. -FRAMEWORK_BACKEND_MLX="backend_mlx:\ -libmlxdelegate.a,\ -libmlx.a,\ -libextension_llm_cache.a,\ -:" - FRAMEWORK_KERNELS_LLM="kernels_llm:\ libcustom_ops.a,\ :" @@ -322,8 +318,8 @@ for mode in "${MODES[@]}"; do append_framework_flag "" "$FRAMEWORK_EXECUTORCH_LLM" "$mode" append_framework_flag "" "$FRAMEWORK_THREADPOOL" "$mode" append_framework_flag "EXECUTORCH_BUILD_COREML" "$FRAMEWORK_BACKEND_COREML" "$mode" - append_framework_flag "EXECUTORCH_BUILD_XNNPACK" "$FRAMEWORK_BACKEND_XNNPACK" "$mode" append_framework_flag "EXECUTORCH_BUILD_MLX" "$FRAMEWORK_BACKEND_MLX" "$mode" + append_framework_flag "EXECUTORCH_BUILD_XNNPACK" "$FRAMEWORK_BACKEND_XNNPACK" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_LLM" "$FRAMEWORK_KERNELS_LLM" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_OPTIMIZED" "$FRAMEWORK_KERNELS_OPTIMIZED" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_QUANTIZED" "$FRAMEWORK_KERNELS_QUANTIZED" "$mode" @@ -333,27 +329,19 @@ for mode in "${MODES[@]}"; do "$SOURCE_ROOT_DIR"/scripts/create_frameworks.sh "${FRAMEWORK_FLAGS[@]}" done -# Ship the MLX Metal kernels as a SwiftPM resource. Unlike the static archives -# merged into the xcframework, the metallib is a data file MLX loads at runtime by -# bundle name. A metallib is platform specific, so one is copied per slice into the -# shared backend_mlx_resources target, where SwiftPM turns them into -# executorch_backend_mlx_resources.bundle. Each slice's MLX binary was compiled to -# ask for its own mlx-.metallib, so all three ship and each binary picks its -# own. +# The MLX Metal kernels ship as a per-slice metallib in a shared SwiftPM resource +# bundle, not merged into the xcframework, since MLX loads them at runtime by name. if [[ ! " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then mlx_resources_dir="$SOURCE_ROOT_DIR/.Package.swift/backend_mlx_resources" mkdir -p "${mlx_resources_dir}" for preset_out_dir in "${PRESETS_RELATIVE_OUT_DIR[@]}"; do mlx_metallib="${OUTPUT_DIR}/${preset_out_dir}/backends/mlx/mlx/mlx/backend/metal/kernels/mlx.metallib" - # The metallib name compiled into each slice (see backends/mlx/CMakeLists.txt): - # the simulator preset dir is "simulator" but the slice name is "ios-simulator". + # The simulator preset dir is "simulator" but the compiled-in slice name is "ios-simulator". case "${preset_out_dir}" in simulator) slice="ios-simulator" ;; *) slice="${preset_out_dir}" ;; esac - # A missing metallib means the delegate ships but throws at first device init on - # that platform only, so fail the build here rather than ship a half-populated - # bundle. This is the metallib counterpart of the framework-set guard below. + # Fail loudly rather than ship a bundle missing a slice, which would only fault at device init. if [[ ! -f "${mlx_metallib}" ]]; then echo "error: MLX is enabled but ${mlx_metallib} was not produced for the ${slice} slice" >&2 exit 1 diff --git a/tools/cmake/preset/apple_common.cmake b/tools/cmake/preset/apple_common.cmake index e720c1910f3..cfcb5dcf3d2 100644 --- a/tools/cmake/preset/apple_common.cmake +++ b/tools/cmake/preset/apple_common.cmake @@ -18,9 +18,6 @@ add_compile_options( set_overridable_option(BUILD_TESTING OFF) set_overridable_option(EXECUTORCH_BUILD_XNNPACK ON) set_overridable_option(EXECUTORCH_BUILD_COREML ON) -# The MLX backend runs models on the Apple GPU through Metal. It builds only for -# Apple Silicon and requires a deployment target of macOS 14 or iOS 17, which -# the Apple presets set. set_overridable_option(EXECUTORCH_BUILD_MLX ON) set_overridable_option(EXECUTORCH_XNNPACK_ENABLE_WEIGHT_CACHE ON) set_overridable_option(EXECUTORCH_XNNPACK_SHARED_WORKSPACE ON) From 82e0bd03613299f8d79d24631a3b8a2b9dc2fd2c Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 14:04:29 -0700 Subject: [PATCH 10/28] Ship the Release MLX metallib, and document the backend and platform floor The Apple framework build compiles each preset twice, once in Release and once in Debug, into the same directory. The step that copies the MLX Metal kernel file into the SwiftPM resource bundle ran after both, so it always shipped the Debug kernels. Copy the file during the Release pass instead, before the Debug pass overwrites it, so the published bundle carries the Release kernels. Also update the iOS docs: add the MLX backend to the list of shipped frameworks, which had every backend but this one, and state the iOS 17 and macOS 14 minimum so a consumer knows the required platform versions before resolving the package. Test Plan: Ran the Apple framework build and confirmed the resource bundle receives the Release metallib for each slice, and that a build with MLX turned off still skips it. Verified the capture picks Release across mode orderings and the Debug-only fallback with a shell trace. --- docs/source/using-executorch-ios.md | 5 +++ scripts/build_apple_frameworks.sh | 70 +++++++++++++++++++---------- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/docs/source/using-executorch-ios.md b/docs/source/using-executorch-ios.md index 79844e49f64..df0c94fb0dd 100644 --- a/docs/source/using-executorch-ios.md +++ b/docs/source/using-executorch-ios.md @@ -9,6 +9,7 @@ The ExecuTorch Runtime for iOS and macOS (ARM64) is distributed as a collection * `executorch` - Core runtime components * `executorch_llm` - LLM-specific runtime components * `backend_coreml` - Core ML backend +* `backend_mlx` - MLX backend * `backend_xnnpack` - XNNPACK backend * `kernels_llm` - Custom kernels for LLMs * `kernels_optimized` - Accelerated generic CPU kernels @@ -79,6 +80,10 @@ let package = Package( ) ``` +The ExecuTorch package requires a minimum of iOS 17 and macOS 14. Your package +has to declare at least these versions, as shown above, or resolving the +dependency fails. + Then check if everything works correctly: ```bash diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index f192cb56593..663c4d833c5 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -210,8 +210,47 @@ if [[ ${#MODES[@]} -eq 0 ]]; then MODES=("Release" "Debug") fi +# The MLX Metal kernels ship as a per-slice metallib in a shared SwiftPM resource +# bundle, not merged into the xcframework, since MLX loads them at runtime by name. +# Capture it during the build loop: both modes build into the same directory, so +# the Debug pass overwrites the Release metallib, and the shipped kernels should be +# the Release build. +capture_mlx_metallib() { + local preset_out_dir="$1" + if [[ " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then + return + fi + local mlx_resources_dir="$SOURCE_ROOT_DIR/.Package.swift/backend_mlx_resources" + mkdir -p "${mlx_resources_dir}" + local mlx_metallib="${OUTPUT_DIR}/${preset_out_dir}/backends/mlx/mlx/mlx/backend/metal/kernels/mlx.metallib" + # The simulator preset dir is "simulator" but the compiled-in slice name is "ios-simulator". + local slice + case "${preset_out_dir}" in + simulator) slice="ios-simulator" ;; + *) slice="${preset_out_dir}" ;; + esac + # Fail loudly rather than ship a bundle missing a slice, which would only fault at device init. + if [[ ! -f "${mlx_metallib}" ]]; then + echo "error: MLX is enabled but ${mlx_metallib} was not produced for the ${slice} slice" >&2 + exit 1 + fi + cp "${mlx_metallib}" "${mlx_resources_dir}/mlx-${slice}.metallib" +} + echo "Building libraries" +# The MLX metallib should be captured from the Release build. Both modes build +# into the same directory, so a later Debug pass would overwrite it. Prefer +# Release; if it is not being built, fall back to the last mode in the list. +MLX_CAPTURE_MODE="${MODES[0]}" +for mode in "${MODES[@]}"; do + if [[ "${mode}" == "Release" ]]; then + MLX_CAPTURE_MODE="Release" + break + fi + MLX_CAPTURE_MODE="${mode}" +done + rm -rf "${OUTPUT_DIR}" for preset_index in "${!PRESETS[@]}"; do preset="${PRESETS[$preset_index]}" @@ -231,6 +270,12 @@ for preset_index in "${!PRESETS[@]}"; do cmake --build "${preset_output_dir}" \ --config "${mode}" + + # Capture the metallib on the chosen pass, before a later pass building into + # the same directory overwrites it. + if [[ "${mode}" == "${MLX_CAPTURE_MODE}" ]]; then + capture_mlx_metallib "${PRESETS_RELATIVE_OUT_DIR[$preset_index]}" + fi done done @@ -325,31 +370,10 @@ for mode in "${MODES[@]}"; do append_framework_flag "EXECUTORCH_BUILD_KERNELS_QUANTIZED" "$FRAMEWORK_KERNELS_QUANTIZED" "$mode" append_framework_flag "EXECUTORCH_BUILD_KERNELS_TORCHAO" "$FRAMEWORK_KERNELS_TORCHAO" "$mode" - cd "${OUTPUT_DIR}" - "$SOURCE_ROOT_DIR"/scripts/create_frameworks.sh "${FRAMEWORK_FLAGS[@]}" + cd "${OUTPUT_DIR}" + "$SOURCE_ROOT_DIR"/scripts/create_frameworks.sh "${FRAMEWORK_FLAGS[@]}" done -# The MLX Metal kernels ship as a per-slice metallib in a shared SwiftPM resource -# bundle, not merged into the xcframework, since MLX loads them at runtime by name. -if [[ ! " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then - mlx_resources_dir="$SOURCE_ROOT_DIR/.Package.swift/backend_mlx_resources" - mkdir -p "${mlx_resources_dir}" - for preset_out_dir in "${PRESETS_RELATIVE_OUT_DIR[@]}"; do - mlx_metallib="${OUTPUT_DIR}/${preset_out_dir}/backends/mlx/mlx/mlx/backend/metal/kernels/mlx.metallib" - # The simulator preset dir is "simulator" but the compiled-in slice name is "ios-simulator". - case "${preset_out_dir}" in - simulator) slice="ios-simulator" ;; - *) slice="${preset_out_dir}" ;; - esac - # Fail loudly rather than ship a bundle missing a slice, which would only fault at device init. - if [[ ! -f "${mlx_metallib}" ]]; then - echo "error: MLX is enabled but ${mlx_metallib} was not produced for the ${slice} slice" >&2 - exit 1 - fi - cp "${mlx_metallib}" "${mlx_resources_dir}/mlx-${slice}.metallib" - done -fi - echo "Cleaning up" for preset_out_dir in "${PRESETS_RELATIVE_OUT_DIR[@]}"; do From 0fbaa886a7ef8077ef33e6c54a878a47ad17d8fe Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 27 Aug 2026 14:10:55 -0700 Subject: [PATCH 11/28] Rewrap two comment lines to satisfy cmake-format The comment trims in the previous review pass left two lines over the 80 column limit, which lintrunner flags as a CMAKEFORMAT warning. --- backends/mlx/CMakeLists.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index c139b2c225f..bec66032404 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -194,9 +194,9 @@ set(_mlx_patches # In a framework build the delegate is static, so MLX cannot find a colocated # metallib and instead loads one from a SwiftPM resource bundle. SWIFTPM_BUNDLE # is the bundle name and MLX_SWIFTPM_METALLIB_NAME the per-slice file inside it -# (one per slice, since a slice's metallib does not load on another). PLATFORM is -# set only for the Apple presets; a plain wheel build leaves it empty and keeps -# the colocated path. +# (one per slice, since a slice's metallib does not load on another). PLATFORM +# is set only for the Apple presets; a plain wheel build leaves it empty and +# keeps the colocated path. set(_mlx_extra_cxx_flags "") if(PLATFORM) if(PLATFORM STREQUAL "OS64") @@ -211,7 +211,8 @@ if(PLATFORM) ) endif() -# Prefer the preset's per-slice DEPLOYMENT_TARGET; fall back to the toolchain value. +# Prefer the preset's per-slice DEPLOYMENT_TARGET; fall back to the toolchain +# value. if(DEPLOYMENT_TARGET) set(_mlx_osx_deployment_target ${DEPLOYMENT_TARGET}) else() From 429564cbf0e17f33dd039f1a4935a906e7358493 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 27 Aug 2026 15:01:19 -0700 Subject: [PATCH 12/28] Restore the Foundation linked framework for the MLX product Removing it in the previous review pass was wrong. The MLX delegate's ObjC++ Metal code calls into Foundation, and a static archive inside an xcframework carries no autolink hints for the consumer's link line, so the framework has to be named explicitly. Linking Metal alone leaves _NSClassFromString and ___CFConstantStringClassReference undefined. This also brings the manifest back in line with the delegate's own CMake, which finds and links Foundation, and with the SwiftPM template. --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index e0e813ce555..e5e61704e30 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,7 @@ let products = deliverables([ "backend_mlx": [ "frameworks": [ "Metal", + "Foundation", "QuartzCore", ], ], From ee6e36ed55fea95e2b52d333666a92a25d2ef159 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 15:55:50 -0700 Subject: [PATCH 13/28] Do not clear the MLX sub-build's CXX flags on non-Apple builds The MLX external build was always passed -DCMAKE_CXX_FLAGS=, even when the value was empty, which is every non-Apple build including the wheel. An empty -DCMAKE_CXX_FLAGS= on the command line overrides the environment, so a wheel build's CXXFLAGS were silently dropped for MLX only. Pass the argument just for the Apple presets, where it carries the resource-bundle defines; leave it off otherwise so the environment is honored. Test Plan: Configured a child project with CXXFLAGS set in the environment and confirmed the flags survive when the argument is omitted and are cleared when an empty one is passed. Confirmed the argument expands to one entry on Apple and drops out cleanly elsewhere. --- backends/mlx/CMakeLists.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index bec66032404..0fa3b40f4f7 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -198,6 +198,7 @@ set(_mlx_patches # is set only for the Apple presets; a plain wheel build leaves it empty and # keeps the colocated path. set(_mlx_extra_cxx_flags "") +set(_mlx_cxx_flags_arg "") if(PLATFORM) if(PLATFORM STREQUAL "OS64") set(_mlx_metallib_slice "ios") @@ -209,6 +210,10 @@ if(PLATFORM) set(_mlx_extra_cxx_flags "-DSWIFTPM_BUNDLE=\\\"executorch_backend_mlx_resources\\\" -DMLX_SWIFTPM_METALLIB_NAME=\\\"mlx-${_mlx_metallib_slice}\\\"" ) + # Only override the sub-build's CXX flags when there is something to add. An + # empty -DCMAKE_CXX_FLAGS= on the command line beats the environment, so passing + # it unconditionally would silently drop a wheel build's CXXFLAGS for MLX only. + set(_mlx_cxx_flags_arg "-DCMAKE_CXX_FLAGS=${_mlx_extra_cxx_flags}") endif() # Prefer the preset's per-slice DEPLOYMENT_TARGET; fall back to the toolchain @@ -234,7 +239,7 @@ ExternalProject_Add( ${MLX_SOURCE_DIR} ${_mlx_patches} CMAKE_ARGS -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD} - "-DCMAKE_CXX_FLAGS=${_mlx_extra_cxx_flags}" + ${_mlx_cxx_flags_arg} # The preset's DEPLOYMENT_TARGET carries the correct per-slice # minimum (iOS 17, macOS 14); the ios.toolchain leaves # CMAKE_OSX_DEPLOYMENT_TARGET at 12.0 on the iOS slices and only From aba2597620a0d723d7e6f2ca81a1e01511606880 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 16:10:08 -0700 Subject: [PATCH 14/28] Address review nits on the MLX Apple build Three small corrections found in review. The iOS docs said a too-low platform version makes dependency resolution fail; it is the build that fails, with a message that the target's platform version is too low. Reword to match. The comment on the libmlx.a copy claimed it is correct under every generator; it relies on the multi-config Xcode generator the Apple presets use, so say that. The captured metallib lives outside the build output directory, so the top-level clean does not remove it. Remove any previous copy before writing the new one so a stale metallib cannot survive into a later build and ship. Test Plan: Ran cmake-format and bash -n on the changed files. Confirmed the metallib capture overwrites cleanly and the docs render. --- backends/mlx/CMakeLists.txt | 6 +++--- docs/source/using-executorch-ios.md | 5 +++-- scripts/build_apple_frameworks.sh | 4 ++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index 0fa3b40f4f7..36c55b7e5b3 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -336,9 +336,9 @@ add_dependencies(mlxdelegate mlx_external) # output directory (it merges them without an install step), but libmlx.a is # produced by the MLX sub-build in its own binary dir, not next to the delegate. # Copy it beside libmlxdelegate.a after the delegate builds so the framework -# packaging finds it where it expects. TARGET_FILE_DIR resolves to the -# per-config directory the packaging reads, so this is correct under every -# generator. +# packaging finds it where it expects. Under the multi-config Xcode generator +# the Apple presets use, TARGET_FILE_DIR resolves to the per-config directory +# the packaging reads (/Release), which is what this relies on. add_custom_command( TARGET mlxdelegate POST_BUILD diff --git a/docs/source/using-executorch-ios.md b/docs/source/using-executorch-ios.md index df0c94fb0dd..1ecabef715b 100644 --- a/docs/source/using-executorch-ios.md +++ b/docs/source/using-executorch-ios.md @@ -81,8 +81,9 @@ let package = Package( ``` The ExecuTorch package requires a minimum of iOS 17 and macOS 14. Your package -has to declare at least these versions, as shown above, or resolving the -dependency fails. +has to declare at least these versions, as shown above. If it declares a lower +one, the dependency resolves but the build then fails with a message that the +target's platform version is too low. Then check if everything works correctly: diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index 663c4d833c5..35db4352561 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -234,6 +234,10 @@ capture_mlx_metallib() { echo "error: MLX is enabled but ${mlx_metallib} was not produced for the ${slice} slice" >&2 exit 1 fi + # The destination lives outside OUTPUT_DIR, so the top-level rm -rf does not + # reach it; drop any previous copy so a stale metallib cannot survive into this + # build and ship. + rm -f "${mlx_resources_dir}/mlx-${slice}.metallib" cp "${mlx_metallib}" "${mlx_resources_dir}/mlx-${slice}.metallib" } From fb4e8b7c6639c9d4105b33074b7cbcd6f98996e7 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 17:47:51 -0700 Subject: [PATCH 15/28] Keep the MLX sub-build optimized and always patched Two problems with the MLX external build, both from forcing it to a single-config generator. A single-config generator reads CMAKE_BUILD_TYPE, not the --config passed to the parent build. The Apple presets do not set CMAKE_BUILD_TYPE, so a plain `cmake --preset ios && cmake --build cmake-out --config Release` compiled MLX with no optimization flags at all. Default the sub-build to Release when the parent left the type empty; the framework script and the wheel set it explicitly and are unaffected. The patch step is stamped, and BUILD_ALWAYS re-runs only the build step, so a reused build directory whose MLX source was reset would recompile an unpatched MLX and silently drop the iOS Metal SDK selection and the SwiftPM metallib name. Re-apply the patches on every configure through an always-run step; apply.sh is idempotent and skips patches already applied. Test Plan: Confirmed the build-type default resolves to Release when CMAKE_BUILD_TYPE is empty, and that the always-run patch step is accepted by a real cmake configure. --- backends/mlx/CMakeLists.txt | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index 36c55b7e5b3..9a2d280f478 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -211,8 +211,9 @@ if(PLATFORM) "-DSWIFTPM_BUNDLE=\\\"executorch_backend_mlx_resources\\\" -DMLX_SWIFTPM_METALLIB_NAME=\\\"mlx-${_mlx_metallib_slice}\\\"" ) # Only override the sub-build's CXX flags when there is something to add. An - # empty -DCMAKE_CXX_FLAGS= on the command line beats the environment, so passing - # it unconditionally would silently drop a wheel build's CXXFLAGS for MLX only. + # empty -DCMAKE_CXX_FLAGS= on the command line beats the environment, so + # passing it unconditionally would silently drop a wheel build's CXXFLAGS for + # MLX only. set(_mlx_cxx_flags_arg "-DCMAKE_CXX_FLAGS=${_mlx_extra_cxx_flags}") endif() @@ -224,6 +225,17 @@ else() set(_mlx_osx_deployment_target ${CMAKE_OSX_DEPLOYMENT_TARGET}) endif() +# The sub-build is forced to a single-config generator (Unix Makefiles), which +# reads CMAKE_BUILD_TYPE, not the --config passed to the parent. The Apple +# presets do not set CMAKE_BUILD_TYPE, so without a default MLX would compile +# unoptimized (no -O3 -DNDEBUG) even for a release framework. Default to Release +# when the parent left it empty; build_apple_frameworks.sh and the wheel set it +# explicitly and are unaffected. +set(_mlx_build_type ${CMAKE_BUILD_TYPE}) +if(NOT _mlx_build_type) + set(_mlx_build_type Release) +endif() + ExternalProject_Add( mlx_external SOURCE_DIR ${MLX_SOURCE_DIR} @@ -235,9 +247,7 @@ ExternalProject_Add( # single-config generator emits it, which is also how the wheel build already # produces it. CMAKE_GENERATOR "Unix Makefiles" - PATCH_COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/patches/apply.sh - ${MLX_SOURCE_DIR} ${_mlx_patches} - CMAKE_ARGS -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} + CMAKE_ARGS -DCMAKE_BUILD_TYPE=${_mlx_build_type} -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD} ${_mlx_cxx_flags_arg} # The preset's DEPLOYMENT_TARGET carries the correct per-slice @@ -275,6 +285,20 @@ ExternalProject_Add( BUILD_BYPRODUCTS ${_mlx_static_lib} ${_mlx_metallib} ) +# ExternalProject stamps the patch step and BUILD_ALWAYS does not re-run it, so a +# reused build directory whose MLX source was reset (patches reverted) would +# recompile an unpatched MLX and silently drop the iOS Metal SDK selection and the +# SwiftPM metallib name. Re-apply the patches on every configure; apply.sh is +# idempotent (it reverse-checks each patch and skips the ones already applied). +ExternalProject_Add_Step( + mlx_external reapply_patches + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/patches/apply.sh ${MLX_SOURCE_DIR} + ${_mlx_patches} + DEPENDEES download + DEPENDERS configure + ALWAYS 1 +) + # Imported target for the MLX static library produced by mlx_external. A static # libmlx.a carries no transitive link deps, so re-add the frameworks MLX itself # links (mirrors third-party/mlx/CMakeLists.txt:209). CPU is OFF, so Accelerate From c6cd3fc3bb2a5cafe3580a470cae0c8292636dd8 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 23:08:16 -0700 Subject: [PATCH 16/28] Build MLX in the configuration actually being built The MLX sub-build was defaulted to Release whenever the parent left the build type empty. Under the multi-config Xcode generator the Apple presets use, the type is empty for a Debug build too, so a bare-preset Debug build linked an optimized, assertion-disabled MLX. Follow the active configuration through a generator expression, which resolves to the config being built under a multi-config parent and to the build type under a single-config one, and fall back to Release only when it is genuinely empty (a bare single-config parent with no type set). Test Plan: Reproduced with a child ExternalProject under an Xcode parent: --config Debug now yields Debug and --config Release yields Release, while a bare Unix Makefiles parent with no type falls back to Release. --- backends/mlx/CMakeLists.txt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index 9a2d280f478..acb81578a4e 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -226,15 +226,12 @@ else() endif() # The sub-build is forced to a single-config generator (Unix Makefiles), which -# reads CMAKE_BUILD_TYPE, not the --config passed to the parent. The Apple -# presets do not set CMAKE_BUILD_TYPE, so without a default MLX would compile -# unoptimized (no -O3 -DNDEBUG) even for a release framework. Default to Release -# when the parent left it empty; build_apple_frameworks.sh and the wheel set it -# explicitly and are unaffected. -set(_mlx_build_type ${CMAKE_BUILD_TYPE}) -if(NOT _mlx_build_type) - set(_mlx_build_type Release) -endif() +# reads CMAKE_BUILD_TYPE, not the --config passed to the parent. Follow the +# active configuration through $, which resolves to the config being built +# under a multi-config parent (Xcode) and to CMAKE_BUILD_TYPE under a single-config +# one. It is empty only for a bare single-config parent that set no type, so +# default that case to Release rather than compile MLX with no optimization. +set(_mlx_build_type "$>,$,Release>") ExternalProject_Add( mlx_external @@ -247,7 +244,7 @@ ExternalProject_Add( # single-config generator emits it, which is also how the wheel build already # produces it. CMAKE_GENERATOR "Unix Makefiles" - CMAKE_ARGS -DCMAKE_BUILD_TYPE=${_mlx_build_type} + CMAKE_ARGS "-DCMAKE_BUILD_TYPE=${_mlx_build_type}" -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD} ${_mlx_cxx_flags_arg} # The preset's DEPLOYMENT_TARGET carries the correct per-slice From 80522a787a4b8f8e50a6b72272cdc5109797b6f0 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Thu, 27 Aug 2026 23:10:45 -0700 Subject: [PATCH 17/28] Address MLX review nits: docs and a dead resource reader Document the MLX backend on the iOS page properly: mention MLX alongside Core ML in the opening line, scope the MLX bullet to macOS on Apple Silicon to match the C++ guide, add the MLX archive to the force-load example, and note that a source integration must also copy the MLX Metal kernel file, which is not among the frameworks in cmake-out. Remove the generic product loop's resource reader: no product declares a resources key, so it was dead, and it used the bare relative path that fails for a consumer, which would mislead anyone who later added one. The MLX metallib bundle is handled by its own target. Test Plan: Manifest parses. Reviewed the rendered docs. --- Package.swift | 5 ----- docs/source/using-executorch-ios.md | 7 +++++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index e5e61704e30..c165aff2c62 100644 --- a/Package.swift +++ b/Package.swift @@ -119,10 +119,6 @@ for (key, value) in products { key.hasSuffix(debug_suffix) ? $0 + debug_suffix : $0 } let dependencies: [Target.Dependency] = targetNames.map { .target(name: $0) } - let resources: [Resource] = (value["resources"] as? [String] ?? []).filter { - // Gitignored until the build script produces them; skip a missing one so resolve does not fail. - FileManager.default.fileExists(atPath: ".Package.swift/\(key)/\($0)") - }.map { .copy($0) } let frameworks: [LinkerSetting] = (value["frameworks"] as? [String] ?? []).map { .linkedFramework($0) } let libraries: [LinkerSetting] = @@ -131,7 +127,6 @@ for (key, value) in products { name: "\(key)\(dependencies_suffix)", dependencies: dependencies, path: ".Package.swift/\(key)", - resources: resources, linkerSettings: frameworks + libraries )) } diff --git a/docs/source/using-executorch-ios.md b/docs/source/using-executorch-ios.md index 1ecabef715b..beece6116a8 100644 --- a/docs/source/using-executorch-ios.md +++ b/docs/source/using-executorch-ios.md @@ -1,6 +1,6 @@ # Using ExecuTorch on iOS -ExecuTorch supports both iOS and macOS via Objective-C, Swift, and C++. ExecuTorch also provides backends to leverage Core ML for hardware-accelerated execution on Apple platforms. +ExecuTorch supports both iOS and macOS via Objective-C, Swift, and C++. ExecuTorch also provides backends to leverage Core ML and MLX for hardware-accelerated execution on Apple platforms. ## Integration @@ -9,7 +9,7 @@ The ExecuTorch Runtime for iOS and macOS (ARM64) is distributed as a collection * `executorch` - Core runtime components * `executorch_llm` - LLM-specific runtime components * `backend_coreml` - Core ML backend -* `backend_mlx` - MLX backend +* `backend_mlx` - MLX backend (macOS on Apple Silicon) * `backend_xnnpack` - XNNPACK backend * `kernels_llm` - Custom kernels for LLMs * `kernels_optimized` - Accelerated generic CPU kernels @@ -160,6 +160,7 @@ ET_PLATFORM[sdk=macos*] = macos OTHER_LDFLAGS = $(inherited) \ -force_load $(BUILT_PRODUCTS_DIR)/libexecutorch_debug_$(ET_PLATFORM).a \ -force_load $(BUILT_PRODUCTS_DIR)/libbackend_coreml_$(ET_PLATFORM).a \ + -force_load $(BUILT_PRODUCTS_DIR)/libbackend_mlx_$(ET_PLATFORM).a \ -force_load $(BUILT_PRODUCTS_DIR)/libbackend_xnnpack_$(ET_PLATFORM).a \ -force_load $(BUILT_PRODUCTS_DIR)/libkernels_optimized_$(ET_PLATFORM).a \ -force_load $(BUILT_PRODUCTS_DIR)/libkernels_quantized_$(ET_PLATFORM).a @@ -167,6 +168,8 @@ OTHER_LDFLAGS = $(inherited) \ **Note:** In the example above, we link against the Debug version of the ExecuTorch runtime (`libexecutorch_debug`) to preserve the logs. Normally, that does not impact the performance too much. Nevertheless, remember to link against the release version of the runtime (`libexecutorch`) for the best performance and no logs. +**Note:** The MLX backend loads its Metal kernels at runtime from an `mlx.metallib` file. That file is not part of the frameworks in `cmake-out`; the build writes it under the MLX subdirectory of the build tree. If you integrate MLX from a source build, copy that `mlx.metallib` into your app bundle as well, or MLX links and registers but has no kernels to run. + You can assign such a config file to your target in Xcode: 1. Add the `.xcconfig` file to your project. From 5c526cde5c4ef30e9bc00d5e74e8f27403f313de Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Fri, 28 Aug 2026 00:09:31 -0700 Subject: [PATCH 18/28] Fix the MLX patch race, serial build, and unconditional enable Three issues with the MLX Apple build. The re-apply-patches step depended on the download step, but the built-in patch step also runs between download and configure, so a parallel build ran both against the same MLX checkout at once and they collided. Depend on the patch step instead, which orders the re-apply after it. The MLX sub-build ran fully serially under the multi-config Xcode parent, whose generated build step passes no parallelism and has no jobserver to inherit. Give it an explicit parallel build command. The Apple presets forced MLX on for every consumer, so an ordinary `cmake --preset ios` hard-failed on a machine without the Metal compiler or the MLX submodule, and plain PR CI built MLX three times. Probe for the Metal compiler and enable MLX only on Apple Silicon when it is present, degrading with a message otherwise, the way the wheel's pybind preset already does. Test Plan: Verified the metal-compiler probe enables MLX when present. cmake-format clean. --- backends/mlx/CMakeLists.txt | 29 ++++++++++++++++++--------- tools/cmake/preset/apple_common.cmake | 22 +++++++++++++++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index acb81578a4e..e9332eac188 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -227,10 +227,11 @@ endif() # The sub-build is forced to a single-config generator (Unix Makefiles), which # reads CMAKE_BUILD_TYPE, not the --config passed to the parent. Follow the -# active configuration through $, which resolves to the config being built -# under a multi-config parent (Xcode) and to CMAKE_BUILD_TYPE under a single-config -# one. It is empty only for a bare single-config parent that set no type, so -# default that case to Release rather than compile MLX with no optimization. +# active configuration through $, which resolves to the config being +# built under a multi-config parent (Xcode) and to CMAKE_BUILD_TYPE under a +# single-config one. It is empty only for a bare single-config parent that set +# no type, so default that case to Release rather than compile MLX with no +# optimization. set(_mlx_build_type "$>,$,Release>") ExternalProject_Add( @@ -273,6 +274,11 @@ ExternalProject_Add( # MLX's own install() does not emit libmlx.a where we consume it or the # metallib at all, so skip the install step and read both from the build tree. INSTALL_COMMAND "" + # Build in parallel. Under the multi-config Xcode parent the generated + # sub-build step is `cmake --build . --config ` with no -j and no + # jobserver to inherit, so MLX would compile fully serially; pass --parallel + # explicitly. + BUILD_COMMAND ${CMAKE_COMMAND} --build --parallel # ExternalProject stamps its build, so a bare MLX submodule bump (git # submodule update) would not invalidate the stamp and we'd link a stale # libmlx.a with no signal. BUILD_ALWAYS reruns the build step every configure; @@ -282,16 +288,19 @@ ExternalProject_Add( BUILD_BYPRODUCTS ${_mlx_static_lib} ${_mlx_metallib} ) -# ExternalProject stamps the patch step and BUILD_ALWAYS does not re-run it, so a -# reused build directory whose MLX source was reset (patches reverted) would -# recompile an unpatched MLX and silently drop the iOS Metal SDK selection and the -# SwiftPM metallib name. Re-apply the patches on every configure; apply.sh is -# idempotent (it reverse-checks each patch and skips the ones already applied). +# ExternalProject stamps the patch step and BUILD_ALWAYS does not re-run it, so +# a reused build directory whose MLX source was reset (patches reverted) would +# recompile an unpatched MLX and silently drop the iOS Metal SDK selection and +# the SwiftPM metallib name. Re-apply the patches on every configure; apply.sh +# is idempotent (it reverse-checks each patch and skips the ones already +# applied). Depend on the built-in patch step, not download: both call apply.sh +# on the same checkout, so ordering after patch keeps a parallel build from +# running the two concurrently against one source tree. ExternalProject_Add_Step( mlx_external reapply_patches COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/patches/apply.sh ${MLX_SOURCE_DIR} ${_mlx_patches} - DEPENDEES download + DEPENDEES patch DEPENDERS configure ALWAYS 1 ) diff --git a/tools/cmake/preset/apple_common.cmake b/tools/cmake/preset/apple_common.cmake index cfcb5dcf3d2..ce35caab27f 100644 --- a/tools/cmake/preset/apple_common.cmake +++ b/tools/cmake/preset/apple_common.cmake @@ -18,7 +18,27 @@ add_compile_options( set_overridable_option(BUILD_TESTING OFF) set_overridable_option(EXECUTORCH_BUILD_XNNPACK ON) set_overridable_option(EXECUTORCH_BUILD_COREML ON) -set_overridable_option(EXECUTORCH_BUILD_MLX ON) +# MLX is Apple Silicon only and needs the Metal compiler (xcrun -sdk macosx +# metal), which ships with Xcode, not the Command Line Tools. Probe for it and +# degrade gracefully rather than force MLX on for every Apple preset, which +# would hard-fail a plain `cmake --preset ios` on a machine without the Metal +# toolchain or the MLX submodule. This mirrors how the wheel's pybind preset +# gates MLX. +if(CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64") + execute_process( + COMMAND xcrun -sdk macosx --find metal + RESULT_VARIABLE _metal_compiler_result + OUTPUT_QUIET ERROR_QUIET + ) + if(_metal_compiler_result EQUAL 0) + set_overridable_option(EXECUTORCH_BUILD_MLX ON) + else() + message( + STATUS + "Metal compiler not found, disabling MLX backend. Install Xcode to enable MLX." + ) + endif() +endif() set_overridable_option(EXECUTORCH_XNNPACK_ENABLE_WEIGHT_CACHE ON) set_overridable_option(EXECUTORCH_XNNPACK_SHARED_WORKSPACE ON) set_overridable_option(EXECUTORCH_BUILD_EXTENSION_APPLE ON) From 5f673f9269f58888eb87924c95f2dbe4a1724581 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Fri, 28 Aug 2026 00:23:07 -0700 Subject: [PATCH 19/28] Correct the metallib-capture comments The comments claimed a Debug pass would overwrite a Release metallib and that the shipped kernels should be the Release build. The metallib does not depend on the build type, so the two passes emit the same file and there is no meaningful overwrite. Reword to say the capture runs once from a single pass because the file is build-type independent. No behavior change. Test Plan: Comment-only; confirmed the script still parses. --- scripts/build_apple_frameworks.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index 35db4352561..60f77f89ca4 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -212,9 +212,9 @@ fi # The MLX Metal kernels ship as a per-slice metallib in a shared SwiftPM resource # bundle, not merged into the xcframework, since MLX loads them at runtime by name. -# Capture it during the build loop: both modes build into the same directory, so -# the Debug pass overwrites the Release metallib, and the shipped kernels should be -# the Release build. +# Capture it during the build loop, from one pass per slice: both modes build into +# the same directory and the metallib does not depend on the build type, so one +# copy is enough and re-copying per mode would only rewrite an identical file. capture_mlx_metallib() { local preset_out_dir="$1" if [[ " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then @@ -243,9 +243,9 @@ capture_mlx_metallib() { echo "Building libraries" -# The MLX metallib should be captured from the Release build. Both modes build -# into the same directory, so a later Debug pass would overwrite it. Prefer -# Release; if it is not being built, fall back to the last mode in the list. +# Capture the metallib from a single deterministic pass. The metallib does not +# depend on the build type, so any one mode's is fine; prefer Release when it is +# being built, otherwise fall back to the last mode in the list. MLX_CAPTURE_MODE="${MODES[0]}" for mode in "${MODES[@]}"; do if [[ "${mode}" == "Release" ]]; then @@ -275,8 +275,8 @@ for preset_index in "${!PRESETS[@]}"; do cmake --build "${preset_output_dir}" \ --config "${mode}" - # Capture the metallib on the chosen pass, before a later pass building into - # the same directory overwrites it. + # Capture the metallib on the chosen pass only, so it is copied once rather + # than rewritten by every mode. if [[ "${mode}" == "${MLX_CAPTURE_MODE}" ]]; then capture_mlx_metallib "${PRESETS_RELATIVE_OUT_DIR[$preset_index]}" fi From eaa7dbc07584d8093313496681f341f2d9696b47 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Fri, 28 Aug 2026 08:59:50 -0700 Subject: [PATCH 20/28] Do not gate the MLX metal probe on the wrong processor variable The previous change guarded the Metal-compiler probe with `CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64"`, but under the Apple presets that variable is the target the iOS toolchain set, which is `aarch64` for Apple Silicon and never `arm64`. The guard was therefore always false, so MLX was silently disabled on every Apple preset, defeating the purpose of the build and announced only by a status message. Drop the processor condition and let the Metal-compiler probe be the whole gate, the way the wheel's pybind preset does. Test Plan: Confirmed the toolchain sets CMAKE_SYSTEM_PROCESSOR to aarch64, and that the probe alone enables MLX on a machine with the Metal compiler present. --- tools/cmake/preset/apple_common.cmake | 38 +++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tools/cmake/preset/apple_common.cmake b/tools/cmake/preset/apple_common.cmake index ce35caab27f..b738276fe1e 100644 --- a/tools/cmake/preset/apple_common.cmake +++ b/tools/cmake/preset/apple_common.cmake @@ -18,26 +18,26 @@ add_compile_options( set_overridable_option(BUILD_TESTING OFF) set_overridable_option(EXECUTORCH_BUILD_XNNPACK ON) set_overridable_option(EXECUTORCH_BUILD_COREML ON) -# MLX is Apple Silicon only and needs the Metal compiler (xcrun -sdk macosx -# metal), which ships with Xcode, not the Command Line Tools. Probe for it and -# degrade gracefully rather than force MLX on for every Apple preset, which -# would hard-fail a plain `cmake --preset ios` on a machine without the Metal -# toolchain or the MLX submodule. This mirrors how the wheel's pybind preset -# gates MLX. -if(CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64") - execute_process( - COMMAND xcrun -sdk macosx --find metal - RESULT_VARIABLE _metal_compiler_result - OUTPUT_QUIET ERROR_QUIET +# MLX needs the Metal compiler (xcrun -sdk macosx metal), which ships with +# Xcode, not the Command Line Tools. Probe for it and degrade gracefully rather +# than force MLX on for every Apple preset, which would hard-fail a plain `cmake +# --preset ios` on a machine without the Metal toolchain or the MLX submodule. +# This mirrors how the wheel's pybind preset gates MLX. The metal probe is the +# whole gate: do not add a CMAKE_SYSTEM_PROCESSOR check here, because under the +# Apple presets that variable is the target the toolchain set (aarch64, never +# arm64), so such a check silently disables MLX on every Apple build. +execute_process( + COMMAND xcrun -sdk macosx --find metal + RESULT_VARIABLE _metal_compiler_result + OUTPUT_QUIET ERROR_QUIET +) +if(_metal_compiler_result EQUAL 0) + set_overridable_option(EXECUTORCH_BUILD_MLX ON) +else() + message( + STATUS + "Metal compiler not found, disabling MLX backend. Install Xcode to enable MLX." ) - if(_metal_compiler_result EQUAL 0) - set_overridable_option(EXECUTORCH_BUILD_MLX ON) - else() - message( - STATUS - "Metal compiler not found, disabling MLX backend. Install Xcode to enable MLX." - ) - endif() endif() set_overridable_option(EXECUTORCH_XNNPACK_ENABLE_WEIGHT_CACHE ON) set_overridable_option(EXECUTORCH_XNNPACK_SHARED_WORKSPACE ON) From c06ddcda8acc80d9c6790beb1a390d94400e14fb Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Fri, 28 Aug 2026 09:13:32 -0700 Subject: [PATCH 21/28] Correct the MLX manual-integration note The note named `mlx.metallib` and told the reader to copy it into the app bundle, but the shipped library looks for a per-slice `mlx-.metallib` inside a bundle named `executorch_backend_mlx_resources`, and the file it pointed at in the build tree is removed by the build's own cleanup. Point at the correctly named files the build stages under `.Package.swift/backend_mlx_resources/` and describe the bundle the runtime actually reads, so following the note prevents the no-kernels failure instead of causing it. Test Plan: Documentation only. Cross-checked the file names and bundle name against the build script and the MLX metallib-name compile flag. --- docs/source/using-executorch-ios.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/using-executorch-ios.md b/docs/source/using-executorch-ios.md index beece6116a8..82c7f0fc5b4 100644 --- a/docs/source/using-executorch-ios.md +++ b/docs/source/using-executorch-ios.md @@ -168,7 +168,7 @@ OTHER_LDFLAGS = $(inherited) \ **Note:** In the example above, we link against the Debug version of the ExecuTorch runtime (`libexecutorch_debug`) to preserve the logs. Normally, that does not impact the performance too much. Nevertheless, remember to link against the release version of the runtime (`libexecutorch`) for the best performance and no logs. -**Note:** The MLX backend loads its Metal kernels at runtime from an `mlx.metallib` file. That file is not part of the frameworks in `cmake-out`; the build writes it under the MLX subdirectory of the build tree. If you integrate MLX from a source build, copy that `mlx.metallib` into your app bundle as well, or MLX links and registers but has no kernels to run. +**Note:** The MLX backend loads its Metal kernels at runtime from a per-slice metallib inside a resource bundle named `executorch_backend_mlx_resources`, not from the frameworks in `cmake-out`. The build stages the correctly named files (`mlx-ios.metallib`, `mlx-ios-simulator.metallib`, `mlx-macos.metallib`) under `.Package.swift/backend_mlx_resources/`. If you integrate MLX from a source build, ship those files in a bundle of that name for the slices you use, or MLX links and registers but has no kernels to run. You can assign such a config file to your target in Xcode: From 3f248d1c52177f8d184fd0f835130f3cf96e8311 Mon Sep 17 00:00:00 2001 From: PyTorch Bot Date: Fri, 28 Aug 2026 09:16:07 -0700 Subject: [PATCH 22/28] Correct the test libc++ link comment The comment gave two wrong reasons for linking libc++ into the test bundles. The setting is necessary and correct; only the explanation was off. The tests link libc++ because they depend on the executorch binary target directly, which carries no linker settings, not the with-dependencies target that owns the link, and the implicit supplier that disappears at this package's floor is the Swift shim that existed below a macOS 13 deployment target, not 14. Comment only. Test Plan: Manifest parses. --- Package.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index c165aff2c62..6d742e616db 100644 --- a/Package.swift +++ b/Package.swift @@ -190,7 +190,11 @@ if FileManager.default.fileExists(atPath: "\(objcTestsDir)/add_mul_coreml.pte") } let testLinkerSettings: [LinkerSetting] = [ - // Test targets do not inherit the executorch product's libc++ link, and the macOS 14 floor drops the shim that used to supply it. + // The test targets depend on the executorch binary target directly, which + // carries no linker settings, rather than the with-dependencies target that + // owns the libc++ link, so they must link libc++ themselves. Below a macOS 13 + // deployment target a Swift back-deployment shim used to supply it implicitly; + // at this package's floor that shim is gone, so name it explicitly here. .linkedLibrary("c++"), .unsafeFlags([ "-Xlinker", "-force_load", From 4ac6418a3aa9e1c5c9535de6a9ec0f74ed1a280f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 11:02:43 -0700 Subject: [PATCH 23/28] Fix the MLX sub-build's parallelism, patch step, and enable detection Three separate defects found by review, each measured. The sub-build's BUILD_COMMAND passed a bare --parallel, which expands to `make -j` with no job limit. It also applied unconditionally, and under a single-config parent it replaced ExternalProject's default `$(MAKE)`, which already inherits the parent's jobserver, so it broke that and made MLX build serially on the wheel path. Measured: jobserver-unavailable warnings went 0 to 1 with the override. Now it names a job count and only overrides for a multi-config parent, where there is no jobserver to inherit. The reapply_patches step used DEPENDERS configure, which invalidates the sub-build's configure stamp on every build, so MLX re-ran its whole configure, including the metal_cpp FetchContent, on every incremental build of all six slice and mode combinations. Measured with the step: apply and configure both ran on builds 1, 2 and 3; with DEPENDERS build, configure runs once. Its comment also described depending on PATCH_COMMAND, which this branch no longer sets. capture_mlx_metallib decided "MLX is enabled" from CMAKE_OPTIONS_OVERRIDE, which is empty unless a -- flag was passed. The Apple presets probe for the Metal compiler and leave MLX off when it is missing, so a plain no-flag build on a machine without the Metal toolchain hard-failed with a message asserting MLX was enabled. Read the option out of the preset's CMakeCache.txt instead. Also drop the "(macOS on Apple Silicon)" qualifier from the docs bullet: the build produces all three slices, a missing iOS metallib is a hard error, and no sibling entry carries a platform parenthetical. --- backends/mlx/CMakeLists.txt | 42 +++++++++++++++++++++-------- docs/source/using-executorch-ios.md | 2 +- scripts/build_apple_frameworks.sh | 10 +++++++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index e9332eac188..2d7925d00e9 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -234,6 +234,20 @@ endif() # optimization. set(_mlx_build_type "$>,$,Release>") +# Decide the MLX sub-build's build command, consumed by BUILD_COMMAND below. An +# empty value leaves ExternalProject's default in place. +cmake_host_system_information( + RESULT _mlx_host_cores QUERY NUMBER_OF_LOGICAL_CORES +) +if(CMAKE_CONFIGURATION_TYPES) + set(_mlx_build_command + ${CMAKE_COMMAND} --build --config $ --parallel + ${_mlx_host_cores} + ) +else() + set(_mlx_build_command "") +endif() + ExternalProject_Add( mlx_external SOURCE_DIR ${MLX_SOURCE_DIR} @@ -274,11 +288,14 @@ ExternalProject_Add( # MLX's own install() does not emit libmlx.a where we consume it or the # metallib at all, so skip the install step and read both from the build tree. INSTALL_COMMAND "" - # Build in parallel. Under the multi-config Xcode parent the generated - # sub-build step is `cmake --build . --config ` with no -j and no - # jobserver to inherit, so MLX would compile fully serially; pass --parallel - # explicitly. - BUILD_COMMAND ${CMAKE_COMMAND} --build --parallel + # Build in parallel, but only where it is needed and always with a job count. + # Under the multi-config Xcode parent the generated sub-build step is + # `cmake --build . --config ` with no -j and no jobserver to inherit, so + # MLX would compile fully serially. Under a single-config parent the default + # build command is `$(MAKE)`, which already inherits the parent's jobserver, + # so overriding it there would break that and serialize MLX instead. A bare + # --parallel with no number expands to `make -j`, unbounded, so name a count. + BUILD_COMMAND ${_mlx_build_command} # ExternalProject stamps its build, so a bare MLX submodule bump (git # submodule update) would not invalidate the stamp and we'd link a stale # libmlx.a with no signal. BUILD_ALWAYS reruns the build step every configure; @@ -291,17 +308,20 @@ ExternalProject_Add( # ExternalProject stamps the patch step and BUILD_ALWAYS does not re-run it, so # a reused build directory whose MLX source was reset (patches reverted) would # recompile an unpatched MLX and silently drop the iOS Metal SDK selection and -# the SwiftPM metallib name. Re-apply the patches on every configure; apply.sh -# is idempotent (it reverse-checks each patch and skips the ones already -# applied). Depend on the built-in patch step, not download: both call apply.sh -# on the same checkout, so ordering after patch keeps a parallel build from -# running the two concurrently against one source tree. +# the SwiftPM metallib name. +# Re-apply the patches on every build; apply.sh is idempotent (it reverse-checks +# each patch, skipping those already applied). DEPENDERS build, not +# configure: +# naming configure invalidates the sub-build's configure stamp each time, +# so MLX +# re-runs its whole configure, including the metal_cpp FetchContent, on +# every incremental build of all six slice and mode combinations. ExternalProject_Add_Step( mlx_external reapply_patches COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/patches/apply.sh ${MLX_SOURCE_DIR} ${_mlx_patches} DEPENDEES patch - DEPENDERS configure + DEPENDERS build ALWAYS 1 ) diff --git a/docs/source/using-executorch-ios.md b/docs/source/using-executorch-ios.md index 82c7f0fc5b4..791c003abe0 100644 --- a/docs/source/using-executorch-ios.md +++ b/docs/source/using-executorch-ios.md @@ -9,7 +9,7 @@ The ExecuTorch Runtime for iOS and macOS (ARM64) is distributed as a collection * `executorch` - Core runtime components * `executorch_llm` - LLM-specific runtime components * `backend_coreml` - Core ML backend -* `backend_mlx` - MLX backend (macOS on Apple Silicon) +* `backend_mlx` - MLX backend * `backend_xnnpack` - XNNPACK backend * `kernels_llm` - Custom kernels for LLMs * `kernels_optimized` - Accelerated generic CPU kernels diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index 60f77f89ca4..f992ea0ee10 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -217,6 +217,16 @@ fi # copy is enough and re-copying per mode would only rewrite an identical file. capture_mlx_metallib() { local preset_out_dir="$1" + # Read the decision the preset actually made, not the command-line flags. The + # Apple presets probe for the Metal compiler and leave EXECUTORCH_BUILD_MLX OFF + # when it is missing, and CMAKE_OPTIONS_OVERRIDE is empty unless the caller + # passed a -- flag, so testing that array alone would hard-fail a + # plain no-flag build on a machine without the Metal toolchain. + local cache="${OUTPUT_DIR}/${preset_out_dir}/CMakeCache.txt" + if [[ -f "${cache}" ]] && + ! grep -q "^EXECUTORCH_BUILD_MLX:BOOL=ON" "${cache}"; then + return + fi if [[ " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then return fi From 0d274ac7143ff14fcb3460ed2ce8db6451748275 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 11:39:55 -0700 Subject: [PATCH 24/28] Match the MLX cache entry without naming its type The probe added in the previous commit grepped for EXECUTORCH_BUILD_MLX:BOOL=ON, but the preset path writes STRING: set_overridable_option uses `CACHE STRING ""`, and that is what apple_common.cmake calls. Measured against a real preset chain, the cache holds EXECUTORCH_BUILD_MLX:STRING=ON, so the grep never matched and the guard skipped the metallib capture even when MLX was enabled, which is the inverse of the intended behaviour. Match any cache type and accept CMake's truthy spellings. Verified for STRING=ON, BOOL=ON, STRING=1 and STRING=TRUE (proceed) against STRING=OFF and BOOL=OFF (skip). --- scripts/build_apple_frameworks.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index f992ea0ee10..9a1cdc35fcc 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -221,10 +221,12 @@ capture_mlx_metallib() { # Apple presets probe for the Metal compiler and leave EXECUTORCH_BUILD_MLX OFF # when it is missing, and CMAKE_OPTIONS_OVERRIDE is empty unless the caller # passed a -- flag, so testing that array alone would hard-fail a - # plain no-flag build on a machine without the Metal toolchain. + # plain no-flag build on a machine without the Metal toolchain. Do not name a + # cache type: set_overridable_option writes STRING, define_overridable_option + # writes BOOL, and the preset path produces the former. local cache="${OUTPUT_DIR}/${preset_out_dir}/CMakeCache.txt" if [[ -f "${cache}" ]] && - ! grep -q "^EXECUTORCH_BUILD_MLX:BOOL=ON" "${cache}"; then + ! grep -qE "^EXECUTORCH_BUILD_MLX:[A-Z]+=(ON|1|TRUE|YES)$" "${cache}"; then return fi if [[ " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then From ef2f187a18fa14457f480abdcef2ce9ed5f0900d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 12:00:29 -0700 Subject: [PATCH 25/28] Keep the patch step before the sub-configure, and parallelize on Ninja too Two regressions from my own previous commit, both found by review. DEPENDERS build ran the MLX sub-configure on unpatched sources. Measured: with DEPENDERS build the order is SUB_CONFIGURE UNPATCHED then the patch step; with DEPENDERS configure the patch lands first. That matters because mlx_metal_sdk_per_platform.patch edits the sub-project's CMake to pick the Metal SDK from PLATFORM, so configuring before it is applied generates shaders against the macOS SDK for every slice, which is the exact bug that patch exists to stop. Reverted to DEPENDERS configure and said in the comment why the per-build reconfigure is the price of the ordering. The build-command override was gated on CMAKE_CONFIGURATION_TYPES, which sends a Ninja parent down the empty branch. ExternalProject emits $(MAKE), and so inherits the parent's jobserver, only when the PARENT generator matches "Make"; under Ninja it emits a bare `cmake --build` with no -j, so MLX compiled on one core. Gate on the generator instead. Also drop --config $ from the override, which is inert against the forced single-config sub-build. --- backends/mlx/CMakeLists.txt | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index 2d7925d00e9..c4db1d38fdf 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -235,17 +235,21 @@ endif() set(_mlx_build_type "$>,$,Release>") # Decide the MLX sub-build's build command, consumed by BUILD_COMMAND below. An -# empty value leaves ExternalProject's default in place. +# empty value leaves ExternalProject's default in place, which is only the right +# choice for a Makefiles parent: ExternalProject emits `$(MAKE)` (inheriting the +# parent's jobserver) only when the PARENT generator matches "Make". Under Xcode +# or Ninja it emits a bare `cmake --build`, with no -j and no jobserver, so MLX +# would compile on one core. Name a job count there; a bare --parallel with no +# number would expand to `make -j`, unbounded. cmake_host_system_information( RESULT _mlx_host_cores QUERY NUMBER_OF_LOGICAL_CORES ) -if(CMAKE_CONFIGURATION_TYPES) +if(CMAKE_GENERATOR MATCHES "Make") + set(_mlx_build_command "") +else() set(_mlx_build_command - ${CMAKE_COMMAND} --build --config $ --parallel - ${_mlx_host_cores} + ${CMAKE_COMMAND} --build --parallel ${_mlx_host_cores} ) -else() - set(_mlx_build_command "") endif() ExternalProject_Add( @@ -310,18 +314,17 @@ ExternalProject_Add( # recompile an unpatched MLX and silently drop the iOS Metal SDK selection and # the SwiftPM metallib name. # Re-apply the patches on every build; apply.sh is idempotent (it reverse-checks -# each patch, skipping those already applied). DEPENDERS build, not -# configure: -# naming configure invalidates the sub-build's configure stamp each time, -# so MLX -# re-runs its whole configure, including the metal_cpp FetchContent, on -# every incremental build of all six slice and mode combinations. +# each patch, skipping those already applied). DEPENDERS configure, not build: +# mlx_metal_sdk_per_platform.patch edits the sub-project's own CMake to pick the +# Metal SDK from PLATFORM, so it must land before the sub-configure runs, or the +# shaders are built against the macOS SDK for every slice. That does mean the +# sub-configure re-runs on each build, which is the price of the ordering. ExternalProject_Add_Step( mlx_external reapply_patches COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/patches/apply.sh ${MLX_SOURCE_DIR} ${_mlx_patches} DEPENDEES patch - DEPENDERS build + DEPENDERS configure ALWAYS 1 ) From aff237060c14a194eb2d74799284634cc8aa3aaa Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 12:58:15 -0700 Subject: [PATCH 26/28] Decide MLX from the configure, and assert the metallibs before publishing Four related gaps, each measured. append_framework_flag still decided from the command-line flags, so a no-flag build on a machine whose preset disabled MLX asked create_frameworks.sh for libraries that were never built, and it exits 1 on a missing input. The previous commit fixed only the capture side. Both now read the same cache value through one helper. capture_mlx_metallib created the resources directory before deciding whether it had anything to put there, so a disabled build left an empty directory behind. The publish step tests for that directory to decide whether to commit metallibs, so an empty one made the test pass and the copy silently no-op. Create it only once there is a file to copy. The publish step had no check that the metallibs arrived, while the copy that puts them there is conditional and the capture upstream is best effort. Assert all three are present and non-empty whenever backend_mlx is being published, beside the two checks already there for the same class of silent failure. Nothing evaluated the manifest before consumers did: the existing checks are text greps, the swiftpm branch has no CI, and a .template is not a manifest. Run dump-package on the substituted file so a Swift-level defect fails the run instead of every consumer of the published branch. --- .github/workflows/apple.yml | 22 +++++++++++++++++ scripts/build_apple_frameworks.sh | 39 +++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 1788098f765..cde80b39ca8 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -336,6 +336,28 @@ jobs: exit 1 fi done < "${RUNNER_TEMP}/checksums.txt" + # If MLX is being published, its Metal kernels have to come with it. + # The copy below is conditional and the capture upstream is best + # effort, so without this an incomplete set publishes a manifest + # declaring three resources that are not on the branch, and the + # consumer only finds out at Metal device init. + if grep -q "^backend_mlx " "${RUNNER_TEMP}/checksums.txt"; then + for SLICE in mlx-ios mlx-ios-simulator mlx-macos; do + METALLIB="${RUNNER_TEMP}/frameworks-ios/backend_mlx_resources/${SLICE}.metallib" + if [ ! -s "${METALLIB}" ]; then + echo "::error::backend_mlx is being published but ${SLICE}.metallib is missing or empty" + exit 1 + fi + done + fi + # Nothing else evaluates this manifest before consumers do: the checks + # above are text greps, the swiftpm branch has no CI of its own, and a + # .template is not a manifest. Resolve it here so a Swift-level defect + # fails this run rather than every consumer of the published branch. + if ! swift package dump-package > /dev/null; then + echo "::error::the substituted Package.swift does not evaluate" + exit 1 + fi git config --global user.name "PyTorch Bot" git config --global user.email "pytorchbot@users.noreply.github.com" diff --git a/scripts/build_apple_frameworks.sh b/scripts/build_apple_frameworks.sh index 9a1cdc35fcc..40a370dcfbc 100755 --- a/scripts/build_apple_frameworks.sh +++ b/scripts/build_apple_frameworks.sh @@ -217,13 +217,9 @@ fi # copy is enough and re-copying per mode would only rewrite an identical file. capture_mlx_metallib() { local preset_out_dir="$1" - # Read the decision the preset actually made, not the command-line flags. The - # Apple presets probe for the Metal compiler and leave EXECUTORCH_BUILD_MLX OFF - # when it is missing, and CMAKE_OPTIONS_OVERRIDE is empty unless the caller - # passed a -- flag, so testing that array alone would hard-fail a - # plain no-flag build on a machine without the Metal toolchain. Do not name a - # cache type: set_overridable_option writes STRING, define_overridable_option - # writes BOOL, and the preset path produces the former. + # Ask the configure that just ran, not the command-line flags: the Apple presets + # probe for the Metal compiler and leave MLX off when it is missing, while + # CMAKE_OPTIONS_OVERRIDE is empty unless the caller passed a -- flag. local cache="${OUTPUT_DIR}/${preset_out_dir}/CMakeCache.txt" if [[ -f "${cache}" ]] && ! grep -qE "^EXECUTORCH_BUILD_MLX:[A-Z]+=(ON|1|TRUE|YES)$" "${cache}"; then @@ -232,8 +228,6 @@ capture_mlx_metallib() { if [[ " ${CMAKE_OPTIONS_OVERRIDE[*]:-} " =~ "-DEXECUTORCH_BUILD_MLX=OFF" ]]; then return fi - local mlx_resources_dir="$SOURCE_ROOT_DIR/.Package.swift/backend_mlx_resources" - mkdir -p "${mlx_resources_dir}" local mlx_metallib="${OUTPUT_DIR}/${preset_out_dir}/backends/mlx/mlx/mlx/backend/metal/kernels/mlx.metallib" # The simulator preset dir is "simulator" but the compiled-in slice name is "ios-simulator". local slice @@ -246,6 +240,12 @@ capture_mlx_metallib() { echo "error: MLX is enabled but ${mlx_metallib} was not produced for the ${slice} slice" >&2 exit 1 fi + # Create the directory only once there is something to put in it. Creating it + # earlier would leave an empty directory behind on the paths that return above, + # and the workflow tests for the directory to decide whether to publish + # metallibs, so an empty one makes that test pass and the copy silently no-op. + local mlx_resources_dir="$SOURCE_ROOT_DIR/.Package.swift/backend_mlx_resources" + mkdir -p "${mlx_resources_dir}" # The destination lives outside OUTPUT_DIR, so the top-level rm -rf does not # reach it; drop any previous copy so a stale metallib cannot survive into this # build and ship. @@ -344,11 +344,23 @@ EOF echo "Creating frameworks" +# Whether a build option ended up ON according to the configure that just ran, +# rather than according to the command-line flags. A preset can switch an option +# off on its own (the Apple presets probe for the Metal compiler), so the flags +# the caller passed are not the whole story. Do not name a cache type: +# set_overridable_option writes STRING, define_overridable_option writes BOOL, +# and the preset path produces the former. +option_is_on_in_cache() { + local option_name="$1" + local cache="${OUTPUT_DIR}/${PRESETS_RELATIVE_OUT_DIR[0]}/CMakeCache.txt" + [[ -f "${cache}" ]] || return 0 + grep -qE "^${option_name}:[A-Z]+=(ON|1|TRUE|YES)$" "${cache}" +} + append_framework_flag() { local option_name="$1" local framework="$2" local mode="$3" - if [[ ${#CMAKE_OPTIONS_OVERRIDE[@]} -gt 0 && -n "$option_name" ]]; then for cmake_option in "${CMAKE_OPTIONS_OVERRIDE[@]}"; do if [[ "$cmake_option" =~ "-D${option_name}=OFF" ]]; then @@ -357,6 +369,13 @@ append_framework_flag() { fi done fi + # A preset can disable an option with no flag passed, in which case those + # libraries were never built and asking for them here makes + # create_frameworks.sh exit 1 on a missing input. + if [[ -n "$option_name" ]] && ! option_is_on_in_cache "$option_name"; then + echo "Skipping framework: ${framework} (disabled by the preset)" + return + fi if [[ -n "$mode" && "$mode" != "Release" ]]; then local name spec From 89c095afd707d343568eb1ddeb95df398d3b34b7 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 13:03:41 -0700 Subject: [PATCH 27/28] Match cmake-format's wrapping for the build-command set() lintrunner flagged CMAKEFORMAT on this file. Align the continuation with cmake-format's argument-aligned style under dangle_parens. --- backends/mlx/CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index c4db1d38fdf..a46e2fc4645 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -247,8 +247,8 @@ cmake_host_system_information( if(CMAKE_GENERATOR MATCHES "Make") set(_mlx_build_command "") else() - set(_mlx_build_command - ${CMAKE_COMMAND} --build --parallel ${_mlx_host_cores} + set(_mlx_build_command ${CMAKE_COMMAND} --build --parallel + ${_mlx_host_cores} ) endif() From 085a1d60517002a843753a8613c8e8b430e853d8 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 28 Aug 2026 13:15:03 -0700 Subject: [PATCH 28/28] Reflow two comments to cmake-format's wrapping Taken verbatim from the lintrunner diff rather than guessed: cmake-format reflows comment paragraphs, and both blocks had a short line mid-paragraph. --- backends/mlx/CMakeLists.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/backends/mlx/CMakeLists.txt b/backends/mlx/CMakeLists.txt index a46e2fc4645..32be05462e4 100644 --- a/backends/mlx/CMakeLists.txt +++ b/backends/mlx/CMakeLists.txt @@ -293,11 +293,11 @@ ExternalProject_Add( # metallib at all, so skip the install step and read both from the build tree. INSTALL_COMMAND "" # Build in parallel, but only where it is needed and always with a job count. - # Under the multi-config Xcode parent the generated sub-build step is - # `cmake --build . --config ` with no -j and no jobserver to inherit, so - # MLX would compile fully serially. Under a single-config parent the default - # build command is `$(MAKE)`, which already inherits the parent's jobserver, - # so overriding it there would break that and serialize MLX instead. A bare + # Under the multi-config Xcode parent the generated sub-build step is `cmake + # --build . --config ` with no -j and no jobserver to inherit, so MLX + # would compile fully serially. Under a single-config parent the default build + # command is `$(MAKE)`, which already inherits the parent's jobserver, so + # overriding it there would break that and serialize MLX instead. A bare # --parallel with no number expands to `make -j`, unbounded, so name a count. BUILD_COMMAND ${_mlx_build_command} # ExternalProject stamps its build, so a bare MLX submodule bump (git @@ -312,13 +312,13 @@ ExternalProject_Add( # ExternalProject stamps the patch step and BUILD_ALWAYS does not re-run it, so # a reused build directory whose MLX source was reset (patches reverted) would # recompile an unpatched MLX and silently drop the iOS Metal SDK selection and -# the SwiftPM metallib name. -# Re-apply the patches on every build; apply.sh is idempotent (it reverse-checks -# each patch, skipping those already applied). DEPENDERS configure, not build: -# mlx_metal_sdk_per_platform.patch edits the sub-project's own CMake to pick the -# Metal SDK from PLATFORM, so it must land before the sub-configure runs, or the -# shaders are built against the macOS SDK for every slice. That does mean the -# sub-configure re-runs on each build, which is the price of the ordering. +# the SwiftPM metallib name. Re-apply the patches on every build; apply.sh is +# idempotent (it reverse-checks each patch, skipping those already applied). +# DEPENDERS configure, not build: mlx_metal_sdk_per_platform.patch edits the +# sub-project's own CMake to pick the Metal SDK from PLATFORM, so it must land +# before the sub-configure runs, or the shaders are built against the macOS SDK +# for every slice. That does mean the sub-configure re-runs on each build, which +# is the price of the ordering. ExternalProject_Add_Step( mlx_external reapply_patches COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/patches/apply.sh ${MLX_SOURCE_DIR}