PennyLane
Install
Install
  1. Blog/
  2. Compilers/
  3. Compiler concepts for physicists: introduction to blocks

March 03, 2026

Compiler concepts for physicists: introduction to blocks

Daniela Angulo

Daniela Angulo

Josh Izaac

Josh Izaac

Erick Ochoa

Erick Ochoa

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
Daniela Angulo

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 Izaac

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
Erick Ochoa

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.

Last modified: March 03, 2026

Related Blog Posts

PennyLane

PennyLane is a cross-platform Python library for quantum computing, quantum machine learning, and quantum chemistry. Built by researchers, for research. Created with ❤️ by Xanadu.

Research

  • Research
  • Performance
  • Hardware & Simulators
  • Demos
  • Quantum Compilation
  • Quantum Datasets

Education

  • Teach
  • Learn
  • Codebook
  • Coding Challenges
  • Videos
  • Glossary

Software

  • Install PennyLane
  • Features
  • Documentation
  • Catalyst Compilation Docs
  • Development Guide
  • API
  • GitHub
Stay updated with our newsletter

© Copyright 2026 | Xanadu | All rights reserved

TensorFlow, the TensorFlow logo and any related marks are trademarks of Google Inc.

Privacy Policy|Terms of Service|Cookie Policy|Code of Conduct