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

Pluggable Clifford synthesis for RB circuits #1288

Merged
merged 17 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
@@ -0,0 +1,101 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2023.
#
# 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.
"""
Clifford synthesis plugins for randomized benchmarking
"""
from __future__ import annotations

from typing import Sequence

from qiskit.circuit import QuantumCircuit, Operation
from qiskit.circuit.equivalence_library import SessionEquivalenceLibrary as sel
from qiskit.exceptions import QiskitError
from qiskit.synthesis.clifford import synth_clifford_full
from qiskit.transpiler import PassManager, CouplingMap, Layout, Target
from qiskit.transpiler.passes import (
SabreSwap,
LayoutTransformation,
BasisTranslator,
CheckGateDirection,
GateDirection,
Optimize1qGatesDecomposition,
)
from qiskit.transpiler.passes.synthesis.plugin import HighLevelSynthesisPlugin


class RBDefaultCliffordSynthesis(HighLevelSynthesisPlugin):
"""Default Clifford synthesis plugin for randomized benchmarking."""

def run(
self,
high_level_object: Operation,
coupling_map: CouplingMap | None = None,
target: Target | None = None,
qubits: Sequence | None = None,
**options,
) -> QuantumCircuit:
"""Run synthesis for the given Clifford.

Args:
high_level_object: The operation to synthesize to a
:class:`~qiskit.circuit.QuantumCircuit` object.
coupling_map: The reduced coupling map of the backend. For example,
if physical qubits [5, 6, 7] to be benchmarked is connected
as 5 - 7 - 6 linearly, the reduced coupling map is 0 - 2 - 1.
target: A target representing the target backend, which will be ignored in this plugin.
qubits: List of physical qubits over which the operation is defined,
which will be ignored in this plugin.
options: Additional method-specific optional kwargs,
which must include ``basis_gates``, basis gates to be used for the synthesis.

Returns:
The quantum circuit representation of the Operation
when successful, and ``None`` otherwise.

Raises:
QiskitError: If basis_gates is not supplied.
"""
# synthesize cliffords
circ = synth_clifford_full(high_level_object)
Copy link
Contributor Author

@itoko itoko Oct 26, 2023

Choose a reason for hiding this comment

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

I’ve examined synth_clifford_depth_lnn (as suggested by @alexanderivrii) using random RB circuits on 3 linear qubits (i.e. no need for SabreSwap routing) and found that it is a bit faster as expected but surprisingly it produces circuits with more CNOT counts and deeper depths (about 2x/1.5x) than synth_clifford_full + SabreSwap. So I’ll stick to synth_clifford_full in the PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

synth_clifford_depth_lnn:

total cx counts = 2961, depth = 4418, max_length=10, time_transpiled_circuits:	1.233635034004692
total cx counts = 6286, depth = 9193, max_length=20, time_transpiled_circuits:	1.9450378390029073
total cx counts = 8659, depth = 12850, max_length=30, time_transpiled_circuits:	2.565633656005957
total cx counts = 11074, depth = 16313, max_length=40, time_transpiled_circuits:	3.270593570006895
total cx counts = 13727, depth = 20190, max_length=50, time_transpiled_circuits:	3.964288625997142

synth_clifford_full:

total cx counts = 1589, depth = 3092, max_length=10, time_transpiled_circuits:	1.8188774910086067
total cx counts = 2915, depth = 6009, max_length=20, time_transpiled_circuits:	2.883968324007583
total cx counts = 4120, depth = 8318, max_length=30, time_transpiled_circuits:	3.982568758990965
total cx counts = 5439, depth = 10996, max_length=40, time_transpiled_circuits:	4.483078286997625
total cx counts = 6493, depth = 13110, max_length=50, time_transpiled_circuits:	5.633502009004587

Copy link
Contributor

Choose a reason for hiding this comment

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

synth_clifford_depth_lnn is asymptotically better as it guarantees a total cx-depth bounded by 7n+2 (when n is large), but for n=3 qubits perhaps it's less useful.


# post processing to comply with basis gates and coupling map
if coupling_map is None: # Sabre does not work with coupling_map=None
return circ

basis_gates = options.get("basis_gates", None)
if basis_gates is None:
raise QiskitError("basis_gates are required to run this synthesis plugin")

basis_gates = list(basis_gates)

# Run Sabre routing and undo the layout change
# assuming Sabre routing does not change the initial layout.
# And then decompose swap gates, fix 2q-gate direction and optimize 1q gates
initial_layout = Layout.generate_trivial_layout(*circ.qubits)
undo_layout_change = LayoutTransformation(
coupling_map=coupling_map, from_layout="final_layout", to_layout=initial_layout
)

def _direction_condition(property_set):
return not property_set["is_direction_mapped"]

pm = PassManager(
[
SabreSwap(coupling_map),
undo_layout_change,
BasisTranslator(sel, basis_gates),
CheckGateDirection(coupling_map),
]
)
pm.append([GateDirection(coupling_map)], condition=_direction_condition)
pm.append([Optimize1qGatesDecomposition(basis=basis_gates)])
Comment on lines +91 to +100

Choose a reason for hiding this comment

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

This is clever. This allows to turn any synthesis plugin into one that adheres to the coupling map and to the basis gates.

return pm.run(circ)
178 changes: 154 additions & 24 deletions qiskit_experiments/library/randomized_benchmarking/clifford_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@
from qiskit.compiler import transpile
from qiskit.exceptions import QiskitError
from qiskit.quantum_info import Clifford, random_clifford
from qiskit.transpiler import CouplingMap, PassManager
from qiskit.transpiler.passes.synthesis.high_level_synthesis import HLSConfig, HighLevelSynthesis
from qiskit.utils.deprecation import deprecate_func

DEFAULT_SYNTHESIS_METHOD = "rb_default"

_DATA_FOLDER = os.path.join(os.path.dirname(__file__), "data")

_CLIFFORD_COMPOSE_1Q = np.load(f"{_DATA_FOLDER}/clifford_compose_1q.npz")["table"]
Expand Down Expand Up @@ -110,37 +114,141 @@ def _circuit_compose(
return self


def _truncate_inactive_qubits(
circ: QuantumCircuit, active_qubits: Sequence[Qubit]
def _synthesize_clifford(
clifford: Clifford,
basis_gates: Optional[Tuple[str]],
coupling_tuple: Optional[Tuple[Tuple[int, int]]] = None,
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
) -> QuantumCircuit:
res = QuantumCircuit(active_qubits, name=circ.name, metadata=circ.metadata)
for inst in circ:
if all(q in active_qubits for q in inst.qubits):
res.append(inst)
res.calibrations = circ.calibrations
return res
"""Synthesize a circuit of a Clifford element. The resulting circuit contains only
``basis_gates`` and it complies with ``coupling_tuple``.

Args:
clifford: Clifford element to be converted
basis_gates: basis gates to use in the conversion
coupling_tuple: coupling map to use in the conversion in the form of tuple of edges
synthesis_method: conversion algorithm name

Returns:
Synthesized circuit
"""
qc = QuantumCircuit(clifford.num_qubits, name=str(clifford))
qc.append(clifford, qc.qubits)
return _synthesize_clifford_circuit(
qc,
basis_gates=basis_gates,
coupling_tuple=coupling_tuple,
synthesis_method=synthesis_method,
)
Comment on lines +135 to +142

Choose a reason for hiding this comment

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

This is really minor. For a clifford over 3+ qubits, the code would first construct a quantum circuit with this clifford, then convert it back to Clifford (as done on line 191) and then against construct a quantum circuit with this new clifford (as also done on line 191). Possibly these extra translations may be avoided.

Copy link
Contributor Author

@itoko itoko Jan 17, 2024

Choose a reason for hiding this comment

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

I think there's a slight misunderstanding. The extra translations you mentioned happen only when synthesizing a 1- or 2-qubit Clifford using a custom synthesis method. I think it's rare and users who has a Clifford circuit can directly call _synthesize_clifford_circuit as I do so here. Also _synthesize_clifford is used only in the constructor of InterleavedRB and not repeatedly called so much. I think there is no clear need for its performance improvement at least for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Somewhat related to this, if HighLevelSynthesis had a method such that it takes a Clifford and returns a QuantumCircuit (namely synthesize?), it would be very helpful. Actually, if it was, I didn't need to wrap a Clifford with a QuantumCircuit here just because HighLevelSynthesis.run requires a DAGCircuit and support only the QuantumCircuit-to-QuantumCircuit conversion through PassManager.run.



def _synthesize_clifford_circuit(
circuit: QuantumCircuit, basis_gates: Tuple[str]
circuit: QuantumCircuit,
basis_gates: Optional[Tuple[str]],
coupling_tuple: Optional[Tuple[Tuple[int, int]]] = None,
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
) -> QuantumCircuit:
# synthesizes clifford circuits using given basis gates, for use during
# custom transpilation during RB circuit generation.
return transpile(circuit, basis_gates=list(basis_gates), optimization_level=1)
"""Convert a Clifford circuit into one composed of ``basis_gates`` with
satisfying ``coupling_tuple`` using the specified synthesis method.

Args:
circuit: Clifford circuit to be converted
basis_gates: basis gates to use in the conversion
coupling_tuple: coupling map to use in the conversion in the form of tuple of edges
synthesis_method: name of Clifford synthesis algorithm to use

Returns:
Synthesized circuit
"""
if basis_gates:
basis_gates = list(basis_gates)
coupling_map = CouplingMap(coupling_tuple) if coupling_tuple else None

# special handling for 1q or 2q case for speed
if circuit.num_qubits <= 2:
if synthesis_method == DEFAULT_SYNTHESIS_METHOD:
return transpile(
circuit,
basis_gates=basis_gates,
coupling_map=coupling_map,
optimization_level=1,
)
else:
# Provided custom synthesis method, re-synthesize Clifford circuit
# convert the circuit back to a Clifford object and then call the synthesis plugin
new_circuit = QuantumCircuit(circuit.num_qubits, name=circuit.name)
new_circuit.append(Clifford(circuit), new_circuit.qubits)
circuit = new_circuit

# for 3q+ or custom synthesis method, synthesizes clifford circuit
hls_config = HLSConfig(clifford=[(synthesis_method, {"basis_gates": basis_gates})])
pm = PassManager([HighLevelSynthesis(hls_config=hls_config, coupling_map=coupling_map)])
circuit = pm.run(circuit)
Comment on lines +183 to +186

Choose a reason for hiding this comment

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

This is great! You are using all of the functionality offered by the high level synthesis interface, including passing additional options (like "basis_gates") for the plugin. Not related to the PR, but I would like to hear your feedback on this: do you think the interface could be simplified? extended?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for providing this useful HighLevelSynthesisPlugin interface. I like it and it's sufficient for this PR.
Let me share a minor point that could be simplified. I was a bit confused with the fact that HighLevelSynthesisPlugin.run takes over coupling_map option set up via HighLevelSynthesis.__init__, but it does not take over basis_gates option set up via HighLevelSynthesis.__init__, so I had to set it via hls_config here. Is this (run does not have basis_gates option) intentional?
It was not clear to me which options should be given through hls_config (HLSConfig) and which options should be given directly (like coupling_map) in the current API. Probably, more explanation on that point in the API reference doc would be helpful for developers like me.

As for an extension, it may be worth considering an introduction of HighLevelSynthesis.synthesize for the ease of custom Clifford-to-QuantumCircuit conversion as I mentioned above.

return circuit


@lru_cache(maxsize=None)
@lru_cache(maxsize=256)
def _clifford_1q_int_to_instruction(
num: Integral, basis_gates: Optional[Tuple[str]]
num: Integral,
basis_gates: Optional[Tuple[str]],
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
) -> Instruction:
return CliffordUtils.clifford_1_qubit_circuit(num, basis_gates).to_instruction()
return CliffordUtils.clifford_1_qubit_circuit(
num, basis_gates=basis_gates, synthesis_method=synthesis_method
).to_instruction()


@lru_cache(maxsize=11520)
def _clifford_2q_int_to_instruction(
num: Integral, basis_gates: Optional[Tuple[str]]
num: Integral,
basis_gates: Optional[Tuple[str]],
coupling_tuple: Optional[Tuple[Tuple[int, int]]],
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
) -> Instruction:
return CliffordUtils.clifford_2_qubit_circuit(num, basis_gates).to_instruction()
return CliffordUtils.clifford_2_qubit_circuit(
num,
basis_gates=basis_gates,
coupling_tuple=coupling_tuple,
synthesis_method=synthesis_method,
).to_instruction()


def _hash_cliff(cliff):
return cliff.tableau.tobytes(), cliff.tableau.shape


def _dehash_cliff(cliff_hash):
tableau = np.frombuffer(cliff_hash[0], dtype=bool).reshape(cliff_hash[1])
return Clifford(tableau)


def _clifford_to_instruction(
clifford: Clifford,
basis_gates: Optional[Tuple[str]],
coupling_tuple: Optional[Tuple[Tuple[int, int]]],
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
) -> Instruction:
return _cached_clifford_to_instruction(
_hash_cliff(clifford),
basis_gates=basis_gates,
coupling_tuple=coupling_tuple,
synthesis_method=synthesis_method,
)


@lru_cache(maxsize=256)
def _cached_clifford_to_instruction(
cliff_hash: Tuple[str, Tuple[int, int]],
basis_gates: Optional[Tuple[str]],
coupling_tuple: Optional[Tuple[Tuple[int, int]]],
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
) -> Instruction:
return _synthesize_clifford(
_dehash_cliff(cliff_hash),
basis_gates=basis_gates,
coupling_tuple=coupling_tuple,
synthesis_method=synthesis_method,
).to_instruction()


# The classes VGate and WGate are not actually used in the code - we leave them here to give
Expand Down Expand Up @@ -254,7 +362,12 @@ def random_clifford_circuits(

@classmethod
@lru_cache(maxsize=24)
def clifford_1_qubit_circuit(cls, num, basis_gates: Optional[Tuple[str, ...]] = None):
def clifford_1_qubit_circuit(
cls,
num,
basis_gates: Optional[Tuple[str, ...]] = None,
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
):
"""Return the 1-qubit clifford circuit corresponding to ``num``,
where ``num`` is between 0 and 23.
"""
Expand All @@ -275,20 +388,28 @@ def clifford_1_qubit_circuit(cls, num, basis_gates: Optional[Tuple[str, ...]] =
qc.z(0)

if basis_gates:
qc = _synthesize_clifford_circuit(qc, basis_gates)
qc = _synthesize_clifford_circuit(qc, basis_gates, synthesis_method=synthesis_method)

return qc

@classmethod
@lru_cache(maxsize=11520)
def clifford_2_qubit_circuit(cls, num, basis_gates: Optional[Tuple[str, ...]] = None):
def clifford_2_qubit_circuit(
cls,
num,
basis_gates: Optional[Tuple[str, ...]] = None,
coupling_tuple: Optional[Tuple[Tuple[int, int]]] = None,
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
):
"""Return the 2-qubit clifford circuit corresponding to `num`
where `num` is between 0 and 11519.
"""
qc = QuantumCircuit(2, name=f"Clifford-2Q({num})")
for layer, idx in enumerate(_layer_indices_from_num(num)):
if basis_gates:
layer_circ = _transformed_clifford_layer(layer, idx, basis_gates)
layer_circ = _transformed_clifford_layer(
layer, idx, basis_gates, coupling_tuple, synthesis_method=synthesis_method
)
else:
layer_circ = _CLIFFORD_LAYER[layer][idx]
_circuit_compose(qc, layer_circ, qubits=(0, 1))
Expand Down Expand Up @@ -578,13 +699,22 @@ def _clifford_2q_nums_from_2q_circuit(qc: QuantumCircuit) -> Iterable[Integral]:
]


@lru_cache(maxsize=None)
@lru_cache(maxsize=256)
def _transformed_clifford_layer(
layer: int, index: Integral, basis_gates: Tuple[str, ...]
layer: int,
index: Integral,
basis_gates: Tuple[str, ...],
coupling_tuple: Optional[Tuple[Tuple[int, int]]],
synthesis_method: str = DEFAULT_SYNTHESIS_METHOD,
) -> QuantumCircuit:
# Return the index-th quantum circuit of the layer translated with the basis_gates.
# The result is cached for speed.
return _synthesize_clifford_circuit(_CLIFFORD_LAYER[layer][index], basis_gates)
return _synthesize_clifford_circuit(
_CLIFFORD_LAYER[layer][index],
basis_gates=basis_gates,
coupling_tuple=coupling_tuple,
synthesis_method=synthesis_method,
)


def _num_from_layer_indices(triplet: Tuple[Integral, Integral, Integral]) -> Integral:
Expand Down
Loading