Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fast transpilation method to BaseExperiment #1459

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,13 @@
from qiskit import QuantumCircuit
from qiskit.providers.options import Options
from qiskit.pulse import ScheduleBlock
from qiskit.transpiler import StagedPassManager, PassManager, Layout, CouplingMap
from qiskit.transpiler.passes import (
EnlargeWithAncilla,
FullAncillaAllocation,
ApplyLayout,
SetLayout,
)

from qiskit_experiments.calibration_management.calibrations import Calibrations
from qiskit_experiments.calibration_management.update_library import BaseUpdater
from qiskit_experiments.framework.base_analysis import BaseAnalysis
from qiskit_experiments.framework.base_experiment import BaseExperiment
from qiskit_experiments.framework.experiment_data import ExperimentData
from qiskit_experiments.framework.transpilation import map_qubits, minimal_transpile
from qiskit_experiments.exceptions import CalibrationError

LOG = logging.getLogger(__name__)
Expand Down Expand Up @@ -198,20 +192,6 @@ def _default_experiment_options(cls) -> Options:
options.update_options(result_index=-1, group="default")
return options

@classmethod
def _default_transpile_options(cls) -> Options:
"""Return empty default transpile options as optimization_level is not used."""
return Options()

def set_transpile_options(self, **fields):
r"""Add a warning message.

.. note::
If your experiment has overridden `_transpiled_circuits` and needs
transpile options then please also override `set_transpile_options`.
"""
warnings.warn(f"Transpile options are not used in {self.__class__.__name__ }.")

def update_calibrations(self, experiment_data: ExperimentData):
"""Update parameter values in the :class:`.Calibrations` instance.

Expand Down Expand Up @@ -295,42 +275,13 @@ def _transpiled_circuits(self) -> List[QuantumCircuit]:
Returns:
A list of transpiled circuits.
"""
transpiled = []
for circ in self.circuits():
circ = self._map_to_physical_qubits(circ)
circuits = [map_qubits(c, self.physical_qubits) for c in self.circuits()]
for circ in circuits:
self._attach_calibrations(circ)

transpiled.append(circ)
transpiled = minimal_transpile(circuits, self.backend, self.transpile_options)

return transpiled

def _map_to_physical_qubits(self, circuit: QuantumCircuit) -> QuantumCircuit:
"""Map program qubits to physical qubits.

Args:
circuit: The quantum circuit to map to device qubits.

Returns:
A quantum circuit that has the same number of qubits as the backend and where
the physical qubits of the experiment have been properly mapped.
"""
initial_layout = Layout.from_intlist(list(self.physical_qubits), *circuit.qregs)

coupling_map = self._backend_data.coupling_map
if coupling_map is not None:
coupling_map = CouplingMap(self._backend_data.coupling_map)

layout = PassManager(
[
SetLayout(initial_layout),
FullAncillaAllocation(coupling_map),
EnlargeWithAncilla(),
ApplyLayout(),
]
)

return StagedPassManager(["layout"], layout=layout).run(circuit)

@abstractmethod
def _attach_calibrations(self, circuit: QuantumCircuit):
"""Attach the calibrations to the quantum circuit.
Expand Down
43 changes: 36 additions & 7 deletions qiskit_experiments/framework/base_experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,23 @@
Base Experiment class.
"""

from abc import ABC, abstractmethod
import copy
from abc import ABC, abstractmethod
from collections import OrderedDict
from typing import Sequence, Optional, Tuple, List, Dict, Union

from qiskit import transpile, QuantumCircuit
from qiskit import QuantumCircuit
from qiskit.providers import Job, Backend
from qiskit.exceptions import QiskitError
from qiskit.qobj.utils import MeasLevel
from qiskit.providers.options import Options
from qiskit_experiments.framework import BackendData
from qiskit_experiments.framework.store_init_args import StoreInitArgs
from qiskit_experiments.framework.transpilation import (
DEFAULT_TRANSPILE_OPTIONS,
map_qubits,
minimal_transpile,
)
from qiskit_experiments.framework.base_analysis import BaseAnalysis
from qiskit_experiments.framework.experiment_data import ExperimentData
from qiskit_experiments.framework.configs import ExperimentConfig
Expand Down Expand Up @@ -373,9 +378,8 @@ def _transpiled_circuits(self) -> List[QuantumCircuit]:

This function can be overridden to define custom transpilation.
"""
transpile_opts = copy.copy(self.transpile_options.__dict__)
transpile_opts["initial_layout"] = list(self.physical_qubits)
transpiled = transpile(self.circuits(), self.backend, **transpile_opts)
circuits = [map_qubits(c, self.physical_qubits) for c in self.circuits()]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why you don't take actual qubit size from the backend when available?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just because I had never seen it matter that the circuit size matched the backend. We can set it if it matters (for qasm3?).

transpiled = minimal_transpile(circuits, self.backend, self.transpile_options)

return transpiled

Expand Down Expand Up @@ -418,11 +422,36 @@ def set_experiment_options(self, **fields):

@classmethod
def _default_transpile_options(cls) -> Options:
"""Default transpiler options for transpilation of circuits"""
"""Default transpiler options for transpilation of circuits

Transpile Options:
optimization_level (int): Optimization level to pass to
:func:`qiskit.transpile`.
num_processes (int): Number of processes to use during
transpilation on Qiskit >= 1.0.
full_transpile (bool): If ``True``,
``BaseExperiment._transpiled_circuits`` (called by
:meth:`BaseExperiment.run` if not overridden by a subclass)
will call :func:`qiskit.transpile` on the output of
:meth:`BaseExperiment.circuits` before executing the circuits.
If ``False``, ``BaseExperiment._transpiled_circuits`` will
reindex the qubits in the output of
:meth:`BaseExperiment.circuits` using the experiments'
:meth:`BaseExperiment.physical_qubits`. Then it will check if
the circuit operations are all defined in the
:class:`qiskit.transpiler.Target` of the experiment's backend
or in the indiivdual circuit calibrations. If not, it will use
:class:`qiskit.transpiler.passes.BasisTranslator` to map the
circuit instructions to the backend. Additionally,
the :class:`qiskit.transpiler.passes.PulseGates` transpiler
pass will be run if the :class:`qiskit.transpiler.Target`
contains any custom pulse gate calibrations.

"""
# Experiment subclasses can override this method if they need
# to set specific default transpiler options to transpile the
# experiment circuits.
return Options(optimization_level=0)
return copy.copy(DEFAULT_TRANSPILE_OPTIONS)

@property
def transpile_options(self) -> Options:
Expand Down
9 changes: 4 additions & 5 deletions qiskit_experiments/framework/experiment_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,11 +866,10 @@ def _add_job_data(
LOG.warning("Job was cancelled before completion [Job ID: %s]", jid)
return jid, False
if status == JobStatus.ERROR:
LOG.error(
"Job data not added for errored job [Job ID: %s]\nError message: %s",
jid,
job.error_message(),
)
msg = f"Job data not added for errored job [Job ID: {jid}]"
if hasattr(job, "error_message"):
msg += f"\nError message: {job.error_message()}"
LOG.error(msg)
return jid, False
LOG.warning("Adding data from job failed [Job ID: %s]", job.job_id())
raise ex
Expand Down
217 changes: 217 additions & 0 deletions qiskit_experiments/framework/transpilation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2021.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""
Functions for preparing circuits for execution
"""

from __future__ import annotations

import importlib.metadata
import logging
from collections.abc import Sequence

from qiskit import QuantumCircuit, QuantumRegister, transpile
from qiskit.exceptions import QiskitError
from qiskit.providers import Backend
from qiskit.providers.options import Options
from qiskit.pulse.calibration_entries import CalibrationPublisher
from qiskit.transpiler import Target


LOGGER = logging.getLogger(__file__)

DEFAULT_TRANSPILE_OPTIONS = Options(optimization_level=0, full_transpile=False)
if importlib.metadata.version("qiskit").partition(".")[0] != "0":
DEFAULT_TRANSPILE_OPTIONS["num_processes"] = 1


def map_qubits(
circuit: QuantumCircuit,
physical_qubits: Sequence[int],
n_qubits: int | None = None,
) -> QuantumCircuit:
"""Generate a new version of a circuit with new qubit indices

This function iterates through the instructions of ``circuit`` and copies
them into a new circuit with qubit indices replaced according to the
entries in ``physical_qubits``. So qubit 0's instructions are applied to
``physical_qubits[0]`` and qubit 1's to ``physical_qubits[1]``, etc.

This function behaves similarly to passing ``initial_layout`` to
:func:`qiskit.transpile` but does not use a Qiskit
:class:`~qiskit.transpiler.PassManager` and does not fill the circuit with
ancillas.

Args:
circuit: The :class:`~qiskit.QuantumCircuit` to re-index.
physical_qubits: The list of new indices for ``circuit``'s qubit indices.
n_qubits: Optional qubit size to use for the output circuit. If
``None``, then the maximum of ``physical_qubits`` will be used.

Returns:
The quantum circuit with new qubit indices
"""
if len(physical_qubits) != circuit.num_qubits:
raise QiskitError(
f"Circuit to map has {circuit.num_qubits} qubits, but "
f"{len(physical_qubits)} physical qubits specified for mapping."
)

# if all(p == r for p, r in zip(physical_qubits, range(circuit.num_qubits))):
# # No mapping necessary
# return circuit

circ_size = n_qubits if n_qubits is not None else (max(physical_qubits) + 1)
p_qregs = QuantumRegister(circ_size)
p_circ = QuantumCircuit(
p_qregs,
*circuit.cregs,
name=circuit.name,
metadata=circuit.metadata,
global_phase=circuit.global_phase,
)
p_circ.compose(
circuit,
qubits=physical_qubits,
inplace=True,
copy=False,
)
return p_circ
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps you need to set ._layout since this is how the Qiskit qasm3 module distinguishes between physical and virtual circuits (our primary payload is qpy which doesn't care layout but in case someone prefers qasm3).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. I was not familiar with that property. It does seem like it should be set. I would want to check that it is right to set that private attribute for this case.



def _has_calibration(target: Target, name: str, qubits: tuple[int, ...]) -> bool:
"""Wrapper to work around bug in Target.has_calibration"""
try:
has_cal = target.has_calibration(name, qubits)
except AttributeError:
has_cal = False

return has_cal


def check_transpilation_needed(
circuits: Sequence[QuantumCircuit],
backend: Backend,
) -> bool:
"""Test if circuits are already compatible with backend

This function checks if circuits are able to be executed on ``backend``
without transpilation. It loops through the circuits to check if any gate
instructions are not included in the backend's
:class:`~qiskit.transpiler.Target`. The :class:`~qiskit.transpiler.Target`
is also checked for custom pulse gate calibrations for circuit's
instructions. If all gates are included in the target and there are no
custom calibrations, the function returns ``False`` indicating that
transpilation is not needed.

This function returns ``True`` if the version of ``backend`` is less than
2.

The motivation for this function is that when no transpilation is necessary
it is faster to check the circuits in this way than to run
:func:`~qiskit.transpile` and have it do nothing.

Args:
circuits: The circuits to prepare for the backend.
backend: The backend for which the circuits should be prepared.

Returns:
``True`` if transpilation is needed. Otherwise, ``False``.
"""
transpilation_needed = False

if getattr(backend, "version", 0) <= 1:
# Fall back to transpilation for BackendV1
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this always requires full transpile?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could relax this condition if we want. For simplicity, I just wanted to use a Target below. Since the fall back case is just the current status, it is not a regression to ignore BackendV1. We could instead do the BackendV1 to BackendV2 conversion or some other approach. Partly I was thinking that that backend conversion occasionally hits edge cases and also Qiskit wants to drop BackendV1 in 2.0, I think, so it seemed more future proof not handle BackendV1, but I don't feel strongly.

return True

target = backend.target

for circ in circuits:
for inst in circ.data:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have performance profile for something like RB which is ISA but circuit is extremely deep? (RB overwrites the transpile method and probably not a good test case though). The most of experiment instances in our library are with short depth circuit and this double loop should be acceptable considering the transpiler overhead, but I'd like to see some extreme case.

Alternatively we can let experiment class report dependency (e.g. BaseExperiment.__required_gates__) and we can test against it, which seems very performant but not really maintainable.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I wish Qiskit maintained something like the equivalent of count_ops in QuantumCircuit as it is built, so it would not be necessary to iterate through the circuit to find its instructions. I wonder if that has been considered before?

I did not feel too bad about this double loop because I think that transpile has to do it as well for every transpiler pass that operates on individual instructions, like BasisTranslator, so I don't think this could be slower.

RB is a good example of where _transpiled_circuits can still be overridden when necessary for performance.

I don't like BaseExperiment.__required_gates__ unless there is a convenient way to manage it since most experiments have small circuits like you mentioned. I have also wondered about transpiling on the fly -- instead of doing circ.x(0), doing circ.compose(self.get("x")) (needs the backend to already be set), but I don't like the way it breaks from idiomatic way of building circuits.

if inst.operation.name == "barrier" or circ.has_calibration_for(inst):
continue
qubits = tuple(circ.find_bit(q).index for q in inst.qubits)
if not target.instruction_supported(inst.operation.name, qubits):
transpilation_needed = True
break
if _has_calibration(target, inst.operation.name, qubits):
cal = target.get_calibration(inst.operation.name, qubits, *inst.operation.params)
if (
cal.metadata.get("publisher", CalibrationPublisher.QISKIT)
!= CalibrationPublisher.BACKEND_PROVIDER
):
transpilation_needed = True
break
if transpilation_needed:
break

return transpilation_needed


def minimal_transpile(
circuits: Sequence[QuantumCircuit],
backend: Backend,
options: Options,
) -> list[QuantumCircuit]:
"""Prepare circuits for execution on a backend

This function is a wrapper around :func:`~qiskit.transpile` to prepare
circuits for execution ``backend`` that tries to do less work in the case
in which the ``circuits`` can already be executed on the backend without
modification.

The instructions in ``circuits`` are checked to see if they can be executed
by the ``backend`` using :func:`check_transpilation_needed`. If the
circuits can not be executed, :func:`~qiskit.transpile` is called on them.
``options`` is a set of options to pass to the :func:`~qiskit.transpile`
(see detailed description of ``options``). The special ``full_transpile``
option can also be set to ``True`` to force calling
:func:`~qiskit.transpile`.

Args:
circuits: The circuits to prepare for the backend.
backend: The backend for which the circuits should be prepared.
options: Options for the transpilation. ``full_transpile`` can be set
to ``True`` to force this function to pass the circuits to
:func:`~qiskit.transpile`. Other options are passed as arguments to
:func:`qiskit.transpile` if it is called.

Returns:
The prepared circuits
"""
options = dict(options.items())

if "full_transpile" not in options:
LOGGER.debug(
"Performing full transpile because base transpile options "
"were overwritten and full_transpile was not specified."
)
full_transpile = True
else:
full_transpile = options.pop("full_transpile", False)
if not full_transpile and set(options) - set(DEFAULT_TRANSPILE_OPTIONS):
# If an experiment specifies transpile options, it needs to go
# through transpile()
full_transpile = True
LOGGER.debug(
"Performing full transpile because non-default transpile options are specified."
)

if not full_transpile:
full_transpile = check_transpilation_needed(circuits, backend)

if full_transpile:
transpiled = transpile(circuits, backend, **options)
else:
transpiled = circuits

return transpiled
Loading
Loading