Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

CUDA-Q Support

Tags: integration optimization variational

This page shows how to use Qamomile’s CUDA-Q quantum SDK integration through a concrete optimization problem. In this tutorial, we use QAOA optimization for a small MaxCut instance as an example. We transpile a Qamomile qkernel to CUDA-Q, then run sampling and expectation-value evaluation. Later, we run the same QAOA circuit on CPU and GPU simulators and compare the sampled results and execution time. Along the way, we inspect the generated CUDA-Q source and compare Qamomile’s STATIC and RUNNABLE CUDA-Q execution modes.

The MaxCut problem

We use the same small 5-node graph from the QAOA for MaxCut tutorial so the focus stays on the CUDA-Q integration. Maximizing ∑(i,j)∈E(1−sisj)/2\sum_{(i,j) \in E}(1 - s_i s_j)/2 is equivalent, up to a constant, to minimizing the antiferromagnetic Ising Hamiltonian HC=∑(i,j)∈EsisjH_C = \sum_{(i,j) \in E} s_i s_j. For unweighted MaxCut, every Jij=1J_{ij} = 1 and every hi=0h_i = 0, so we pass these coefficients directly to BinaryModel.from_ising. We use the model object for the quad / linear dictionaries passed to the QAOA qkernel and for decoding measurements back into spin values (+1/−1)(+1 / -1).

<Figure size 500x400 with 1 Axes>

Building the QAOA ansatz with @qkernel

We write the sampling QAOA ansatz as reusable qkernels. The recipe is the same as in the QAOA for MaxCut tutorial. After preparing a uniform superposition in the computational basis, we alternately apply cost and mixer layers pp times, then measure in the computational basis.

qaoa_ansatz.draw(...) renders the Qamomile circuit diagram. We pass concrete values for the arguments that determine the problem structure (p, quad, linear, n) so the layered shape is visible. Meanwhile, gammas / betas are left as parameters whose values are supplied later.

<Figure size 1524.19x204 with 1 Axes>

Transpile to CUDA-Q

CudaqTranspiler is used with transpile() the same way as any other quantum SDK. We bind the arguments that determine the problem structure and keep gammas / betas as runtime parameters.

executable.get_first_circuit() returns Qamomile’s generated CUDA-Q artifact, CudaqKernelArtifact. This is a Qamomile-side wrapper for the generated @cudaq.kernel function, not a type from the upstream cudaq Python package. The artifact keeps an inspectable Python source string for the generated CUDA-Q kernel. The 2p2p QAOA angles (gammas[0..p-1], betas[0..p-1]) remain available as named runtime parameters. We can confirm that with type(...), the qubit count, and the parameter count, then inspect the generated source. This source string is useful when you want to see exactly what Qamomile handed to CUDA-Q, including gate decompositions such as the rzz layer below.

CudaqKernelArtifact
execution_mode : static
num_qubits     : 5
param_count    : 6
parameter_names: ['gammas[0]', 'gammas[1]', 'gammas[2]', 'betas[0]', 'betas[1]', 'betas[2]']
@cudaq.kernel
def _qamomile_kernel(thetas: list[float]):
    q = cudaq.qvector(5)
    __b0 = False
    __b1 = False
    __b2 = False
    __b3 = False
    __b4 = False
    h(q[0])
    h(q[1])
    h(q[2])
    h(q[3])
    h(q[4])
    x.ctrl(q[0], q[1])
    rz((1.0) * (thetas[0]), q[1])
    x.ctrl(q[0], q[1])
    x.ctrl(q[0], q[2])
    rz((1.0) * (thetas[0]), q[2])
    x.ctrl(q[0], q[2])
    x.ctrl(q[1], q[2])
    rz((1.0) * (thetas[0]), q[2])
    x.ctrl(q[1], q[2])
    x.ctrl(q[1], q[3])
    rz((1.0) * (thetas[0]), q[3])
    x.ctrl(q[1], q[3])
    x.ctrl(q[2], q[3])
    rz((1.0) * (thetas[0]), q[3])
    x.ctrl(q[2], q[3])
    x.ctrl(q[3], q[4])
    rz((1.0) * (thetas[0]), q[4])
    x.ctrl(q[3], q[4])
    rx((thetas[3]) * (2.0), q[0])
    rx((thetas[3]) * (2.0), q[1])
    rx((thetas[3]) * (2.0), q[2])
    rx((thetas[3]) * (2.0), q[3])
    rx((thetas[3]) * (2.0), q[4])
    x.ctrl(q[0], q[1])
    rz((1.0) * (thetas[1]), q[1])
    x.ctrl(q[0], q[1])
    x.ctrl(q[0], q[2])
    rz((1.0) * (thetas[1]), q[2])
    x.ctrl(q[0], q[2])
    x.ctrl(q[1], q[2])
    rz((1.0) * (thetas[1]), q[2])
    x.ctrl(q[1], q[2])
    x.ctrl(q[1], q[3])
    rz((1.0) * (thetas[1]), q[3])
    x.ctrl(q[1], q[3])
    x.ctrl(q[2], q[3])
    rz((1.0) * (thetas[1]), q[3])
    x.ctrl(q[2], q[3])
    x.ctrl(q[3], q[4])
    rz((1.0) * (thetas[1]), q[4])
    x.ctrl(q[3], q[4])
    rx((thetas[4]) * (2.0), q[0])
    rx((thetas[4]) * (2.0), q[1])
    rx((thetas[4]) * (2.0), q[2])
    rx((thetas[4]) * (2.0), q[3])
    rx((thetas[4]) * (2.0), q[4])
    x.ctrl(q[0], q[1])
    rz((1.0) * (thetas[2]), q[1])
    x.ctrl(q[0], q[1])
    x.ctrl(q[0], q[2])
    rz((1.0) * (thetas[2]), q[2])
    x.ctrl(q[0], q[2])
    x.ctrl(q[1], q[2])
    rz((1.0) * (thetas[2]), q[2])
    x.ctrl(q[1], q[2])
    x.ctrl(q[1], q[3])
    rz((1.0) * (thetas[2]), q[3])
    x.ctrl(q[1], q[3])
    x.ctrl(q[2], q[3])
    rz((1.0) * (thetas[2]), q[3])
    x.ctrl(q[2], q[3])
    x.ctrl(q[3], q[4])
    rz((1.0) * (thetas[2]), q[4])
    x.ctrl(q[3], q[4])
    rx((thetas[5]) * (2.0), q[0])
    rx((thetas[5]) * (2.0), q[1])
    rx((thetas[5]) * (2.0), q[2])
    rx((thetas[5]) * (2.0), q[3])
    rx((thetas[5]) * (2.0), q[4])

Each runtime parameter remains unbound until execution time. That means binding gammas / betas is treated as a parameter-value update on the CUDA-Q side rather than a circuit rebuild. The problem structure, such as the Ising coefficients, qubit count, and number of layers, is fixed when the circuit is transpiled, leaving only the variational angles as runtime inputs.

Sampling QAOA with CudaqExecutor

executable.sample(executor, bindings=..., shots=...) returns a SampleJob. Calling .result() gives a SampleResult, which BinaryModel.decode_from_sampleresult decodes into a BinarySampleSet of spin variables (+1/−1)(+1 / -1). This lets us count cut edges without any additional conversion.

Mean energy at random init: -0.5890

Optimizing the QAOA parameters

A QAOA optimization loop reuses the same executable across many (gammas, betas) vectors. Call transpiler.transpile() once, then call executable.sample() many times. In this example, we define the sampling and decoding work as cost_fn() and optimize it with SciPy’s minimize function. The classical optimizer updates (gammas, betas) while lowering the mean sampled Ising energy. Each iteration reuses the same executable and CudaqExecutor.

Optimized mean energy: -2.4640
Optimal gammas       : [0.8664, -0.1324, 1.1053]
Optimal betas        : [0.6689, -1.0023, 1.4188]
<Figure size 800x400 with 1 Axes>

Sampling is probabilistic, and different CUDA-Q targets can use different simulator implementations. Because of that, the optimization trajectory and final sampled energy may vary slightly across runs. The optimizer should still converge close to the ground-state energy of HCH_C on this 5-node graph. The optimized parameters from this run (opt_gammas, opt_betas) are reused throughout the rest of this page.

Expectation values

In Qamomile, you write expectation values in a qkernel with qmc.expval(...). When this is transpiled to CUDA-Q, it becomes an executable object that can be called with ExecutableProgram.run(executor, bindings=...). For CUDA-Q, run() uses Qamomile’s recorded parameter information to bind runtime parameters before calling CUDA-Q’s observe API.

We first use Qamomile’s run() path, then open the lower-level path that passes the generated CUDA-Q artifact directly to executor.estimate(...).

run()

First build a Qamomile Hamiltonian for HC=∑(i,j)∈EZiZjH_C = \sum_{(i,j) \in E} Z_i Z_j. Then transpile the expectation-value qkernel and evaluate it at the optimized QAOA parameters.

ExecutableProgram.run: -2.4026377899

ExecutableProgram.run(...) is the recommended route when you work through the Qamomile API. The observable and named runtime parameter bindings are managed by Qamomile’s ExecutableProgram.

The next section opens the lower-level path and passes the CUDA-Q artifact emitted by transpile() directly to executor.estimate(...).

cudaq.observe

For STATIC CUDA-Q artifacts, CudaqExecutor.estimate(circuit, hamiltonian, params=...) calls cudaq.observe() under the hood. The measured QAOA ansatz above is still usable as a state-preparation circuit because Qamomile does not write terminal measurements into the generated CUDA-Q kernel for STATIC artifacts. Sampling handles the final measurement separately, so the same emitted artifact can also be used by cudaq.observe(). In a QAOA optimizer, you can keep the same circuit and replace ExecutableProgram.sample() plus decoding with executor.estimate(circuit, hamiltonian, params=...).

When calling executor.estimate(...) directly, you provide the flat parameter order expected by the CUDA-Q artifact yourself.

artifact type       : CudaqKernelArtifact
artifact param_count: 6
artifact parameter order: ['gammas[0]', 'gammas[1]', 'gammas[2]', 'betas[0]', 'betas[1]', 'betas[2]']
executor.estimate: -2.4026377899

The run() and executor.estimate(...) paths agree to numerical precision. They evaluate the same QAOA state against the same Ising cost Hamiltonian. The resulting noise-free expectation value at the optimized parameters should also match the sample-mean energy printed earlier within shot noise.

Choosing CUDA-Q targets: Using the GPU target

A CUDA-Q target is the execution target CUDA-Q uses for kernel calls, such as the qpp-cpu CPU simulator, the nvidia GPU simulator, or a configured QPU target. CudaqExecutor() uses the currently selected CUDA-Q target. If you do not choose a target explicitly, CUDA-Q uses its default target, so the examples above run in a local CPU-only environment without extra configuration. CudaqExecutor(target=...) or CudaqTranspiler.executor(target=...) selects a target explicitly. The custom executor can be used anywhere executor appeared above. Changing the CUDA-Q target does not require re-transpiling the kernel. The ExecutableProgram carries the emitted CUDA-Q artifact, while the executor chooses the target used at execution time. The list of simulator targets you can select is maintained in CUDA-Q’s Circuit Simulation documentation, and CUDA-Q’s Running on a GPU section shows the same qpp-cpu / nvidia target pattern directly.

As a concrete example, we sample the same optimized QAOA ExecutableProgram on the CPU and GPU targets. Both runs below are noise-free: we do not install a CUDA-Q noise model, and each target uses the same QAOA circuit and the same parameter vector. We also set the same CUDA-Q random seed before each sampling call. The finite-shot samples are still produced by each target’s simulator, so we compare the sampled mean energies with a tolerance and plot the energy histograms rather than relying on identical raw shot ordering. We also time the sampling call on each target.

CPU backend target  : qpp-cpu
CPU hardware        : arm; logical CPUs: 8
CPU qpp-cpu mean energy: -2.3950
CPU qpp-cpu sample time: 0.0258 s
No NVIDIA GPU was detected. Run this notebook in a Google Colab GPU runtime to execute the `nvidia` target.

When a GPU is available, the CPU and GPU samples should describe the same QAOA output distribution. The helper below visualizes the sampled energy histograms and checks that the mean energies remain close. The tolerances are deliberately finite-shot tolerances, not exact-equality checks; the common seed makes the comparison reproducible for each target implementation.

<Figure size 550x400 with 1 Axes>

When Kernels Include Classical Control Flow: STATIC and RUNNABLE artifacts

Most variational circuits, including the QAOA ansatz above, transpile to ExecutionMode.STATIC. STATIC artifacts have no explicit terminal measurement in the generated CUDA-Q source, so they are compatible with CUDA-Q’s sample and observe APIs.

Qamomile quantum kernels are designed with hardware-level execution in mind and can express classical control flow based on mid-circuit measurement results (see Classical Flow Patterns for details). For this reason, if a quantum kernel contains control flow that depends on runtime measurements, such as if branches or while loops, the CUDA-Q integration emits an ExecutionMode.RUNNABLE artifact instead. RUNNABLE artifacts use explicit mz(...) measurements in the generated source and execute through cudaq.run(). The following tiny feed-forward circuit demonstrates that path.

execution_mode: runnable
@cudaq.kernel
def _qamomile_kernel() -> list[bool]:
    q = cudaq.qvector(2)
    __b0 = [False]
    __b1 = [False]
    x(q[0])
    __b0[0] = mz(q[0])
    if __b0[0]:
        x(q[1])
    else:
        pass
    __b1[0] = mz(q[1])
    return [__b0[0], __b1[0]]

ExecutableProgram.sample(...) still works for RUNNABLE artifacts, but the executor calls cudaq.run() rather than cudaq.sample(). The example above is deterministic: the first measurement is always 1, so the branch flips q1 and the returned bit is always 1.

[(1, 128)]

The trade-off is that RUNNABLE artifacts are not compatible with CUDA-Q’s observe API. Qamomile surfaces that distinction as a TypeError when estimate() is called on a RUNNABLE artifact.

TypeError Expectation-value estimation is not available for this CUDA-Q circuit. The kernel uses mid-circuit measurement or measurement-dependent control flow (e.g. `if bit:` / `while bit:`), so it is emitted in RUNNABLE mode and executed with `cudaq.run()`. CUDA-Q's expectation-value primitive `cudaq.observe()` only accepts static state-preparation kernels and cannot consume a RUNNABLE kernel. To estimate an expectation value, express the state preparation as a static kernel without measurement-dependent control flow; expectation values cannot be computed for measurement-conditioned circuits on the CUDA-Q engine.

Summary

In this tutorial, we transpiled a MaxCut QAOA quantum kernel to CUDA-Q, then exercised sampling, expectation-value estimation, CPU/GPU target selection, and execution of a circuit with classical control flow.

  • The CUDA-Q artifact emitted by CudaqTranspiler keeps inspectable Python source and can be reused with runtime parameters.

  • The same ExecutableProgram can run on the qpp-cpu target or the nvidia GPU target, with target selection handled by the executor.

  • CudaqExecutor supports both QAOA-style sampling and noise-free expectation values through CUDA-Q’s observe API.

  • Quantum kernels with classical control flow that depends on runtime measurements are emitted as ExecutionMode.RUNNABLE artifacts and execute through cudaq.run().

See also

  • QURI Parts Support covers the same MaxCut QAOA workflow with QURI Parts.

  • Qiskit Support covers the same workflow on Qiskit, including Aer simulators, Qiskit primitives, and native Qiskit circuit features.