March 03, 2026
Compiler concepts for physicists: introduction to blocks
This is the second entry of a blog post series introducing compiler concepts for quantum physicists, based on a series of tutorials by Erick Ochoa Lopez. In the second entry of this series, we will explore how to capture and execute a quantum program with MLIR dialects, using PennyLane and xDSL.
In the previous tutorial, we learned how to define simple operations in xDSL that can hold properties and how to "pretty-print" them. But a mere list of operations is not a program; they must be grouped into a cohesive sequence.
In PennyLane, a sequence of operations is typically contained within a QuantumTape or a QuantumScript. In the world of xDSL and MLIR, we use the concept of a block. A block is a linearized sequence of instructions, meaning that every instruction within a block is ordered.
In this entry, we move from "What is an operation?" to "How do we run a sequence of them?" Let's learn how to capture our structured program (with both quantum instructions and classical control flow) and represent it in our xDSL dialect.
Before we dive in, we would like to remind you that the following code builds upon the code from the previous entry.
Inserting operations into a block
Let's build a block by inserting operations into it. The "manual" way to do this is using the ImplicitBuilder context manager. Every xDSL operation created inside the ImplicitBuilder context manager will automatically append the latest instruction to the block's sequence. Conceptually, this is very similar to how a quantum tape records operations in PennyLane.
from xdsl import builder
block = ir.Block()
with builder.ImplicitBuilder(block):
deviceOp(device_name="default.qubit", wires=2)
HadamardOp(wires=[0])
CNOTOp(wires=[0, 1])
measureOp(wires=[0])
measureOp(wires=[1])
print(block)
print(block.ops)
for op in block.ops:
print(op)
<Block 140285557466576(_args=(), num_ops=5)>
BlockOps(block=<Block 140285557466576(_args=(), num_ops=5)>)
qml.device("default.qubit",wires=2)
qml.Hadamard(wires=[0])
qml.CNOT(wires=[0, 1])
%0 = qml.measure(wires=[0])
%0 = qml.measure(wires=[1])
While ImplicitBuilder is powerful, we can wrap this xDSL logic inside a familiar @qnode decorator and create a nicer interface that looks a lot more like PennyLane with just a few changes. Let's do that!
class Device:
def __init__(self, name, wires):
assert name == "default.qubit" and wires > 0
self.name = name
self.wires = wires
def device(name, wires: int=None) -> Device:
return Device(name, wires)
def qnode(dev: Device):
def wrapper(qnode):
def qnode_wrapper(*args, **kwargs):
block = ir.Block()
with builder.ImplicitBuilder(block):
deviceOp(device_name=dev.name, wires=dev.wires)
result = qnode(*args, **kwargs)
# As a side effect, we will pretty print the block
for op in block.ops:
print(op)
return result
return qnode_wrapper
return wrapper
Let's test our qnode decorator with an example.
@qnode(device("default.qubit", wires=2))
def example():
HadamardOp(wires=[0])
CNOTOp(wires=[0, 1])
return measureOp(wires=[0])
x = example()
print(x)
qml.device("default.qubit",wires=2)
qml.Hadamard(wires=[0])
qml.CNOT(wires=[0, 1])
%0 = qml.measure(wires=[0])
%0 = qml.measure(wires=[0])
Perfect! Now when we run code that looks a lot like PennyLane, we will store the operations in a block.
However, there is one problem here: the result of the qnode is not a value, but rather an operation itself! To resolve this, we must introduce a layer of indirection by creating a wrapper around the measure operation.
def measure(wires: list[int]):
wires = sanitize_wires(wires)
results = measureOp(wires=wires).results
# notice how the we are returning the results of the
# operation and not the operation itself
return results[0]
deviceOp can remain the same as we are adding it manually.
@qnode(device("default.qubit", wires=2))
def example():
HadamardOp(wires=[0])
CNOTOp(wires=[0, 1])
return measure(wires=[0])
print(example())
qml.device("default.qubit",wires=2)
qml.Hadamard(wires=[0])
qml.CNOT(wires=[0, 1])
%0 = qml.measure(wires=[0])
<OpResult[i64] name_hint: None, index: 0, operation: qml.measure, uses: 0>
This is a lot better!
Interpreting a block of instructions
Defining a HadamardOp is fine, but the compiler needs to know what that operation actually does to a quantum state. This is where the Interpreter comes in.
xDSL is designed to make prototyping straightforward; as such, it provides dedicated classes to build and compose interpreters. In a sense, we can recreate a PennyLane simulator device that applies quantum operations, but adapting it to work with blocks.
To make this work, we must provide the semantics for these operations.
from xdsl import interpreter
import numpy as np
import pennylane as qml
@interpreter.register_impls
class QMLFunctions(interpreter.InterpreterFunctions):
state = None
@interpreter.impl(deviceOp)
def run_deviceOp(self, interpreter: interpreter.Interpreter, op: deviceOp, args: interpreter.PythonValues) -> interpreter.PythonValues:
"""Initialize a state for op.wire_count number of qubits."""
num_qubits = op.wire_count.value.data
QMLFunctions.state = np.zeros([2**num_qubits], dtype=complex)
QMLFunctions.state[0] = 1.+0.j
QMLFunctions.state = np.reshape(QMLFunctions.state, [2] * num_qubits)
# returning an empty tuple states that this operation does not return anything.
return tuple()
@interpreter.impl(HadamardOp)
def run_HadamardOp(self, interpreter: interpreter.Interpreter, op: HadamardOp, args: interpreter.PythonValues) -> interpreter.PythonValues:
"""We are going to interface with PennyLane's implementation but supply our own state"""
wire = op.wire.value.data
h = qml.Hadamard(wire)
QMLFunctions.state = qml.devices.qubit.apply_operation(h, QMLFunctions.state)
return tuple()
@interpreter.impl(SOp)
def run_SOp(self, interpreter: interpreter.Interpreter, op: SOp, args: interpreter.PythonValues) -> interpreter.PythonValues:
wire = op.wire.value.data
s = qml.S(wire)
QMLFunctions.state = qml.devices.qubit.apply_operation(s, QMLFunctions.state)
return tuple()
@interpreter.impl(TOp)
def run_TOp(self, interpreter: interpreter.Interpreter, op: TOp, args: interpreter.PythonValues) -> interpreter.PythonValues:
wire = op.wire.value.data
t = qml.T(wire)
QMLFunctions.state = qml.devices.qubit.apply_operation(t, QMLFunctions.state)
return tuple()
@interpreter.impl(CNOTOp)
def run_CNOTOp(self, interpreter: interpreter.Interpreter, op: CNOTOp, args: interpreter.PythonValues) -> interpreter.PythonValues:
wire = op.wire.value.data
target = op.target.value.data
cnot = qml.CNOT(wires=[wire, target])
QMLFunctions.state = qml.devices.qubit.apply_operation(cnot, QMLFunctions.state)
return tuple()
@interpreter.impl(measureOp)
def run_measureOp(self, interpreter: interpreter.Interpreter, op: measureOp, args: interpreter.PythonValues) -> interpreter.PythonValues:
wire = op.wire.value.data
m0 = qml.measure(wire)
mid_measure = {m0.measurements[0]:[0]}
QMLFunctions.state = qml.devices.qubit.apply_operation(m0.measurements[0], QMLFunctions.state, mid_measurements=mid_measure, rng=np.random.default_rng())
# This operation actually returns something
return (mid_measure[m0.measurements[0]],)
Executing a qnode
Now that we have sane semantics, we can actually change the definition of qnode and execute the block of operations before returning the result.
def qnode(dev: Device):
def wrapper(qnode):
def qnode_wrapper(*args, **kwargs):
block = ir.Block()
with builder.ImplicitBuilder(block):
deviceOp(device_name=dev.name, wires=dev.wires)
qnode(*args, **kwargs)
interp = interpreter.Interpreter(block)
interp.register_implementations(QMLFunctions)
for op in block.ops:
# One disadvantage so far is that the operations
# will never take operands
# and we can only return the last result...
retval = interp.run_op(op, ())
return retval
return qnode_wrapper
return wrapper
@qnode(device("default.qubit", wires=2))
def example():
Hadamard(wires=[0])
CNOT(wires=[0, 1])
return measure(wires=[0])
print(example())
(1,)
We have now built a "mini-compiler" stack: a @qnode that looks like PennyLane, a structured xDSL Block holding the operations, and an interpreter that executes those operations.
Stay tuned for the next blog post in this series, we will improve on a few issues regarding our interpreter with the help of PyTrees and Tracers.
In the meantime, check out our Catalyst compiler which utilize MLIR and LLVM for quantum compilation, and also explore some of our compilation demos, such as How to quantum just-in-time (QJIT) compile Grover's algorithm with Catalyst.
About the authors
Daniela Angulo
I like quantum mechanics, optics, and linear algebra. Most of my time is devoted to making or reviewing content about quantum computing. Fun stuff!
Josh Izaac
Josh is a theoretical physicist, software tinkerer, and occasional baker. At Xanadu, he contributes to the development and growth of Xanadu’s open-source quantum software products.
Erick Ochoa
I love compilers. I have worked on compilers for the last seven years and I enjoy the chance to teach others about them.