Package & Distribute a Backend Plugin

This guide covers how to package a CUDA-Q backend implementation as an installable Python package, distribute it to end users of the target, and make it discoverable by both the Python runtime and nvq++ after they install it. For how to implement a backend (ServerHelper or QPU subclass), see Extending CUDA-Q with a new Hardware Backend.

Plugin Package Layout

Every plugin follows the same directory convention:

my-backend/
├── targets/
│   ├── my-backend.so            # Pre-compiled target config (.dylib on macOS)
|   |     # OR (mutually exclusive)
│   └── my-backend.yml           # Raw YAML target config
├── lib/
│   └── libcudaq-serverhelper-my-backend.so  # Backend shared library (.dylib on macOS)
├── data/                        # Optional auxiliary files
│   └── topology.txt
├── pyproject.toml               # Python package metadata
├── __init__.py                  # Entry-point registration
└── __main__.py                  # --install-nvqpp hook

The targets/ and lib/ directories are required. The data/ directory is optional and holds any auxiliary files your backend needs at runtime. The plugin can choose to ship either a pre-compiled target config library (my-backend.so) or a raw YAML target config (my-backend.yml). The pre-compiled format is recommended and required for the plugin to be used in CUDA-Q C++ programs, whereas the YAML config is for use in Python programs only. Do not ship the two formats together as the compiled library will always shadow the YAML config.

Note

This guide writes shared library filenames with the Linux .so extension. On macOS the corresponding extension is .dylib; substitute it throughout.

How to pre-compile a target config YAML

The target config is always authored as a YAML file. To be used at runtime by compiled C++ programs, it must be distributed as a pre-compiled shared library. This is achieved in two steps:

  1. Use the cudaq-target-db-gen --plugin command that is shipped with the CUDA-Q installation to convert the YAML file into a .cpp C++ file.

  2. Compile the .cpp file into a shared library and install it as targets/<name>.so (or targets/<name>.dylib).

# Linux
cudaq-target-db-gen --plugin -o my-backend.gen.cpp my-backend=my-backend.yml
${CXX} -std=c++20 -shared -fPIC -I "${CUDAQ_INSTALL_DIR}/include" \
    my-backend.gen.cpp -o targets/my-backend.so
# macOS
cudaq-target-db-gen --plugin -o my-backend.gen.cpp my-backend=my-backend.yml
${CXX} -std=c++20 -shared -fPIC -isysroot "$(xcrun --show-sdk-path)" \
    -I "${CUDAQ_INSTALL_DIR}/include" \
    my-backend.gen.cpp -o targets/my-backend.dylib

The generated translation unit is self-contained - it only needs CUDA-Q’s headers, not any CUDA-Q library. The resulting library is platform-specific and must be built and distributed separately for each supported platform. On macOS, pass -isysroot explicitly if ${CXX} is the LLVM toolchain that ships with CUDA-Q rather than Apple’s own clang++: only the latter infers the SDK location, and without it the C++ standard library headers are not found.

The generated library exports a single, ABI-versioned symbol (cudaq::config::kTargetPluginSymbolName); CUDA-Q dlopens it and dlsyms exactly that symbol name, so a library built against an incompatible CUDA-Q version fails immediately and unambiguously at load time rather than being silently misinterpreted.

Target YAML Reference (Plugin Fields)

Every target YAML begins with a schema version:

version: 1
name: my-backend
description: "My backend."

The source YAML uses the same schema as in-tree targets, with these plugin-relevant fields:

%PLUGIN_ROOT%

A substitution token expanded to the plugin’s root directory when cudaq-target-db-gen --plugin compiles your YAML (so it must be staged at its final <pkgRoot>/targets/my-backend.yml location, even temporarily, when you invoke the generator):

config:
  jit-mid-level-pipeline: "qubit-mapping{device=file(%PLUGIN_ROOT%/data/topology.txt)}"

target-arguments:
  - key: device
    type: string
    default: "%PLUGIN_ROOT%/data/topology.txt"
    platform-arg: device

Use this to reference files shipped inside the plugin without hard-coding install paths.

target-arguments

Declares per-target parameters that surface as CLI flags for nvq++ and keyword arguments for cudaq.set_target():

target-arguments:
  - key: api-key
    required: true
    type: string
    platform-arg: api_key
    help-string: "API key for the backend."
  - key: shots
    required: false
    type: integer
    platform-arg: shots
    help-string: "Default shot count."

These appear as --my-backend-api-key <value> on the command line and cudaq.set_target("my-backend", api_key=...) in Python.

Building with CUDAQ_EXTERNAL_PROJECTS

During development, build your plugin inside the CUDA-Q source tree without modifying any in-tree files:

cmake -B build \
  -DCUDAQ_EXTERNAL_PROJECTS="my-backend" \
  -DCUDAQ_EXTERNAL_MY_BACKEND_SOURCE_DIR=/path/to/my-backend

ninja -C build cudaq-serverhelper-my-backend   # or cudaq-qpu-my-backend

The plugin output lands in build/external/my-backend/ with the standard targets/, lib/, and packaging files ready to use.

Multiple plugins can be built together:

cmake -B build \
  -DCUDAQ_EXTERNAL_PROJECTS="foo;bar" \
  -DCUDAQ_EXTERNAL_FOO_SOURCE_DIR=/path/to/foo \
  -DCUDAQ_EXTERNAL_BAR_SOURCE_DIR=/path/to/bar

Baking a target into the pre-compiled database instead of shipping a plugin

An external project added via CUDAQ_EXTERNAL_PROJECTS is add_subdirectory()’d into the same CMake configure as the rest of CUDA-Q, so it shares the same global target-registration state as CUDA-Q’s own in-tree targets. This means it can call the internal add_target_config(<name>) CMake function (from cmake/modules/AddCUDAQ.cmake, already include()’d before any subdirectory is processed) directly from its own CMakeLists.txt:

# my-backend/CMakeLists.txt
add_target_config(my-backend)   # expects my-backend.yml next to this file

Doing this bakes my-backend directly into the same pre-compiled CUDAQTargetDatabase database as every other in-tree target - the target is resolved with zero YAML parsing and avoids dynamically loading a library at runtime. In summary, there are two different distribution models:

  • Compiled plugin library (``targets/<name>.so`` or ``targets/<name>.dylib``): works against an already-built, already-installed CUDA-Q. Can be distributed independently of CUDA-Q’s own release cadence.

  • ``add_target_config`` via ``CUDAQ_EXTERNAL_PROJECTS``: requires rebuilding (this part of) CUDA-Q itself together with your target - there is no pre-built CUDA-Q install this can attach to after the fact. This is the right choice for an organization assembling and shipping its own customized CUDA-Q distribution with extra targets baked in from the start, not for distributing a target independently to users of a stock CUDA-Q install.

Python Packaging

A plugin ships as a standard Python package with a cudaq.backends entry point that makes it discoverable at import cudaq time. The package includes the compiled target plugin library and shared library, allowing the plugin author to build a wheel and publish it to a package index or distribute it directly. End users can then install that package into their own CUDA-Q environments without building the plugin from source.

Because the package contains a native shared library, it is platform-specific. Plugin authors must build and distribute a separate package for each operating system and architecture required by their users. When distributing wheels, ensure that each wheel has the platform tag matching the platform targeted by its shared library; do not distribute the package as a platform-agnostic wheel.

pyproject.toml

[build-system]
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "my-backend-cudaq"
version = "0.1.0"
description = "My Backend plugin for CUDA-Q."
requires-python = ">=3.9"

[project.entry-points."cudaq.backends"]
my-backend = "my_backend_cudaq:register"

[tool.setuptools]
packages = ["my_backend_cudaq"]

[tool.setuptools.package-dir]
"my_backend_cudaq" = "."

[tool.setuptools.package-data]
"my_backend_cudaq" = [
    "targets/*.so", "targets/*.dylib", "targets/*.yml", "lib/*",
]

The critical piece is the [project.entry-points."cudaq.backends"] section. The key (my-backend) is a free-form identifier; the value points to the register() function in your package.

__init__.py

from importlib.resources import files


def register():
    import cudaq
    cudaq.register_backend_path(str(files(__name__)))

When import cudaq runs, it discovers all cudaq.backends entry points and calls each one. Your register() function calls cudaq.register_backend_path() with the package root, which scans targets/ for compiled plugin libraries (.so, .dylib) and .yml files and makes those targets available.

If your entry point raises an exception, CUDA-Q logs a warning with the entry-point name and traceback and continues — other plugins still load.

__main__.py (--install-nvqpp hook)

PACKAGE_NAME = "my_backend_cudaq"
PLUGIN_NAME = "my-backend"


def main() -> int:
    from cudaq.plugins import install_plugin_for_nvqpp_main
    return install_plugin_for_nvqpp_main(PACKAGE_NAME, PLUGIN_NAME)


if __name__ == "__main__":
    raise SystemExit(main())

This lets users run python -m my_backend_cudaq --install-nvqpp to symlink the installed package into the nvq++ user plugin scope.

Installing the Plugin for End Users

In this section, “installation” means installation by an end user who wants to use the target. It is separate from the preceding build and packaging steps performed by the plugin author. The steps an end user follows depend on how the plugin is distributed and whether they access the target from Python or C++:

pip install (Python — zero config)

pip install my-backend-cudaq

This installs the Python package previously published or otherwise distributed by the plugin author. After installation in the end user’s Python environment, import cudaq discovers the entry point automatically. No further configuration is needed — cudaq.set_target("my-backend") works immediately.

--install-nvqpp (make visible to nvq++)

The Python entry-point mechanism only fires inside a Python process. To make the Python package that the end user installed above visible to the nvq++ compiler driver as well:

python -m my_backend_cudaq --install-nvqpp

This symlinks the installed package directory into the user plugin scope. After this, nvq++ --target=my-backend resolves the target.

cudaq-install-plugin (C++-only workflows)

For plugins distributed without Python packaging (built from source, distributed as a tarball):

# Install to user scope (default, no sudo required)
cudaq-install-plugin /path/to/my-backend

# Install to system scope (shared across users)
sudo cudaq-install-plugin --system /path/to/my-backend

# Copy instead of symlink
cudaq-install-plugin --copy /path/to/my-backend

# List installed plugins
cudaq-install-plugin --list

# Remove a plugin
cudaq-install-plugin --uninstall my-backend

Discovery Mechanics

nvq++ target resolution

When nvq++ --target=my-backend is invoked, cudaq-target-resolve looks the name up in the unified target registry:

  1. Built-in: the pre-compiled CUDAQTargetDatabase linked into CUDA-Q

  2. User scope: ${CUDAQ_PLUGIN_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/cudaq/plugins}/*/targets/my-backend.{so,dylib}

  3. System scope: ${install_dir}/plugins/*/targets/my-backend.{so,dylib}

A compiled plugin library in the same plugin root shadows a same-named .yml. C++ programs do not support parsing YAML target configuration files at runtime and expect to find a pre-compiled target config library. Built-in names cannot be shadowed; an external root that reuses a built-in name is skipped with a warning. User-scope plugins take precedence over system-scope.

nvq++ --list-targets (and cudaq.get_targets() / has_target()) show targets that are available on this host. Pass --include-unavailable or include_unavailable=True to include known-but-unavailable targets; each entry exposes an availability diagnostic (missing GPU, simulator, platform library, plugin library, or incompatible CUDA-Q version).

When a plugin target is resolved, nvq++ automatically adds the plugin’s lib/ directory to the linker search path (-L) and runtime path (-Wl,-rpath).

Python target resolution

At import cudaq, the runtime:

  1. Loads built-in targets from the pre-compiled database

  2. Calls each cudaq.backends entry point, which registers additional plugin roots via cudaq.register_backend_path()

After this, cudaq.set_target("my-backend") works for any registered target.

Environment variables

Variable

Purpose

CUDAQ_PLUGIN_ROOT

Overrides the user-scope plugin directory for nvq++ and cudaq-install-plugin. Useful for CI, sandboxed builds, or shared filesystems.

XDG_DATA_HOME

Standard XDG variable. When CUDAQ_PLUGIN_ROOT is unset, the user scope defaults to ${XDG_DATA_HOME}/cudaq/plugins.

Reference Plugins

CUDA-Q ships a reference plugin under docs/sphinx/examples/plugins/ that demonstrates the full plugin lifecycle:

Plugin

Shape

What it demonstrates

mock_rest

REST

ServerHelper subclass, remote_rest QPU, mock server testing

Use this as a starter template for new plugins. It includes a complete build configuration, Python packaging, lit tests, and documentation.

Quick-Start Checklist

□ Implement ServerHelper subclass
□ Author targets/<name>.yml (``version: 1``) and optionally distribute it
  as a pre-compiled library
□ Create CMakeLists.txt (build with CUDAQ_EXTERNAL_PROJECTS)
□ Add pyproject.toml with cudaq.backends entry point
□ Add __init__.py with register() function
□ Add __main__.py with --install-nvqpp hook
□ Add tests/ with at least one end-to-end test
□ Verify: pip install . && python -c "import cudaq; assert cudaq.has_target('<name>')"
□ Verify: python -m <pkg> --install-nvqpp && nvq++ --target=<name> ...