Repository navigation
Fix the CUDA exit abort and the completion-worker busy loop, and add three CUDA throughput changes - #34
Fix the CUDA exit abort and the completion-worker busy loop, and add three CUDA throughput changes#34davidtai wants to merge 14 commits into
Conversation
tests/cuda_tests.cpp is the upstream file at ml-explore/mlx 64ea011, unchanged. Upstream added it in e0a92d9 (ml-explore#4457) and changed it in 229f5b4 (ml-explore#4490). The CMake wiring follows upstream: CUDA builds add cuda_tests.cpp to the GPU test sources and link CUDA::cudart. The Windows-only cases compile only under _WIN32. The fork needs the file so that the mlx-serve CUDA patches, which add test cases to it, apply without edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 24c699e) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in eval/compile (ml-explore#4514) Co-authored-by: Cheng <git@zcbenz.com> Fork note: CUDA hunks only (allocator.cpp, cuda_utils.h, device.cpp, event.cu). The default-stream changes in compile.cpp, stream.{h,cpp}, transforms.cpp and tests/scheduler_tests.cpp are not taken. (cherry picked from commit 9eb3e3a) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 64ea011) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit ce916db) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l-explore#4661) Co-authored-by: Cheng <git@zcbenz.com> (cherry picked from commit 9ab7999) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the mlx-serve patch header: > MLX's CUDA backend has no fused attention past head dim 128: the vector > kernel takes 64/96/128 and cuDNN is capped at 128, so Gemma 4 (256 sliding, > 512 global) and the Qwen3.5 family (256) run every attention call on the > unfused fallback, which materializes the score matrix. This patch: > > - runs the vector kernel (1..3 query rows) at head dims 256 and 512, with > vectorized per-lane row loads (one 16-byte load instead of eight scalar > ones) and __launch_bounds__(1024) so the 512 variants fit the 1024-thread > block's register budget; > - lets cuDNN take head dim 256 on sm_120 (verified with cuDNN 9.27), which > serves prefill-sized blocks 2-3x faster than the fallback; > - keeps cuDNN off 2..15 query rows: its graph is cached per shape, so a > speculative-verify block over a growing kv cache rebuilt it every step > (milliseconds of encode per attention layer). > > Upstream PR pending (the user submits it); drop this file and its step in > build-mlx-linux.sh once lib/mlx-src includes it. Source: mlx-serve patches/mlx-cuda-sdpa-head-dim-256-512.patch, added in ddalcu/mlx-serve b6aaf690, read at ddalcu/mlx-serve 3877b79. mlx-serve applies it to ml-explore/mlx 64ea011. On 2026-10-13 ml-explore/mlx has no PR or commit with this change. Source hunks are unchanged. The test case is unchanged, but its hunk was applied with one line of context (git apply -C1): in mlx-serve it follows the LRU test case of mlx-cuda-lru-consecutive-misses, which the fork does not carry (that test calls env::get_var, which libmlx.so does not export, so it does not link in a BUILD_SHARED_LIBS=ON build). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es (mlx-serve) From the mlx-serve patch header: > MLX's CUDA quantized matmul read the weights once per row of x for a few > rows, and once per batch of x always: > > - On GPUs with qmm_sm80 (sm_80 and later, sm_120 included) it ran qmv for > M*B < 8. qmv launches one block row per row of x, so every row streams the > whole weight matrix again: 2..7 rows (speculative verify) cost about M > times one row. qmm_sm80 reads the weights once for all rows and from two > rows is as fast as qmv at one (sm_120: a 6-row 4096x12288 4-bit matmul > 485 -> 94 us). Use qmv for a single row only, as the sm90 branch does. > - x of shape [B, M, K] against a 2D weight ran as B problems of M rows, each > a full pass over the weights: batched decode ([B, 1, K]) cost B single > steps. Fold the batches into rows (a view, x is row-contiguous) so one > pass serves them all. > > Upstream PR pending (the user submits it); drop this file and its step in > build-mlx-linux.sh once lib/mlx-src includes it. Source: mlx-serve patches/mlx-cuda-qmm-read-weights-once.patch, added in ddalcu/mlx-serve b6aaf690, read at ddalcu/mlx-serve 3877b79. mlx-serve applies it to ml-explore/mlx 64ea011. On 2026-10-13 ml-explore/mlx has no PR or commit with this change. Applied here with git apply, unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l-explore#4457) CudaAllocator::clear_cache freed the cached buffers with cudaFreeAsync on the free stream, but the default memory pool kept the memory reserved. clear_cache now synchronizes the free stream of each device and calls cudaMemPoolTrimTo(pool, 0), so the memory goes back to the system. Fork note: only the clear_cache hunk of mlx/backend/cuda/allocator.cpp from ml-explore/mlx e0a92d9 "win: Use DXGI for accurate WDDM VRAM memory budget (ml-explore#4457)" is taken. The WDDM memory budget (wddm.{h,cpp}, get_memory_limit on Windows) is not taken. The upstream CUDA test "test clear cache trims CUDA pool" in tests/cuda_tests.cpp checks this behaviour; without this hunk it fails on GB10 (spark-2). (cherry picked from commit e0a92d9) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… qmm changes Adds one section per change on this branch, with the upstream commit ids and the mlx-serve patch names. The v0.32.2 sync section now says that some of its files carry later fork changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs clang-format 21.1.8 (the pre-commit pin) with the repo .clang-format on mlx/backend/cuda/quantized/quantized.cpp and tests/cuda_tests.cpp. Layout only. The mlx-serve patch commits stay byte-equal to their sources; CI "Check Lint" failed on them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the batch fold of the mlx-serve qmm patch, x [0, M, K] against a 2D weight becomes [0, K], so M = 0 and B = y.size() / (M * N) divides by zero. Return after the output allocation when out.size() == 0, as the CUDA indexing primitives do. This also covers a 2D [0, K] input, which crashed before the patch too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libmlx builds with hidden visibility. The inline helpers detail::in_tracing() and detail::retain_graph() read InTracing::trace_stack() and RetainGraph::tracing_counter, which were not exported. The Layr-Labs mlx-c array.cpp calls both helpers, so a shared libmlxc had undefined symbols against a shared libmlx. Only BUILD_SHARED_LIBS=ON shows this. The SwiftPM build of Cmlx compiles MLX and mlx-c into one static target, so the symbols were always visible there. Upstream ml-explore/mlx main (243bdf0) does not export these structs either, so there is no upstream commit to take. Code is the mlx-shim stage patch patches/mlx/0001-export-intracing- retaingraph.patch (Layr-Labs/mlx-shim origin/adapters 4d4807c), unchanged, so the stage script's guard ("struct MLX_API RetainGraph" in mlx/transforms_impl.h) sees it and skips the patch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
qmm: the 2 to 7-row and [B, 1, K] cases now run qmm_sm80 with a different summation order, so they are not bitwise equal to the 1-row path and CUDA goldens taken on the base must be made again; fp modes move too and are untested; the clang-format and empty-output commits. SDPA: the cuDNN cut for 2 to 15 query rows applies at all head dims. LRU: also the cuFFT plan and SDPA backward caches. New section for the InTracing / RetainGraph export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Wording note for commit The cuDNN cut for 2 to 15 query rows ( 🤖 Generated with Claude Code |
|
Box results on spark-2 for the two open review findings (record:
Finding 7 (exit abort end to end) is closed. Finding 6 is confirmed: the 1-row cost is the worker-thread wake-up that |
Summary
This PR fixes the CUDA exit-time abort, stops the CUDA completion-worker busy loop, and adds three CUDA throughput changes from mlx-serve. It is item 1.3 of the mlx-shim program (DBLM-727). Base:
main=cb77239. Every commit is signed. Upstream commits keep their upstream author.20999217tests: add the CUDA C++ tests from upstreamtests/cuda_tests.cpp(upstream file at64ea011, unchanged). CUDA builds compile it and linkCUDA::cudart, as upstream does.64ea011(file frome0a92d9ml-explore#4457,229f5b4ml-explore#4490)a14f55a8Leak global CommandEncoder on exitnew, so its destructors do not run at exit.24c699e(ml-explore#4480)5e03c71eDo not throw from CUDA destructors~CommandEncodercatches a failedsynchronize().~CudaHandle,unified_free,free_asyncand theAtomicEventdeleter do not throw. CUDA hunks only (allocator.cpp,cuda_utils.h,device.cpp,event.cu). The default-stream changes incompile.cpp,stream.{h,cpp},transforms.cppare not taken.9eb3e3a(ml-explore#4514), partial67142db0Leak thread local streams on exit in main threadnew. CUDA, CPU and Metal hunks all applied without conflicts.64ea011(ml-explore#4576)46cd2e1dFix completion worker busy loopce916db(ml-explore#4452)52a82aedLRU cache counts consecutive missesmlx-cuda-lru-consecutive-misses. The test case of that patch is not taken: it callsenv::get_var, whichlibmlx.sodoes not export, so it does not link withBUILD_SHARED_LIBS=ON.9ab7999(ml-explore#4661)f4c5a936Fused SDPA at head dims 256 and 512git apply -C1, because in mlx-serve it follows the LRU test that is not taken.mlx-cuda-sdpa-head-dim-256-512(ddalcu/mlx-serveb6aaf69). No upstream PR.44847947Quantized matmul reads the weights onceqmm_sm80, only 1 row runsqmv; 2 to 7 rows runqmm_sm80.[B, M, K]x against a 2D weight is folded to[B*M, K]. Unchanged.mlx-cuda-qmm-read-weights-once(b6aaf69). No upstream PR.7a42db35clear_cache trims the CUDA memory poolsclear_cachesynchronizes each free stream and callscudaMemPoolTrimTo(pool, 0). Not in the original item list. The upstream test "test clear cache trims CUDA pool" (commit 1) failed on spark-2 without it. Only theclear_cachehunk is taken; the Windows WDDM budget is not.e0a92d9(ml-explore#4457), partialb1d0ec45fork.yaml7b80b8b6style: clang-formatquantized.cppandcuda_tests.cpp. Layout only. The output equals the CI "Check Lint" diff (blobsdcc9665,da1e33a). Commits 7 and 8 stay byte-equal to the mlx-serve patches.4d3bdafeQuantizedMatmul returns early on an empty output[0, M, K]with a 2D weight gave M = 0 and a divide by zero. Return after the output allocation whenout.size() == 0, as the CUDA indexing primitives do.a1b60f40Export InTracing and RetainGraph from a shared libmlxstruct MLX_API InTracing,struct MLX_API RetainGraphinmlx/transforms_impl.h. The Layr-Labs mlx-c callsdetail::in_tracing()anddetail::retain_graph(), which readInTracing::trace_stack()andRetainGraph::tracing_counter. A hidden-visibility shared libmlx did not export them, so a shared libmlxc had undefined symbols. OnlyBUILD_SHARED_LIBS=ONshows this; the static SwiftPMCmlxbuild never did. Upstream main243bdf0does not export them either, so there is no upstream commit to take. Code is unchanged from the stage patch, so the stage guard (struct MLX_API RetainGraph) skips it.adapters4d4807cpatches/mlx/0001-export-intracing-retaingraph.patch(item 1.1)194a14ebfork.yamlfork.yamlsections added: "Export InTracing and RetainGraph from a shared libmlx", "CUDA C++ tests from upstream", "CUDA exit-time abort (upstream backports)", "CUDA completion-worker busy loop (upstream backport)", "CUDA clear_cache trims the memory pools (upstream backport)", "CUDA LRU cache thrashing check counts consecutive misses", "CUDA fused SDPA at head dims 256 and 512 (mlx-serve)", "CUDA quantized matmul reads the weights once (mlx-serve)". The v0.32.2 sync section now says that some of its files carry later fork changes.python3 scripts/check_forkdiff.py --upstream-ref upstream/mainat194a14eb: rc 0, 190 changed files, 190 described.Commits 11 to 14 were not built or run on the box. The box receipts below are for
b1d0ec45.Verification on spark-2 (GB10, sm_121, CUDA 13.0)
Folder:
/home/eigenlabs/mlx-cbv2-cuda/core-1.3/. Both builds use the same flags: CMake Release,BUILD_SHARED_LIBS=ON,MLX_BUILD_CUDA=ON,MLX_CUDA_ARCHITECTURES=121a-real(the Phase 1 value),MLX_BUILD_GGUF=OFF,MLX_BUILD_TESTS=ON, OpenBLAS 0.3.33 and cuDNN 9.27 wheel from the Phase 1 folder (read-only),nice -n 10,-j 8.base=cb77239,branch=b1d0ec45. All GPU runs held/tmp/mtplx-gpu-exclusive.lock.Exit abort
Program
progs/exit_variants.cpp: oneaddon the GPU,eval, then exit.std::exit(3)from mainstd::exit(3)on a second threadstd::exit(3)from a second threadnew_thread_unsafe_stream(gpu), return from mainnew_thread_unsafe_stream(gpu)on a second thread,std::exit(3)thereThe base abort text is the Phase 1 text:
terminate called after throwing an instance of 'std::runtime_error' what(): cudaStreamSynchronize(stream_) failed: driver shutting down. A thread-unsafe stream lives in the global static encoder map, which the Phase 1 stack destroys in__run_exit_handlers. With the default (thread-local) stream, the abort did not occur on base. Receipts:results/base/exitv-*.std{out,err},results/{base,branch}/rc.txt.C++ tests (
build/<v>/tests/tests, run with the installedlibmlx.so)test clear cache trims CUDA pool,test fused sdpa at head dims 256 and 512,test quantized matmul over a few rows)The 6 failures are the same on both: 4 GGUF cases (GGUF is off by the requested flag),
test matrix choleskyandtest matrix eigh(CPU linalg). Receipts:results/{base,branch}/tests.stdout,results/branch/tests-cuda.stdout.Two earlier branch runs failed, and the fixes are in this PR. (a)
test clear cache trims CUDA poolfailed without commit 9. (b)test quantized matmul over a few rowsfailed when the binary loadedlibmlx.sofrom the build tree: NVRTC could not findcute/numeric/numeric_types.hpp, because the JIT include path isdirname(libmlx.so)/../include. With the installed library it passes. Receipts:results-run1/branch/.Quantized matmul, 4-bit, x
[rows, 4096]bf16, W[12288, 4096], group 64GPU kernel time from
nsys --cuda-graph-trace=node, 300 serial calls (eval and synchronize after each), median:qmv_kernelqmm_sm80_kernelqmv_kernelqmv_kernelWall time per op, 50 independent calls per
eval, median of 21 repeats, two runs (us):The plan acceptance is "6-row case under 150 us". The kernel time of one serial call is 167.1 us, so that acceptance is not met by the kernel time. The 135 us wall time per op is lower than the kernel time because the 50 calls in one graph have no dependencies and their kernels overlap (nsys: 108 to 124 us GPU span per op, 296 us median kernel duration while overlapped). The mlx-serve figure (485 to 94 us) is from an sm_120 part. A full read of the weights, scales and biases is about 28 MB, which is about 104 us at the GB10 nominal 273 GB/s. Receipts:
results/run2b/kern-serial-*.txt,results/run4/*-kern.txt,results/{base,branch}/qmm.csv,results-run1/{base,branch}/qmm.csv.Completion worker
Program
progs/worker_cpu.cpp: 20 s loop of one 1-row 4-bit matmul,eval,synchronize, then a sleep. CPU time per thread from/proc/self/task/*/stat. Runs alternate base and branch (ABAB).The fix removes the spinning thread: process CPU falls by 72 % to 94 %. It also makes each
synchronize()slower on this box, by about 175 us with no sleep and about 370 us after a 5 ms sleep. The reason:CommandEncoder::synchronize()waits for a completion handler that the worker thread runs. Before, the spinning thread picked it up at once. Now the thread must wake from its condition variable. Upstream accepted the same trade. A decode loop that synchronizes once per token pays this cost once per token. Receipts:results/run2b/worker-*.txt,results/run3/worker-nosleep-*.txt.GPU lock hold times (UTC, 2026-10-13)
8f80cfa(superseded)Review findings (open)
From the independent review (verdict MERGE WITH FIXES). Findings 1, 3, 5, 8 and 16 are fixed by commits 11, 12 and 14. These stay open:
supports_qmm_sm80(mlx/backend/cuda/quantized/qmm/qmm.cu:64-100) has no mode check. On sm_120/121, mxfp4, nvfp4 and mxfp8 at 2 to 7 rows move fromfp_qmv(whichqmm.cu:138-139says is faster on sm_120) toqmm_sm80.46cd2e1dreverted is pending, before the engine pin moves.7a42db35changesclear_cache: it now blocks until the free stream drains and trims the pool to 0. The engine callsclearCache()every 256 tokens in the Gemma 4 MTP loop and after weight load. Cost not measured.tests/cuda_tests.cpp:72-111.synchronize()latency after46cd2e1dcomes from the upstream design (the worker sleeps on a condition variable andsynchronize()waits for a handler that it runs). It is not a setting.Not measured
Finding 4: fp modes (mxfp4, nvfp4, mxfp8) at 2 to 7 rows on sm_120/121 now run
qmm_sm80instead offp_qmv. No test and no timing. To keep the change to affine mode only, one condition atmlx/backend/cuda/quantized/quantized.cpp:126is enough:if (can_use_qmv && (M * B == 1 || (mode_ != QuantizationMode::Affine && M * B < 8))). This restores the oldfp_qmvrouting for fp modes and keeps affine onqmm_sm80. Not applied.SDPA at head dims 256 and 512: only correctness (the C++ test passes). No timing.
LRU change: no test in this PR (see commit 6).
The exit fix was not checked through mlx-swift or BenchCBv2. Only the C++ programs above.
The CPU and Metal hunks of
64ea011compile on CUDA Linux (CPU part); the Metal part was not built here. Fork macOS CI covers it.No Python build.
🤖 Generated with Claude Code