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.
import cudaq
@cudaq.kernel
def kernel():
A = cudaq.qubit()
B = cudaq.qvector(3)
C = cudaq.qvector(5)
#include <cudaq.h>
using namespace std::complex_literals;
using complex = std::complex<cudaq::real>;
__qpu__ void kernel() {
cudaq::qubit A;
cudaq::qvector B(3);
cudaq::qvector C(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.
N = 2
@cudaq.kernel
def kernel(N: int):
register = cudaq.qvector(N)
int N = 2;
__qpu__ void kernel(int N) { cudaq::qvector r(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.
Passing complex vectors as parameters
# Passing complex vectors as parameters
c = [.707 + 0j, 0 - .707j]
@cudaq.kernel
def kernel(vec: list[complex]):
q = cudaq.qubit(vec)
// Passing complex vectors as parameters
__qpu__ void kernel(const std::vector<complex> &vec) { cudaq::qubit q; }
Capturing complex vectors
# Capturing complex vectors
c = [0.70710678 + 0j, 0., 0., 0.70710678]
@cudaq.kernel
def kernel():
q = cudaq.qvector(c)
// Capturing complex vectors
__qpu__ void kernel0(const std::vector<complex> &vec) { cudaq::qvector q(vec); }
void function0() {
std::vector<complex> d = {
{0.70710678, 0}, {0.0, 0.0}, {0.0, 0.0}, {0.70710678, 0.0}};
kernel0(d);
}
Precision-agnostic API
# 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)
// Precision-Agnostic API
__qpu__ void kernel1(const std::vector<cudaq::complex> &e) {
cudaq::qvector q(e);
}
void function1() {
auto e = {
cudaq::complex{0.70710678, 0}, {0.0, 0.0}, {0.0, 0.0}, {0, 0.70710678}};
kernel1(e);
}
Define as CUDA-Q amplitudes
# 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
# 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.
Gates can be applied to all qubits in a register.
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
h(register)
__qpu__ void kernel2() {
cudaq::qvector r(10);
cudaq::h(r);
}
Or, to individual qubits in a register.
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
h(register[0]) # first qubit
h(register[-1]) # last qubit
__qpu__ void kernel3() {
cudaq::qvector r(10);
cudaq::h(r[0]); // first qubit
cudaq::h(r[9]); // 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.
@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.
@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
__qpu__ void kernel5() {
cudaq::qvector r(10);
x<cudaq::ctrl>(r[0], r[1]); // CNOT gate applied with qubit 0 and 1 as control
}
You can also call a controlled kernel within a kernel.
@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)
__qpu__ void x_kernel(cudaq::qubit &q) { x(q); }
// A kernel that will call `x_kernel` as a controlled operation.
__qpu__ void kernel6() {
cudaq::qvector control_vector(2);
cudaq::qubit target;
x(control_vector);
x(target);
x(control_vector[1]);
cudaq::control(x_kernel, control_vector, target);
}
// The above is equivalent to:
__qpu__ void kernel7() {
cudaq::qvector qvector(3);
x(qvector);
x(qvector[1]);
x<cudaq::ctrl>(qvector[0], qvector[1]);
mz(qvector);
}
int main() {
auto results = cudaq::sample(kernel7);
results.dump();
}
Adjoint Operations¶
The adjoint of a gate can be applied by appending the gate with the adj
designation.
@cudaq.kernel
def kernel():
register = cudaq.qvector(10)
t.adj(register[0])
__qpu__ void kernel_t(cudaq::qvector<> &qubits, double theta) {
ry(theta, qubits[0]);
h<cudaq::ctrl>(qubits[0], qubits[1]);
x(qubits[1]);
}
__qpu__ void kernel8() {
cudaq::qvector<> r(10);
cudaq::adjoint(kernel_t, r, 0.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.
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.
@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])
__qpu__ void kernel_A(cudaq::qubit &q0, cudaq::qubit &q1) {
x<cudaq::ctrl>(q0, q1);
}
__qpu__ void kernel_B() {
cudaq::qvector reg(10);
for (int i = 0; i < 5; i++) {
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.
@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)
__qpu__ void kernel9(std::vector<double> thetas) {
cudaq::qvector qubits(2);
rx(thetas[0], qubits[0]);
ry(thetas[1], qubits[1]);
}
std::vector<double> thetas = {.024, .543};
// kernel9(thetas);