ADR 0030: The CUDA plugin wheels (dyng-cu12, dyng-cu13): how they are built from the same sources#
Status: Accepted under delegation (2026-10-06; GOVERNANCE.md, “Delegation of technical ADRs”). It records how PLAN Sections 5.4 (“CPU core + CUDA plugins”), 7.7 and 7.8 and ADR 0011 items 14-15 are implemented in M6a, step “plugin-build”, and where the implementation refines the plan’s sketch. It changes no rule the author approved: the gates and their measurement protocol, the parity rules, the licence and the licence expression, the names and the publishing steps are untouched, and nothing is published by it. One licensing question it meets (item 9) was left to the author, who extended the plugins’ expression on 2026-10-06.
Date: 2026-10-06
Deciders: the AI assistant, on the author’s behalf (M6a, step “plugin-build”)
Context#
PLAN 5.4 ships the CUDA backends to Python as plugin wheels in the JAX pattern: dyng (the CPU
module dyng._core) is always installed; dyng-cu12 and dyng-cu13 each contain an import
package (dyng_cu12, dyng_cu13) with an extension module built from the same sources with
CUDA on, registered under the entry point group dyng.backends; pip install "dyng[cu13]"
pulls the plugin of the same version. PLAN 7.7 sketches the build as “the release script
rewrites name to dyng-cu13, wheel.packages to dyng_cu13, the defines to
DYNG_ENABLE_CUDA=ON, DYNG_PYTHON_PLUGIN=cu13, and adds the dyng.backends entry point”, with
cudart linked statically, SASS per toolkit plus PTX for the highest architecture, libcuda.so
never bundled, the plugin depending on dyng of the same version and a 90 MB size budget. ADR
0011 item 14 fixed the selection contract in dyng/_backend.py (an entry point names a module
with available() -> bool and native), item 15 left the extras cu12 / cu13 out until the
plugins exist.
Decision#
One source, a rendered
pyproject.toml.ci/plugin_pyproject.py --plugin cu<N> --project-dir <tree>turns a source tree ofdyng(an unpacked core sdist, or a disposable checkout) into the plugin’s: it renders the plugin’spyproject.tomlfrom the root one (so the licence, authors, URLs, build requirements and scikit-build-core settings have one source), copies the plugin package into place (item 3) and the CUDA toolkit’s EULA into the tree (item 9). The rendered file:name = "dyng-cu<N>", its own description and readme (python/plugin/README.md), the CPU package’s keywords and classifiers plusEnvironment :: GPU :: NVIDIA CUDA/:: <N>(withoutTyping :: Typed: the plugin package is private),dependencies = ["dyng==<VERSION>"](the version read fromVERSIONwhen the file is rendered; the plugin’s own version still comes fromVERSIONthrough the regex provider), no extras and no console script,[project.entry-points."dyng.backends"] cu<N> = "dyng_cu<N>",wheel.packages = ["python/plugin/dyng_cu<N>"], and the CPU package’s CMake defines plusDYNG_ENABLE_CUDA=ON,DYNG_ENABLE_OPENMP=ON,DYNG_PYTHON_PLUGIN=cu<N>,CMAKE_CUDA_RUNTIME_LIBRARY=StaticandDYNG_CUDA_ARCHITECTURES=release. The plugins are built from the core sdist (which therefore containspython/plugin/andTHIRD_PARTY_LICENSES_CUDA.txt;ci/wheel_check.pyrequires them), so a release builds every distribution from one source archive. No plugin sdist is published (PLAN 7.7’s table: the plugins are wheels; building one from source is documented indocs/developer/wheels.md). This is PLAN 7.7’s “release script”, as a tested script rather than an in-placesedof the checkout; RAPIDS automates the same withrapids-build-backend.CMake:
DYNG_PYTHON_PLUGIN(cache, empty |cu12|cu13,python/CMakeLists.txt). Empty buildsdyng._coreas before (NB_DOMAIN dyng_cpu, installed todyng/).cu<N>builds the same target_corefrom the same bindings underNB_DOMAIN dyng_cu<N>, installed todyng_cu<N>/, so the plugin’s module isdyng_cu<N>._core(the module keeps the name_core:NB_MODULE(_core, ...)is unchanged). Configuring fails unless the build has CUDA and OpenMP (a plugin module replacesdyng._core, so it has every backend), the CUDA compiler’s major equals<N>, and the CUDA runtime is static. The module’sbuild_configgainsplugin,cuda_toolkit,cuda_architecturesandcuda_runtime(empty strings in the CPU module), whichdyng.show_config()prints for a CUDA module.The plugin package is one directory,
python/plugin/dyng_plugin/, used by both plugins:ci/plugin_pyproject.pycopies it topython/plugin/dyng_cu<N>/in the (disposable) plugin tree, because scikit-build-core requires a package’s directory to carry the package’s name (wheel.packagesas a table{dyng_cu13 = "python/plugin/dyng_plugin"}is refused: “must match in the last component of the paths”). The package learns which plugin it is from its own name (PLUGIN,CUDA_MAJOR,DISTRIBUTION,__version__from the distribution’s metadata). It implements ADR 0011 item 14’s contract and adds what the selection needs to explain itself:status()probes the driver once per process through ctypes (libcuda.so.1:cuDriverGetVersion,cuInit,cuDeviceGetCount) and returnsStatus(usable, driver_version, device_count, reason);available()isstatus().usable: a driver of CUDA<N>.0 or newer (CUDA’s minor-version compatibility lets a plugin built with the latest<N>.x run on any<N>.x driver, its SASS covering the GPUs; a cu12 plugin also runs on a CUDA 13 driver) and at least one visible device (amended by ADR 0031, “Amendments”: nocuInitin the calling process, the devices through NVML or a child process, and a compute capability of 7.5 or newer).nativeimportsdyng_cu<N>._coreon first access (a module__getattr__), so asking a plugin loads neither the extension module nor the CUDA runtime. Which plugin wins when both are installed, the warnings of a fallback and the version check againstdyngare the selection’s (dyng/_backend.py, the next step of M6a); the package gives itCUDA_MAJOR,driver_version()and the reason.The static CUDA runtime.
CMAKE_CUDA_RUNTIME_LIBRARY=Staticalone only governs the links nvcc drives; libdyng, its CUDA modules and the test libraries linkedCUDA::cudart(the shared runtime) explicitly. They now link${DYNG_CUDART_TARGET}:CUDA::cudart_staticwhenCMAKE_CUDA_RUNTIME_LIBRARYisStatic,CUDA::cudartwhen it isShared(the default of every preset, unchanged); any other value is refused at configure time, and so isStaticwithBUILD_SHARED_LIBS=ON: a shared libdyng would carry its own copy of the runtime and every program using it another, two CUDA runtimes with separate error states in one process (the suiteCudaApiErrorsfails exactly so in such a build). The plugin’s module then needs nolibcudart.soand nolibcuda.so(the runtime loads the driver at run time): itsDT_NEEDEDentries are the C library’s and the bundled libgomp only.Architectures come from
cmake/cuda_architectures.cmake’s release list for the toolkit in use (DYNG_CUDA_ARCHITECTURES=release): for CUDA 12.8+ and 13.x75-real;80-real;86-real;89-real;90-real;100-real;120(SASS for seven architectures, PTX for sm_120). The cu12 wheel is built with the latest 12.x, the cu13 wheel with the latest 13.x (PLAN 7.8); a 12.4-12.7 toolkit would give the shorter list, which a release does not use.Repair.
auditwheel repair --plat manylinux_2_28_x86_64bundles libgomp asdyng_cu<N>.libs/libgomp-*.so*and is told--exclude libcuda.so.1 --exclude libcuda.so(never needed after item 4, excluded so that a future change cannot bundle the user’s driver). The module links libstdc++ and libgcc statically with their symbols hidden, as the CPU wheel does (ADR 0025).Checks (
ci/wheel_check.py, recognisingdyng_cu<N>-*.whlby name): the 90 MB budget; the file name and tags (cp312-abi3, the platform); onlydyng_cu<N>/anddyng_cu<N>.libs/besides the.dist-info(neverdyng/); the module and__init__.py; the entry point[dyng.backends] cu<N> = dyng_cu<N>and no console script;Name: dyng-cu<N>;Requires-Distexactlydyng==<VERSION>; the licence files and expression of item 9; no bundledlibcuda*,libcudart*orlibnvidia-*; and, read from the module’s ELF dynamic section, noDT_NEEDEDentry for them. The CPU wheel’sMETADATAmust offer the extrascu12andcu13requiringdyng-cu<N>==<VERSION>.The extras of
dyng.optional-dependenciesbecomes dynamic in the rootpyproject.toml: scikit-build-core’s template provider fills it, withcu12 = ["dyng-cu12=={project[version]}"]andcu13 = ["dyng-cu13=={project[version]}"], so the pin always equalsVERSIONwithout a release-time rewrite (PLAN 7.7’s “filled in by the release script”); the other extras are unchanged (test,torch,pandas), and PLAN 7.7’scupy-cu12 = ["cupy-cuda12x"]/cupy-cu13 = ["cupy-cuda13x"]are added now that the plugins exist (ADR 0011 item 15 deferred them to this point). The sdist’sPKG-INFOcarries the same.Licences. A plugin wheel contains, besides what
THIRD_PARTY_LICENSES.txtlists, the CUDA runtime (libcudart_static.a, which the CUDA Toolkit EULA’s Attachment A lists as distributable), device code from the toolkit’s headers, the CUDA C++ Core Libraries compiled in (CUB: BSD-3-Clause; Thrust: Apache-2.0; libcu++: Apache-2.0 WITH LLVM-exception) and NVTX 3 (Apache-2.0 WITH LLVM-exception). Its licence files are the CPU wheel’s plusTHIRD_PARTY_LICENSES_CUDA.txt(committed: what is in the module, CUB’s notice, the LLVM exception) andNVIDIA_CUDA_EULA.txt, the EULA of the toolkit the wheel is built with, copied in byci/plugin_pyproject.py(from<toolkit>/EULA.txt, a conda prefix’sLICENSE, or thecuda-cudart-staticpackage that a conda-forge environment’sconda-metanames;--cuda-eulaoverrides; the build fails without it). The EULA is not committed: it is NVIDIA’s text, it changes with the toolkit, and the repository’s files are Apache-2.0 (and one CC-BY-SA-4.0). The licence expression of the plugins is the CPU wheel’s plus what they add:Apache-2.0 AND BSD-3-Clause AND MIT AND GPL-3.0-or-later WITH GCC-exception-3.1 AND Apache-2.0 WITH LLVM-exception AND LicenseRef-NVIDIA-End-User-License-Agreement(libcu++ and NVTX; the CUDA runtime under NVIDIA’s EULA, with conda-forge’s identifier; CUB’s BSD-3-Clause and Thrust’s Apache-2.0 are already named). This is the author’s decision of 2026-10-06 (GOVERNANCE.md, approvals log); until then the plugins carried the CPU wheel’s expression. It isci/wheel_check.py’sPLUGIN_LICENSE_EXPRESSION, whichci/plugin_pyproject.pywrites into the renderedpyproject.tomlandci/wheel_check.pychecks in the wheel’sMETADATA; the CPU wheel and the sdist keep their expression.Local build.
ci/plugin_wheels.shis the plugins’ counterpart ofci/wheels.sh: the core sdist and wheel (ci/wheels.sh, build only), then per plugin the tree of item 1,pip wheelwith the glibc 2.28 conda toolchain ofci/wheel-toolchain.ymlas host compiler and the plugin’s CUDA toolkit, item 6’s repair,twine check --strict,ci/wheel_check.py, and an install test per Python version (3.12, 3.13) in a fresh venv holding only the two wheels: the plugin is selected, sssp, cycle_count and mosp run on the CUDA backend (GPU 1), no visible device falls back todyng._core, and the pytest suite passes withDYNG_CPU_ONLY=1. CI builds the same wheels with cibuildwheel in the manylinux_2_28 image (the CI step of M6a).
Consequences#
One code base, one sdist, three wheels per platform. The plugins add no Python code of their own beyond
dyng_plugin(about 150 lines); everything user-facing stays indyng.The wheels are small: 5.5 MB (cu13) and 5.6 MB (cu12), well below the 90 MB budget (the module is 28 MB uncompressed, 24 MB of it the device code of the eight fatbin entries).
Upgrading
dyngwithout its plugin leaves a plugin of another version installed; the selection must refuse it (the next step of M6a) rather than load a module whose bindings differ from the typed layer.CMAKE_CUDA_RUNTIME_LIBRARY=Staticnow works for every C++ build with a static libdyng (embedding withoutlibcudart.so), not only for the wheels; thedev-cudapreset with-DCMAKE_CUDA_RUNTIME_LIBRARY=Static -DBUILD_SHARED_LIBS=OFFpassesctest -L gpu.The licence expression of the plugins awaits the author’s decision (item 9); the licence files are complete in either case.