Executing Quantum Circuits¶
In CUDA Quantum, 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 Quantum 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:483 11:517 }
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)
# Asynchronous execution on multiple qpus via nvidia gpus.
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)
print(result_1.get())
print(result_2.get())
{ 00:480 11:520 }
{ 00:487 11:513 }
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")
# Using the same quantum kernel function as we did with `sample`.
# 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> = -1.0000000000000002
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.