Creating Kernels Dynamically with the cudaq::kernel_builder

There may be use cases whereby one might need the ability to construct quantum kernels dynamically at runtime, as opposed to statically defining kernel structs, lambdas, or functions. CUDA Quantum provides an abstraction called the cudaq::kernel_builder for such cases. Imagine that you wanted to dynamically create a kernel with the following callable structure.

struct kernel {
  void operator()(double theta) __qpu__ {
    cudaq::qarray<2> q;
    x(q[0]);
    ry(theta, q[1]);
    x<cudaq::ctrl>(q[1], q[0]);
  }
};

This can be expressed dynamically at runtime with the builder as follows:

// Build a quantum kernel dynamically
// Start by creating the cudaq::builder, the kernel argument types
// should be provided here as template parameters.
auto [ansatz, theta] = cudaq::make_kernel<double>();
// Allocate some qubits
auto q = ansatz.qalloc(2);
// Build up the circuit, use the acquired parameter
ansatz.x(q[0]);
ansatz.ry(theta, q[1]);
ansatz.x<cudaq::ctrl>(q[1], q[0]);

// Can be used as input to any generic Kernel cudaq:: function
using namespace cudaq::spin;
cudaq::spin_op h = 5.907 - 2.1433 * x(0) * x(1) - 2.1433 * y(0) * y(1) +
                  .21829 * z(0) - 6.125 * z(1);
auto exp_val = cudaq::observe(ansatz, h, /*theta*/ 0.59);

This builder pattern begins by creating the builder and any runtime parameters required by the CUDA Quantum callable. The builder exposes an API for allocating qubits and applying quantum instructions. Once done constructing the kernel, the builder itself is callable, and can be passed as input to existing CUDA Quantum generic algorithm functions.

The function parameters returned via the cudaq::make_kernel<T...>() call can be modified algebraically, giving the programmer more control over the kernel construction. These parameters can be any arithmetic type or std::vector<T> where T is any arithmetic type. The arithmetic parameters can be multiplied by scalars, negated (unary negation), and summed with scalars. Vector parameters can be indexed. Here’s an example:

// Create the kernel with signature void(std::vector<double>)
auto [ansatz, thetas] = cudaq::make_kernel<std::vector<double>>();

// Allocate some qubits
auto q = ansatz.qalloc(3);

// Build the kernel
ansatz.x(q[0]);
ansatz.ry(thetas[0], q[1]);
ansatz.ry(thetas[1], q[2]);
ansatz.x<cudaq::ctrl>(q[2], q[0]);
ansatz.x<cudaq::ctrl>(q[0], q[1]);
// Can do fancy arithmetic with Parameter types.
ansatz.ry(-thetas[0], q[1]);
// -or- ansatz_builder.ry(-1.0 * thetas[0], q[1]);
// -or- ansatz_builder.ry(thetas[0] * -1.0, q[1]);
// -or- ansatz_builder.ry(-1 * thetas[0], q[1]);
ansatz.x<cudaq::ctrl>(q[0], q[1]);
ansatz.x<cudaq::ctrl>(q[1], q[0]);

The cudaq::kernel_builder internal builds up an MLIR representation of the kernel. You can get a string representation of the MLIR code (in the Quake Dialect) with the following call:

auto quakeCode = ansatz.to_quake();