PyQuil Intermediate Free 6/7 in series 25 min read

Understanding Quil: PyQuil's Quantum Assembly Language

Deep dive into the Quil quantum assembly language underlying PyQuil. Learn DEFGATE, DECLARE, classical control flow, DEFCIRCUIT, and how PyQuil programs compile to Quil strings.

What you'll learn

  • pyquil
  • quil
  • quantum assembly
  • defgate
  • classical memory
  • control flow
  • rigetti

Prerequisites

  • Python proficiency
  • Beginner quantum computing concepts (superposition, entanglement)
  • Linear algebra basics

Quil (Quantum Instruction Language) is the assembly language that powers every PyQuil program. When you write Python code using Program(), H(0), and CNOT(0, 1), PyQuil translates those high-level constructs into Quil instructions that the QVM simulator and Rigetti QPUs execute directly. Understanding Quil gives you finer control over your quantum programs and helps you debug compilation issues, optimize circuits, and use features that the high-level Python API does not fully expose.

Why Learn Quil?

Most PyQuil users never look at the Quil output of their programs. That works fine for simple circuits, but there are several situations where Quil knowledge becomes essential:

  • Debugging compilation artifacts. When Quilc compiles your program for hardware, the output is Quil. Reading it helps you understand what the compiler changed.
  • Custom gates. DEFGATE lets you define arbitrary unitary matrices as native Quil instructions.
  • Classical control flow. Quil supports conditional branching and arithmetic on classical registers, enabling mid-circuit decisions that the Python API can express but that are easier to reason about in Quil.
  • Performance tuning. Writing Quil directly gives you explicit control over instruction ordering and parallelism.

Viewing the Quil Output of a PyQuil Program

Every PyQuil Program object can be converted to its Quil string representation.

from pyquil import Program
from pyquil.gates import H, CNOT, MEASURE, RZ

p = Program()
ro = p.declare('ro', 'BIT', 2)
p += H(0)
p += CNOT(0, 1)
p += RZ(1.5707963, 0)
p += MEASURE(0, ro[0])
p += MEASURE(1, ro[1])

# Print the Quil representation
print(p.out())

Expected Output

DECLARE ro BIT[2]
H 0
CNOT 0 1
RZ(1.5707963) 0
MEASURE 0 ro[0]
MEASURE 1 ro[1]

The .out() method returns the exact Quil string that would be sent to the compiler or QVM. Each line is one instruction: the gate name, any parameters in parentheses, and the qubit operands.

DECLARE: Classical Memory Regions

Quil uses DECLARE to allocate classical memory for storing measurement results and computation parameters. This is analogous to variable declarations in conventional assembly languages.

from pyquil import Program

p = Program()

# Declare different types of classical memory
ro = p.declare('ro', 'BIT', 4)         # 4 classical bits for readout
theta = p.declare('theta', 'REAL', 2)   # 2 real-valued parameters
counter = p.declare('counter', 'INTEGER', 1)  # 1 integer counter

print(p.out())

Expected Output

DECLARE ro BIT[4]
DECLARE theta REAL[2]
DECLARE counter INTEGER[1]

The supported memory types are:

  • BIT: Binary values (0 or 1), used for measurement results
  • REAL: Floating-point values, used for gate parameters
  • INTEGER: Integer values, used for counters and control flow
  • OCTET: 8-bit unsigned integers

Memory references like theta[0] can be used as gate parameters, enabling parameterized circuits that are compiled once and executed with different values.

DEFGATE: Custom Gate Definitions

DEFGATE lets you define custom unitary gates by specifying their matrix representation directly in Quil. This is essential when you need gates beyond the standard library.

import numpy as np
from pyquil import Program
from pyquil.quilbase import DefGate

# Define a custom sqrt(X) gate
sqrt_x_matrix = np.array([
    [0.5 + 0.5j, 0.5 - 0.5j],
    [0.5 - 0.5j, 0.5 + 0.5j],
])

# Create the gate definition and constructor
sqrt_x_def = DefGate('SQRT-X', sqrt_x_matrix)
SQRT_X = sqrt_x_def.get_constructor()

# Use it in a program
p = Program()
p += sqrt_x_def   # Include the definition
p += SQRT_X(0)    # Apply to qubit 0
p += SQRT_X(0)    # Apply again (should equal X gate)

print(p.out())

Expected Output

DEFGATE SQRT-X AS MATRIX:
    0.5+0.5i, 0.5-0.5i
    0.5-0.5i, 0.5+0.5i
SQRT-X 0
SQRT-X 0

You can also define parameterized custom gates.

from pyquil.quilbase import DefGate

# Define a parameterized rotation gate
# This creates an Rn(theta) gate that rotates around the (1,1,0) axis
def custom_rotation(theta):
    c = np.cos(theta / 2)
    s = np.sin(theta / 2)
    return np.array([
        [c, -1j * s / np.sqrt(2)],
        [-1j * s / np.sqrt(2), c],
    ])

# For parameterized gates, use DefGateByPaulis or define
# the matrix for a specific angle
angle = np.pi / 4
custom_gate_def = DefGate('MY-ROT', custom_rotation(angle))
MY_ROT = custom_gate_def.get_constructor()

p = Program()
p += custom_gate_def
p += MY_ROT(0)
print(p.out())

MEASURE and Classical Readout

Measurement in Quil transfers quantum state information into classical memory. The syntax is MEASURE <qubit> <classical_addr>.

from pyquil import Program
from pyquil.gates import H, CNOT, MEASURE

p = Program()
ro = p.declare('ro', 'BIT', 3)

p += H(0)
p += CNOT(0, 1)
p += CNOT(0, 2)

# Measure each qubit into a different classical bit
p += MEASURE(0, ro[0])
p += MEASURE(1, ro[1])
p += MEASURE(2, ro[2])

print(p.out())

Expected Output

DECLARE ro BIT[3]
H 0
CNOT 0 1
CNOT 0 2
MEASURE 0 ro[0]
MEASURE 1 ro[1]
MEASURE 2 ro[2]

You can also use MEASURE <qubit> without a classical destination, which discards the result but still collapses the qubit state. This is useful for reset operations.

Classical Control Flow

One of Quil’s most powerful features is classical control flow. Using labels, jumps, and comparison instructions, you can create programs that branch based on measurement outcomes during execution.

from pyquil import Program
from pyquil.gates import H, X, MEASURE
from pyquil.quilbase import (
    Declare, Jump, JumpWhen, JumpUnless, Label, Halt
)

# Repeat-until-success: keep measuring qubit 0 until we get |1>
p = Program()
ro = p.declare('ro', 'BIT', 1)

# Label for the loop start
p += Label('START')

# Prepare superposition
p += H(0)

# Measure
p += MEASURE(0, ro[0])

# If ro[0] is 0 (failure), jump back to START
p += JumpUnless('START', ro[0])

# If we reach here, ro[0] is 1 (success)
p += Label('DONE')

print("Repeat-until-success program:")
print(p.out())

Expected Output

Repeat-until-success program:
DECLARE ro BIT[1]
LABEL @START
H 0
MEASURE 0 ro[0]
JUMP-UNLESS @START ro[0]
LABEL @DONE

This program loops until the measurement of qubit 0 yields 1. On average, it takes two iterations. This pattern is useful for probabilistic quantum protocols where you need a specific measurement outcome to proceed.

Comparison and Arithmetic Instructions

Quil also supports classical comparison and arithmetic operations.

from pyquil import Program
from pyquil.gates import H, MEASURE, X
from pyquil.quilbase import (
    Label, Jump, JumpWhen, JumpUnless,
    ClassicalNot, ClassicalAnd, ClassicalOr,
    ClassicalMove, ClassicalExchange,
)

p = Program()
ro = p.declare('ro', 'BIT', 2)
flag = p.declare('flag', 'BIT', 1)

p += H(0)
p += MEASURE(0, ro[0])

p += H(1)
p += MEASURE(1, ro[1])

# Classical NOT: flip the first bit
p += ClassicalNot(ro[0])

# Classical AND: flag = ro[0] AND ro[1]
p += ClassicalAnd(ro[0], flag[0])

print(p.out())

These classical operations execute on the control processor (not the quantum processor) and enable conditional logic within a single program execution, without round-trips to the host computer.

RESET

The RESET instruction returns all qubits to the |0> state. You can also reset individual qubits.

from pyquil import Program
from pyquil.gates import H, MEASURE, RESET
from pyquil.quilbase import Reset, ResetQubit

p = Program()
ro = p.declare('ro', 'BIT', 2)

# First round
p += H(0)
p += MEASURE(0, ro[0])

# Reset qubit 0 to |0> and reuse it
p += ResetQubit(0)

# Second round
p += H(0)
p += MEASURE(0, ro[1])

print(p.out())

Expected Output

DECLARE ro BIT[2]
H 0
MEASURE 0 ro[0]
RESET 0
H 0
MEASURE 0 ro[1]

Qubit reset is valuable for circuits that reuse qubits, reducing the total qubit count required. On real hardware, reset is typically implemented by measuring the qubit and conditionally applying an X gate.

DEFCIRCUIT: Reusable Circuit Blocks

DEFCIRCUIT lets you define named, reusable subcircuits in Quil. This is similar to defining a function or macro.

from pyquil import Program
from pyquil.gates import H, CNOT, MEASURE

# Write raw Quil with DEFCIRCUIT
quil_string = """
DEFCIRCUIT BELL q0 q1:
    H q0
    CNOT q0 q1

DECLARE ro BIT[4]
BELL 0 1
BELL 2 3
MEASURE 0 ro[0]
MEASURE 1 ro[1]
MEASURE 2 ro[2]
MEASURE 3 ro[3]
"""

p = Program(quil_string)
print(p.out())

Expected Output

DEFCIRCUIT BELL q0 q1:
    H q0
    CNOT q0 q1

DECLARE ro BIT[4]
BELL 0 1
BELL 2 3
MEASURE 0 ro[0]
MEASURE 1 ro[1]
MEASURE 2 ro[2]
MEASURE 3 ro[3]

DEFCIRCUIT is expanded by the compiler before execution. The subcircuit is inlined wherever it is called, so there is no runtime overhead. This makes it a useful tool for organizing complex programs without sacrificing performance.

You can also define parameterized circuits:

quil_param = """
DEFCIRCUIT RX-ECHO(%angle) q:
    RX(%angle) q
    RX(-%angle) q

DECLARE ro BIT[1]
RX-ECHO(pi/4) 0
MEASURE 0 ro[0]
"""

p = Program(quil_param)
print(p.out())

How PyQuil Compiles to Quil

When you call qc.compile(program), several things happen:

  1. PyQuil generates Quil. Your Python Program object is serialized to a Quil string using .out().
  2. Quilc compiles the Quil. The Quilc compiler optimizes the circuit, maps virtual qubits to physical qubits based on the device topology, and decomposes gates into the hardware’s native gate set.
  3. The compiled Quil is returned. This is a new Quil program using only native gates (typically RZ, RX with specific angles, and CZ for Rigetti hardware).
from pyquil import Program, get_qc
from pyquil.gates import H, CNOT, MEASURE, SWAP

p = Program()
ro = p.declare('ro', 'BIT', 2)
p += SWAP(0, 1)
p += H(0)
p += CNOT(0, 1)
p += MEASURE(0, ro[0])
p += MEASURE(1, ro[1])

qc = get_qc('2q-qvm')
compiled = qc.compile(p)
print("Compiled Quil:")
print(compiled.program)

The compiled output will look quite different from your source program. SWAP gates get decomposed into three CZ and single-qubit rotations, and all gates are expressed in the native instruction set. Understanding this compilation step is crucial for estimating actual circuit depth and anticipating where noise will accumulate on real hardware.

Summary

Quil is more than just an intermediate representation. It is a full quantum instruction language with classical memory, custom gates, control flow, and reusable circuit definitions. Even if you primarily use PyQuil’s Python API, understanding the Quil layer gives you better insight into how your programs compile, execute, and interact with Rigetti hardware. When you need fine-grained control over your quantum programs, writing or inspecting Quil directly is often the clearest path forward.

Was this tutorial helpful?