Skip to content

feat(cuda): run Windows CUDA builds from a relocatable runtime bundle - #2651

Merged
inureyes merged 6 commits into
mainfrom
feature/issue-2640-cuda-runtime-bundle
Oct 26, 2026
Merged

inureyes merged 6 commits into
mainfrom
feature/issue-2640-cuda-runtime-bundle

Conversation

@inureyes

@inureyes inureyes commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Summary

Windows --features cuda binaries now load every CUDA, cuDNN and NVRTC DLL from one runtime directory and can run from a relocatable bundle, following the ROCm design of #2606/#2607. No NVIDIA GPU was available, so this PR is verified by building and by GPU-less runtime checks only (see below); runs on an NVIDIA host are still open.

  • overlays/cuda/.../runtime_dir.h (new, header-only, host-tested): choose_cuda_runtime_dir() picks MLXCEL_CUDA_RUNTIME_DIR (absolute, must hold the runtime, no fallback), else the executable directory when it holds the CUDA runtime DLL named in the binary's own delay-import table, else the build-time layout. nvcc links the static cudart, so the marker is cublasLt64_13.dll.
  • delayload.cpp: in a bundle, the DLLs the libraries open by plain name (nvrtc64_*, nvrtc-builtins*, cublas64_*, cublasLt64_*, nvJitLink_*, seven required cudnn_* sub-libraries; cusparse64_* checked only) are checked before any CUDA DLL loads and preloaded by full path; a failed load raises the delay-load exception naming the expected path instead of returning null. The build-time path keeps upstream's per-library lookup and its fallback; it only adds the [mlx-cuda] CUDA runtime: line, plus a message naming the missing marker path when a bundle-looking exe directory lacks it.
  • jit_module.cpp: NVRTC headers, CCCL and CUTLASS come from the bundle's include\ (in or beside the runtime directory), ranked after CUDA_HOME and ahead of CUDA_PATH.
  • scripts/package_windows_cuda_runtime.py: stages or zips bin\ (DLLs + OpenBLAS), include\ (JIT headers, CCCL from the build's _deps, CUTLASS), licenses\ (CUDA EULA, cuDNN, CCCL, CUTLASS); fails naming every missing DLL, header or license. Licenses are told apart by content, because the redistributable archives all ship a LICENSE that overwrites the others when unpacked into one directory.
  • make verify-cuda-runtime-dir (in make verify), docs in installation.md and environment-variables.md, overlay README record.

Decisions and open points

  • The DLL closure is derived statically (import tables and embedded DLL names of the x64 CUDA 13.4.3 / cuDNN 9.27 DLLs), not from a loaded-module list of a generate run; to be confirmed on hardware.
  • The JIT header set was found by compiling each of the 17 headers MLX embeds for the JIT with NVRTC 13.4 against only the staged bundle's include\ (needs no GPU). That added cuda_runtime.h, mma.h and crt/mma.h(pp), which CUTLASS includes. Several of these headers (cooperative_groups.h, math_constants.h, cuda_runtime.h, mma.h, ...) are not on the CUDA EULA Attachment A list; the docs say so and advise confirming terms with NVIDIA before publishing a bundle. Prebuilt archives stay out of scope.
  • delayload.cpp is 595 lines, over the 500-line guideline; it is an overlay of an upstream file (like jit_module.cpp at 805), so it was not split.

Changes during review

  • CUDA_HOME now really outranks the bundle's headers: the bundle include\ was placed ahead of the chosen CUDA header dir on the NVRTC include list, so NVRTC took every CUDA header from the bundle even with CUDA_HOME set. With CUDA_HOME set the bundle include now goes last and only supplies CUTLASS/CuTe (implementation review, HIGH).
  • cusparse64_*, which only cuSOLVER imports, is checked before any CUDA DLL loads, so a missing cuSPARSE is reported under its own name instead of as cuSOLVER (implementation review).
  • MLXCEL_CUDA_RUNTIME_DIR is normalized (./.., trailing separator) before use; C:\b\bin\ previously made the header lookup miss the bundle's include\ and fall back to CUDA_PATH (security review).
  • The baked-mode cuDNN log line no longer converts through the ANSI code page unguarded inside the delay-load hook (security review).
  • Documented, not fixed: the CUDA libraries also open nvcuda.dll, nvcudart_hybrid64.dll, zlibwapi.dll, cask_profile_interface.dll, nvtileiras64.dll and the two optional cuDNN sub-libraries by plain name; the bundle neither ships nor checks them, so the standard search order (application directory, System32, then current directory and PATH) applies, as it does in developer builds. Closing that needs a decision and is left to the DLL-planting follow-up (fix(security): Close remaining ROCm DLL and JIT-cache planting paths #2641).
  • On Linux the only behavior difference is that an empty CUDA_HOME now counts as unset.

Test plan

Toolkit: NVIDIA redistributables for windows-x86_64, CUDA 13.4.3 (cudart/nvcc/crt/nvrtc/nvjitlink/nvvm 13.4.92, cccl 13.3.4.3.1, cublas 13.8.0.4, cufft 12.4.0.43, cusolver 12.3.5.3, cusparse 12.8.6.72) and cuDNN 9.27.0.42 for CUDA 13, sha256-checked. Host: Windows 11 x64, Radeon 8060S, no NVIDIA GPU.

  • x64 cargo build --release --features cuda (MLX_CUDA_ARCHITECTURES=120): cold build 32 min, rebuilt after the review fixes (17 min 37 s, mlxcel.exe 133,756,928 bytes) with the harness re-run, no build-glue change needed; mlxcel.exe 128 MiB, mlxcel-server.exe 118 MiB. llvm-readobj --coff-imports: cuBLASLt, cuFFT, cuSOLVER, cuDNN and NVRTC are DelayImport, no CUDA DLL in the regular import table.
  • cargo clippy --release --features cuda -- -D warnings (x64), cargo fmt --all --check, cargo test -p mlxcel-mlx-pin --lib (49 passed).
  • make verify-cuda-runtime-dir host test (clang++), checked to fail on mutated selection logic; 25 unit tests in tests/test_package_windows_cuda_runtime.py; env-var docs check; license header check.
  • Packaging on the real archives: 1830 files, 1968 MiB; fails naming the file when one is renamed, and refuses cuDNN's LICENSE as the CUDA EULA.
  • Hook exercised with no GPU: a harness linking this delayload.cpp unchanged calls cublasLtGetVersion, nvrtcVersion, cufftGetVersion and cudnnGetVersion through delay-loaded DLLs. With the toolkit renamed away and CUDA_PATH unset, every CUDA/cuDNN/NVRTC module loads from the bundle (exe directory and MLXCEL_CUDA_RUNTIME_DIR), nvcuda.dll from System32; relative and wrong-folder overrides, a removed delay-loaded DLL, a removed cudnn_ops and a removed marker each fail with the expected path. With the toolkit present the harness loads from the toolkit as before.
  • mlxcel generate and mlxcel-server from the bundle (toolkit hidden) and the developer build both stop with 0xC0000409 at the first device query after Detected 1 GPU(s)., before any delay-loaded CUDA DLL is requested (this host has a stale nvcuda.dll but no NVIDIA GPU). Everything after device initialization is unverified.
  • On a Windows NVIDIA host (RTX Spark N1X or x64): generate (including the first NVRTC JIT) and the server from a fresh bundle folder with the toolkit moved away; module list; removed-DLL failure; developer build unchanged.

Closes #2640

A Windows --features cuda build only ran where the toolkit and cuDNN sat at their build-time paths. This mirrors the ROCm bundle design (#2606/#2607) for CUDA.

The CUDA overlay's delay-load hook now picks one runtime directory per process through the pure choose_cuda_runtime_dir() in the new runtime_dir.h: MLXCEL_CUDA_RUNTIME_DIR (absolute, must hold the runtime, no fallback), else the executable directory when it holds the CUDA runtime DLL named in the binary's own delay-import table (cuBLASLt, since nvcc links the static cudart), else the unchanged build-time per-library lookup. In a bundle the DLLs the libraries open by plain name (NVRTC and its builtins, cuBLAS, cuBLASLt, nvJitLink, the cuDNN sub-libraries) must be present and are preloaded from it, and a load failure raises the delay-load exception with the expected path instead of falling back to PATH. jit_module.cpp takes NVRTC headers, CCCL and CUTLASS from the bundle's include, ranked after CUDA_HOME and ahead of CUDA_PATH.

scripts/package_windows_cuda_runtime.py stages or zips the bundle (DLL closure derived statically from import tables, the JIT header set, CCCL and CUTLASS from the build's _deps, OpenBLAS, and the CUDA, cuDNN, CCCL and CUTLASS licenses) and fails naming every missing file.

Validation: host test via make verify-cuda-runtime-dir, 25 unit tests for the script, an x64 CUDA 13.4.3 release build, the bundle staged from the real archives, and each JIT header compiled with NVRTC against only the bundle's include. No NVIDIA GPU was available, so runtime loading past device initialization is unverified.

Refs #2640
A bundle without cublasLt64_13.dll does not qualify as a runtime directory, so the hook fell back to the build-time toolkit and, with the toolkit gone, failed with no message. When that lookup fails and the executable's directory still holds CUDA DLLs, the hook now names the marker path it expected there. The build-time path is otherwise unchanged and still falls back to the loader's own search. looks_like_runtime_bundle() is pure and covered by the host test.

Verified with a harness that links this delayload.cpp: removing the marker from a staged bundle with the toolkit hidden prints the expected path.

Refs #2640
@inureyes inureyes added status:review Under review type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers platform:windows Windows (native) specific labels Oct 26, 2026
…eaders

Two review findings on the Windows CUDA runtime bundle.

cuSPARSE is imported only by cuSOLVER, so a bundle without it failed with a message naming cusolver64_12.dll ("a DLL it imports may be missing") instead of the missing file. runtime_dir.h now lists import-only DLLs (kImportOnlyPrefixes, cuSPARSE) in the up-front check, so the binary names the expected cusparse64_*.dll path before it loads any CUDA DLL. They are checked, not preloaded.

The bundle's include/ was added as an NVRTC include path ahead of the CUDA header directory, so with CUDA_HOME set NVRTC still took every CUDA header from the bundle, contrary to the documented order (CUDA_HOME, bundle, CUDA_PATH, baked). With CUDA_HOME set the bundle's include/ now goes last and only supplies CUTLASS/CuTe. Linux and builds without a bundle produce the same include paths as before.

Verified with the host test (new cuSPARSE case), the packaging tests, an x64 CUDA release build, and the hook harness: a bundle without cuSPARSE now fails naming its path; bundle, override, error and developer-layout runs behave as before.

Refs #2640
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Windows CUDA builds load every CUDA, cuDNN and NVRTC DLL from one runtime directory and run from a relocatable bundle built by scripts/package_windows_cuda_runtime.py, mirroring the ROCm design of #2606/#2607, with developer builds and Linux unchanged.

Findings Addressed

  • With CUDA_HOME set, NVRTC still took every CUDA header from the bundle, because the bundle's include was added as an include path ahead of the CUDA header directory. The docs and choose_cuda_include_dir() say CUDA_HOME outranks the bundle. With CUDA_HOME set, the bundle's include now goes last and only supplies CUTLASS/CuTe. Include paths on Linux and in builds without a bundle are unchanged (HIGH, ae955bc)
  • A bundle without cusparse64_12.dll (imported only by cuSOLVER) failed with a message naming cusolver64_12.dll instead of the missing file. cuSPARSE is now part of the up-front check (checked, not preloaded), and the host test covers it (MEDIUM, ae955bc)

Remaining Items

  • The packaging script requires cudnn_ext64_* and cudnn_engines_tensor_ir64_*, which the hook treats as optional, so packaging an earlier cuDNN 9 fails. This is conservative and documented as the cuDNN 9.27 closure (LOW), left as is
  • Runs on NVIDIA hardware: generate with the first NVRTC JIT compile, the server, the module list, and an unchanged developer build. Needs an RTX Spark N1X or an x64 NVIDIA host; this is not a failure of the PR

Verification

  • All stated requirements implemented (hardware criteria verified only by building and with a harness)
  • No placeholder/mock code remaining
  • Integrated into project code flow (delay-load hook, NVRTC include path, make verify)
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes (Linux CUDA paths unchanged)
  • Tests pass: host test, 25 packaging tests, env-var docs check, x64 cargo build --release --features cuda (15m 50s incremental), and a hook harness with the toolkit hidden. Bundle, MLXCEL_CUDA_RUNTIME_DIR, relative and wrong-folder overrides, a missing cuFFT and a missing cuSPARSE all give the expected path or message, and the developer layout still loads from the toolkit

A trailing separator in MLXCEL_CUDA_RUNTIME_DIR (C:\b\bin\) made parent_path() return the directory itself, so jit_module.cpp looked for the bundle headers in bin\include instead of the include\ beside bin\ and fell back to CUDA_PATH or the build-time toolkit, pairing the bundle's NVRTC with other headers. choose_cuda_runtime_dir() now resolves . and .. and drops a trailing separator; the host test covers both spellings and fails without the change.

The baked-mode cuDNN log line converted cudnn_bin_dir() through the ANSI code page inside the delay-load hook's static initializer, where an exception must not escape; it is now guarded.

docs/installation.md no longer implies that no CUDA-related load reaches the current directory: the libraries open nvcuda.dll, nvcudart_hybrid64.dll, zlibwapi.dll, cask_profile_interface.dll and nvtileiras64.dll themselves (and the optional cudnn_ext and cudnn_engines_tensor_ir when a bundle lacks them), and those follow the standard search order.

Verified with the host test, the 25 packaging tests, the env-var docs check, an x64 CUDA release build and the hook harness (exe-dir bundle, trailing-separator override, wrong-folder override).

Refs #2640
@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review

No CRITICAL or HIGH findings. Fixed in d73cb01:

  • MEDIUM: MLXCEL_CUDA_RUNTIME_DIR with a trailing separator (C:\b\bin\) made parent_path() return bin itself, so the NVRTC JIT missed the bundle's include\ and fell back to CUDA_PATH or the baked toolkit (bundle NVRTC paired with other headers). The override is now normalized (., .., trailing separator); host test added, fails without the fix.
  • MEDIUM (docs): the libraries open nvcuda.dll, nvcudart_hybrid64.dll, zlibwapi.dll, cask_profile_interface.dll and nvtileiras64.dll themselves (embedded names), plus the optional cudnn_ext64_9 / cudnn_engines_tensor_ir64_9 when a bundle lacks them. The hook neither ships nor checks these, so the standard search (application directory, System32, then the current directory and PATH) applies. docs/installation.md now says so.
  • LOW: the baked-mode cuDNN log line converted a narrow path inside the hook's static initializer; now guarded against throwing.

Checked and fine: the regular-import closure of every bundle DLL stays inside the bundle or the system; no fallback after a bad override or an incomplete bundle; the upstream return-null path is reachable only in the build-time layout; the env override and exe-dir candidates add no planting surface beyond the application directory Windows already searches first; NVRTC include order; the packaging script's destinations come from fixed names, with no traversal, and the zip is written through .partial.

Remaining (follow-up, not fixed here):

  • Plain-name loads outside the bundle can reach the current directory. Closing this needs either a packaging manifest that the hook enforces, for the optional cuDNN sub-libraries, or a process-wide SetDllDirectoryW(L""), a narrower relative of the settled no-SetDefaultDllDirectories decision. A planting probe could not be run here, and none of it is verified on an NVIDIA host.
  • LOW: --stage-dir leaves a partial tree on failure; rglob over _deps follows directory symlinks on Python 3.12 and older; the cuDNN license marker cuDNN is loose but does not collide with the CUDA EULA at 13.4.

Verification: host test, 25 packaging tests, env-var docs check, x64 cargo build --release --features cuda (17m 37s; same five DelayImports), and the hook harness with the patched delayload.cpp. The exe-dir bundle and the trailing-separator override both load every CUDA DLL from the bundle, and a wrong-folder override fails naming it.

Add a row to the Windows current-limits table for the CUDA runtime directory and bundle (#2640). It lists what was checked by building and host tests, and what has not run on an NVIDIA host: the statically derived DLL list, the DLLs a generate run loads from the bundle, and everything after device initialization.

Validation: check_env_var_docs.py OK, cargo fmt --all --check OK, tests.test_script_executable_modes and tests.test_package_windows_cuda_runtime (26 tests) OK.

Refs #2640
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers platform:windows Windows (native) specific priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Run Windows CUDA builds from a relocatable runtime bundle

1 participant