Executing Quantum Circuits

In CUDA-Q, quantum circuits are stored as quantum kernels. For estimating the probability distribution of a measured quantum state in a circuit, we use the sample function call, and for computing the expectation value of a quantum state with a given observable, we use the observe function call.

Sample

Quantum states collapse upon measurement and hence need to be sampled many times to gather statistics. The CUDA-Q sample call enables this:

[1]:
import cudaq

qubit_count = 2

# Define the simulation target.
cudaq.set_target("qpp-cpu")

# Define a quantum kernel function.


@cudaq.kernel
def kernel(qubit_count: int):
    qvector = cudaq.qvector(qubit_count)

    # 2-qubit GHZ state.
    h(qvector[0])
    for i in range(1, qubit_count):
        x.ctrl(qvector[0], qvector[i])

    # If we dont specify measurements, all qubits are measured in
    # the Z-basis by default.
    mz(qvector)


print(cudaq.draw(kernel, qubit_count))

result = cudaq.sample(kernel, qubit_count, shots_count=1000)

print(result)
     ╭───╮
q0 : ┤ h ├──●──
     ╰───╯╭─┴─╮
q1 : ─────┤ x ├
          ╰───╯

{ 00:505 11:495 }

Note that there is a subtle difference between how sample is executed with the target device set to a simulator or with the target device set to a QPU. In simulation mode, the quantum state is built once and then sampled \(s\) times where \(s\) equals the shots_count. In hardware execution mode, the quantum state collapses upon measurement and hence needs to be rebuilt over and over again.

Sample Async

Asynchronous programming is a technique that enables your program to start a potentially long-running task and still be able to be responsive to other events while that task runs, rather than having to wait until that task has finished. Once that task has finished, your program is presented with the result.

sample can be a time intensive task. We can parallelize the execution of sample via the arguments it accepts.

[2]:
# Parallelize over the various kernels one would like to execute.

import cudaq

qubit_count = 2

# Set the simulation target.
cudaq.set_target("nvidia-mqpu")

# Kernel 1


@cudaq.kernel
def kernel_1(qubit_count: int):
    qvector = cudaq.qvector(qubit_count)

    # 2-qubit GHZ state.
    h(qvector[0])
    for i in range(1, qubit_count):
        x.ctrl(qvector[0], qvector[i])

    # If we dont specify measurements, all qubits are measured in
    # the Z-basis by default.
    mz(qvector)


# Kernel 2


@cudaq.kernel
def kernel_2(qubit_count: int):
    qvector = cudaq.qvector(qubit_count)

    # 2-qubit GHZ state.
    h(qvector[0])
    for i in range(1, qubit_count):
        x.ctrl(qvector[0], qvector[i])

    # If we dont specify measurements, all qubits are measured in
    # the Z-basis by default.
    mz(qvector)


if cudaq.num_available_gpus() > 1:
    # Asynchronous execution on multiple virtual QPUs, each simulated by an NVIDIA GPU.
    result_1 = cudaq.sample_async(kernel_1, qubit_count, shots_count=1000, qpu_id=0)
    result_2 = cudaq.sample_async(kernel_2, qubit_count, shots_count=1000, qpu_id=1)
else:
    # Schedule for execution on the same virtual QPU.
    result_1 = cudaq.sample_async(kernel_1, qubit_count, shots_count=1000, qpu_id=0)
    result_2 = cudaq.sample_async(kernel_2, qubit_count, shots_count=1000, qpu_id=0)

print(result_1.get())
print(result_2.get())
{ 00:493 11:507 }

{ 00:509 11:491 }

Similar to the above, one can also parallelize over the shots_count or the variational parameters of a quantum circuit.

Observe

The observe function allows us to gather qubit statistics and calculate expectation values. We must supply a spin operator in the form of a Hamiltonian from which we would like to calculate \(\bra{\psi}H\ket{\psi}\).

[3]:
import cudaq
from cudaq import spin

qubit_count = 2

# Define the simulation target.
cudaq.set_target("qpp-cpu")

# Define a quantum kernel function.


@cudaq.kernel
def kernel(qubit_count: int):
    qvector = cudaq.qvector(qubit_count)

    # 2-qubit GHZ state.
    h(qvector[0])
    for i in range(1, qubit_count):
        x.ctrl(qvector[0], qvector[i])


# Define a Hamiltonian in terms of Pauli Spin operators.
hamiltonian = spin.z(0) + spin.y(1) + spin.x(0) * spin.z(0)

# Compute the expectation value given the state prepared by the kernel.
result = cudaq.observe(kernel, hamiltonian, qubit_count).expectation()

print('<H> =', result)
<H> = 0.0

Observe Async

Similar to sample_async above, observe also supports asynchronous execution for the arguments it accepts. One can parallelize over various kernels, spin operators, variational parameters or even noise models.