Skip to content

Make CUDA-Q an optional [cudaq] extra of the cudaq-qec wheel - #901

Draft
bmhowe23 wants to merge 3 commits into
NVIDIA:mainfrom
bmhowe23:bmh/cudaq-optional-extra
Draft

bmhowe23 wants to merge 3 commits into
NVIDIA:mainfrom
bmhowe23:bmh/cudaq-optional-extra

Conversation

@bmhowe23

@bmhowe23 bmhowe23 commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Make CUDA-Q an optional [cudaq] extra of the cudaq-qec wheel

Summary

pip install cudaq-qec no longer installs CUDA-Q. The base wheel depends only
on numpy and provides the decoders (including nv-qldpc-decoder), the decoding
configuration and task-graph tooling, DEM construction from parity-check
matrices and the extended-DEM helpers. Everything that needs CUDA-Q is
installed with the new extra:

pip install "cudaq-qec[cudaq]"        # or cudaq-qec-cu12[cudaq] / cudaq-qec-cu13[cudaq]

Without the extra, touching a CUDA-Q-only name raises an ImportError that
says exactly what to install:

ImportError: cudaq_qec.get_code requires CUDA-Q, but CUDA-Q (the `cudaq` package)
is not installed. Install it with: pip install "cudaq-qec-cu13[cudaq]"

cudaq_qec.has_cudaq() reports which mode is active. The metapackage picks the
extra up automatically from pyproject.toml.cuXX, so pip install cudaq-qec[cudaq] works unchanged.

Why

The nv-qldpc decoder and the rest of the decoder stack never needed CUDA-Q.
Today a user who only wants to decode pays for cuda-quantum, cuquantum-python
and their transitive dependencies (cupy, custatevec, cutensornet, cudensitymat,
nvmath-python; well over a gigabyte on disk) for nothing. Installing the base
wheel in a bare venv now pulls in exactly one dependency, numpy.

What is CUDA-Q-free and what is not

Works without CUDA-Q Needs [cudaq]
get_decoder, Decoder, all result types, every built-in and plugin decoder get_code, Code, get_available_codes, operation, TwoQubit* noise ops
DetectorErrorModel, dem_from_stim_text, all pcm_* helpers sample_memory_circuit, dem_from_memory_circuit and their x/z variants
decoder_config, multi_decoder_config, configure_decoders*, schemas sample_code_capacity, generate_random_bit_flips
task-graph specs, experimental, relay_solutions decoder_context_from_memory_circuit, DecoderContext, d_sparse
css_matrices_from_code/dem_from_css_matrices matrix overloads, extended DEMs the code-object overloads of those two
plugins.decoders, plugins.nv_qldpc stabilizer_grid, role_to_str, sc_orientation, patch, dem_sampling, device calls, playback emulator, plugins.codes

How

Python bindings. A second nanobind extension, _qec_core, binds the
CUDA-Q-free pieces and links only libcudaq-qec-decoders and
libcudaq-qec-realtime-decoding. The existing extension keeps its name and
binds the CUDA-Q-dependent pieces into the core module's qecrt submodule, so
there is still one cudaq_qec.qecrt namespace and the device-kernel registry
names are unchanged. The only binding source that had to be split is
py_dem_construction.cpp: its two code-object overloads moved behind
CUDAQX_QEC_BIND_CODE_BRIDGE and are added to the core module as nanobind
overloads by the CUDA-Q extension. Both modules share NB_DOMAIN cudaq, so
types bound in one are usable from the other.

Package init. cudaq_qec/__init__.py imports the CUDA-Q half only if the
cudaq package is importable; otherwise a module-level __getattr__ raises the
ImportError above for the CUDA-Q-only names. CUDAQ_QEC_DISABLE_CUDAQ=1
forces the CUDA-Q-free mode on a machine that has CUDA-Q, which is how the new
test runs.

Static cudart. libcudaq-qec-decoders, libcudaq-qec-realtime-decoding
and _qec_core link cudart_static with --exclude-libs, so the base wheel
needs no CUDA runtime wheel and the static symbols never interpose on a shared
libcudart loaded by CUDA-Q or TensorRT. libcudaq-qec.so and the TensorRT
plugin stay on the shared runtime that their own dependencies already bring.
Device memory, streams and events are driver objects and remain interoperable
across runtime copies. The one visible change is that cudaSetDevice from user
code no longer reaches the decoders; the existing cuda_device_id construction
parameter is the way to pick a GPU.

Wheel tooling. The redundant wheel-root _qec_decoders_standalone module
is dropped (the decoders-only build's module of that name is unchanged).
build_wheels.sh also excludes TensorRT 11 and libibverbs from auditwheel
grafting, and check_grafted_libs.py now fails on any .libs/ content, since
every runtime dependency comes from a sibling wheel or is linked statically.
validate_wheels.sh installs the cudaq extra, and test_wheels.sh replaces
the smoke test of the removed module with the same check on cudaq_qec
itself.

Two-mode test suite. tests/conftest.py adds a requires_cudaq marker.
When cudaq_qec.has_cudaq() is false, the eight test modules that need
CUDA-Q throughout (they import cudaq at module level or build every fixture
from a code object) are not collected and marked tests are skipped; the
eleven individual tests in otherwise CUDA-Q-free modules that build fixtures
from a code object carry the marker. The GPU-test job in lib_qec.yaml and
test_wheels.sh run the suite twice, once normally and once with
CUDAQ_QEC_DISABLE_CUDAQ=1, so the pymatching, chromobius, LUT, nv-qldpc,
decoding-config, task-graph and DEM-construction tests are exercised with no
cudaq in the process on every PR.

Docs. The shared qecrt submodule is now named after the CUDA-Q-free
extension, so nanobind spells type names as cudaq_qec._qec_core.qecrt.* in
generated signatures. conf.py.in learned the new prefix alongside the old
one, and build_docs.sh's leaked-name check covers it.

Verification

Local, against the in-tree build and a locally built cu13 wheel:

  • Python suite with CUDA-Q present: 1042 passed, 13 skipped, 10 xfailed. With CUDAQ_QEC_DISABLE_CUDAQ=1: 839 passed, 17 skipped, 10 xfailed, and cudaq never enters sys.modules.
  • cu13 wheel built with scikit-build-core, repaired with auditwheel using the CI exclude list, check_grafted_libs.py passes, no .libs/ directory.
  • Installed wheel in a bare venv (numpy only, no cudaq anywhere): import works, LUT and pymatching decode, nv-qldpc decodes via a locally supplied plugin binary, get_code raises the hint.
  • readelf: the decoders library, realtime-decoding library and _qec_core have no libcudart or CUDA-Q DT_NEEDED entries and export zero cuda* symbols.

CI on this PR is green across all lanes (cu12 and cu13, amd64 and arm64,
Python 3.11 to 3.13, CPU and A100 wheel tests, docs). Cost of the added
CUDA-Q-disabled pass, from this PR's run:

Job Normal pass Without CUDA-Q
GPU tests, amd64 / arm64, CUDA 12.6 and 13.0 204 to 288 s 30 to 115 s
Wheel tests, CPU runners 105 to 124 s 4 s
Wheel tests, A100 without TensorRT 17 s 8 s
Wheel tests, A100 with TensorRT (amd64) 119 s 103 s

The TensorRT decoder tests dominate the disabled pass wherever TensorRT is
installed, since that plugin is CUDA-Q-free and runs in both modes.

Breaking change

Users who relied on pip install cudaq-qec to install CUDA-Q must add the
[cudaq] extra. Follow-ups, not in this PR: a release note and an update to
the PyPI-facing README for the dependency change, and documentation that
cuda_device_id is the GPU selector for decoders.

🤖 Generated with Claude Code

bmhowe23 and others added 3 commits November 1, 2026 20:09
The base cudaq-qec-cuXX wheel now depends only on numpy. `import cudaq_qec`
works without CUDA-Q and gives the decoders (including nv-qldpc-decoder),
decoding configuration, DEM construction from matrices, extended DEMs and
the decoding task graph. Codes, circuit experiments, device calls, GPU DEM
sampling and the surface-code layout need CUDA-Q, which is installed with
`pip install cudaq-qec-cuXX[cudaq]`; touching one of those names without it
raises an ImportError that names that command. `cudaq_qec.has_cudaq()`
reports which mode is active, and CUDAQ_QEC_DISABLE_CUDAQ=1 forces the
CUDA-Q-free mode for testing.

Python bindings: a new CUDA-Q-free extension, `_qec_core`, binds the decoder
API, decoding config, DEM construction, extended DEMs and the task graph and
links only libcudaq-qec-decoders and libcudaq-qec-realtime-decoding. The
existing extension keeps its name, binds the CUDA-Q-dependent parts into the
core module's `qecrt` submodule, and adds the code-object overloads of
`css_matrices_from_code` / `dem_from_css_matrices` (now behind
CUDAQX_QEC_BIND_CODE_BRIDGE) as nanobind overloads on the core module. The
redundant wheel-root `_qec_decoders_standalone` copy is dropped; the
decoders-only build's module of that name is unchanged.

cudart is linked statically into libcudaq-qec-decoders,
libcudaq-qec-realtime-decoding and _qec_core, with --exclude-libs so the
symbols never interpose on a shared libcudart loaded by CUDA-Q or TensorRT.
libcudaq-qec.so and the TensorRT plugin stay on the shared runtime that their
own dependencies bring.

Wheel tooling: build_wheels.sh also excludes TensorRT 11 and libibverbs from
auditwheel grafting, and check_grafted_libs.py now fails on any `.libs/`
content. validate_wheels.sh installs the `cudaq` extra. Docs and the README
describe the extra; test_no_cudaq.py exercises the package in a subprocess
with CUDA-Q disabled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Ben Howe <bhowe@nvidia.com>
Docs: the shared `qecrt` submodule is now named after the CUDA-Q-free
extension, so nanobind spells the `operation` type as
`cudaq_qec._qec_core.qecrt.operation` in generated signatures. Teach
conf.py.in the new prefix (doc_replacements, the __module__ fix-up and
nitpick_ignore_regex) and extend build_docs.sh's leaked-name check to it.

Tests: add tests/conftest.py with a `requires_cudaq` marker. Without CUDA-Q
(or with CUDAQ_QEC_DISABLE_CUDAQ=1) the eight modules that need CUDA-Q
throughout are not collected and marked tests are skipped; everything else
must pass with no `cudaq` in the process. Mark the eleven individual tests
in otherwise CUDA-Q-free modules that build fixtures from a code object.
lib_qec.yaml and test_wheels.sh run the suite a second time with
CUDAQ_QEC_DISABLE_CUDAQ=1.

dem_sampling(): raise the same install-hint ImportError as the package
attributes when called without CUDA-Q, instead of a bare undefined-symbol
error from loading the CUDA-Q extension without cudaq.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Ben Howe <bhowe@nvidia.com>
…moved module

The wheel no longer ships the root-level `_qec_decoders_standalone` module,
so the smoke test that imported it failed. Replace it with the equivalent
check on the package itself: with CUDAQ_QEC_DISABLE_CUDAQ=1, `import
cudaq_qec` must decode with no `cudaq` in the process and `get_code` must
raise the install hint. cudart is static in the decoder stack now, so the
CUDA runtime wheel install and LD_LIBRARY_PATH juggling are gone too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Ben Howe <bhowe@nvidia.com>
@bmhowe23
bmhowe23 marked this pull request as draft November 2, 2026 15:44

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant