Skip to main content
IBM Quantum Platform

Spinful Fermi-Hubbard dynamics - when mode ordering meets hardware connectivity

Usage estimate: about 2 minutes of classical compute and no QPU time, because all chips are fake backends (NOTE: This is an estimate only. Your runtime might vary.)

This tutorial explores Qiskit Fermions in depth. It shows a workflow to construct and transpile application circuits that is easier to use and more future-proof.

The tutorial follows a flow that is similar to the Simulate 1D Fermi-Hubbard dynamics with flow sets guide, but it extends the approach to spinful systems.


Learning outcomes

After completing this tutorial, you can expect to understand the following information:

  • How the choice between blocked and interleaved ordering of the spin modes changes which fermionic modes interact, and how that choice appears as Pauli patterns after a flow-set fermion-to-qubit encoding.
  • How to build the time-evolution circuit of the spinful Fermi-Hubbard model as a FermionicCircuit, and validate it against ffsim.
  • How a flow-set synthesis keeps the two-qubit depth of a Trotter step constant as the chain grows, compared with a term-by-term LieTrotter synthesis.
  • How to route such circuits onto the heavy-hex lattice of an IBM® Heron QPU and onto the square lattice of a Nighthawk QPU, including the effect of fractional gates.
  • Why the square lattice of Nighthawk favors blocked ordering with two ancillas while a ladder fits on the QPU, and what it costs to fold the ladder for larger systems.

Outline of this tutorial

  1. Recap of the spinless 1D Fermi-Hubbard system
  2. Spin: blocked or interleaved mode ordering
  3. Encoding of the fermions in qubits
  4. Scaling of the interleaved mode ordering
  5. Connectivity of Nighthawk devices
  6. Scaling beyond the native dimensions of Nighthawk
  7. Beyond 1D
  8. Takeaways and next steps

Requirements

Before starting this tutorial, ensure that you have the following installed. The versions are the ones that were used to run this tutorial.

  • Qiskit SDK v2.6.2, with visualization support
  • qiskit-fermions v0.4.1, with the ffsim validation backend (pip install "qiskit-fermions[ffsim]")
  • qiskit-ibm-runtime v0.51.0 (pip install qiskit-ibm-runtime), used only for the fake backends FakeFez (Heron) and FakeNighthawk. You do not need an IBM Quantum® account.
  • rustworkx v0.18.1 and matplotlib v3.11.2, with pylatexenc, which Qiskit needs to draw circuits

0. Import packages and define helper functions

import copy
import math
from collections import defaultdict
from importlib.metadata import version
from typing import NamedTuple

import ffsim
import matplotlib.pyplot as plt
from matplotlib.colors import to_rgb
from matplotlib.patches import FancyArrowPatch
import numpy as np
import rustworkx as rx
import scipy.sparse.linalg as spla
from rustworkx.visualization import mpl_draw
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter, QuantumRegister
from qiskit.circuit.library import (
    RXGate,
    RZZGate,
)
from qiskit.passmanager import MultiStagePassManager
from qiskit.quantum_info import (
    Operator,
    SparseObservable,
)
from qiskit.synthesis import LieTrotter
from qiskit.synthesis.evolution import EvolutionSynthesis
from qiskit.transpiler import CouplingMap, InstructionProperties
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

from qiskit_ibm_runtime.fake_provider import FakeFez, FakeNighthawk

# Importing this module registers the rule that folds out-of-range rzz angles into (0, pi/2] (used by `WrapAngles`).
import qiskit_ibm_runtime.utils.backend_converter  # noqa: F401

from qiskit_fermions.circuit import FermionicCircuit
from qiskit_fermions.circuit.library import Evolution
from qiskit_fermions.circuit.library.synthesis import FermionicLieTrotter
from qiskit_fermions.mappers import map_transfer_vertex_generators
from qiskit_fermions.mappers.library import (
    fermion_operator,
)
from qiskit_fermions.operators import TransferVertexOperator
from qiskit_fermions.transpiler import (
    FermionicCircuitToDAG,
    QuantumDAGToCircuit,
)
from qiskit_fermions.transpiler.passes import (
    CustomF2QLayout,
    F2QSynthesis,
    MapperFnEvolutionSynthesis,
)
for package in (
    "qiskit",
    "qiskit-fermions",
    "qiskit-ibm-runtime",
    "ffsim",
    "rustworkx",
):
    print(f"{package:20s} {version(package)}")

Output:

qiskit               2.6.2
qiskit-fermions      0.4.1
qiskit-ibm-runtime   0.51.0
ffsim                0.0.86
rustworkx            0.18.1

Constants

L_TINY = 2  # small enough to print every term of an operator
L_SMALL = 4  # the size of every explicit check and drawing below (the spinless guide uses four sites, too)
NUM_SEEDS = 20  # the number of seeds to try for randomized transpilation runs
GATE_SETS = (
    "cz basis",
    "fractional",
)  # the two gate sets of the routing studies

# Colors of the drawings: one meaning per color throughout the notebook. Spinless sites and the two spin species are shades of
# one blue (spin up dark, spin down light); hops are a dark/light pair of orange (east/west); ancillas are green, and red is
# kept for links that are not couplers.
SPIN_UP, SPIN_DOWN, SPINLESS, ANCILLA = (
    "#1b4f8a",
    "#9ecae1",
    "tab:blue",
    "tab:green",
)
HOP_EAST, HOP_WEST = (
    "#e6550d",
    "#fdae6b",
)  # hops (transfer operators) along a chain, dark / light orange: pointing to the larger / the smaller mode index

# Device-specific coupling map constants
ROWS, WIDTH = 12, 10

Data plotting utilities

# The style of every curve in the plots below, keyed by its label. Routed variants: solid = cz basis, dashed = fractional gates.
STYLES = {
    "LieTrotter, line": {"color": "0.7", "marker": "o"},
    "flow set, line": {"color": "0.3", "marker": "s"},
    "interleaved, 1 ancilla, Heron, cz basis": {
        "color": "tab:green",
        "marker": "o",
    },
    "interleaved, 1 ancilla, Heron, fractional": {
        "color": "tab:green",
        "marker": "o",
        "linestyle": "--",
    },
    "interleaved, 1 ancilla, Nighthawk, cz basis": {
        "color": "tab:olive",
        "marker": "^",
    },
    "interleaved, 1 ancilla, Nighthawk, fractional": {
        "color": "tab:olive",
        "marker": "^",
        "linestyle": "--",
    },
    "blocked, 2 ancillas, Nighthawk, cz basis": {
        "color": "tab:blue",
        "marker": "D",
    },
    "blocked, 2 ancillas, Nighthawk, fractional": {
        "color": "tab:blue",
        "marker": "D",
        "linestyle": "--",
    },
    "blocked, 2 ancillas, Heron, cz basis": {
        "color": "tab:red",
        "marker": "v",
    },
    "blocked, 2 ancillas, Heron, fractional": {
        "color": "tab:red",
        "marker": "v",
        "linestyle": "--",
    },
}
def plot_2q_depth_and_counts(
    sizes, results, xlabel="sites L", depth_ticks=(5, 10, 20, 50, 100)
):
    """Two-qubit depth (left, log scale) and two-qubit gate count (right) against the system size: one line per entry of `results`.

    `results` maps a label to one `Stats` per size (as returned by `route`).
    The shaded band reaches from the best to the worst of the seeds.
    Pure plotting: nothing is computed here. Returns the figure, so that a cell can end with the call."""
    figure, axes = plt.subplots(1, 2, figsize=(11, 4), layout="constrained")
    for label, series in results.items():
        style = STYLES[label]
        axes[0].plot(sizes, [s.depth for s in series], label=label, **style)
        axes[0].fill_between(
            sizes,
            [s.depth for s in series],
            [s.high for s in series],
            alpha=0.15,
            lw=0,
            color=style["color"],
        )
        axes[1].plot(sizes, [s.count for s in series], label=label, **style)
    axes[0].set(
        xlabel=xlabel,
        ylabel=f"two-qubit depth of the best routed circuit\n(band up to the worst of {NUM_SEEDS} seeds)",
        title="depth",
        yscale="log",
    )
    axes[0].yaxis.set_major_formatter(plt.ScalarFormatter())
    axes[0].set_yticks(list(depth_ticks))
    axes[1].set(
        xlabel=xlabel,
        ylabel="two-qubit gates of that circuit",
        title="two-qubit gates",
    )
    for axis in axes:
        axis.grid(axis="y")
    axes[1].legend(fontsize=8)
    plt.close(
        figure
    )  # drawn once, by being the cell's value; otherwise the inline backend shows it a second time
    return figure
def offset_in_L(offset, num_sites):
    """Show an offset in terms of the number of sites L: constants stay numbers, offsets that grow with L read `L + c`.

    This is a display rule, not a fit. It assumes every constant offset in the tables below is smaller than L (the largest
    is 3, from `ZXXZ`) and that no offset reaches 2L, which would have to be written `L + L`. The asserts in `pattern_table`
    guard that, so a size where the rule would mislabel an offset fails loudly instead.
    """
    if offset < num_sites:
        return str(offset)
    return "L" if offset == num_sites else f"L + {offset - num_sites}"


def pattern_table(operator, encoding, num_qubits, num_sites):
    """Per flow set: the Pauli patterns its terms take after encoding.

    A pattern is a Pauli string together with the offsets of the qubits it touches, measured from the first one:
    `ZZ @ (0, 2)` is a Z on some qubit q and a Z on qubit q + 2. This says which qubits of the *encoding register* a term
    couples. It says nothing about distance on a chip; that depends on where the layout puts those qubits. Offsets that
    grow with the number of sites `num_sites` (= L) are shown as `L + c`, so the scaling reads off directly.
    """
    assert (
        num_sites >= 4
    ), "constant offsets (up to 3) would be mistaken for offsets that grow with L"
    for index, group in enumerate(operator.split_out_groups()):
        encoded = [
            (label, idx)
            for label, idx, _ in encoding(group, num_qubits).to_sparse_list()
            if idx
        ]
        patterns = sorted(
            {
                (label, tuple(i - idx[0] for i in idx))
                for label, idx in encoded
            }
        )
        assert all(
            max(offsets) < 2 * num_sites for _, offsets in patterns
        ), "an offset reaches 2L"
        shown = ", ".join(
            f"{label} @ ({', '.join(offset_in_L(o, num_sites) for o in offsets)})"
            for label, offsets in patterns
        )
        print(
            f"  flow set {index}: {len(encoded):2d} terms, Pauli patterns: {shown}"
        )

Layout visualization utilities

def draw_flow_set(num_sites=L_SMALL):
    """The vertex and transfer operators of a spinless chain: the sites on a line, the east and west arrows as arcs below and above it.

    `mpl_draw` puts every edge label on the straight line between its two nodes, so the labels of the east and the west arrow
    of a bond would collide. We draw the arrows with `mpl_draw` and write the labels ourselves."""
    graph = rx.PyDiGraph()
    graph.add_nodes_from(range(num_sites))
    graph.add_edges_from([(i, i + 1, None) for i in range(num_sites - 1)])
    graph.add_edges_from([(i + 1, i, None) for i in range(num_sites - 1)])
    figure, axis = plt.subplots(
        figsize=(1.8 * num_sites, 2.2), layout="constrained"
    )
    for i in range(
        num_sites
    ):  # the grid of `draw_orderings`: the position of every site on the line
        axis.axvline(i, color="0.9", lw=1, zorder=0)
    axis.axhline(0, color="0.9", lw=1, zorder=0)
    mpl_draw(
        graph,
        pos={i: (i, 0) for i in range(num_sites)},
        ax=axis,
        labels=lambda v: f"$V_{v}$",
        with_labels=True,
        node_color=SPINLESS,
        node_size=600,
        edge_color=[HOP_EAST] * (num_sites - 1)
        + [HOP_WEST] * (num_sites - 1),
        width=2,
        connectionstyle="arc3,rad=0.3",
    )
    for i in range(num_sites - 1):
        axis.text(
            i + 0.5,
            -0.12,
            f"$T_{{{i},{i + 1}}}$",
            ha="center",
            va="center",
            fontsize=9,
            bbox=dict(
                boxstyle="round,pad=0.1", fc="white", ec="none", alpha=0.85
            ),
            zorder=5,
        )
        axis.text(
            i + 0.5,
            0.12,
            f"$T_{{{i + 1},{i}}}$",
            ha="center",
            va="center",
            fontsize=9,
            bbox=dict(
                boxstyle="round,pad=0.1", fc="white", ec="none", alpha=0.85
            ),
            zorder=5,
        )
    axis.set(xlim=(-0.5, num_sites - 0.5), ylim=(-0.4, 0.4))
    axis.axis("off")
    plt.close(
        figure
    )  # drawn once, by being the cell's value; otherwise the inline backend shows it a second time
    return figure


def draw_orderings(num_sites=L_SMALL):
    """Both mode orderings as two parallel chains (spin up on top, spin down below), each node at x = its mode index.

    The grid marks the position of every mode on the line. Hops are the arrows along a chain, the on-site interaction is the
    dotted on-site link between the two spins of a site. The ordering shows in the on-site links."""
    figure, axes = plt.subplots(2, 1, figsize=(9, 5.2), layout="constrained")
    for axis, order in zip(axes, ("blocked", "interleaved")):
        up, down = mode_maps(num_sites, order)
        pos = {
            mode(j): (mode(j), height)
            for mode, height in ((up, 0.5), (down, 0))
            for j in range(num_sites)
        }
        hops = [
            pair
            for mode in (up, down)
            for j in range(num_sites - 1)
            for pair in ((mode(j), mode(j + 1)), (mode(j + 1), mode(j)))
        ]
        graph = rx.PyDiGraph()
        graph.add_nodes_from(range(2 * num_sites))
        graph.add_edges_from([(a, b, None) for a, b in hops])
        for m in range(2 * num_sites):
            axis.axvline(m, color="0.9", lw=1, zorder=0)
        for height in (0, 0.5):
            axis.axhline(height, color="0.9", lw=1, zorder=0)
        mpl_draw(
            graph,
            pos=pos,
            ax=axis,
            labels=lambda m: f"$V_{{{m}}}$",
            with_labels=True,
            node_color=[
                SPIN_UP if pos[m][1] else SPIN_DOWN
                for m in range(2 * num_sites)
            ],
            node_size=600,
            edge_color=[HOP_EAST if a < b else HOP_WEST for a, b in hops],
            width=2,
            connectionstyle="arc3,rad=0.25",
        )
        for j in range(num_sites):
            axis.plot(
                *zip(pos[up(j)], pos[down(j)]),
                color=SPINLESS,
                lw=2,
                ls=":",
                zorder=1,
            )
        for (
            a,
            b,
        ) in hops:  # the transfer operator T_ab of every arrow, written next to the middle of its arc
            (xa, ya), (xb, _) = pos[a], pos[b]
            axis.text(
                (xa + xb) / 2,
                ya + (-0.12 if a < b else 0.12) * (1 + 0.1 * abs(b - a)),
                f"$T_{{{a},{b}}}$",
                ha="center",
                va="center",
                fontsize=9,
                bbox=dict(
                    boxstyle="round,pad=0.1",
                    fc="white",
                    ec="none",
                    alpha=0.85,
                ),
                zorder=5,
            )
        axis.text(-0.6, 0.5, "spin ↑", ha="right", va="center")
        axis.text(-0.6, 0, "spin ↓", ha="right", va="center")
        axis.set(
            title=f"{order} ordering",
            xlim=(-1.6, 2 * num_sites - 0.4),
            ylim=(-0.45, 0.95),
        )
        axis.axis("off")
    plt.close(
        figure
    )  # drawn once, by being the cell's value; otherwise the inline backend shows it a second time
    return figure


def ladder_strips(num_sites, orientation):
    """Strips needed for a chain of L + 1 qubits: it runs along 10 columns (horizontal) or 12 rows (vertical)."""
    return -(
        -(num_sites + 1) // (WIDTH if orientation == "horizontal" else ROWS)
    )


def ladder_orientation(num_sites):
    """The orientation with fewer strips (the vertical one never needs more), horizontal on a tie, among those that fit."""
    fitting = [
        o
        for o in ("horizontal", "vertical")
        if 2 * ladder_strips(num_sites, o)
        <= (ROWS if o == "horizontal" else WIDTH)
    ]
    if not fitting:
        raise ValueError(
            f"a ladder of {num_sites} sites does not fit on the {ROWS} x {WIDTH} lattice"
        )
    return min(
        fitting, key=lambda o: (ladder_strips(num_sites, o), o == "vertical")
    )


def folded_ladder(num_sites, orientation=None):
    """Virtual qubit -> physical qubit for the two-chain encoding.

    A strip is two adjacent grid lines, one per chain, so rung q joins the two lines at the same position along the
    strip. Strips are entered from alternating ends (a boustrophedon). Every strip boundary stretches one up link and
    one down link to distance 2, so the fewer the strips the fewer the stretched links.

    Horizontal strips run along the 10 columns and stack down the 12 rows; vertical strips run along the 12 rows and stack
    across the 10 columns. With `orientation=None` we take `ladder_orientation(num_sites)`."""
    orientation = orientation or ladder_orientation(num_sites)
    along_length = WIDTH if orientation == "horizontal" else ROWS
    layout = [None] * (2 * num_sites + 2)
    for q in range(num_sites + 1):
        strip, c = divmod(q, along_length)
        along = c if strip % 2 == 0 else along_length - 1 - c
        for chain, cross in ((0, 2 * strip), (num_sites + 1, 2 * strip + 1)):
            layout[chain + q] = (
                cross * WIDTH + along
                if orientation == "horizontal"
                else along * WIDTH + cross
            )
    assert (
        None not in layout
        and len(set(layout)) == len(layout)
        and max(layout) < nighthawk.size()
    )
    return layout


def draw_on_chip(
    axis, coupling_map, positions, layout, links, colours, labels, title
):
    """One layout drawn on the whole coupling map of a chip (unused qubits and couplers in gray).

    `positions[p]` is the (x, y) place of physical qubit `p` in the drawing and `layout[v]` the physical qubit of virtual
    qubit `v`. `links` are pairs of virtual qubits that the circuit couples along a chain or a lattice bond: a solid line if
    the pair is a coupler, a dotted red one if it is not. Each circle has the colour and the label given for its virtual qubit."""
    edges = {tuple(sorted(e)) for e in coupling_map.get_edges()}
    for a, b in edges:
        axis.plot(
            *zip(positions[a], positions[b]), color="0.85", lw=1, zorder=1
        )
    axis.scatter(
        *zip(*positions.values()),
        s=60,
        color="0.9",
        edgecolor="0.7",
        linewidth=0.6,
        zorder=2,
    )
    for q, r in links:
        stretched = tuple(sorted((layout[q], layout[r]))) not in edges
        start, end = positions[layout[q]], positions[layout[r]]
        style = dict(
            color="tab:red" if stretched else "0.4",
            lw=2 if stretched else 1.5,
            ls=":" if stretched else "-",
            zorder=3,
        )
        # a link that runs over the circle of another qubit of the layout is drawn as an arc, so that it is not mistaken for
        # a link that is missing between that qubit and its neighbors
        direction = np.subtract(end, start)
        covered = any(
            np.isclose(direction[0] * offset[1] - direction[1] * offset[0], 0)
            and 0 < np.dot(offset, direction) < np.dot(direction, direction)
            for offset in (
                np.subtract(positions[p], start)
                for p in layout
                if p not in (layout[q], layout[r])
            )
        )
        if covered:
            axis.add_patch(
                FancyArrowPatch(
                    start,
                    end,
                    connectionstyle="arc3,rad=-0.5",
                    arrowstyle="-",
                    **{**style, "linestyle": style.pop("ls")},
                )
            )
        else:
            axis.plot(*zip(start, end), **style)
    for virtual, physical in enumerate(layout):
        x, y = positions[physical]
        axis.scatter(x, y, s=330, color=colours[virtual], zorder=4)
        light = (
            sum(to_rgb(colours[virtual])) / 3 > 0.62
        )  # dark text on light circles
        axis.annotate(
            labels[virtual],
            (x, y),
            ha="center",
            va="center",
            fontsize=7,
            color="black" if light else "white",
            zorder=5,
        )
    xs, ys = zip(*positions.values())
    axis.set_xlim(
        min(xs) - 0.7, max(xs) + 0.7
    )  # room for the circles at the edge of the chip
    axis.set_ylim(min(ys) - 0.7, max(ys) + 0.7)
    axis.set_aspect("equal")
    axis.set_title(title, fontsize=9)
    axis.axis("off")


def chip_figure(num_panels):
    return plt.subplots(
        1,
        num_panels,
        figsize=(num_panels * (0.5 * WIDTH + 0.6), 0.5 * ROWS + 1.2),
        layout="constrained",
    )


def draw_fold(sizes):
    """The ladder layout for each size in `sizes`, side by side on the whole Nighthawk coupling map.

    Each circle carries the number of its virtual qubit, as in the drawings of section 2. The two ancillas are green and
    labeled `a↑` and `a↓`. A dotted red link is a chain link that is not a coupler."""
    figure, axes_fold = chip_figure(len(sizes))
    for axis, num_sites in zip(np.atleast_1d(axes_fold), sizes):
        up, down = (
            range(0, num_sites + 1),
            range(num_sites + 1, 2 * num_sites + 2),
        )
        ancillas = (num_sites, 2 * num_sites + 1)
        colours = [
            ANCILLA
            if v in ancillas
            else (SPIN_UP if v < num_sites else SPIN_DOWN)
            for v in range(2 * num_sites + 2)
        ]
        labels = [
            ("a↑" if v == num_sites else "a↓") if v in ancillas else str(v)
            for v in range(2 * num_sites + 2)
        ]
        links = [
            pair
            for chain in (up, down)
            for pair in zip(chain[:-1], chain[1:])
        ]
        orientation = ladder_orientation(num_sites)
        strips = ladder_strips(num_sites, orientation)
        title = (
            f"L = {num_sites}: {strips} {orientation} strip{'s' if strips > 1 else ''}\n"
            "up (dark blue), down (light blue), ancillas (green); dotted red = stretched link"
        )
        draw_on_chip(
            axis,
            nighthawk,
            nighthawk_positions,
            folded_ladder(num_sites),
            links,
            colours,
            labels,
            title,
        )
    plt.close(figure)  # shown once, as the cell's value
    return figure


def draw_interleaved(sizes):
    """The interleaved one-ancilla layouts for each size in `sizes` (one row each), Nighthawk and Heron side by side.

    Virtual qubit $2j$ is spin up and $2j + 1$ spin down on site $j$ (dark and light blue), the last one is the ancilla
    (green). The links are the chain links $q - (q + 1)$ of the encoded circuit."""
    # the layouts of the routing studies (section 4): a snake through the rows, a path found by search
    devices = (
        ("Nighthawk", nighthawk, nighthawk_positions, nighthawk_snake),
        (
            "Heron",
            heron,
            heron_positions,
            lambda num_qubits: find_path(heron, num_qubits),
        ),
    )
    figure, axes = plt.subplots(
        len(sizes),
        2,
        figsize=(0.4 * (WIDTH + 17) + 0.6, 0.4 * 16 * len(sizes) + 1.0),
        width_ratios=[WIDTH, 17],
        layout="constrained",
        squeeze=False,
    )
    for row, num_sites in zip(axes, sizes):
        num_qubits = 2 * num_sites + 1
        colours = [
            ANCILLA
            if v == 2 * num_sites
            else (SPIN_UP if v % 2 == 0 else SPIN_DOWN)
            for v in range(num_qubits)
        ]
        labels = [
            "a" if v == 2 * num_sites else str(v) for v in range(num_qubits)
        ]
        links = list(zip(range(num_qubits - 1), range(1, num_qubits)))
        for axis, (name, coupling_map, positions, layout_for) in zip(
            row, devices
        ):
            if name == "Nighthawk" and num_qubits > 120:
                print(
                    f"Cannot lay out {num_qubits} qubits on a 120-qubit Nighthawk chip!"
                )
                continue
            if name == "Heron" and num_qubits > 127:
                print(
                    f"Cannot lay out {num_qubits} qubits on a 1D chain on a Heron chip!"
                )
                continue
            layout = layout_for(num_qubits)
            title = f"{name}, L = {num_sites}: {num_qubits} qubits\nspin ↑ (dark blue), spin ↓ (light blue), ancilla (green)"
            draw_on_chip(
                axis,
                coupling_map,
                positions,
                layout,
                links,
                colours,
                labels,
                title,
            )
    plt.close(figure)  # shown once, as the cell's value
    return figure


def stripe_layout(rows, cols):
    """Virtual qubit -> Nighthawk qubit. Lattice column c is the chip row pair (2c, 2c + 1) and lattice row r is chip column r,
    so a lattice row is a stripe that alternates site, ancilla, site, ancilla, ... down one chip column."""
    if cols > ROWS // 2 or rows > WIDTH:
        raise ValueError(
            f"a {rows} x {cols} lattice does not fit the stripe layout on the {ROWS} x {WIDTH} chip"
        )
    layout = [None] * (2 * rows * cols)
    for r in range(rows):
        for c in range(cols):
            layout[r * cols + c] = (2 * c) * WIDTH + r  # the site
            layout[rows * cols + r * cols + c] = (
                2 * c + 1
            ) * WIDTH + r  # its ancilla
    return layout


def draw_stripes(shapes):
    """The stripe layout for each (rows, cols) in `shapes`, side by side on the whole Nighthawk coupling map.

    Circles carry the number of their virtual qubit: sites are $0 .. N-1$ (blue, row by row), their ancillas
    $N .. 2N-1$ (green). A solid line is a lattice bond between two sites on a coupler, a dotted red one is a bond without."""
    figure, axes_stripes = chip_figure(len(shapes))
    for axis, (rows, cols) in zip(np.atleast_1d(axes_stripes), shapes):
        num_sites = rows * cols
        links = [
            (j, j + 1) for j in range(num_sites) if j % cols < cols - 1
        ] + [(j, j + cols) for j in range(num_sites - cols)]
        colours = [SPINLESS] * num_sites + [ANCILLA] * num_sites
        labels = [str(v) for v in range(2 * num_sites)]
        title = f"{rows} x {cols} lattice, {2 * num_sites} qubits\nsites (blue), ancillas (green); dotted red = bond without a coupler"
        draw_on_chip(
            axis,
            nighthawk,
            nighthawk_positions,
            stripe_layout(rows, cols),
            links,
            colours,
            labels,
            title,
        )
    plt.close(figure)  # shown once, as the cell's value
    return figure

1. Recap of the spinless 1D Fermi-Hubbard system

The 1D guide writes the model with vertex and transfer operators, Vj=1−2aj†ajV_j = 1 - 2a_j^\dagger a_j and Tjk=−12(aj†−aj)(ak†+ak)T_{jk} = -\tfrac12 (a_j^\dagger - a_j)(a_k^\dagger + a_k). With these operators, hopping is t (Tj,j+1+Tj+1,j)t\,(T_{j,j+1} + T_{j+1,j}), and the density-density interaction is a product of VV operators.

draw_flow_set()

Output:

Output of the previous code cell

This tutorial reuses three main ideas from that guide:

  • Group terms before you map them. Transfer operators along a directed path commute, and form a flow set. On a chain, the flow sets are the east-pointing arrows (dark orange), the west-pointing arrows (light orange), and the diagonal interaction terms. The drawing shows the arrows of the spinless chain between its sites. Spinless sites are blue in every drawing.
  • Use an ancilla qubit to gain structure. The parity-delocalized fermion-to-qubit encoding stores cumulative parities. One extra qubit (an ancilla, which is green in every drawing) turns a whole flow set into weight-1 Pauli operators.
  • Use the structure to synthesize circuits. A single Clifford circuit diagonalizes a whole flow set, so the circuit depth does not grow with the chain length.
def flow_set_groups(operator, stride=1):
    """0: arrows pointing 'east', 1: arrows pointing 'west', 2: the diagonal terms."""
    groups = []
    for terms, _ in operator.iter_terms():
        match terms:
            case [(left, right)] if right == left + stride:
                groups.append(0)
            case [(left, right)] if right == left - stride:
                groups.append(1)
            case _:
                groups.append(2)
    return groups


def spinless_chain(num_sites, tunneling, interaction):
    data = defaultdict(complex)
    for j in range(num_sites - 1):
        k = j + 1
        data[((j, k),)] += tunneling  # hopping: t (T_jk + T_kj)
        data[((k, j),)] += tunneling
        data[()] += (
            interaction / 4
        )  # interaction: U n_j n_k, with n_j = (1 - V_j) / 2
        data[((j, j),)] -= interaction / 4
        data[((k, k),)] -= interaction / 4
        data[((j, j), (k, k))] += interaction / 4
    operator = TransferVertexOperator.from_dict(data)
    operator.groups = flow_set_groups(operator)
    return operator


spinless = spinless_chain(L_SMALL, 1.0, 2.0)
print(f"spinless chain, {L_SMALL} sites")
for group, op in enumerate(spinless.split_out_groups()):
    print(f"{group=}\n{format(op)}")

Output:

spinless chain, 4 sites
group=0
  1.000000e0 +0.000000e0j * (T(0,1))
  1.000000e0 +0.000000e0j * (T(1,2))
  1.000000e0 +0.000000e0j * (T(2,3))
group=1
  1.000000e0 +0.000000e0j * (T(1,0))
  1.000000e0 +0.000000e0j * (T(2,1))
  1.000000e0 +0.000000e0j * (T(3,2))
group=2
  1.500000e0 +0.000000e0j * ()
-5.000000e-1 +0.000000e0j * (V(0))
 5.000000e-1 +0.000000e0j * (V(0) V(1))
 -1.000000e0 +0.000000e0j * (V(1))
 5.000000e-1 +0.000000e0j * (V(1) V(2))
 -1.000000e0 +0.000000e0j * (V(2))
 5.000000e-1 +0.000000e0j * (V(2) V(3))
-5.000000e-1 +0.000000e0j * (V(3))
def parity_delocalized(action, num_qubits):
    """Image of ONE vertex/transfer operator under the one-ancilla parity-delocalized encoding.

    The guide needs the first three cases only. The last two (modes TWO apart) appear once spins are interleaved.
    """
    match action:
        case (j, k) if j == k:  # V_j = Z_j Z_{j+1}
            return SparseObservable.from_sparse_list(
                [("ZZ", [j, j + 1], 1.0)], num_qubits=num_qubits
            )
        case (j, k) if k == j + 1:  # weight 1
            # east flow-set in spinless or blocked spinful order
            return SparseObservable.from_sparse_list(
                [("X", [k], -0.5)], num_qubits=num_qubits
            )
        case (j, k) if k == j - 1:  # weight 3
            # west flow-set in spinless or blocked spinful order
            return SparseObservable.from_sparse_list(
                [("ZXZ", [k, k + 1, k + 2], 0.5)], num_qubits=num_qubits
            )
        case (j, k) if k == j + 2:  # weight 2
            # east flow-set in interleaved spinful order
            return SparseObservable.from_sparse_list(
                [("YY", [j + 1, j + 2], 0.5)], num_qubits=num_qubits
            )
        case (j, k) if k == j - 2:  # weight 4
            # west flow-set in interleaved spinful order
            return SparseObservable.from_sparse_list(
                [("ZXXZ", [k, k + 1, k + 2, k + 3], 0.5)],
                num_qubits=num_qubits,
            )
        case _:
            raise ValueError(f"no image for transfer operator {action}")


def encode(operator, num_qubits, action=parity_delocalized):
    """Map a whole operator by composing the images of its generators."""
    return map_transfer_vertex_generators(
        operator,
        lambda generator: action(generator, num_qubits),
        identity=lambda: SparseObservable.identity(num_qubits),
        compose=SparseObservable.compose,
    ).simplify()


print(f"flow-set encoded spinless chain -> {L_SMALL + 1} qubits")
pattern_table(spinless, encode, L_SMALL + 1, L_SMALL)

Output:

flow-set encoded spinless chain -> 5 qubits
  flow set 0:  3 terms, Pauli patterns: X @ (0)
  flow set 1:  3 terms, Pauli patterns: ZXZ @ (0, 1, 2)
  flow set 2:  7 terms, Pauli patterns: ZZ @ (0, 1), ZZ @ (0, 2)

2. Spin: blocked or interleaved mode ordering

This tutorial uses the spinful Fermi-Hubbard model. On a 1D chain of sites, its Hamiltonian is as follows:

+ \frac{U}{2} \sum_j \left(n_{j\uparrow} - \tfrac12\right)\left(n_{j\downarrow} - \tfrac12\right)$$ The vertex and transfer operators do not represent spin. They see $2L$ fermionic modes, so you decide which mode index belongs to which (site, spin) pair. Two orderings are natural: **blocked** (all spin-up modes first, then all spin-down modes) and **interleaved** (the spin-up and spin-down modes of a site next to each other). Both orderings describe the same physics, but they couple different modes, and the encoding and the hardware see those couplings. The choice of ordering changes the distance that the hopping terms and the on-site interaction terms must cover: | Ordering | Spin-up site $j$ | Spin-down site $j$ | Modes that a hop connects | Modes that the on-site term connects | |---|---|---|---|---| | **Blocked** | $j$ | $L + j$ | $j \leftrightarrow j+1$ | $j \leftrightarrow L + j$ | | **Interleaved** | $2j$ | $2j + 1$ | $2j \leftrightarrow 2j+2$ | $2j \leftrightarrow 2j+1$ | ```python def mode_maps(num_sites, order): """(up, down): site index -> fermionic mode index.""" if order == "blocked": return (lambda j: j), (lambda j: num_sites + j) if order == "interleaved": return (lambda j: 2 * j), (lambda j: 2 * j + 1) raise ValueError(order) ``` The following drawing shows both orderings for $L = 4$ sites. Each node is a mode ($V_m$, as in section 1) at the horizontal position of its mode index. The spin-up modes are on the top chain (dark blue), and the spin-down modes are on the bottom chain (light blue). The arrows along a chain are the hops. East-pointing hops are dark orange and west-pointing hops are light orange, as in the flow sets of section 1. The dotted blue link between the two modes of a site is the on-site interaction, and the grid marks the position of every mode on the line. ```python draw_orderings() ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/c353bde8-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> The ordering is visible in the on-site links. In blocked order, the on-site links are long, because the on-site term couples mode $j$ and mode $L + j$, while the hops stay between neighboring modes. In interleaved order, the on-site links are short (mode $2j$ to mode $2j + 1$), but the hops skip over the mode of the other spin. In both orderings, every term still acts on **two modes**. If $m(j, \sigma)$ is the mode of site $j$ and spin $\sigma$, hopping becomes $t\,(T_{m(j,\sigma),\,m(j+1,\sigma)} + T_{m(j+1,\sigma),\,m(j,\sigma)})$ between neighbors with the same spin, and the on-site term is $\tfrac{U}{8} V_{m(j,\uparrow)} V_{m(j,\downarrow)}$. The following code builds these terms and prints them, one flow set at a time, for $L = 2$. ```python def hubbard(num_sites, tunneling, interaction, order): up, down = mode_maps(num_sites, order) data = defaultdict(complex) # hopping, same spin: t (T_{m(j), m(j+1)} + T_{m(j+1), m(j)}) for j in range(num_sites - 1): for mode in (up, down): left, right = mode(j), mode(j + 1) data[((left, right),)] += tunneling data[((right, left),)] += tunneling # on-site interaction: (U / 8) V_{m(j, up)} V_{m(j, down)} for j in range(num_sites): data[((up(j), up(j)), (down(j), down(j)))] += interaction / 8 operator = TransferVertexOperator.from_dict(data).simplify() operator.groups = flow_set_groups( operator, 1 if order == "blocked" else 2 ) return operator for order in ("blocked", "interleaved"): print(f"--- {order}, L = {L_TINY}") for group, op in enumerate( hubbard(L_TINY, 1.0, 8.0, order).split_out_groups() ): print(f"{group=}\n{format(op)}") ``` <p className="text-text-helper text-label-01">Output:</p> ``` --- blocked, L = 2 group=0 1.000000e0 +0.000000e0j * (T(0,1)) 1.000000e0 +0.000000e0j * (T(2,3)) group=1 1.000000e0 +0.000000e0j * (T(1,0)) 1.000000e0 +0.000000e0j * (T(3,2)) group=2 1.000000e0 +0.000000e0j * (V(0) V(2)) 1.000000e0 +0.000000e0j * (V(1) V(3)) --- interleaved, L = 2 group=0 1.000000e0 +0.000000e0j * (T(0,2)) 1.000000e0 +0.000000e0j * (T(1,3)) group=1 1.000000e0 +0.000000e0j * (T(2,0)) 1.000000e0 +0.000000e0j * (T(3,1)) group=2 1.000000e0 +0.000000e0j * (V(0) V(1)) 1.000000e0 +0.000000e0j * (V(2) V(3)) ``` <div className="mt-32" /> **At the fermionic level, the choice of ordering is only a naming convention.** Blocked and interleaved order give each fermionic mode a different index, and nothing else changes. The Hamiltonian is the same operator, with its modes renamed. You can verify this with `relabel_modes`, which renames mode $i$ to `permutation[i]` in every term of a `TransferVertexOperator`. Anticommutation has no effect, because the operation renames the labels of the modes and does not move particles past each other. Consider the permutation that takes each blocked index to the interleaved index of the same (site, spin) pair. If this permutation, applied to the blocked Hamiltonian, gives the interleaved Hamiltonian, then the two orderings are the same model, and they have the same spectrum and Trotter error. A difference can appear only *after* an encoding, where the index decides which qubits are neighbors. ```python def blocked_to_interleaved(num_sites): """permutation[blocked mode] = interleaved mode of the same (site, spin).""" permutation = [0] * (2 * num_sites) permutation[:num_sites] = list(range(0, num_sites * 2, 2)) permutation[num_sites:] = list(range(1, num_sites * 2, 2)) return permutation print("Mode relabeling makes blocked ordering match the interleaved one?") for num_sites in range(L_TINY, L_SMALL + 2): blocked = hubbard(num_sites, 1.0, 8.0, "blocked") interleaved = hubbard(num_sites, 1.0, 8.0, "interleaved") relabeled = blocked.relabel_modes(blocked_to_interleaved(num_sites)) assert relabeled.equiv(interleaved) and not blocked.equiv(interleaved) print(f"L = {num_sites}: {relabeled.equiv(interleaved)}") ``` <p className="text-text-helper text-label-01">Output:</p> ``` Mode relabeling makes blocked ordering match the interleaved one? L = 2: True L = 3: True L = 4: True L = 5: True ``` <div className="mt-32" /> ### Construct the time-evolution circuit Use the Hamiltonian to construct its time evolution as a **fermionic circuit**. A `FermionicCircuit` has one wire per fermionic mode, not per qubit, and a single `Evolution` gate holds the whole Hamiltonian. The following code builds the circuit for $L = 2$ (four modes, which is small enough to read), before any encoding or qubits are involved. ```python TOTAL_TIME = 1.0 def fermionic_circuit(operator, num_modes, reps=1): """The whole Trotter step as ONE `Evolution` gate over all modes. `FermionicLieTrotter` decides how it splits.""" circuit = FermionicCircuit(num_modes) circuit.append( Evolution( num_modes, operator, time=TOTAL_TIME, synthesis=FermionicLieTrotter(reps=reps), ), circuit.modes, ) return circuit fcirc = fermionic_circuit( hubbard(L_TINY, 1.0, 8.0, "interleaved"), 2 * L_TINY ) fcirc.draw("mpl", fold=-1) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/c81043f3-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> The `FermionicLieTrotter` synthesis splits that gate by the groups that `flow_set_groups` sets, into one `Evolution` gate per flow set. A single call to `decompose()` shows the split. At this small size, the two orderings look alike, because every term acts on two modes, and the only difference is *which* two modes. ```python fcirc.decompose().draw("mpl", fold=-1) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/292dc410-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> ### Validate the time-evolution circuit Before you continue, compare the results with an independent implementation. The `fermi_hubbard_1d` function of ffsim uses $-t\sum(\dots) + U' \sum n_\uparrow n_\downarrow - \mu \sum n$. This Hamiltonian equals the Hamiltonian of this tutorial for $U' = U/2$ and $\mu = U/4$, up to the constant $UL/8$. Therefore, the lowest eigenvalue at half filling must be the same. The same cell also checks the circuit itself. `FermionicCircuit` implements the `apply_unitary` protocol of ffsim, so ffsim can apply the time-evolution circuit to a state vector directly. The code compares the result with the matrix exponential of the Hamiltonian, applied to the same random state in the half-filling sector. ```python L_check, t_check, U_check = L_SMALL, 1.0, 8.0 nelec = (L_SMALL // 2, L_SMALL // 2) # the independent reference: ffsim's own Fermi-Hubbard model, in ffsim's block-spin convention (modes 0..L-1 up, L..2L-1 down) ffsim_ham = ffsim.fermi_hubbard_1d( norb=L_check, tunneling=t_check, interaction=U_check / 2, chemical_potential=U_check / 4, ) reference = ( spla.eigsh( ffsim.linear_operator(ffsim_ham, L_check, nelec), k=1, which="SA" )[0][0] + U_check * L_check / 8 ) def ground_energy(operator): """Lowest eigenvalue at half filling. Our operator reaches ffsim through its native protocols: `fermion_operator` converts the transfer/vertex operator and `ffsim.linear_operator` accepts the result directly.""" fermionic = fermion_operator(operator).normal_ordered().simplify() # qiskit-fermions' FermionOperator works natively with ffsim return spla.eigsh( ffsim.linear_operator(fermionic, norb=L_check, nelec=nelec), k=1, which="SA", )[0][0] # ffsim expects the block-spin convention, so the interleaved operator is first relabeled to it: the inverse of the permutation above interleaved_to_blocked = np.argsort(blocked_to_interleaved(L_check)).tolist() blocked = hubbard(L_check, t_check, U_check, "blocked") interleaved = hubbard(L_check, t_check, U_check, "interleaved").relabel_modes( interleaved_to_blocked ) print("ground energy at half filling, through ffsim") print(f" ffsim's own model: {reference:.10f}") print(f" blocked order: {ground_energy(blocked):.10f}") print(f" interleaved, relabeled: {ground_energy(interleaved):.10f}") # the circuit against the matrix exponential, on a random state of the half-filling sector (ffsim applies a FermionicCircuit natively) rng = np.random.default_rng(0) state = ffsim.hartree_fock_state(L_check, nelec).astype( complex ) + 0.1 * rng.normal(size=ffsim.dim(L_check, nelec)) state /= np.linalg.norm(state) print( f"time evolution for t = {TOTAL_TIME}: circuit (ffsim.apply_unitary) and expm of the Hamiltonian are within numerical accuracy" ) for name, operator in ( ("blocked", blocked), ("interleaved, relabeled", interleaved), ): exact = spla.expm_multiply( -1j * TOTAL_TIME * ffsim.linear_operator( fermion_operator(operator).normal_ordered().simplify(), norb=L_check, nelec=nelec, ), state, ) evolved = ffsim.apply_unitary( state, fermionic_circuit(operator, 2 * L_check), norb=L_check, nelec=nelec, ) print(f" {name:24s}{np.all(np.isclose(exact - evolved, 0.0))}") ``` <p className="text-text-helper text-label-01">Output:</p> ``` ground energy at half filling, through ffsim ffsim's own model: -5.9531453087 blocked order: -5.9531453087 interleaved, relabeled: -5.9531453087 time evolution for t = 1.0: circuit (ffsim.apply_unitary) and expm of the Hamiltonian are within numerical accuracy blocked True interleaved, relabeled True ``` <div className="mt-32" /> ## 3. Encoding the fermions in qubits The choice of spin ordering starts to matter when an encoding maps the modes to qubits, because the mode index then decides which qubits are neighbors. To see this effect, use the same one-ancilla flow-set encoding that section 1 uses for the spinless case. The effect appears as a Pauli pattern, which is a Pauli string together with the offsets of the qubits that it acts on. For example, `ZZ @ (0, 2)` is a Z on some qubit $q$ and a Z on qubit $q+2$. The following table prints the patterns for each flow set. The patterns describe only the encoding register, not a QPU. The layout decides how far apart two qubits are on a device. ```python for order in ("blocked", "interleaved"): print( f"{order}: L = {L_SMALL}, {2 * L_SMALL} modes -> {2 * L_SMALL + 1} qubits" ) pattern_table( hubbard(L_SMALL, 1.0, 8.0, order), encode, 2 * L_SMALL + 1, L_SMALL ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` blocked: L = 4, 8 modes -> 9 qubits flow set 0: 6 terms, Pauli patterns: X @ (0) flow set 1: 6 terms, Pauli patterns: ZXZ @ (0, 1, 2) flow set 2: 4 terms, Pauli patterns: ZZZZ @ (0, 1, L, L + 1) interleaved: L = 4, 8 modes -> 9 qubits flow set 0: 6 terms, Pauli patterns: YY @ (0, 1) flow set 1: 6 terms, Pauli patterns: ZXXZ @ (0, 1, 2, 3) flow set 2: 4 terms, Pauli patterns: ZZ @ (0, 2) ``` <div className="mt-32" /> The east (`0`) and west (`1`) flow sets of the *blocked* ordering are the same as in the spinless case, because the hopping terms still act on nearest neighbors. The on-site interaction terms, however, become weight-4 and scale with the length $L$ of the chain, as the pattern `ZZZZ @ (0, 1, L, L + 1)` shows. In contrast, in the interleaved ordering, none of the encoded flow sets scale with the size of the system. The on-site interaction terms remain weight-2 and are identical to the spinless case, because they act on nearest neighbors. The cost is more expensive hopping terms (`YY` and `ZXXZ`). The Pauli strings alone do not decide which ordering is cheaper in practice. The answer depends on **which qubits the hardware can place next to each other**, and that connectivity is not necessarily one-dimensional. A QPU can place the two chains of blocked order side by side, which a single line cannot do. Section 5 uses this property. ### Transpile the `FermionicCircuit` As shown previously, a single Trotter step is one `Evolution` gate that `FermionicLieTrotter` splits into one gate per flow set. The `product_formula` argument of `MapperFnEvolutionSynthesis` then decides how each flow set becomes qubit gates. This tutorial compares two synthesis paths: * The Qiskit `LieTrotter` synthesis, which synthesizes every Pauli rotation separately, in the order that it receives them. * A custom `FlowSetSynthesis` class (defined in the following cell) that uses the structure of the flow sets. It recognizes every Pauli shape that the encodings in this tutorial produce (`X`, `ZXZ`, `YY`, `ZXXZ`, `ZZ`, and, for two chains, the `ZZZZ` plaquettes), and diagonalizes each flow set with a single Clifford layer. For these Hamiltonians, it does not need a term-by-term fallback, and the code asserts that every term matches a known shape. ```python class FlowSetSynthesis(EvolutionSynthesis): """Synthesize ONE flow set of an encoded Hamiltonian, recognizing every shape the encodings in this notebook produce. Each shape is a whole flow set, diagonalized by ONE Clifford layer instead of once per term: X bare weight-1 rotations, no entangling gate at all. ZXZ windows [q-1, q, q+1]: a CZ brickwork along the chain maps X_q to Z_{q-1} X_q Z_{q+1}, so the set is CZ brickwork, bare Rx, CZ brickwork. YY neighbors (q, q+1): Y -> Z on the touched qubits, then Rzz on neighbors in two layers of disjoint pairs. ZXXZ windows [q-1 .. q+2]: the same CZ brickwork maps Y_q Y_{q+1} to Z_{q-1} X_q X_{q+1} Z_{q+2} (sign +1), so the set is CZ brickwork, the YY recipe, CZ brickwork. ZZ diagonal pairs, any distance: Rzz packed into layers of disjoint pairs. ZZZZ plaquettes, only if `rung_offset` is given: one CX per rung folds each 2x2 plaquette between two parallel chains into a nearest-neighbor ZZ on the first chain. chains: qubit indices of each parity chain, in order (the CZ brickwork runs along each). rung_offset: distance between the two chains, for the plaquettes. Any other terms are not handled by this implementation and an assertion checks no generic terms fall through. """ preserve_order = True def __init__(self, chains, rung_offset=None): self.chains = chains self.rung_offset = rung_offset @staticmethod def _disjoint_layers(rotations): """First-fit packing of commuting rotations (payload, support) into layers of disjoint supports.""" layers = [] for entry in rotations: for layer in layers: if all(set(entry[1]).isdisjoint(other[1]) for other in layer): layer.append(entry) break else: layers.append([entry]) return layers @staticmethod def _pauli_rotation(circuit, label, indices, angle): """exp(-i angle/2 P) for any Pauli string P: local basis change, CX ladder, Rz, undo. The generic fallback.""" indices = list(indices) for character, qubit in zip(label, indices): if character == "X": circuit.h(qubit) elif character == "Y": circuit.sdg(qubit) circuit.h(qubit) pairs = list(zip(indices[:-1], indices[1:])) for control, target in pairs: circuit.cx(control, target) circuit.rz(angle, indices[-1]) for control, target in reversed(pairs): circuit.cx(control, target) for character, qubit in zip(reversed(label), reversed(indices)): if character == "X": circuit.h(qubit) elif character == "Y": circuit.h(qubit) circuit.s(qubit) def _cz_brickwork(self, circuit): for parity in ( 0, 1, ): # two layers: a chain of overlapping CZs would take depth ~L for chain in self.chains: for i in range(parity, len(chain) - 1, 2): circuit.cz(chain[i], chain[i + 1]) def _is_plaquette(self, indices): a, b, c, d = indices # b < offset: the two rung targets (a, a+1) must lie below every rung control (a+offset, ...). If they overlap, # one qubit would be a CX control and a CX target at once, the CXs no longer commute, and the layer is wrong. # That is exactly what happens at the seam between the spin blocks when both share one chain. return ( self.rung_offset is not None and b == a + 1 and d == c + 1 and c == a + self.rung_offset and b < self.rung_offset ) @staticmethod def _yy_rotations(circuit, pairs): """exp(-i angle/2 Y_q Y_{q+1}) for every (q, angle): Y -> Z everywhere, Rzz in two layers, undo.""" touched = sorted({q for q, _ in pairs} | {q + 1 for q, _ in pairs}) for qubit in touched: circuit.sdg(qubit) circuit.h(qubit) for parity in ( 0, 1, ): # pairs of equal parity are disjoint: two layers however many pairs there are for q, angle in pairs: if q % 2 == parity: circuit.rzz(angle, q, q + 1) for qubit in touched: circuit.h(qubit) circuit.s(qubit) def synthesize(self, evolution): circuit = QuantumCircuit(evolution.operator.num_qubits) ( bare_x, windows, yy_pairs, xx_windows, diagonal, plaquettes, generic, ) = [], [], [], [], [], {}, [] for label, indices, coeff in evolution.operator.to_sparse_list(): angle = 2 * evolution.time * coeff.real consecutive = all( b == a + 1 for a, b in zip(indices, indices[1:]) ) if label == "": circuit.global_phase -= angle / 2 elif label == "X": bare_x.append((indices[0], angle)) elif label == "ZXZ" and consecutive: windows.append((indices[1], angle)) elif label == "YY" and consecutive: yy_pairs.append((indices[0], angle)) elif label == "ZXXZ" and consecutive: xx_windows.append( (indices[1], angle) ) # the YY pair (q, q+1) after the CZ brickwork elif label == "ZZ": diagonal.append((angle, tuple(indices))) elif label == "ZZZZ" and self._is_plaquette(indices): plaquettes[indices[0]] = angle else: generic.append(((label, angle), tuple(indices))) assert len(generic) == 0 for ( qubit, angle, ) in bare_x: # a weight-1 flow set: no entangling gates at all circuit.rx(angle, qubit) if windows: # one Clifford diagonalizes the whole set self._cz_brickwork(circuit) for qubit, angle in windows: circuit.rx(angle, qubit) self._cz_brickwork(circuit) if yy_pairs: self._yy_rotations(circuit, yy_pairs) if xx_windows: self._cz_brickwork(circuit) self._yy_rotations(circuit, xx_windows) self._cz_brickwork(circuit) for layer in self._disjoint_layers( diagonal ): # all diagonal terms commute: pack them for angle, (a, b) in layer: circuit.rzz(angle, a, b) if plaquettes: # rungs -> ZZ along the first chain -> undo rungs = sorted(set(plaquettes) | {a + 1 for a in plaquettes}) for a in rungs: circuit.cx(a + self.rung_offset, a) for parity in (0, 1): for a, angle in plaquettes.items(): if a % 2 == parity: circuit.rzz(angle, a, a + 1) for a in rungs: circuit.cx(a + self.rung_offset, a) return circuit ``` <Admonition type="note"> Work is in progress on an implementation that can find a low-depth diagonalizing Clifford circuit automatically. Future releases of `qiskit-fermions` plan to support this feature, which can greatly simplify the use of flow-set-based fermion-to-qubit encodings. </Admonition> The following helper function constructs the transpiler pipeline and applies it to the `FermionicCircuit`. The function is useful in this tutorial because the flow-set encoding requires a `CustomF2QLayout`, which couples the transpiler pipeline to the fermionic circuit. ```python def to_qubits(fermionic, encoding, num_qubits, product_formula): synthesis = F2QSynthesis() synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis( encoding, product_formula=product_formula ) pass_manager = MultiStagePassManager( input=FermionicCircuitToDAG(), layout=CustomF2QLayout( {fermionic.register: QuantumRegister(num_qubits, "q")} ), synthesis=synthesis, output=QuantumDAGToCircuit(), ) return pass_manager.run(fermionic) ``` ## 4. Scaling of the interleaved mode ordering Start with the interleaved ordering and the encoding of section 3. With the one ancilla, there are $2L + 1$ qubits in a row. The flow sets are `YY @ (0, 1)`, `ZXXZ @ (0, 1, 2, 3)`, and `ZZ @ (0, 2)`, and `FlowSetSynthesis` has a one-Clifford recipe for each one. Build the circuit for $L = 4$ (`L_SMALL`, the size of every explicit check), and compare it with `LieTrotter` before you measure anything. ```python def single_ancilla(order, num_sites, formula): """Qubit circuit of one Trotter step with the one-ancilla encoding.""" operator = hubbard(num_sites, 1.0, 8.0, order) num_modes = 2 * num_sites return to_qubits( fermionic_circuit(operator, num_modes).decompose(), encode, num_modes + 1, formula, ) def same_up_to_phase(a, b): a, b = ( Operator(a.decompose(reps=6)).data, Operator(b.decompose(reps=6)).data, ) pivot = np.unravel_index(np.argmax(np.abs(b)), b.shape) return bool(np.allclose(a * (b[pivot] / a[pivot]), b, atol=1e-8)) L = L_SMALL chain = [list(range(2 * L + 1))] lie = single_ancilla("interleaved", L, LieTrotter()) flow = single_ancilla("interleaved", L, FlowSetSynthesis(chain)) print( f"interleaved, one ancilla, L = {L}: FlowSetSynthesis == LieTrotter up to a phase:", same_up_to_phase(flow, lie), ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` interleaved, one ancilla, L = 4: FlowSetSynthesis == LieTrotter up to a phase: True ``` <div className="mt-32" /> The previous check shows that the two circuits give identical results, up to a global phase. The following drawing shows the regular structure that the flow-set encoding produces in the qubit circuit. Later sections show how this structure helps you scale to larger systems. ```python flow.decompose().draw("mpl", fold=-1) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/67a517b7-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> ### Why the circuit needs routing Every gate of this circuit acts on qubits that are neighbors along the encoding chain, except the gates of the `ZZ` flow set. That flow set couples qubits that are two apart (`ZZ @ (0, 2)`, as shown in the table in section 3). On hardware that couples only neighbors, such a pair needs a SWAP gate, and the router must insert it. The weakest connectivity that you can use is a plain line of $2L + 1$ qubits. The following code counts how many two-qubit gates act on a pair that the line does not couple, for both the term-by-term `LieTrotter` circuit and the flow-set circuit. ```python def off_coupler(circuit, coupling_map, layout): """(gates on two qubits that are not coupled under `layout`, all two-qubit gates), counted as `route` lowers the circuit.""" edges = {tuple(sorted(edge)) for edge in coupling_map.get_edges()} pairs = [ tuple(sorted(layout[circuit.find_bit(q).index] for q in i.qubits)) for i in circuit.decompose(reps=6).data if len(i.qubits) == 2 ] return sum(pair not in edges for pair in pairs), len(pairs) SIZES = (4, 10, 20, 30, 40, 50) # the sizes of the scaling studies lie_circuits = [ single_ancilla("interleaved", num_sites, LieTrotter()) for num_sites in SIZES ] flow_circuits = [ single_ancilla( "interleaved", num_sites, FlowSetSynthesis([list(range(2 * num_sites + 1))]), ) for num_sites in SIZES ] print("The number of two-qubit gates that are not on a coupler of the line:") print(f"{'':7s}{'LieTrotter':>22s}{'flow set':>22s}") for num_sites, lie_circuit, flow_circuit in zip( SIZES, lie_circuits, flow_circuits ): line_map, line_layout = ( CouplingMap.from_line(flow_circuit.num_qubits), list(range(flow_circuit.num_qubits)), ) lie_far, lie_total = off_coupler(lie_circuit, line_map, line_layout) flow_far, flow_total = off_coupler(flow_circuit, line_map, line_layout) print( f"L = {num_sites:2d} {f'{lie_far} of {lie_total} ({100 * lie_far / lie_total:.0f}%)':>22s}" f"{f'{flow_far} of {flow_total} ({100 * flow_far / flow_total:.0f}%)':>22s}", flush=True, ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` The number of two-qubit gates that are not on a coupler of the line: LieTrotter flow set L = 4 8 of 56 (14%) 8 of 48 (17%) L = 10 20 of 164 (12%) 20 of 132 (15%) L = 20 40 of 344 (12%) 40 of 272 (15%) L = 30 60 of 524 (11%) 60 of 412 (15%) L = 40 80 of 704 (11%) 80 of 552 (14%) L = 50 100 of 884 (11%) 100 of 692 (14%) ``` <div className="mt-32" /> The number of gates that are not on a coupler of the line is the same for both circuits: $2L$ at every size (8 at $L = 4$, 100 at $L = 50$). These gates come from the `ZZ @ (0, 2)` pairs. There are $L$ of these pairs, and each one becomes two entangling gates. `LieTrotter` already produces a diagonal `ZZ` rotation as a single `rzz` gate, as the flow-set synthesis does, so both circuits contain the same $L$ distance-two `rzz` gates. The circuits differ in their total number of gates (884 for `LieTrotter` compared with 692 for the flow set at $L = 50$). Therefore, the share of gates that are not on a coupler is 11% compared with 14% at $L = 50$. The routing problem is the same for both circuits. The synthesis changes the rest of the circuit: the hopping terms (`YY` and `ZXXZ`). `LieTrotter` synthesizes these terms one by one, and the flow-set synthesis uses one Clifford circuit for the whole flow set. ### Find a chain on a QPU To route onto a QPU, pick a chain of $2L + 1$ qubits on it. The routing task is then the same as on a line. The chain links are couplers, the `ZZ` pairs are two apart along the chain, and the layout decides only which physical qubits the chain uses. The tutorial uses fake backends for two QPUs, so the comparison is of structure, not noise: * **Heron** (`FakeFez`): 156 qubits on a heavy-hex lattice. The lattice has no 4-cycles, but it does contain long simple paths. * **Nighthawk** (`FakeNighthawk`): 120 qubits on a square lattice of 12 rows and 10 columns. A snake through the rows is a path. The `find_path` function searches for a path of a given length on any coupling map. It uses a depth-first search, and always prefers the qubit with the fewest free neighbors. The `nighthawk_snake` function walks the rows of Nighthawk. ```python heron = FakeFez().coupling_map nighthawk = FakeNighthawk().coupling_map # where to draw each qubit: Nighthawk is a grid, for Heron the fake backend stores the (column, row) of every qubit nighthawk_positions = { p: (p % WIDTH, -(p // WIDTH)) for p in range(nighthawk.size()) } heron_positions = { p: (x, -y) for p, (x, y) in enumerate(FakeFez().configuration().coords) } print( f"Heron: {heron.size()} qubits, max degree {max(len(heron.graph.to_undirected().neighbors(q)) for q in heron.physical_qubits)}" ) print( f"Nighthawk: {nighthawk.size()} qubits, max degree {max(len(nighthawk.graph.to_undirected().neighbors(q)) for q in nighthawk.physical_qubits)}" ) def find_path(coupling_map, length, budget=200_000): """A simple path through `length` qubits of the device (depth-first, fewest-free-neighbors-first).""" adjacency = {q: set() for q in coupling_map.physical_qubits} for a, b in coupling_map.get_edges(): adjacency[a].add(b) adjacency[b].add(a) steps = [0] def extend(path, seen): if len(path) >= length: return True steps[0] += 1 if steps[0] > budget: return False for n in sorted( (n for n in adjacency[path[-1]] if n not in seen), key=lambda n: sum(m not in seen for m in adjacency[n]), ): path.append(n) seen.add(n) if extend(path, seen): return True path.pop() seen.discard(n) return False for start in sorted(adjacency, key=lambda q: (len(adjacency[q]), q)): steps[0] = 0 path = [start] if extend(path, {start}): return path raise RuntimeError(f"no path of {length} qubits found") def nighthawk_snake(num_qubits): """Row 0 left to right, row 1 right to left, and so on.""" path = [] for row in range(ROWS): columns = range(WIDTH) if row % 2 == 0 else range(WIDTH - 1, -1, -1) path += [row * WIDTH + column for column in columns] return path[:num_qubits] ``` <p className="text-text-helper text-label-01">Output:</p> ``` Heron: 156 qubits, max degree 3 Nighthawk: 120 qubits, max degree 4 ``` <div className="mt-32" /> The following drawing shows the chains that the two functions return for $L = 50$ (101 qubits), the largest size in this tutorial. Each QPU is drawn in full. The gray lines are its couplers, and the light gray circles are the qubits that the circuit does not use. Each colored circle is a virtual qubit of the encoded circuit and shows its number. Dark blue circles are spin up (even numbers), light blue circles are spin down (odd numbers), and the green circle is the ancilla (the last qubit, $2L$). The dark lines are the chain links between consecutive virtual qubits, and they all lie on couplers. On Nighthawk, the chain follows the rows of the square lattice in a snake pattern and fills ten of its twelve rows. A shorter chain is the beginning of the same snake. On Heron, the chain winds through the heavy-hex lattice, and the search finds a different path for every length. <Admonition type="note"> The longest 1D chain that you can find on a Heron QPU is 127 qubits long, which allows 63 spinful sites in this case study. </Admonition> When you transpile for real hardware, take the actual noise and calibration data into account to find the best layout on the QPU. ```python draw_interleaved([50]) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/0c8d3dd9-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> ### Routing and the role of the seeds The metrics for routing are the **two-qubit depth** and the **two-qubit gate count** after the preset pass manager at optimization level 3, in a given basis. The default basis in this tutorial is `{cz, rz, sx, x}`. *Which part is random?* The layout is fixed, but the router is not deterministic across seeds. The Qiskit `SabreSwap` pass runs several randomized trials (20 at optimization level 3), seeded from the `seed_transpiler` value that you pass, and keeps the trial with the fewest SWAP gates. Therefore, a seed fixes the whole set of trials, and a different seed gives a different set. The router has work to do on every layout, because of the `ZZ` pairs that the previous section counted. Different seeds find different SWAP gates, and the router selects by SWAP count, not by two-qubit depth. For these reasons, the code routes every circuit with 20 seeds and **keeps the best result**: the smallest two-qubit depth, with ties going to the smaller gate count. The gate count that this tutorial reports is the count of that same circuit. ```python BASIS = ["cz", "rz", "sx", "x"] SEEDS = range( NUM_SEEDS ) # the router is randomized, so we route every circuit with several seeds and keep the best result def two_qubit(instruction): return len(instruction.qubits) == 2 class Stats(NamedTuple): depth: int # two-qubit depth of the BEST routed circuit (smallest depth; ties go to the smaller gate count) count: int # two-qubit gate count of that same circuit median: int # median depth over the seeds, for context high: int # largest depth seen: the spread of the router circuit: QuantumCircuit # the best routed circuit itself def route(circuit, coupling_map, layout, target=None): """Route `circuit` once per seed (preset pass manager, level 3) and keep the best result. Onto a `coupling_map` in the {cz, rz, sx, x} basis, or onto a full `target` with its own basis (which can include fractional gates). """ lowered = circuit.decompose( reps=6 ) # PauliEvolutionGate -> our gates -> u/cx best, depths = None, [] for seed in SEEDS: if target is None: pass_manager = generate_preset_pass_manager( coupling_map=coupling_map, basis_gates=BASIS, optimization_level=3, seed_transpiler=seed, initial_layout=layout, ) else: pass_manager = generate_preset_pass_manager( target=target, optimization_level=3, seed_transpiler=seed, initial_layout=layout, ) routed = pass_manager.run(lowered) key = (routed.depth(two_qubit), routed.num_nonlocal_gates()) depths.append(key[0]) if best is None or key < best[0]: best = (key, routed) (depth, count), routed = best return Stats(depth, count, int(np.median(depths)), max(depths), routed) ``` ### Fractional gates: a second gate set IBM Heron and Nighthawk devices can run `rzz(theta)` for $0 < \theta \le \pi/2$ and `rx(theta)` for any $\theta$ as native gates. These *fractional gates* are experimental and are off by default. On a real backend, you turn them on with `QiskitRuntimeService.backend("ibm_torino", use_fractional_gates=True)`. This setting is a *backend* option, not a transpiler flag. The returned `Target` contains `rzz` with `angle_bounds=[(0, pi/2)]`, and `rx`, and the transpiler then uses them. The code routes every circuit on the QPUs twice: onto the plain `{cz, rz, sx, x}` default basis, and onto a fractional target. A two-qubit layer is a layer of `cz` **or** `rzz` gates, and each of these gates counts as one two-qubit gate. `FakeFez` and `FakeNighthawk` in this environment do not include fractional gates, so the next cell **builds the targets manually**, based on the assumptions that are documented in the cell. The depths that later cells report therefore show what the transpiler can do with such a gate set. They do not show what a real backend with fractional gates delivers. ```python # ---- ASSUMPTIONS (all of them) --------------------------------------------------------------------------------------- # 1. The fake backends in this environment have no fractional gates: FakeFez and FakeNighthawk have {cz, id, rz, sx, x}. # We take their targets and ADD what `QiskitRuntimeService.backend(..., use_fractional_gates=True)` adds to a real # target: rzz(theta) with angle bounds (0, pi/2] on every cz edge, and rx(theta) for any theta on every qubit that # has sx. Nighthawk is treated exactly like Heron; we did not check that a real Nighthawk exposes the same gates. # 2. rzz and rx durations/errors are copied from cz and sx. They only matter for noise-aware choices, not for depth. # 3. Two-qubit depth counts every cz and every rzz as one layer; "2q gates" counts each cz and each rzz as one. # 4. cz stays in the target, so the transpiler may still use it where it is cheaper. # 5. The circuits and the layouts are the ones of the cz basis. The router, the optimization level (3) and the seeds are # the same too. # ---------------------------------------------------------------------------------------------------------------------- def fractional_target(backend): target = copy.deepcopy(backend.target) theta = Parameter("theta") cz, sx = target["cz"], target["sx"] target.add_instruction( RZZGate(theta), { edge: InstructionProperties(duration=p.duration, error=p.error) for edge, p in cz.items() }, angle_bounds=[(0, math.pi / 2)], ) target.add_instruction( RXGate(theta), { qubit: InstructionProperties(duration=p.duration, error=p.error) for qubit, p in sx.items() }, ) return target heron_fractional_target = fractional_target(FakeFez()) nighthawk_fractional_target = fractional_target(FakeNighthawk()) for name, target in ( ("Heron", heron_fractional_target), ("Nighthawk", nighthawk_fractional_target), ): print( f"{name:9s} fractional target: {sorted(g for g in target.operation_names if g in ('cz', 'rz', 'sx', 'x', 'rzz', 'rx'))}," f" rzz has angle bounds: {target.gate_has_angle_bounds('rzz')}" ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` Heron fractional target: ['cz', 'rx', 'rz', 'rzz', 'sx', 'x'], rzz has angle bounds: True Nighthawk fractional target: ['cz', 'rx', 'rz', 'rzz', 'sx', 'x'], rzz has angle bounds: True ``` <div className="mt-32" /> ### Route onto the QPUs, in both gate sets The following code routes the flow-set circuit onto Heron and onto Nighthawk with the chains from the previous section. For comparison, it also routes the `LieTrotter` circuit and the flow-set circuit onto a plain line. ```python # One function per curve, called in the loop below. studies = { "LieTrotter, line": lambda: [ route( circuit, CouplingMap.from_line(circuit.num_qubits), list(range(circuit.num_qubits)), ) for circuit in lie_circuits ], "flow set, line": lambda: [ route( circuit, CouplingMap.from_line(circuit.num_qubits), list(range(circuit.num_qubits)), ) for circuit in flow_circuits ], "interleaved, 1 ancilla, Heron, cz basis": lambda: [ route(circuit, heron, find_path(heron, circuit.num_qubits)) for circuit in flow_circuits ], "interleaved, 1 ancilla, Heron, fractional": lambda: [ route( circuit, None, find_path(heron, circuit.num_qubits), target=heron_fractional_target, ) for circuit in flow_circuits ], "interleaved, 1 ancilla, Nighthawk, cz basis": lambda: [ route(circuit, nighthawk, nighthawk_snake(circuit.num_qubits)) for circuit in flow_circuits ], "interleaved, 1 ancilla, Nighthawk, fractional": lambda: [ route( circuit, None, nighthawk_snake(circuit.num_qubits), target=nighthawk_fractional_target, ) for circuit in flow_circuits ], } interleaved_results = {} print("best 2q depth (median, worst) / 2q gates of the best circuit") print( f"{'':46s}" + "".join(f"{f'L = {num_sites}':>25s}" for num_sites in SIZES) ) for label, study in studies.items(): interleaved_results[label] = series = study() print( f"{label:46s}" + "".join( f"{f'{s.depth} ({s.median}, {s.high}) / {s.count}':>25s}" for s in series ), flush=True, ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` best 2q depth (median, worst) / 2q gates of the best circuit L = 4 L = 10 L = 20 L = 30 L = 40 L = 50 LieTrotter, line 47 (50, 50) / 66 119 (119, 122) / 192 239 (242, 242) / 402 359 (359, 362) / 612 479 (479, 482) / 822 599 (599, 602) / 1032 flow set, line 20 (22, 25) / 59 20 (24, 25) / 164 23 (25, 26) / 343 23 (25, 28) / 524 25 (25, 26) / 699 23 (25, 28) / 882 interleaved, 1 ancilla, Heron, cz basis 20 (22, 25) / 59 20 (23, 25) / 164 23 (25, 26) / 347 23 (25, 28) / 526 23 (25, 25) / 703 25 (25, 28) / 880 interleaved, 1 ancilla, Heron, fractional 14 (16, 19) / 43 14 (17, 19) / 118 17 (19, 20) / 251 17 (19, 22) / 380 17 (19, 19) / 507 19 (19, 22) / 634 interleaved, 1 ancilla, Nighthawk, cz basis 20 (22, 25) / 59 22 (22, 25) / 162 22 (25, 25) / 335 22 (25, 30) / 496 22 (25, 30) / 669 25 (25, 25) / 838 interleaved, 1 ancilla, Nighthawk, fractional 14 (16, 19) / 43 16 (16, 19) / 116 16 (19, 19) / 239 16 (19, 23) / 350 16 (19, 23) / 473 19 (19, 19) / 592 ``` <div className="mt-32" /> ```python plot_2q_depth_and_counts( SIZES, interleaved_results, depth_ticks=(10, 20, 50, 100, 200, 500, 1000) ) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/47dd8196-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> Solid lines are the `{cz, rz, sx, x}` basis, and dashed lines are the fractional gate set. The two gray lines are the plain line. **The synthesis.** With `LieTrotter`, the depth grows with every site (from 47 at $L = 4$ to 599 at $L = 50$, or 12 layers per site). The flow-set synthesis stays between 20 and 25 over the same range. The bands reach from the best to the worst of the 20 seeds. The gate count grows linearly for both syntheses, and is lower for the flow-set synthesis (882 compared with 1032 at $L = 50$). Therefore, the lower depth does not cost extra gates. **The QPUs.** On both QPUs, the flow-set circuit has the same low depth as on the line. The best depth is between 20 and 25 for $L$ from 4 to 50 on Heron, on Nighthawk, and on the line. The gate counts are also similar. At $L = 10$, the circuit has 164 two-qubit gates on Heron, 162 on Nighthawk, and 164 on the line. At $L = 50$, it has 880, 838, and 882. Therefore, **any hardware that has a long enough path can run this circuit at an almost constant depth**. The layout is a simple search on Heron, or a snake through the rows on Nighthawk. **Fractional gates.** The fractional gate set lowers the best depth by six layers at every size. On Heron, the depth goes from 20, 20, 23, 23, 23, 25 to 14, 14, 17, 17, 17, 19. On Nighthawk, it goes from 20, 22, 22, 22, 22, 25 to 14, 16, 16, 16, 16, 19 (at $L = 4, 10, 20, 30, 40, 50$). This result is expected. The `YY` and `ZZ` flow sets consist of `rzz` rotations, which the fractional gate set runs as one gate, while the `cz` basis needs two `cz` gates. These rotations cause the constant drop in depth, and a drop in gate count that increases with $L$. ```python def active_qubits(circuit): """Number of device qubits that carry at least one gate.""" return len( { circuit.find_bit(q).index for instruction in circuit.data for q in instruction.qubits } ) L_DRAWN = 10 interleaved_heron = interleaved_results[ "interleaved, 1 ancilla, Heron, fractional" ][SIZES.index(L_DRAWN)] print( f"interleaved on Heron, L = {L_DRAWN}: 2q depth {interleaved_heron.depth}, 2q gates {interleaved_heron.count}, " f"qubits used {active_qubits(interleaved_heron.circuit)}" ) interleaved_heron.circuit.draw("mpl", fold=-1, idle_wires=False) ``` <p className="text-text-helper text-label-01">Output:</p> ``` interleaved on Heron, L = 10: 2q depth 14, 2q gates 118, qubits used 21 ``` <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/af0aefae-1.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> The following drawing shows one of the routed circuits, for $L = 10$ on Heron (the best of the seeds, from the previous results). Each wire is labeled `q_i ↦ p`, which means that virtual qubit `i` of the encoded circuit starts on physical qubit `p` of the device. For Nighthawk, `p = 10 * row + col`. The drawing shows only the qubits that the circuit acts on. ## 5. Connectivity of Nighthawk devices The interleaved circuit needs only a path. Suppose the QPU offers more connectivity. The qubits of Nighthawk are on a square lattice, and a square lattice contains a **ladder**: two parallel chains whose rungs are real couplers. A ladder is the interaction graph of the Hubbard model in blocked order. The spin-up chain is on one rail, the spin-down chain is on the other, and the on-site term acts across a rung. The single-chain encoding of section 4 cannot use the ladder. It places all $2L$ modes along **one** chain. With blocked order, the spin-up and spin-down blocks are joined end to end, and the on-site plaquettes stretch across the join. Instead, give **each spin its own chain and its own ancilla**: * Spin-up modes $0..L-1$ are on qubits $0..L-1$, with the ancilla on qubit $L$. * Spin-down modes $L..2L-1$ are on qubits $L+1..2L$, with the ancilla on qubit $2L+1$. Each block uses the same images as before: $V_j = Z_qZ_{q+1}$, $T_{j,j+1} = -X_{q+1}/2$, and $T_{j+1,j} = \tfrac12 Z_qX_{q+1}Z_{q+2}$, where $q$ is the qubit of mode $j$. The total is $2L + 2$ qubits, which is one more than before. ```python def two_chain_action(generator, num_sites, num_qubits): def qubit(mode): return mode + ( 1 if mode >= num_sites else 0 ) # the down chain starts after the up ancilla j, k = generator if j == k: return parity_delocalized((qubit(j), qubit(j)), num_qubits) if (j < num_sites) != (k < num_sites): raise ValueError( f"cross-spin transfer operator {generator}: not part of this model" ) # same chain, so the one-chain image applies at the shifted qubits return parity_delocalized((qubit(j), qubit(k)), num_qubits) def two_chain_encode(operator, num_qubits, num_sites): return encode( operator, num_qubits, action=lambda g, n: two_chain_action(g, num_sites, n), ) L = L_SMALL operator = hubbard(L, 1.0, 8.0, "blocked") def encoding(op, n): return two_chain_encode(op, n, L) print(f"two chains, blocked: L = {L} -> {2 * L + 2} qubits") pattern_table(operator, encoding, 2 * L + 2, L) ``` <p className="text-text-helper text-label-01">Output:</p> ``` two chains, blocked: L = 4 -> 10 qubits flow set 0: 6 terms, Pauli patterns: X @ (0) flow set 1: 6 terms, Pauli patterns: ZXZ @ (0, 1, 2) flow set 2: 4 terms, Pauli patterns: ZZZZ @ (0, 1, L + 1, L + 2) ``` <div className="mt-32" /> Every flow set is now local on the ladder. The `X` and `ZXZ` terms act along a rail, and the interaction `ZZZZ @ (0, 1, L+1, L+2)` is a 2x2 plaquette (two neighbors on one rail and the two qubits next to them on the other rail). In the encoded space, spin-up and spin-down operators now *commute* instead of anticommuting. This difference has no effect on this Hamiltonian, because it contains only bilinears of the same spin and products of vertex operators. An operator with hopping between spins raises an error in the action, so it cannot silently give a wrong answer. Perform the same check against the known `LieTrotter` circuit as for the single-ancilla interleaved ordering: ```python def two_chain(num_sites, formula): operator = hubbard(num_sites, 1.0, 8.0, "blocked") return to_qubits( fermionic_circuit(operator, 2 * num_sites).decompose(), lambda op, n: two_chain_encode(op, n, num_sites), 2 * num_sites + 2, formula, ) def two_chain_synthesis(num_sites): up = list(range(num_sites + 1)) down = list(range(num_sites + 1, 2 * num_sites + 2)) return FlowSetSynthesis([up, down], rung_offset=num_sites + 1) def blocked_flow_set(num_sites): """The qubit circuit of one Trotter step with the flow-set synthesis: blocked order, two ancillas.""" return two_chain(num_sites, two_chain_synthesis(num_sites)) L = L_SMALL reference = two_chain(L, LieTrotter()) ours = two_chain(L, two_chain_synthesis(L)) print( f"two chains, L = {L}: FlowSetSynthesis == LieTrotter up to a phase:", same_up_to_phase(ours, reference), ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` two chains, L = 4: FlowSetSynthesis == LieTrotter up to a phase: True ``` <div className="mt-32" /> ### A ladder on the QPU A chain of $L + 1$ qubits fits on Nighthawk in a straight line if it has at most 12 qubits (along the 12 rows) or at most 10 (along the 10 columns). Therefore, up to $L = 11$, the two chains are side by side on two neighboring grid lines, rung by rung, and every coupler that the circuit needs is a real coupler. The `folded_ladder` function returns this layout. For larger $L$, it also folds the ladder, as section 6 describes. The function also selects the orientation that needs fewer strips, and selects the horizontal orientation if both need the same number. The following drawings show both straight cases. For $L = 8$, a chain of 9 qubits fits along the 10 columns, so the ladder is horizontal. For $L = 10$, a chain of 11 qubits does not fit along the 10 columns (it needs two strips), but it fits along the 12 rows, so the layout uses a vertical ladder. ```python draw_fold([8, 10]) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/25d1a8f2-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> The following code compares the two orderings at the sizes that fit natively: interleaved on the snake, and blocked on the ladder, both on Nighthawk and in both gate sets. ```python NATIVE_SIZES = ( 4, 6, 8, 10, 12, ) # 12 no longer fits a straight ladder: the motivation for the next section interleaved_native = [ single_ancilla( "interleaved", num_sites, FlowSetSynthesis([list(range(2 * num_sites + 1))]), ) for num_sites in NATIVE_SIZES ] blocked_native = [blocked_flow_set(num_sites) for num_sites in NATIVE_SIZES] snake_layouts = [ nighthawk_snake(circuit.num_qubits) for circuit in interleaved_native ] ladder_layouts = [folded_ladder(num_sites) for num_sites in NATIVE_SIZES] print( "two-qubit gates of the unrouted circuit that are not on a coupler of Nighthawk under the hand layout:" ) for k, num_sites in enumerate(NATIVE_SIZES): snake_far, snake_total = off_coupler( interleaved_native[k], nighthawk, snake_layouts[k] ) ladder_far, ladder_total = off_coupler( blocked_native[k], nighthawk, ladder_layouts[k] ) print( f" L = {num_sites:2d}: interleaved on the snake {snake_far:3d} of {snake_total:3d} blocked on the ladder {ladder_far:3d} of {ladder_total:3d}" ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` two-qubit gates of the unrouted circuit that are not on a coupler of Nighthawk under the hand layout: L = 4: interleaved on the snake 8 of 48 blocked on the ladder 0 of 34 L = 6: interleaved on the snake 12 of 76 blocked on the ladder 0 of 50 L = 8: interleaved on the snake 16 of 104 blocked on the ladder 0 of 66 L = 10: interleaved on the snake 20 of 132 blocked on the ladder 0 of 82 L = 12: interleaved on the snake 24 of 160 blocked on the ladder 6 of 98 ``` <div className="mt-32" /> For the blocked ladder that fits natively on the QPU ($L \leq 10$), no gates are off a coupler. Therefore, for these sizes, the routing step is not expected to change the circuit statistics. ```python native_studies = { "interleaved, 1 ancilla, Nighthawk, cz basis": lambda: [ route(circuit, nighthawk, nighthawk_snake(circuit.num_qubits)) for circuit in interleaved_native ], "interleaved, 1 ancilla, Nighthawk, fractional": lambda: [ route( circuit, None, nighthawk_snake(circuit.num_qubits), target=nighthawk_fractional_target, ) for circuit in interleaved_native ], "blocked, 2 ancillas, Nighthawk, cz basis": lambda: [ route(circuit, nighthawk, folded_ladder(num_sites)) for circuit, num_sites in zip(blocked_native, NATIVE_SIZES) ], "blocked, 2 ancillas, Nighthawk, fractional": lambda: [ route( circuit, None, folded_ladder(num_sites), target=nighthawk_fractional_target, ) for circuit, num_sites in zip(blocked_native, NATIVE_SIZES) ], } native_comparison = {} print("best 2q depth (median, worst) / 2q gates of the best circuit") print( f"{'':46s}" + "".join(f"{f'L = {num_sites}':>25s}" for num_sites in NATIVE_SIZES) ) for label, study in native_studies.items(): native_comparison[label] = series = study() print( f"{label:46s}" + "".join( f"{f'{s.depth} ({s.median}, {s.high}) / {s.count}':>25s}" for s in series ), flush=True, ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` best 2q depth (median, worst) / 2q gates of the best circuit L = 4 L = 6 L = 8 L = 10 L = 12 interleaved, 1 ancilla, Nighthawk, cz basis 20 (22, 25) / 59 22 (22, 25) / 92 22 (22, 25) / 125 22 (22, 25) / 162 22 (23, 25) / 191 interleaved, 1 ancilla, Nighthawk, fractional 14 (16, 19) / 43 16 (16, 19) / 66 16 (16, 19) / 89 16 (16, 19) / 116 16 (17, 19) / 135 blocked, 2 ancillas, Nighthawk, cz basis 10 (10, 10) / 34 10 (10, 10) / 50 10 (10, 10) / 66 10 (10, 10) / 82 25 (25, 25) / 113 blocked, 2 ancillas, Nighthawk, fractional 8 (8, 8) / 30 8 (8, 8) / 44 8 (8, 8) / 58 8 (8, 8) / 72 23 (23, 23) / 101 ``` <div className="mt-32" /> ```python plot_2q_depth_and_counts(NATIVE_SIZES, native_comparison) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/b8e745c2-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> At the sizes that fit natively ($L \le 10$), the blocked circuit is **about half as deep**. At $L = 10$, its depth is 10 compared with 22 in the `cz` basis, and 8 compared with 16 with fractional gates. At $L = 4$, the values are 10 compared with 20, and 8 compared with 14. The depth of the blocked circuit does not change between $L = 4$ and $L = 10$. The blocked circuit also needs fewer gates. At $L = 10$, it needs 82 compared with 162 in the `cz` basis, and 72 compared with 116 with fractional gates. These results are expected, because no gates are off a coupler. Therefore, one more ancilla qubit reduces the depth by a factor of two for these sizes. At $L = 12$, this advantage ends. A chain of 13 qubits does not fit on a straight grid line, so the ladder must bend. Then six of the 98 two-qubit gates are off a coupler, and the depth of the blocked circuit increases to 25 in the `cz` basis and 23 with fractional gates. These depths are *greater* than the interleaved depths at the same size (22 and 16). The next section examines that fold. As before, you can also view the routed circuit of the blocked encoding for $L = 10$: ```python blocked_nighthawk = native_comparison[ "blocked, 2 ancillas, Nighthawk, fractional" ][NATIVE_SIZES.index(L_DRAWN)] print( f"blocked on Nighthawk, L = {L_DRAWN}: 2q depth {blocked_nighthawk.depth}, 2q gates {blocked_nighthawk.count}, " f"qubits used {active_qubits(blocked_nighthawk.circuit)}" ) blocked_nighthawk.circuit.draw("mpl", fold=-1, idle_wires=False) ``` <p className="text-text-helper text-label-01">Output:</p> ``` blocked on Nighthawk, L = 10: 2q depth 8, 2q gates 72, qubits used 22 ``` <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/ba54a995-1.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> ## 6. Scaling beyond the native dimensions of Nighthawk For $L$ greater than 11, a chain does not fit in a straight line, so the ladder must **fold**. The `folded_ladder` function cuts each chain into segments, and stacks the segments in strips of two adjacent grid lines. Each strip starts at the opposite end from the end of the previous strip. Each strip boundary stretches one spin-up link and one spin-down link from distance 1 to distance 2, and this stretch is the only imperfection. Rungs stay real couplers everywhere. A ladder cannot bend on a square lattice without that stretch, so fewer strips are better. The strips can run along the 12 rows instead of the 10 columns, which needs fewer strips, and `folded_ladder` selects the orientation that needs fewer strips (the orientation changes between $L=49$ and $L=50$). In the following drawings, the stretched links are red and dotted. ```python draw_fold([49, 50]) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/c35f19ce-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> The following code shows the cost of the fold. It runs the same comparison as before, up to 50 sites, and adds blocked order on Heron. The heavy-hex lattice has no ladder, so on Heron the router chooses the layout. This comparison is the final comparison of all four variants, with the depth on the left and the two-qubit gate count on the right. ```python blocked_circuits = [blocked_flow_set(num_sites) for num_sites in SIZES] blocked_studies = { "blocked, 2 ancillas, Nighthawk, cz basis": lambda: [ route(circuit, nighthawk, folded_ladder(num_sites)) for circuit, num_sites in zip(blocked_circuits, SIZES) ], "blocked, 2 ancillas, Nighthawk, fractional": lambda: [ route( circuit, None, folded_ladder(num_sites), target=nighthawk_fractional_target, ) for circuit, num_sites in zip(blocked_circuits, SIZES) ], "blocked, 2 ancillas, Heron, cz basis": lambda: [ route(circuit, heron, None) for circuit in blocked_circuits ], "blocked, 2 ancillas, Heron, fractional": lambda: [ route(circuit, None, None, target=heron_fractional_target) for circuit in blocked_circuits ], } all_variants = { label: series for label, series in interleaved_results.items() if label.startswith("interleaved") } # from section 4 print("best 2q depth (median, worst) / 2q gates of the best circuit") print( f"{'':46s}" + "".join(f"{f'L = {num_sites}':>25s}" for num_sites in SIZES) ) for label, study in blocked_studies.items(): all_variants[label] = series = study() print( f"{label:46s}" + "".join( f"{f'{s.depth} ({s.median}, {s.high}) / {s.count}':>25s}" for s in series ), flush=True, ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` best 2q depth (median, worst) / 2q gates of the best circuit L = 4 L = 10 L = 20 L = 30 L = 40 L = 50 blocked, 2 ancillas, Nighthawk, cz basis 10 (10, 10) / 34 10 (10, 10) / 82 25 (25, 25) / 177 25 (25, 25) / 272 25 (25, 25) / 367 25 (25, 25) / 462 blocked, 2 ancillas, Nighthawk, fractional 8 (8, 8) / 30 8 (8, 8) / 72 23 (23, 23) / 157 23 (23, 23) / 242 23 (23, 23) / 327 23 (23, 23) / 412 blocked, 2 ancillas, Heron, cz basis 29 (37, 54) / 76 32 (36, 61) / 205 52 (73, 99) / 463 63 (89, 139) / 741 61 (105, 153) / 931 95 (134, 166) / 1346 blocked, 2 ancillas, Heron, fractional 27 (35, 51) / 72 30 (34, 57) / 207 50 (70, 96) / 443 60 (85, 136) / 713 59 (100, 146) / 893 92 (132, 161) / 1297 ``` <div className="mt-32" /> ```python plot_2q_depth_and_counts( SIZES, all_variants, depth_ticks=(5, 10, 20, 50, 100, 200) ) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/9756fef7-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> Between $L = 10$ and $L = 12$, the depth of the blocked circuit increases from 10 to 25 in the `cz` basis (from eight to 23 with fractional gates), and then stays the same up to $L = 50$. **The fold increases the depth once, not once per strip.** The drawing for $L = 50$ has four folds and eight stretched links, and the depth is the same as at $L = 12$, which has one fold. The depth stays the same because the depth penalties of all folds occur in parallel. **Result of the comparison.** Beyond the width of the QPU, blocked order loses its depth advantage. At $L = 50$, both orderings reach 25 in the `cz` basis. The best interleaved seed reaches 22 between $L = 12$ and $L = 40$, but its median is 23 to 25. With fractional gates, blocked order (23) is deeper than interleaved order (19). Blocked order still has a lower gate count: 462 compared with 838 in the `cz` basis (45% fewer) and 412 compared with 592 with fractional gates (30% fewer), at the cost of one more qubit. Fewer gates can mean less accumulated error on a real device, which can be a reason to prefer blocked order on a QPU with a ladder. The observable that you measure at the end of the circuit can also affect the choice of encoding. **On Heron, which has no ladder, blocked order is much worse.** Its best depth grows from 29 at $L = 4$ to 95 at $L = 50$ in the `cz` basis (92 with fractional gates), and the median and worst seeds at $L = 50$ give 134 and 166. ## 7. Beyond one dimension All of the previous sections are one-dimensional, where the hardware needs to hold only a path or a ladder. A square lattice of sites is the case where the square connectivity of Nighthawk matters most, so this section briefly examines it. To keep the section short, it omits spin and uses the **spinless** model of the [Simulate 2D Fermi-Hubbard dynamics with flow sets](/docs/addons/qiskit-fermions/guides/2-d-fermi-hubbard) guide (Gandon et al., arXiv:2512.11418). That model has hopping and interaction on every nearest-neighbor bond of an $R \times C$ lattice. In the Verstraete-Cirac encoding of the model, every site gets **its own ancilla** ($N = RC$ modes use $2N$ qubits), and the hopping splits into **four flow sets** (east, west, north, and south). A Clifford circuit of two-qubit depth 2, which acts on all rows or all columns at the same time, diagonalizes each flow set. As in 1D, the depth of the circuit therefore does not grow with the lattice. The guide shows each of these steps, but it does not transpile to a given connectivity. The following drawing shows how the model fits on Nighthawk. **The stripe layout on Nighthawk.** Lattice row $r$ runs down QPU column $r$ as a stripe that alternates site, ancilla, site, ancilla (lattice column $c$ is on QPU rows $2c$ and $2c+1$). A site and its own ancilla are neighbors, an ancilla and the *next* site along the row are neighbors, and vertical neighbors are on adjacent QPU columns. Therefore, **all hopping terms are on couplers**, and the Clifford circuits need no routing. The interaction between two sites of the same row is not native, because the two sites are two QPU qubits apart, with the ancilla between them (the red arcs). These pairs are the same $\texttt{ZZ}$-type pairs that you routed in 1D, with one `rzz` gate per horizontal bond, so the routing cost is similar to the cost in 1D. The largest lattice that fits this layout is $10 \times 6$ (60 sites, which use all 120 qubits). ```python draw_stripes([(3, 3), (10, 6)]) ``` <p className="text-text-helper text-label-01">Output:</p> <Image src="/docs/images/tutorials/spinful-fermi-hubbard-dynamics/extracted-outputs/b789431d-0.avif" alt="Output of the previous code cell" /> <div className="mt-32" /> ## 8. Takeaways and next steps The following code summarizes the circuit cost of a single Trotter step with the flow-set synthesis, for each QPU connectivity. ```python for num_sites in (10, 50): print(f"L = {num_sites} ({2 * num_sites} modes)") for label, series in all_variants.items(): stats = series[SIZES.index(num_sites)] print( f" {label:46s} best 2q depth {stats.depth:3d} (median {stats.median:3d}, worst {stats.high:3d}), " f"2q gates {stats.count:4d}, qubits used {active_qubits(stats.circuit)}" ) ``` <p className="text-text-helper text-label-01">Output:</p> ``` L = 10 (20 modes) interleaved, 1 ancilla, Heron, cz basis best 2q depth 20 (median 23, worst 25), 2q gates 164, qubits used 21 interleaved, 1 ancilla, Heron, fractional best 2q depth 14 (median 17, worst 19), 2q gates 118, qubits used 21 interleaved, 1 ancilla, Nighthawk, cz basis best 2q depth 22 (median 22, worst 25), 2q gates 162, qubits used 21 interleaved, 1 ancilla, Nighthawk, fractional best 2q depth 16 (median 16, worst 19), 2q gates 116, qubits used 21 blocked, 2 ancillas, Nighthawk, cz basis best 2q depth 10 (median 10, worst 10), 2q gates 82, qubits used 22 blocked, 2 ancillas, Nighthawk, fractional best 2q depth 8 (median 8, worst 8), 2q gates 72, qubits used 22 blocked, 2 ancillas, Heron, cz basis best 2q depth 32 (median 36, worst 61), 2q gates 205, qubits used 22 blocked, 2 ancillas, Heron, fractional best 2q depth 30 (median 34, worst 57), 2q gates 207, qubits used 22 L = 50 (100 modes) interleaved, 1 ancilla, Heron, cz basis best 2q depth 25 (median 25, worst 28), 2q gates 880, qubits used 101 interleaved, 1 ancilla, Heron, fractional best 2q depth 19 (median 19, worst 22), 2q gates 634, qubits used 101 interleaved, 1 ancilla, Nighthawk, cz basis best 2q depth 25 (median 25, worst 25), 2q gates 838, qubits used 101 interleaved, 1 ancilla, Nighthawk, fractional best 2q depth 19 (median 19, worst 19), 2q gates 592, qubits used 101 blocked, 2 ancillas, Nighthawk, cz basis best 2q depth 25 (median 25, worst 25), 2q gates 462, qubits used 102 blocked, 2 ancillas, Nighthawk, fractional best 2q depth 23 (median 23, worst 23), 2q gates 412, qubits used 102 blocked, 2 ancillas, Heron, cz basis best 2q depth 95 (median 134, worst 166), 2q gates 1346, qubits used 105 blocked, 2 ancillas, Heron, fractional best 2q depth 92 (median 132, worst 161), 2q gates 1297, qubits used 105 ``` <div className="mt-32" /> These results lead to the following high-level takeaways: * **On Nighthawk, use blocked order with two ancillas.** While the ladder fits on the QPU, its depth is half the depth of interleaved order (10 compared with 22 in the `cz` basis at $L = 10$). After the fold, its depth stays at 25 (23 with fractional gates). Interleaved order has an equally constant depth in the `cz` basis and a lower depth with fractional gates (25 and 19 at $L = 50$). Therefore, at large sizes, interleaved order has a small advantage in depth. The blocked circuit still needs far fewer two-qubit gates: 462 compared with 838 in the `cz` basis (about 45% fewer), and 412 compared with 592 with fractional gates (about 30% fewer), at the cost of one more qubit. Fewer gates can mean less accumulated error on a real device, which can be a reason to prefer blocked order. * **Without square connectivity, use interleaved order.** One ancilla and any path through the device give a constant depth on Heron too (25 in the `cz` basis and 19 with fractional gates at $L = 50$). On Heron, the blocked variant has no ladder to use. Its best depth grows with $L$ (95 and 92 at $L = 50$, and 166 and 161 for the worst routing seed), and fractional gates barely reduce it. * **Fractional gates lower the constant depth.** Where the depth is constant, it drops from 25 to 19 for both interleaved variants and from 25 to 23 for blocked order on Nighthawk. At $L = 10$, the depth of the blocked circuit drops from 10 to 8. The gate counts also drop. * **The ordering is a hardware decision, not a physics decision.** Blocked and interleaved order are the same Hamiltonian with renamed modes, as the relabeling check in section 2 shows. The ordering changes which modes are neighbors, and the encoding and the coupling map see that change. * **The structure is useful only with a synthesis that uses it.** With a term-by-term synthesis (`LieTrotter`), the depth on a line grows linearly, to 599 at $L = 50$, compared with 23 for the flow-set circuit. * **All results in this tutorial use custom syntheses.** Both circuits use a one-Clifford recipe for every flow set, so the comparison is fair, but a different synthesis might change the numbers. Future releases of `qiskit-fermions` might automatically find a short-depth diagonalizing Clifford circuit. **Caveats.** The depths are for noise-free coupling maps (`FakeFez` and `FakeNighthawk`), manually built fractional targets, and manually chosen layouts. The best of 20 routing seeds is an optimistic result by design. It is the depth that you can reach if you route a circuit many times and keep the best result, which is inexpensive to do before a run. The median is the result of a single transpilation, and it is never smaller than the best result. This tutorial compares structure, not fidelity. On Heron, the two-ancilla blocked variant is routed without a manual layout, because the heavy-hex lattice has no ladder. A better placement might do somewhat better than the router, but the lattice still has no ladder.
Was this page helpful?
Report a bug, typo, or request content on GitHub.