From e1280571d3f8110d07b924d1da15bddae5b18c83 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 11:27:28 -0700 Subject: [PATCH 01/22] patch for Cuda builds - local apply of https://github.com/ml-explore/mlx/pull/4480 - fixes crash on exit for cuda builds when using cmake --- .github/scripts/build-linux-cuda-cmake.sh | 5 ++--- CMakeLists.txt | 13 +++++++++++++ cmake/mlx.patch | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 cmake/mlx.patch diff --git a/.github/scripts/build-linux-cuda-cmake.sh b/.github/scripts/build-linux-cuda-cmake.sh index 2a571ea6d..c84e02f27 100755 --- a/.github/scripts/build-linux-cuda-cmake.sh +++ b/.github/scripts/build-linux-cuda-cmake.sh @@ -17,7 +17,6 @@ mkdir -p build pushd build cmake -DMLX_BUILD_METAL=OFF -DMLX_BUILD_CUDA=ON -DMLX_C_BUILD_EXAMPLES=OFF .. -G Ninja ninja -# TODO dkoski -- disabled for now until clear_streams is available -# ./example1 --device gpu -# ./tutorial --device gpu +./example1 --device gpu +./tutorial --device gpu popd diff --git a/CMakeLists.txt b/CMakeLists.txt index 41404a01d..8b3fbcb41 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,19 @@ if(POLICY CMP0135) cmake_policy(SET CMP0135 NEW) endif() +# mlx + +# local apply of https://github.com/ml-explore/mlx/pull/4480 for CUDA +set(mlx_patch git apply + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch) + +FetchContent_Declare( + mlx-c + GIT_REPOSITORY "https://github.com/ml-explore/mlx.git" + GIT_TAG "1f8e74e3f12f31365464a6867c6579f0e9b29d85") + PATCH_COMMAND ${mlx_patch} || true) +FetchContent_MakeAvailable(mlx) + # mlx-c FetchContent_Declare( mlx-c diff --git a/cmake/mlx.patch b/cmake/mlx.patch new file mode 100644 index 000000000..384d7dd3f --- /dev/null +++ b/cmake/mlx.patch @@ -0,0 +1,17 @@ +diff --git a/mlx/backend/cuda/device.cpp b/mlx/backend/cuda/device.cpp +index 472b3d99..e7d8f262 100644 +--- a/mlx/backend/cuda/device.cpp ++++ b/mlx/backend/cuda/device.cpp +@@ -617,8 +617,10 @@ std::unordered_map& get_command_encoders() { + } + + std::unordered_map& get_global_command_encoders() { +- static std::unordered_map encoders; +- return encoders; ++ // encoders are leaked intentionally as they would synchronize on process ++ // shutdown ++ static auto* encoders = new std::unordered_map(); ++ return *encoders; + } + + } // namespace mlx::core::cu From 5adc4d386086fa0af2f98c417f7203010e523ec2 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 14:24:04 -0700 Subject: [PATCH 02/22] fix cmake file --- CMakeLists.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b3fbcb41..9829d23ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,13 +29,12 @@ endif() # mlx # local apply of https://github.com/ml-explore/mlx/pull/4480 for CUDA -set(mlx_patch git apply - ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch) +set(mlx_patch git apply ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch) FetchContent_Declare( mlx-c GIT_REPOSITORY "https://github.com/ml-explore/mlx.git" - GIT_TAG "1f8e74e3f12f31365464a6867c6579f0e9b29d85") + GIT_TAG "1f8e74e3f12f31365464a6867c6579f0e9b29d85" PATCH_COMMAND ${mlx_patch} || true) FetchContent_MakeAvailable(mlx) From ae88266662ade658e414e0ccce1283d67d1ceb20 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 14:52:05 -0700 Subject: [PATCH 03/22] fix cmake issue --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9829d23ce..d2822d362 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,7 +32,7 @@ endif() set(mlx_patch git apply ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch) FetchContent_Declare( - mlx-c + mlx GIT_REPOSITORY "https://github.com/ml-explore/mlx.git" GIT_TAG "1f8e74e3f12f31365464a6867c6579f0e9b29d85" PATCH_COMMAND ${mlx_patch} || true) From 586ebb42f0bce6feb99db382c76a4f29bd611e55 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 17:51:05 -0700 Subject: [PATCH 04/22] fix cmake build, part n --- CMakeLists.txt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d2822d362..61b4af6a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,9 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # ----------------------------- Configuration ----------------------------- # note: mirrors a subset of MLX options exactly (1:1 mapping) -option(MLX_BUILD_EXAMPLES "Build examples for mlx" ON) +# note: MLX_BUILD_EXAMPLES is owned by mlx itself (see below), so the Swift +# examples use their own option name to avoid colliding with mlx's C++ examples +option(MLX_SWIFT_BUILD_EXAMPLES "Build Swift examples for mlx-swift" ON) option(MLX_BUILD_METAL "Build metal backend" ON) option(MLX_BUILD_CUDA "Build cuda backend" OFF) @@ -31,6 +33,15 @@ endif() # local apply of https://github.com/ml-explore/mlx/pull/4480 for CUDA set(mlx_patch git apply ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch) +# note: this must be declared (and named `mlx`) before mlx-c is made available +# so that our pinned/patched version wins over the one mlx-c declares. Because +# we now pull mlx in first, we are also responsible for the option defaults that +# mlx-c would normally set for us. +set(MLX_BUILD_TESTS OFF) +set(MLX_BUILD_EXAMPLES OFF) +set(MLX_BUILD_BENCHMARKS OFF) +set(MLX_BUILD_PYTHON_BINDINGS OFF) + FetchContent_Declare( mlx GIT_REPOSITORY "https://github.com/ml-explore/mlx.git" @@ -39,6 +50,7 @@ FetchContent_Declare( FetchContent_MakeAvailable(mlx) # mlx-c +set(MLX_C_BUILD_EXAMPLES OFF) FetchContent_Declare( mlx-c GIT_REPOSITORY "https://github.com/ml-explore/mlx-c.git" @@ -119,7 +131,7 @@ add_library(MLXLinalg STATIC ${MLXLinalg-src}) target_link_libraries(MLXLinalg PRIVATE MLX) # Examples -if(MLX_BUILD_EXAMPLES) +if(MLX_SWIFT_BUILD_EXAMPLES) add_executable(example1 ${CMAKE_CURRENT_LIST_DIR}/Source/Examples/Example1.swift) target_link_libraries(example1 PRIVATE MLX) From 8972311e206d1ccabf16fdb2fb83df876d21f00a Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 19:38:49 -0700 Subject: [PATCH 05/22] debug cuda run --- .github/scripts/build-linux-cuda-cmake.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/scripts/build-linux-cuda-cmake.sh b/.github/scripts/build-linux-cuda-cmake.sh index c84e02f27..3edbd44b7 100755 --- a/.github/scripts/build-linux-cuda-cmake.sh +++ b/.github/scripts/build-linux-cuda-cmake.sh @@ -16,6 +16,11 @@ rm -rf build mkdir -p build pushd build cmake -DMLX_BUILD_METAL=OFF -DMLX_BUILD_CUDA=ON -DMLX_C_BUILD_EXAMPLES=OFF .. -G Ninja + +cd _deps/mlx-src +git diff +cd ../.. + ninja ./example1 --device gpu ./tutorial --device gpu From d69ddd674fd426269362db3cc561f8fa7b069f82 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 20:02:13 -0700 Subject: [PATCH 06/22] Revert "debug cuda run" This reverts commit 8972311e206d1ccabf16fdb2fb83df876d21f00a. --- .github/scripts/build-linux-cuda-cmake.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/scripts/build-linux-cuda-cmake.sh b/.github/scripts/build-linux-cuda-cmake.sh index 3edbd44b7..c84e02f27 100755 --- a/.github/scripts/build-linux-cuda-cmake.sh +++ b/.github/scripts/build-linux-cuda-cmake.sh @@ -16,11 +16,6 @@ rm -rf build mkdir -p build pushd build cmake -DMLX_BUILD_METAL=OFF -DMLX_BUILD_CUDA=ON -DMLX_C_BUILD_EXAMPLES=OFF .. -G Ninja - -cd _deps/mlx-src -git diff -cd ../.. - ninja ./example1 --device gpu ./tutorial --device gpu From 4521f0051f4bde97f9f73bdc03967bd1d858d062 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 20:25:28 -0700 Subject: [PATCH 07/22] Reapply "debug cuda run" This reverts commit d69ddd674fd426269362db3cc561f8fa7b069f82. --- .github/scripts/build-linux-cuda-cmake.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/scripts/build-linux-cuda-cmake.sh b/.github/scripts/build-linux-cuda-cmake.sh index c84e02f27..3edbd44b7 100755 --- a/.github/scripts/build-linux-cuda-cmake.sh +++ b/.github/scripts/build-linux-cuda-cmake.sh @@ -16,6 +16,11 @@ rm -rf build mkdir -p build pushd build cmake -DMLX_BUILD_METAL=OFF -DMLX_BUILD_CUDA=ON -DMLX_C_BUILD_EXAMPLES=OFF .. -G Ninja + +cd _deps/mlx-src +git diff +cd ../.. + ninja ./example1 --device gpu ./tutorial --device gpu From 0e8039164ce820730f2348f1530c83161b2ad6fc Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 21:09:02 -0700 Subject: [PATCH 08/22] try and clean up the patches --- .github/scripts/build-linux-cuda-cmake.sh | 9 ++++++--- CMakeLists.txt | 21 ++++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/scripts/build-linux-cuda-cmake.sh b/.github/scripts/build-linux-cuda-cmake.sh index 3edbd44b7..2fc81d589 100755 --- a/.github/scripts/build-linux-cuda-cmake.sh +++ b/.github/scripts/build-linux-cuda-cmake.sh @@ -17,9 +17,12 @@ mkdir -p build pushd build cmake -DMLX_BUILD_METAL=OFF -DMLX_BUILD_CUDA=ON -DMLX_C_BUILD_EXAMPLES=OFF .. -G Ninja -cd _deps/mlx-src -git diff -cd ../.. +# Verify the local mlx patch is actually in place: without it the examples below +# crash on exit, which is easy to misread as an unrelated CUDA failure. +git -C _deps/mlx-src apply --reverse --check "$PWD/../cmake/mlx.patch" || { + echo "error: cmake/mlx.patch is not applied to _deps/mlx-src" + exit 1 +} ninja ./example1 --device gpu diff --git a/CMakeLists.txt b/CMakeLists.txt index 61b4af6a3..8f4c0ce5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,8 +30,17 @@ endif() # mlx +# note: patches are applied via cmake/apply-patch.cmake so that they are +# idempotent (the patch step re-runs on every update step) while still failing +# the build loudly if a patch does not apply -- a silently unpatched dependency +# is very hard to diagnose (it typically shows up as a runtime failure). +set(apply_patch ${CMAKE_COMMAND} -DREPO=) +set(apply_patch_script -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/apply-patch.cmake) + # local apply of https://github.com/ml-explore/mlx/pull/4480 for CUDA -set(mlx_patch git apply ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch) +set(mlx_patch + ${apply_patch} -DPATCH=${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch + ${apply_patch_script}) # note: this must be declared (and named `mlx`) before mlx-c is made available # so that our pinned/patched version wins over the one mlx-c declares. Because @@ -46,7 +55,7 @@ FetchContent_Declare( mlx GIT_REPOSITORY "https://github.com/ml-explore/mlx.git" GIT_TAG "1f8e74e3f12f31365464a6867c6579f0e9b29d85" - PATCH_COMMAND ${mlx_patch} || true) + PATCH_COMMAND ${mlx_patch}) FetchContent_MakeAvailable(mlx) # mlx-c @@ -58,13 +67,15 @@ FetchContent_Declare( FetchContent_MakeAvailable(mlx-c) # swift-numerics -set(swift_numerics_patch git apply - ${CMAKE_CURRENT_SOURCE_DIR}/cmake/swift-numerics.patch) +set(swift_numerics_patch + ${apply_patch} + -DPATCH=${CMAKE_CURRENT_SOURCE_DIR}/cmake/swift-numerics.patch + ${apply_patch_script}) FetchContent_Declare( swift-numerics GIT_REPOSITORY "https://github.com/apple/swift-numerics.git" GIT_TAG "1.0.2" - PATCH_COMMAND ${swift_numerics_patch} || true) + PATCH_COMMAND ${swift_numerics_patch}) FetchContent_MakeAvailable(swift-numerics) # MLX package From 0f003b6aa22815a20da71f5ca9e45e800acf5474 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 21:32:46 -0700 Subject: [PATCH 09/22] add missing files --- cmake/apply-patch.cmake | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 cmake/apply-patch.cmake diff --git a/cmake/apply-patch.cmake b/cmake/apply-patch.cmake new file mode 100644 index 000000000..06e667ce9 --- /dev/null +++ b/cmake/apply-patch.cmake @@ -0,0 +1,68 @@ +# Idempotent `git apply` for FetchContent PATCH_COMMAND. +# +# Usage (from a FetchContent_Declare PATCH_COMMAND): +# +# PATCH_COMMAND ${CMAKE_COMMAND} -DREPO= +# -DPATCH=${CMAKE_CURRENT_SOURCE_DIR}/cmake/foo.patch -P +# ${CMAKE_CURRENT_SOURCE_DIR}/cmake/apply-patch.cmake +# +# Unlike `git apply ... || true` this does not swallow real failures: an +# already-applied patch is a no-op (the patch step re-runs on every update), but +# a patch that fails to apply is a hard error instead of a silently unpatched +# dependency. + +cmake_minimum_required(VERSION 3.16) + +if(NOT DEFINED PATCH OR PATCH STREQUAL "") + message(FATAL_ERROR "apply-patch.cmake: -DPATCH= is required") +endif() + +if(NOT DEFINED REPO + OR REPO STREQUAL "" + OR NOT IS_DIRECTORY "${REPO}") + # the patch step runs with the dependency source dir as its working directory + set(REPO "${CMAKE_CURRENT_BINARY_DIR}") +endif() + +if(NOT EXISTS "${PATCH}") + message(FATAL_ERROR "apply-patch.cmake: no such patch file: ${PATCH}") +endif() + +# note: deliberately not `find_package(Git REQUIRED)` -- that pulls in +# FindPackageHandleStandardArgs in script mode for no benefit here. +if(NOT DEFINED GIT_EXECUTABLE OR GIT_EXECUTABLE STREQUAL "") + find_program(GIT_EXECUTABLE NAMES git git.exe) +endif() + +if(NOT GIT_EXECUTABLE) + message(FATAL_ERROR "apply-patch.cmake: git not found (pass -DGIT_EXECUTABLE=)") +endif() + +message(STATUS "apply-patch: repo=${REPO} patch=${PATCH} git=${GIT_EXECUTABLE}") + +# already applied? (the patch step re-runs whenever the update step re-runs) +execute_process( + COMMAND ${GIT_EXECUTABLE} apply --reverse --check "${PATCH}" + WORKING_DIRECTORY "${REPO}" + RESULT_VARIABLE reverse_check + OUTPUT_QUIET ERROR_QUIET) + +if(reverse_check EQUAL 0) + message(STATUS "apply-patch: already applied: ${PATCH}") + return() +endif() + +# note: git's stdout/stderr are intentionally *not* captured so that the real +# reason a patch failed always reaches the build log, even if this script dies +# before it can format a message of its own. +execute_process( + COMMAND ${GIT_EXECUTABLE} apply --verbose "${PATCH}" + WORKING_DIRECTORY "${REPO}" + RESULT_VARIABLE apply_result) + +if(NOT apply_result EQUAL 0) + message(FATAL_ERROR "apply-patch: failed to apply ${PATCH} in ${REPO} " + "(git apply exit ${apply_result}, see output above)") +endif() + +message(STATUS "apply-patch: applied ${PATCH}") From 9c26f057b94ceb324049bda078e892395c4b51bb Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 21:35:16 -0700 Subject: [PATCH 10/22] need git --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 56452955c..aa7577636 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -89,7 +89,7 @@ jobs: - name: Setup cmake shell: sh run: | - brew install cmake ninja + brew install cmake ninja git - name: Verify documentation run: scripts/verify-docs.sh From 72356911c6d65fa7c5954aae0c913747575eed30 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 21:40:33 -0700 Subject: [PATCH 11/22] need git --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index aa7577636..a805ea144 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -24,7 +24,7 @@ jobs: - name: Setup pre-commit shell: sh run: | - uv pip install pre-commit + uv pip install pre-commit git - name: Get swift-format tag id: swift-format From 973a6fe336357cb8fcd2a4807e27d342ce2ab36b Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 21:42:34 -0700 Subject: [PATCH 12/22] need git --- .github/workflows/pull_request.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a805ea144..004a22fff 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -21,10 +21,15 @@ jobs: with: activate-environment: true + - name: Setup cmake + shell: sh + run: | + brew install cmake ninja git + - name: Setup pre-commit shell: sh run: | - uv pip install pre-commit git + uv pip install pre-commit - name: Get swift-format tag id: swift-format From 071270f236cdfd920d24dbd364a9c2b513500db7 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 21:45:55 -0700 Subject: [PATCH 13/22] git should be there actually --- .github/workflows/pull_request.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 004a22fff..56452955c 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -21,11 +21,6 @@ jobs: with: activate-environment: true - - name: Setup cmake - shell: sh - run: | - brew install cmake ninja git - - name: Setup pre-commit shell: sh run: | @@ -94,7 +89,7 @@ jobs: - name: Setup cmake shell: sh run: | - brew install cmake ninja git + brew install cmake ninja - name: Verify documentation run: scripts/verify-docs.sh From ce7e09e714e1fad48fe2ebd8897b1a2ec05de7f8 Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 21:48:52 -0700 Subject: [PATCH 14/22] haha, it was cmake format, not git --- cmake/apply-patch.cmake | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmake/apply-patch.cmake b/cmake/apply-patch.cmake index 06e667ce9..63e04f83f 100644 --- a/cmake/apply-patch.cmake +++ b/cmake/apply-patch.cmake @@ -35,7 +35,8 @@ if(NOT DEFINED GIT_EXECUTABLE OR GIT_EXECUTABLE STREQUAL "") endif() if(NOT GIT_EXECUTABLE) - message(FATAL_ERROR "apply-patch.cmake: git not found (pass -DGIT_EXECUTABLE=)") + message( + FATAL_ERROR "apply-patch.cmake: git not found (pass -DGIT_EXECUTABLE=)") endif() message(STATUS "apply-patch: repo=${REPO} patch=${PATCH} git=${GIT_EXECUTABLE}") From 77c1ba06b1d959b9bc4317d458816a489c71e36b Mon Sep 17 00:00:00 2001 From: David Koski Date: Fri, 11 Sep 2026 22:32:36 -0700 Subject: [PATCH 15/22] try leaking thread local encoders as well --- cmake/mlx.patch | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/cmake/mlx.patch b/cmake/mlx.patch index 384d7dd3f..b1fc2c697 100644 --- a/cmake/mlx.patch +++ b/cmake/mlx.patch @@ -1,15 +1,28 @@ diff --git a/mlx/backend/cuda/device.cpp b/mlx/backend/cuda/device.cpp -index 472b3d99..e7d8f262 100644 +index 472b3d99..b310b47a 100644 --- a/mlx/backend/cuda/device.cpp +++ b/mlx/backend/cuda/device.cpp -@@ -617,8 +617,10 @@ std::unordered_map& get_command_encoders() { +@@ -612,13 +612,21 @@ CommandEncoder& get_command_encoder(Stream s) { + } + + std::unordered_map& get_command_encoders() { +- static thread_local std::unordered_map encoders; +- return encoders; ++ // encoders are leaked intentionally: destroying a CommandEncoder stops its ++ // cu::Worker, and the detached worker thread owns the last reference to ++ // itself, so ~Worker (and cudaEventDestroy) runs on that thread at an ++ // arbitrary point during process shutdown -- typically after the CUDA runtime ++ // has started unloading, which turns into a throw from a destructor and ++ // terminates the process ++ static thread_local auto* encoders = ++ new std::unordered_map(); ++ return *encoders; } std::unordered_map& get_global_command_encoders() { - static std::unordered_map encoders; - return encoders; -+ // encoders are leaked intentionally as they would synchronize on process -+ // shutdown ++ // leaked for the same reason as above + static auto* encoders = new std::unordered_map(); + return *encoders; } From 775202f22e58bc12ac20489a321c3db0e24d1db9 Mon Sep 17 00:00:00 2001 From: David Koski Date: Sat, 12 Sep 2026 07:33:48 -0700 Subject: [PATCH 16/22] debugging the patch --- .github/scripts/build-linux-cuda-cmake.sh | 19 ++++ cmake/mlx.patch | 106 +++++++++++++++++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/.github/scripts/build-linux-cuda-cmake.sh b/.github/scripts/build-linux-cuda-cmake.sh index 2fc81d589..7b3b3e93c 100755 --- a/.github/scripts/build-linux-cuda-cmake.sh +++ b/.github/scripts/build-linux-cuda-cmake.sh @@ -25,6 +25,25 @@ git -C _deps/mlx-src apply --reverse --check "$PWD/../cmake/mlx.patch" || { } ninja +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== +./example1 --device gpu +echo =================== ./example1 --device gpu ./tutorial --device gpu popd diff --git a/cmake/mlx.patch b/cmake/mlx.patch index b1fc2c697..51f02b038 100644 --- a/cmake/mlx.patch +++ b/cmake/mlx.patch @@ -1,8 +1,56 @@ diff --git a/mlx/backend/cuda/device.cpp b/mlx/backend/cuda/device.cpp -index 472b3d99..b310b47a 100644 +index 472b3d99..5995e94b 100644 --- a/mlx/backend/cuda/device.cpp +++ b/mlx/backend/cuda/device.cpp -@@ -612,13 +612,21 @@ CommandEncoder& get_command_encoder(Stream s) { +@@ -7,11 +7,27 @@ + + #include + #include ++#include + #include ++#include + #include + + namespace mlx::core::cu { + ++// TEMP DEBUG (mlx-swift): trace CommandEncoder lifetime so we can tell whether ++// the thread-local or the global (thread-unsafe) encoder map is in use. ++void encoder_debug_log(const char* what, int stream_index, const void* encoder) { ++ fprintf( ++ stderr, ++ "[mlx-swift/encoders] %s stream=%d encoder=%p thread=%zu main=%d\n", ++ what, ++ stream_index, ++ encoder, ++ std::hash{}(std::this_thread::get_id()), ++ is_main_thread() ? 1 : 0); ++ fflush(stderr); ++} ++ + namespace { + + bool use_cuda_graphs() { +@@ -210,9 +226,11 @@ CommandEncoder::CommandEncoder(Device& d) + graph_cache_("MLX_CUDA_GRAPH_CACHE_SIZE", /* default_capacity */ 400) { + std::tie(max_ops_per_graph_, max_mb_per_graph_) = get_graph_limits(d); + worker_->start(); ++ encoder_debug_log("CommandEncoder()", -1, this); + } + + CommandEncoder::~CommandEncoder() { ++ encoder_debug_log("~CommandEncoder", -1, this); + synchronize(); + worker_->stop(); + } +@@ -601,6 +619,7 @@ CommandEncoder& get_command_encoder(Stream s) { + auto it = encoders.find(s.index); + if (it == encoders.end()) { + auto& global_encoders = get_global_command_encoders(); ++ encoder_debug_log("lookup falls back to global map", s.index, nullptr); + it = global_encoders.find(s.index); + if (it == global_encoders.end()) { + throw std::runtime_error( +@@ -612,13 +631,26 @@ CommandEncoder& get_command_encoder(Stream s) { } std::unordered_map& get_command_encoders() { @@ -14,8 +62,10 @@ index 472b3d99..b310b47a 100644 + // arbitrary point during process shutdown -- typically after the CUDA runtime + // has started unloading, which turns into a throw from a destructor and + // terminates the process -+ static thread_local auto* encoders = -+ new std::unordered_map(); ++ static thread_local auto* encoders = []() { ++ encoder_debug_log("new thread_local encoder map", -1, nullptr); ++ return new std::unordered_map(); ++ }(); + return *encoders; } @@ -23,8 +73,54 @@ index 472b3d99..b310b47a 100644 - static std::unordered_map encoders; - return encoders; + // leaked for the same reason as above -+ static auto* encoders = new std::unordered_map(); ++ static auto* encoders = []() { ++ encoder_debug_log("new global encoder map", -1, nullptr); ++ return new std::unordered_map(); ++ }(); + return *encoders; } } // namespace mlx::core::cu +diff --git a/mlx/backend/cuda/device.h b/mlx/backend/cuda/device.h +index 198f0b5a..2507202c 100644 +--- a/mlx/backend/cuda/device.h ++++ b/mlx/backend/cuda/device.h +@@ -214,6 +214,9 @@ MLX_API Device& device(int cuda_device); + MLX_API Device& device(mlx::core::Device d); + MLX_API CommandEncoder& get_command_encoder(Stream s); + ++// TEMP DEBUG (mlx-swift): see device.cpp ++void encoder_debug_log(const char* what, int stream_index, const void* encoder); ++ + std::unordered_map& get_command_encoders(); + std::unordered_map& get_global_command_encoders(); + +diff --git a/mlx/backend/cuda/eval.cpp b/mlx/backend/cuda/eval.cpp +index f8a71d71..0503d7c8 100644 +--- a/mlx/backend/cuda/eval.cpp ++++ b/mlx/backend/cuda/eval.cpp +@@ -26,6 +26,7 @@ void init() { + + void new_stream(Stream s) { + assert(s.device == Device::gpu); ++ cu::encoder_debug_log("new_stream (thread_local map)", s.index, nullptr); + auto& encoders = cu::get_command_encoders(); + auto& d = cu::device(s.device); + encoders.try_emplace(s.index, d); +@@ -33,6 +34,8 @@ void new_stream(Stream s) { + + void new_thread_unsafe_stream(Stream s) { + assert(s.device == Device::gpu); ++ cu::encoder_debug_log( ++ "new_thread_unsafe_stream (global map)", s.index, nullptr); + auto& encoders = cu::get_global_command_encoders(); + auto& d = cu::device(s.device); + encoders.try_emplace(s.index, d); +@@ -87,6 +90,7 @@ void synchronize(Stream s) { + } + + void clear_streams() { ++ cu::encoder_debug_log("clear_streams", -1, nullptr); + cu::get_command_encoders().clear(); + if (is_main_thread()) { + cu::get_global_command_encoders().clear(); From ab10a8b3f891c6ee5cc22825d40633138a9621c9 Mon Sep 17 00:00:00 2001 From: David Koski Date: Sat, 12 Sep 2026 20:39:06 -0700 Subject: [PATCH 17/22] a different approach --- cmake/mlx.patch | 151 ++++++++++++------------------------------------ 1 file changed, 37 insertions(+), 114 deletions(-) diff --git a/cmake/mlx.patch b/cmake/mlx.patch index 51f02b038..47fb5bdd0 100644 --- a/cmake/mlx.patch +++ b/cmake/mlx.patch @@ -1,126 +1,49 @@ +diff --git a/mlx/backend/cuda/cuda_utils.h b/mlx/backend/cuda/cuda_utils.h +index f8a234ee..cc075f7b 100644 +--- a/mlx/backend/cuda/cuda_utils.h ++++ b/mlx/backend/cuda/cuda_utils.h +@@ -30,7 +30,22 @@ class CudaHandle { + if (cudaPeekAtLastError() != cudaSuccess) { + return; + } +- reset(); ++ // Never CHECK here: a destructor must not throw. ++ // ++ // Handles are routinely destroyed at times when the CUDA runtime is no ++ // longer usable. The worst case is cu::Worker: it is started detached and ++ // holds the last shared_ptr to itself, so ~Worker -- and with it ++ // ~CudaEvent -> CudaEventPool::release() -> cudaEventDestroy, since the ++ // pool only recycles events on its own creation thread -- runs on the ++ // worker thread whenever it next wakes up. When that happens during ++ // process shutdown it races the CUDA runtime's own atexit teardown, and ++ // throwing out of this destructor terminates the process instead of ++ // reporting anything useful. Losing a handle we were about to destroy ++ // anyway is strictly better. ++ if (handle_ != nullptr) { ++ Destroy(handle_); ++ handle_ = nullptr; ++ } + } + + CudaHandle(const CudaHandle&) = delete; diff --git a/mlx/backend/cuda/device.cpp b/mlx/backend/cuda/device.cpp -index 472b3d99..5995e94b 100644 +index 472b3d99..5de6fdf1 100644 --- a/mlx/backend/cuda/device.cpp +++ b/mlx/backend/cuda/device.cpp -@@ -7,11 +7,27 @@ - - #include - #include -+#include - #include -+#include - #include - - namespace mlx::core::cu { - -+// TEMP DEBUG (mlx-swift): trace CommandEncoder lifetime so we can tell whether -+// the thread-local or the global (thread-unsafe) encoder map is in use. -+void encoder_debug_log(const char* what, int stream_index, const void* encoder) { -+ fprintf( -+ stderr, -+ "[mlx-swift/encoders] %s stream=%d encoder=%p thread=%zu main=%d\n", -+ what, -+ stream_index, -+ encoder, -+ std::hash{}(std::this_thread::get_id()), -+ is_main_thread() ? 1 : 0); -+ fflush(stderr); -+} -+ - namespace { - - bool use_cuda_graphs() { -@@ -210,9 +226,11 @@ CommandEncoder::CommandEncoder(Device& d) - graph_cache_("MLX_CUDA_GRAPH_CACHE_SIZE", /* default_capacity */ 400) { - std::tie(max_ops_per_graph_, max_mb_per_graph_) = get_graph_limits(d); - worker_->start(); -+ encoder_debug_log("CommandEncoder()", -1, this); - } - - CommandEncoder::~CommandEncoder() { -+ encoder_debug_log("~CommandEncoder", -1, this); - synchronize(); - worker_->stop(); - } -@@ -601,6 +619,7 @@ CommandEncoder& get_command_encoder(Stream s) { - auto it = encoders.find(s.index); - if (it == encoders.end()) { - auto& global_encoders = get_global_command_encoders(); -+ encoder_debug_log("lookup falls back to global map", s.index, nullptr); - it = global_encoders.find(s.index); - if (it == global_encoders.end()) { - throw std::runtime_error( -@@ -612,13 +631,26 @@ CommandEncoder& get_command_encoder(Stream s) { - } - - std::unordered_map& get_command_encoders() { -- static thread_local std::unordered_map encoders; -- return encoders; -+ // encoders are leaked intentionally: destroying a CommandEncoder stops its -+ // cu::Worker, and the detached worker thread owns the last reference to -+ // itself, so ~Worker (and cudaEventDestroy) runs on that thread at an -+ // arbitrary point during process shutdown -- typically after the CUDA runtime -+ // has started unloading, which turns into a throw from a destructor and -+ // terminates the process -+ static thread_local auto* encoders = []() { -+ encoder_debug_log("new thread_local encoder map", -1, nullptr); -+ return new std::unordered_map(); -+ }(); -+ return *encoders; +@@ -617,8 +617,14 @@ std::unordered_map& get_command_encoders() { } std::unordered_map& get_global_command_encoders() { - static std::unordered_map encoders; - return encoders; -+ // leaked for the same reason as above -+ static auto* encoders = []() { -+ encoder_debug_log("new global encoder map", -1, nullptr); -+ return new std::unordered_map(); -+ }(); ++ // Leaked intentionally. Unlike the thread-local map above -- which is ++ // destroyed when its thread exits, while CUDA is still fully alive, and which ++ // is the only thing that reclaims per-thread streams/events/worker threads -- ++ // this map is a plain static, so its only destruction point is static ++ // destruction during exit(), racing the CUDA runtime teardown. There is ++ // nothing to gain from destroying it there: the process is going away. ++ static auto* encoders = new std::unordered_map(); + return *encoders; } } // namespace mlx::core::cu -diff --git a/mlx/backend/cuda/device.h b/mlx/backend/cuda/device.h -index 198f0b5a..2507202c 100644 ---- a/mlx/backend/cuda/device.h -+++ b/mlx/backend/cuda/device.h -@@ -214,6 +214,9 @@ MLX_API Device& device(int cuda_device); - MLX_API Device& device(mlx::core::Device d); - MLX_API CommandEncoder& get_command_encoder(Stream s); - -+// TEMP DEBUG (mlx-swift): see device.cpp -+void encoder_debug_log(const char* what, int stream_index, const void* encoder); -+ - std::unordered_map& get_command_encoders(); - std::unordered_map& get_global_command_encoders(); - -diff --git a/mlx/backend/cuda/eval.cpp b/mlx/backend/cuda/eval.cpp -index f8a71d71..0503d7c8 100644 ---- a/mlx/backend/cuda/eval.cpp -+++ b/mlx/backend/cuda/eval.cpp -@@ -26,6 +26,7 @@ void init() { - - void new_stream(Stream s) { - assert(s.device == Device::gpu); -+ cu::encoder_debug_log("new_stream (thread_local map)", s.index, nullptr); - auto& encoders = cu::get_command_encoders(); - auto& d = cu::device(s.device); - encoders.try_emplace(s.index, d); -@@ -33,6 +34,8 @@ void new_stream(Stream s) { - - void new_thread_unsafe_stream(Stream s) { - assert(s.device == Device::gpu); -+ cu::encoder_debug_log( -+ "new_thread_unsafe_stream (global map)", s.index, nullptr); - auto& encoders = cu::get_global_command_encoders(); - auto& d = cu::device(s.device); - encoders.try_emplace(s.index, d); -@@ -87,6 +90,7 @@ void synchronize(Stream s) { - } - - void clear_streams() { -+ cu::encoder_debug_log("clear_streams", -1, nullptr); - cu::get_command_encoders().clear(); - if (is_main_thread()) { - cu::get_global_command_encoders().clear(); From 55160e21aca2e9bedbd551b71844563f7c907ac7 Mon Sep 17 00:00:00 2001 From: David Koski Date: Mon, 14 Sep 2026 09:17:58 -0700 Subject: [PATCH 18/22] clean up changes --- CMakeLists.txt | 9 ++++++++- cmake/mlx.patch | 25 +++++-------------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f4c0ce5d..94ae24abb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,7 +37,14 @@ endif() set(apply_patch ${CMAKE_COMMAND} -DREPO=) set(apply_patch_script -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/apply-patch.cmake) -# local apply of https://github.com/ml-explore/mlx/pull/4480 for CUDA +# two local patches for CUDA: +# +# - https://github.com/ml-explore/mlx/pull/4480: leak the global command encoder +# map rather than synchronizing on process shutdown +# - make ~CudaHandle non-throwing: cu::Worker is detached and holds the last +# reference to itself, so ~Worker -> ~CudaEvent -> cudaEventDestroy runs on the +# worker thread at an arbitrary point during exit, racing the CUDA runtime's +# own teardown. A failing destroy there terminated the process. set(mlx_patch ${apply_patch} -DPATCH=${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch ${apply_patch_script}) diff --git a/cmake/mlx.patch b/cmake/mlx.patch index 47fb5bdd0..dc8f2f7b8 100644 --- a/cmake/mlx.patch +++ b/cmake/mlx.patch @@ -7,18 +7,7 @@ index f8a234ee..cc075f7b 100644 return; } - reset(); -+ // Never CHECK here: a destructor must not throw. -+ // -+ // Handles are routinely destroyed at times when the CUDA runtime is no -+ // longer usable. The worst case is cu::Worker: it is started detached and -+ // holds the last shared_ptr to itself, so ~Worker -- and with it -+ // ~CudaEvent -> CudaEventPool::release() -> cudaEventDestroy, since the -+ // pool only recycles events on its own creation thread -- runs on the -+ // worker thread whenever it next wakes up. When that happens during -+ // process shutdown it races the CUDA runtime's own atexit teardown, and -+ // throwing out of this destructor terminates the process instead of -+ // reporting anything useful. Losing a handle we were about to destroy -+ // anyway is strictly better. ++ // Inline reset() without throwing -- handle shutdown cases. + if (handle_ != nullptr) { + Destroy(handle_); + handle_ = nullptr; @@ -27,21 +16,17 @@ index f8a234ee..cc075f7b 100644 CudaHandle(const CudaHandle&) = delete; diff --git a/mlx/backend/cuda/device.cpp b/mlx/backend/cuda/device.cpp -index 472b3d99..5de6fdf1 100644 +index 472b3d99..e7d8f262 100644 --- a/mlx/backend/cuda/device.cpp +++ b/mlx/backend/cuda/device.cpp -@@ -617,8 +617,14 @@ std::unordered_map& get_command_encoders() { +@@ -617,8 +617,10 @@ std::unordered_map& get_command_encoders() { } std::unordered_map& get_global_command_encoders() { - static std::unordered_map encoders; - return encoders; -+ // Leaked intentionally. Unlike the thread-local map above -- which is -+ // destroyed when its thread exits, while CUDA is still fully alive, and which -+ // is the only thing that reclaims per-thread streams/events/worker threads -- -+ // this map is a plain static, so its only destruction point is static -+ // destruction during exit(), racing the CUDA runtime teardown. There is -+ // nothing to gain from destroying it there: the process is going away. ++ // encoders are leaked intentionally as they would synchronize on process ++ // shutdown + static auto* encoders = new std::unordered_map(); + return *encoders; } From 1d7250eb5afd6db0d47ef6a88e7c3c870b68fe76 Mon Sep 17 00:00:00 2001 From: David Koski Date: Mon, 14 Sep 2026 09:21:28 -0700 Subject: [PATCH 19/22] cmake-format --- CMakeLists.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 94ae24abb..b7390ecb7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,12 +39,12 @@ set(apply_patch_script -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/apply-patch.cmake) # two local patches for CUDA: # -# - https://github.com/ml-explore/mlx/pull/4480: leak the global command encoder +# * https://github.com/ml-explore/mlx/pull/4480: leak the global command encoder # map rather than synchronizing on process shutdown -# - make ~CudaHandle non-throwing: cu::Worker is detached and holds the last -# reference to itself, so ~Worker -> ~CudaEvent -> cudaEventDestroy runs on the -# worker thread at an arbitrary point during exit, racing the CUDA runtime's -# own teardown. A failing destroy there terminated the process. +# * make ~CudaHandle non-throwing: cu::Worker is detached and holds the last +# reference to itself, so ~Worker -> ~CudaEvent -> cudaEventDestroy runs on +# the worker thread at an arbitrary point during exit, racing the CUDA +# runtime's own teardown. A failing destroy there terminated the process. set(mlx_patch ${apply_patch} -DPATCH=${CMAKE_CURRENT_SOURCE_DIR}/cmake/mlx.patch ${apply_patch_script}) From 1f466865c424e00adca7b54e34ccf5571375d515 Mon Sep 17 00:00:00 2001 From: David Koski Date: Mon, 14 Sep 2026 09:27:35 -0700 Subject: [PATCH 20/22] fix patch --- cmake/mlx.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/mlx.patch b/cmake/mlx.patch index dc8f2f7b8..de72da7bb 100644 --- a/cmake/mlx.patch +++ b/cmake/mlx.patch @@ -2,7 +2,7 @@ diff --git a/mlx/backend/cuda/cuda_utils.h b/mlx/backend/cuda/cuda_utils.h index f8a234ee..cc075f7b 100644 --- a/mlx/backend/cuda/cuda_utils.h +++ b/mlx/backend/cuda/cuda_utils.h -@@ -30,7 +30,22 @@ class CudaHandle { +@@ -30,6 +30,11 @@ class CudaHandle { if (cudaPeekAtLastError() != cudaSuccess) { return; } From 44e75efd15152655d3aad92c03cf2c74c7fe228b Mon Sep 17 00:00:00 2001 From: David Koski Date: Mon, 14 Sep 2026 09:40:04 -0700 Subject: [PATCH 21/22] fix patch try 2 --- cmake/mlx.patch | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmake/mlx.patch b/cmake/mlx.patch index de72da7bb..de99b7ebc 100644 --- a/cmake/mlx.patch +++ b/cmake/mlx.patch @@ -1,8 +1,8 @@ diff --git a/mlx/backend/cuda/cuda_utils.h b/mlx/backend/cuda/cuda_utils.h -index f8a234ee..cc075f7b 100644 +index f8a234ee6..3b7a79f27 100644 --- a/mlx/backend/cuda/cuda_utils.h +++ b/mlx/backend/cuda/cuda_utils.h -@@ -30,6 +30,11 @@ class CudaHandle { +@@ -30,7 +30,11 @@ class CudaHandle { if (cudaPeekAtLastError() != cudaSuccess) { return; } @@ -16,7 +16,7 @@ index f8a234ee..cc075f7b 100644 CudaHandle(const CudaHandle&) = delete; diff --git a/mlx/backend/cuda/device.cpp b/mlx/backend/cuda/device.cpp -index 472b3d99..e7d8f262 100644 +index 472b3d99f..e7d8f2620 100644 --- a/mlx/backend/cuda/device.cpp +++ b/mlx/backend/cuda/device.cpp @@ -617,8 +617,10 @@ std::unordered_map& get_command_encoders() { From c13b5c5fe643f3f3ad4cb263da89812aa791f3ea Mon Sep 17 00:00:00 2001 From: David Koski Date: Tue, 15 Sep 2026 15:18:15 -0700 Subject: [PATCH 22/22] remove debugging code --- .github/scripts/build-linux-cuda-cmake.sh | 19 ------------------- CMakeLists.txt | 2 +- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/.github/scripts/build-linux-cuda-cmake.sh b/.github/scripts/build-linux-cuda-cmake.sh index 7b3b3e93c..2fc81d589 100755 --- a/.github/scripts/build-linux-cuda-cmake.sh +++ b/.github/scripts/build-linux-cuda-cmake.sh @@ -25,25 +25,6 @@ git -C _deps/mlx-src apply --reverse --check "$PWD/../cmake/mlx.patch" || { } ninja -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== -./example1 --device gpu -echo =================== ./example1 --device gpu ./tutorial --device gpu popd diff --git a/CMakeLists.txt b/CMakeLists.txt index b7390ecb7..22f4b16ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,7 +61,7 @@ set(MLX_BUILD_PYTHON_BINDINGS OFF) FetchContent_Declare( mlx GIT_REPOSITORY "https://github.com/ml-explore/mlx.git" - GIT_TAG "1f8e74e3f12f31365464a6867c6579f0e9b29d85" + GIT_TAG "v0.32.2" PATCH_COMMAND ${mlx_patch}) FetchContent_MakeAvailable(mlx)