Building Kernels¶
This section will cover the most basic CUDA-Q construct, a quantum kernel. Topics include, building kernels, initializing states, and applying gate operations.
# ### Defining Kernels
#
# Kernels are the building blocks of quantum algorithms in CUDA-Q. A kernel is specified by using the following syntax. `cudaq.qubit` builds a register consisting of a single qubit, while `cudaq.qvector` builds a register of $N$ qubits.
# In[14]:
import cudaq
# In[15]:
@cudaq.kernel
def kernel():
A = cudaq.qubit()
B = cudaq.qvector(3)
C = cudaq.qvector(5)
# Inputs to kernels are defined by specifying a parameter in the kernel definition along with the appropriate type. The kernel below takes an integer to define a register of N qubits.
# In[16]:
N = 2
@cudaq.kernel
def kernel(N: int):
register = cudaq.qvector(N)
# ### Initializing states
#
# It is often helpful to define an initial state for a kernel. There are a few ways to do this in CUDA-Q. Note, method 5 is particularly useful for cases where the state of one kernel is passed into a second kernel to prepare its initial state.
#
# 1. Passing complex vectors as parameters
# 2. Capturing complex vectors
# 3. Precision-agnostic API
# 4. Define as CUDA-Q amplitudes
# 5. Pass in a state from another kernel
# In[17]:
# Passing complex vectors as parameters
c = [.707 + 0j, 0 - .707j]
@cudaq.kernel
def kernel(vec: list[complex]):
q = cudaq.qubit(vec)
# Capturing complex vectors
c = [0.70710678 + 0j, 0., 0., 0.70710678]
@cudaq.kernel
def kernel():
q = cudaq.qvector(c)
# Precision-Agnostic API
import numpy as np
c = np.array([0.70710678 + 0j, 0., 0., 0.70710678], dtype=cudaq.complex())
@cudaq.kernel
def kernel():
q = cudaq.qvector(c)
# Define as CUDA-Q amplitudes
c = cudaq.amplitudes([0.70710678 + 0j, 0., 0., 0.70710678])
@cudaq.kernel
def kernel():
q = cudaq.qvector(c)
# Pass in a state from another kernel
c = [0.70710678 + 0j, 0., 0., 0.70710678]
@cudaq.kernel
def kernel_initial():
q = cudaq.qvector(c)
state_to_pass = cudaq.get_state(kernel_initial)
@cudaq.kernel
def kernel(state: cudaq.State):
q = cudaq.qvector(state)
kernel(state_to_pass)
# ### Applying Gates
#
#
# After a kernel is constructed, gates can be applied to start building out a quantum circuit. All the predefined gates in CUDA-Q can be found [here](https://nvidia.github.io/cuda-quantum/latest/api/default_ops.html#unitary-operations-on-qubits).
#
#
# Gates can be applied to all qubits in a register:
# In[18]:
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
h(register)
# Or, to individual qubits in a register:
# In[19]:
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
h(register[0]) # first qubit
h(register[-1]) # last qubit
# ### Controlled Operations
#
# Controlled operations are available for any gate and can be used by adding `.ctrl` to the end of any gate, followed by specification of the control qubit and the target qubit.
# In[20]:
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
x.ctrl(register[0],
register[1]) # CNOT gate applied with qubit 0 as control
# ### Multi-Controlled Operations
#
# It is valid for more than one qubit to be used for multi-controlled gates. The control qubits are specified as a list.
# In[21]:
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
x.ctrl([register[0], register[1]],
register[2]) # X applied to qubit two controlled by qubit 0 and 1
# You can also call a controlled kernel within a kernel:
# In[22]:
@cudaq.kernel
def x_kernel(qubit: cudaq.qubit):
x(qubit)
# A kernel that will call `x_kernel` as a controlled operation.
@cudaq.kernel
def kernel():
control_vector = cudaq.qvector(2)
target = cudaq.qubit()
x(control_vector)
x(target)
x(control_vector[1])
cudaq.control(x_kernel, control_vector, target)
# The above is equivalent to:
@cudaq.kernel
def kernel():
qvector = cudaq.qvector(3)
x(qvector)
x(qvector[1])
x.ctrl([qvector[0], qvector[1]], qvector[2])
mz(qvector)
results = cudaq.sample(kernel)
print(results)
# ### Adjoint Operations
#
# The adjoint of a gate can be applied by appending the gate with the `adj` designation.
# In[23]:
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
t.adj(register[0])
# ### Custom Operations
#
# Custom gate operations can be specified using `cudaq.register_operation`. A one-dimensional Numpy array specifies the unitary matrix to be applied. The entries of the array read from top to bottom through the rows.
# In[24]:
import numpy as np
cudaq.register_operation("custom_x", np.array([0, 1, 1, 0]))
@cudaq.kernel
def kernel():
qubits = cudaq.qvector(2)
h(qubits[0])
custom_x(qubits[0])
custom_x.ctrl(qubits[0], qubits[1])
# ### Building Kernels with Kernels
#
# For many complex applications, it is helpful for a kernel to call another kernel to perform a specific subroutine. The example blow shows how `kernel_A` can be called within `kernel_B` to perform CNOT operations.
# In[25]:
@cudaq.kernel
def kernel_A(qubit_0: cudaq.qubit, qubit_1: cudaq.qubit):
x.ctrl(qubit_0, qubit_1)
@cudaq.kernel
def kernel_B():
reg = cudaq.qvector(10)
for i in range(5):
kernel_A(reg[i], reg[i + 1])
# ### Parameterized Kernels
#
# It is often useful to define parameterized circuit kernels which can be used for applications like VQE.
# In[26]:
@cudaq.kernel
def kernel(thetas: list[float]):
qubits = cudaq.qvector(2)
rx(thetas[0], qubits[0])
ry(thetas[1], qubits[1])
thetas = [.024, .543]
kernel(thetas)