Custom Devices¶
Differences between PennyLane and Catalyst¶
PennyLane and Catalyst treat devices a bit differently. In PennyLane, one is able to define devices in Python. Catalyst cannot interface with Python devices yet. Instead, Catalyst can only interact with devices that implement the QuantumDevice class.
Here is an example of a custom QuantumDevice
in which every single quantum operation is implemented as a no-operation.
Additionally, all measurements will always return true
.
#include <QuantumDevice.hpp>
struct CustomDevice final : public Catalyst::Runtime::QuantumDevice {
CustomDevice([[maybe_unused]] const std::string &kwargs = "{}") {}
~CustomDevice() = default;
CustomDevice &operator=(const QuantumDevice &) = delete;
CustomDevice(const CustomDevice &) = delete;
CustomDevice(CustomDevice &&) = delete;
CustomDevice &operator=(QuantumDevice &&) = delete;
auto AllocateQubit() -> QubitIdType override { return 0; }
auto AllocateQubits(size_t num_qubits) -> std::vector<QubitIdType> override {
return std::vector<QubitIdType>(num_qubits);
}
[[nodiscard]] auto Zero() const -> Result override { return NULL; }
[[nodiscard]] auto One() const -> Result override { return NULL; }
auto Observable(ObsId, const std::vector<std::complex<double>> &,
const std::vector<QubitIdType> &) -> ObsIdType override {
return 0;
}
auto TensorObservable(const std::vector<ObsIdType> &) -> ObsIdType override { return 0; }
auto HamiltonianObservable(const std::vector<double> &, const std::vector<ObsIdType> &)
-> ObsIdType override {
return 0;
}
auto Measure(QubitIdType) -> Result override {
bool *ret = (bool *)malloc(sizeof(bool));
*ret = true;
return ret;
}
void ReleaseQubit(QubitIdType) override {}
void ReleaseAllQubits() override {}
[[nodiscard]] auto GetNumQubits() const -> size_t override { return 0; }
void SetDeviceShots(size_t shots) override {}
[[nodiscard]] auto GetDeviceShots() const -> size_t override { return 0; }
void StartTapeRecording() override {}
void StopTapeRecording() override {}
void PrintState() override {}
void NamedOperation(const std::string &, const std::vector<double> &,
const std::vector<QubitIdType> &,
bool,
const std::vector<QubitIdType> &,
const std::vector<bool> &
) override {}
void MatrixOperation(const std::vector<std::complex<double>> &,
const std::vector<QubitIdType> &,
bool,
const std::vector<QubitIdType> &,
const std::vector<bool> &
) override{}
auto Expval(ObsIdType) -> double override { return 0.0; }
auto Var(ObsIdType) -> double override { return 0.0; }
void State(DataView<std::complex<double>, 1> &) override {}
void Probs(DataView<double, 1> &) override {}
void PartialProbs(DataView<double, 1> &, const std::vector<QubitIdType> &) override {}
void Sample(DataView<double, 2> &, size_t) override {}
void PartialSample(DataView<double, 2> &, const std::vector<QubitIdType> &, size_t) override {}
void Counts(DataView<double, 1> &, DataView<int64_t, 1> &, size_t) override {}
void PartialCounts(DataView<double, 1> &, DataView<int64_t, 1> &,
const std::vector<QubitIdType> &, size_t) override {}
void Gradient(std::vector<DataView<double, 1>> &, const std::vector<size_t> &) override {}
};
In addition to implementing the QuantumDevice
class, one must implement an entry point for the
device library with the name <DeviceIdentifier>Factory
, where DeviceIdentifier
is used to
uniquely identify the entry point symbol. As an example, we use the identifier CustomDevice
:
extern "C" Catalyst::Runtime::QuantumDevice*
CustomDeviceFactory(const char *kwargs) {
return new CustomDevice(std::string(kwargs));
}
For simplicity, you can use the GENERATE_DEVICE_FACTORY(IDENTIFIER, CONSTRUCTOR)
macro to
define this function, where IDENTIFIER
is the device identifier, and CONSTRUCTOR
is the
C++ device constructor including the namespace. For this example, both the device identifier and
constructor are the same:
GENERATE_DEVICE_FACTORY(CustomDevice, CustomDevice);
The entry point function acts as a factory method for the device class. Note that a plugin library may also provide several factory methods in case it packages multiple devices into the same library. However, it is important that the device identifier be unique, as best as possible, to avoid clashes with other plugins.
Importantly, the <DeviceIdentifier>
string in the entry point function needs to match
exactly what is supplied to the __catalyst__rt__device("rtd_name", "<DeviceIdentifier>")
runtime instruction in compiled user programs, or what is returned from the get_c_interface
function when integrating the device into a PennyLane plugin. Please see the “Integration with
Python devices” section further down for details.
CustomDevice(kwargs)
serves as a constructor for your custom device, with kwargs
as a string of device specifications and options, represented in Python dictionary format.
An example could be the default number of device shots, encoded as the following string:
"{'shots': 1000}"
.
Note that these parameters are automatically initialized in the frontend if the library is
provided as a PennyLane plugin device (see qml.device()
).
The destructor of CustomDevice
will be automatically called by the runtime.
Warning
This interface might change quickly in the near future. Please check back regularly for updates and to ensure your device is compatible with a specific version of Catalyst.
How to compile custom devices¶
One can follow the catalyst/runtime/tests/third_party/CMakeLists.txt
as an example.
cmake_minimum_required(VERSION 3.20)
project(third_party_device)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(dummy_device SHARED dummy_device.cpp)
target_include_directories(dummy_device PUBLIC ${runtime_includes})
set_property(TARGET dummy_device PROPERTY POSITION_INDEPENDENT_CODE ON)
Integration with Python devices¶
There are two things that are needed in order to integrate with PennyLane devices:
Adding a
get_c_interface
method to yourqml.devices.Device
class.Adding a
config
class variable pointing to your configuration file. This file should be a toml file with fields that describe what gates and features are supported by your device.
If you already have a custom PennyLane device defined in Python and have added a shared object that corresponds to your implementation of the QuantumDevice
class, then all you need to do is to add a get_c_interface
method to your PennyLane device.
The get_c_interface
method should be a static method that takes no parameters and returns the complete path to your shared library with the QuantumDevice
implementation.
Note
The first result of get_c_interface
needs to match the <DeviceIdentifier>
as described in the first section.
The Pennylane device API allows you to build a QJIT compatible device in a simple way:
class CustomDevice(qml.devices.Device):
"""Custom Device"""
config = pathlib.Path("absolute/path/to/configuration/file.toml")
@staticmethod
def get_c_interface():
""" Returns a tuple consisting of the device name, and
the location to the shared object with the C/C++ device implementation.
"""
return "CustomDevice", "absolute/path/to/librtd_custom.so"
def __init__(self, shots=None, wires=None):
super().__init__(wires=wires, shots=shots)
def execute(self, circuits, config):
"""Your normal definitions"""
@qjit
@qml.qnode(CustomDevice(wires=1))
def f():
return measure(0)
Below is an example configuration file with inline descriptions of how to fill out the fields. All headers and fields are generally required, unless stated otherwise.
# Which version of the specification format is being used.
schema = 2
# The union of all gate types listed in this section must match what
# the device considers "supported" through PennyLane's device API.
# The gate definition has the following format:
#
# GATE = { properties = [ PROPS ], condition = [ COND ] }
#
# Where:
#
# PROPS: zero or more comma-separated quoted strings:
# "controllable", "invertible", "differentiable"
# COND: quoted string, on of:
# "analytic", "finiteshots"
#
[operators.gates.native]
QubitUnitary = { properties = [ "controllable", "invertible"] }
PauliX = { properties = [ "controllable", "invertible"] }
PauliY = { properties = [ "controllable", "invertible"] }
PauliZ = { properties = [ "controllable", "invertible"] }
MultiRZ = { properties = [ "controllable", "invertible" ] }
Hadamard = { properties = [ "controllable", "invertible"] }
S = { properties = [ "controllable", "invertible" ] }
T = { properties = [ "controllable", "invertible" ] }
CNOT = { properties = [ "invertible" ] }
SWAP = { properties = [ "controllable", "invertible" ] }
CSWAP = { properties = [ "invertible" ] }
Toffoli = { properties = [ "controllable", "invertible" ] }
CY = { properties = [ "invertible" ] }
CZ = { properties = [ "invertible" ] }
PhaseShift = { properties = [ "controllable", "invertible" ] }
ControlledPhaseShift = { properties = [ "invertible" ] }
RX = { properties = [ "controllable", "invertible" ] }
RY = { properties = [ "controllable", "invertible" ] }
RZ = { properties = [ "controllable", "invertible" ] }
Rot = { properties = [ "controllable", "invertible" ] }
CRX = { properties = [ "invertible" ] }
CRY = { properties = [ "invertible" ] }
CRZ = { properties = [ "invertible" ] }
CRot = { properties = [ "invertible" ] }
Identity = { properties = [ "controllable", "invertible" ] }
IsingXX = { properties = [ "controllable", "invertible" ] }
IsingYY = { properties = [ "controllable", "invertible" ] }
IsingZZ = { properties = [ "controllable", "invertible" ] }
IsingXY = { properties = [ "controllable", "invertible" ] }
# Operators that should be decomposed according to the algorithm used
# by PennyLane's device API.
# Optional, since gates not listed in this list will typically be decomposed by
# default, but can be useful to express a deviation from this device's regular
# strategy in PennyLane.
[operators.gates.decomp]
SX = {}
ISWAP = {}
PSWAP = {}
SISWAP = {}
SQISW = {}
CPhase = {}
BasisState = {}
StatePrep = {}
ControlledQubitUnitary = {}
MultiControlledX = {}
SingleExcitation = {}
SingleExcitationPlus = {}
SingleExcitationMinus = {}
DoubleExcitation = {}
DoubleExcitationPlus = {}
DoubleExcitationMinus = {}
QubitCarry = {}
QubitSum = {}
OrbitalRotation = {}
QFT = {}
ECR = {}
# Gates which should be translated to QubitUnitary
[operators.gates.matrix]
DiagonalQubitUnitary = {}
# Observables supported by the device
[operators.observables]
PauliX = {}
PauliY = {}
PauliZ = {}
Hadamard = {}
Hermitian = {}
Identity = {}
Projector = {}
SparseHamiltonian = {}
Hamiltonian = {}
Sum = {}
SProd = {}
Prod = {}
Exp = {}
[measurement_processes]
Expval = {}
Var = {}
Probs = {}
Sample = {}
Counts = { condition = [ "finiteshots" ] }
[compilation]
# If the device is compatible with qjit
qjit_compatible = true
# If the device requires run time generation of the quantum circuit.
runtime_code_generation = false
# If the device supports mid circuit measurements natively
mid_circuit_measurement = true
# This field is currently unchecked but it is reserved for the purpose of
# determining if the device supports dynamic qubit allocation/deallocation.
dynamic_qubit_management = false
[options]
# Options is an optional field.
# These options represent runtime parameters that can be passed to the device
# upon the device initialization.
# The option key will be the key in a dictionary.
# The string corresponds to a field queried in the `qml.Device` instance.
option_key = "option_field"
# In the above example, a dictionary will be constructed at run time.
# The dictionary will contain the string key "option_key" and its value
# will be the value in `qml.Device` `option_field`.
# The value can be any Python type, but will be converted to a string.
# During the initialization of your `class QuantumDevice`, the dictionary
# will be sent to the constructor of your implementation of `class QuantumDevice`.
# The dictionary will be a JSON string like the following:
# { 'option_key': option_field }