From f4e9d5042967877db901b1aaaaa6cf0e19dafd4d Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Fri, 7 Jul 2023 16:34:15 -0400 Subject: [PATCH] Add support for QuantumCircuit.layout to qpy (#10148) * Add support for QuantumCircuit.layout to qpy This commit adds the missing support for QuantumCircuit.layout to the qpy format. This necessitates bumping the QPY format version to 8 to accomodate the extra data needed for representing the details of the layout. The tricky piece with representing the 3 TranspileLayout attributes is representing the virtual bits in the initial layout because there is no guarantee that the input circuit's registers are in the output circuit (typically they are not when transpile() is used). Fixes #10112 * Fix handling of empty layout * Expand test coverage * Fix lint * Add qpy compat tests * Fix compat tests * Add release notes * Adjust layout creation to be register independent * Finish docs * Only check layout in compat tests with circuits * Fix typos * Fix doc typo in qiskit/qpy/__init__.py Co-authored-by: John Lapeyre * Adjust introduction version for layout qpy compat tests * Unify qpy compat test version filter style * Add new line to layout error message * Simplify serialization logic Co-authored-by: Jake Lishman * Doc fixes * Improve test coverage * Don't reuse bits between initial layout and circuit in qpy compat tests. * Update qiskit/qpy/__init__.py * Fix test typo * Use a register in compat tests for consistent equality * Update test/python/qpy/test_circuit_load_from_qpy.py --------- Co-authored-by: John Lapeyre Co-authored-by: Jake Lishman (cherry picked from commit dbf1230aba7eeb74e07b5b1882300e9679dddee0) --- qiskit/qpy/__init__.py | 57 ++++++++ qiskit/qpy/binary_io/circuits.py | 136 +++++++++++++++++- qiskit/qpy/common.py | 2 +- qiskit/qpy/formats.py | 11 ++ .../notes/qpy-layout-927ab34f2b47f4aa.yaml | 12 ++ test/python/qpy/test_circuit_load_from_qpy.py | 107 +++++++++++++- test/qpy_compat/test_qpy.py | 31 ++++ 7 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 releasenotes/notes/qpy-layout-927ab34f2b47f4aa.yaml diff --git a/qiskit/qpy/__init__.py b/qiskit/qpy/__init__.py index e56e22efd17..2ef008485a7 100644 --- a/qiskit/qpy/__init__.py +++ b/qiskit/qpy/__init__.py @@ -126,6 +126,63 @@ by ``num_circuits`` in the file header). There is no padding between the circuits in the data. +.. _qpy_version_8: + +Version 8 +========= + +Version 8 adds support for handling a :class:`~.TranspileLayout` stored in the +:attr:`.QuantumCircuit.layout` attribute. In version 8 immediately following the +calibrations block at the end of the circuit payload there is now the +``LAYOUT`` struct. This struct outlines the size of the three attributes of a +:class:`~.TranspileLayout` class. + +LAYOUT +------ + +.. code-block:: c + + struct { + char exists; + int32_t initial_layout_size; + int32_t input_mapping_size; + int32_t final_layout_size; + uint32_t extra_registers; + } + +If any of the signed values are ``-1`` this indicates the corresponding +attribute is ``None``. + +Immediately following the ``LAYOUT`` struct there is a :ref:`qpy_registers` struct +for ``extra_registers`` (specifically the format introduced in :ref:`qpy_version_4`) +standalone register definitions that aren't present in the circuit. Then there +are ``initial_layout_size`` ``INITIAL_LAYOUT_BIT`` structs to define the +:attr:`.TranspileLayout.initial_layout` attribute. + +INITIAL_LAYOUT_BIT +------------------ + +.. code-block:: c + + struct { + int32_t index; + int32_t register_size; + } + +Where a value of ``-1`` indicates ``None`` (as in no register is associated +with the bit). Following each ``INITIAL_LAYOUT_BIT`` struct is ``register_size`` +bytes for a ``utf8`` encoded string for the register name. + +Following the initial layout there is ``input_mapping_size`` array of +``uint32_t`` integers representing the positions of the phyiscal bit from the +initial layout. This enables constructing a list of virtual bits where the +array index is its input mapping position. + +Finally, there is an array of ``final_layout_size`` ``uint32_t`` integers. Each +element is an index in the circuit's ``qubits`` attribute which enables building +a mapping from qubit starting position to the output position at the end of the +circuit. + .. _qpy_version_7: Version 7 diff --git a/qiskit/qpy/binary_io/circuits.py b/qiskit/qpy/binary_io/circuits.py index 95adba8fdbd..ab2401b6e67 100644 --- a/qiskit/qpy/binary_io/circuits.py +++ b/qiskit/qpy/binary_io/circuits.py @@ -14,6 +14,7 @@ """Binary IO for circuit objects.""" +from collections import defaultdict import io import json import struct @@ -36,6 +37,7 @@ from qiskit.qpy.binary_io import value, schedules from qiskit.quantum_info.operators import SparsePauliOp from qiskit.synthesis import evolution as evo_synth +from qiskit.transpiler.layout import Layout, TranspileLayout def _read_header_v2(file_obj, version, vectors, metadata_deserializer=None): @@ -761,6 +763,136 @@ def _write_registers(file_obj, in_circ_regs, full_bits): return len(in_circ_regs) + len(out_circ_regs) +def _write_layout(file_obj, circuit): + if circuit.layout is None: + # Write a null header if there is no layout present + file_obj.write(struct.pack(formats.LAYOUT_PACK, False, -1, -1, -1, 0)) + return + initial_size = -1 + input_qubit_mapping = {} + initial_layout_array = [] + extra_registers = defaultdict(list) + if circuit.layout.initial_layout is not None: + initial_size = len(circuit.layout.initial_layout) + layout_mapping = circuit.layout.initial_layout.get_physical_bits() + for i in range(circuit.num_qubits): + qubit = layout_mapping[i] + input_qubit_mapping[qubit] = i + if qubit._register is not None or qubit._index is not None: + if qubit._register not in circuit.qregs: + extra_registers[qubit._register].append(qubit) + initial_layout_array.append((qubit._index, qubit._register)) + else: + initial_layout_array.append((None, None)) + input_qubit_size = -1 + input_qubit_mapping_array = [] + if circuit.layout.input_qubit_mapping is not None: + input_qubit_size = len(circuit.layout.input_qubit_mapping) + input_qubit_mapping_array = [None] * input_qubit_size + layout_mapping = circuit.layout.initial_layout.get_virtual_bits() + for qubit, index in circuit.layout.input_qubit_mapping.items(): + if ( + getattr(qubit, "_register", None) is not None + and getattr(qubit, "_index", None) is not None + ): + if qubit._register not in circuit.qregs: + extra_registers[qubit._register].append(qubit) + input_qubit_mapping_array[index] = layout_mapping[qubit] + else: + input_qubit_mapping_array[index] = layout_mapping[qubit] + final_layout_size = -1 + final_layout_array = [] + if circuit.layout.final_layout is not None: + final_layout_size = len(circuit.layout.final_layout) + final_layout_physical = circuit.layout.final_layout.get_physical_bits() + for i in range(circuit.num_qubits): + virtual_bit = final_layout_physical[i] + final_layout_array.append(circuit.find_bit(virtual_bit).index) + + file_obj.write( + struct.pack( + formats.LAYOUT_PACK, + True, + initial_size, + input_qubit_size, + final_layout_size, + len(extra_registers), + ) + ) + _write_registers( + file_obj, list(extra_registers), [x for bits in extra_registers.values() for x in bits] + ) + for index, register in initial_layout_array: + reg_name_bytes = None if register is None else register.name.encode(common.ENCODE) + file_obj.write( + struct.pack( + formats.INITIAL_LAYOUT_BIT_PACK, + -1 if index is None else index, + -1 if reg_name_bytes is None else len(reg_name_bytes), + ) + ) + if reg_name_bytes is not None: + file_obj.write(reg_name_bytes) + for i in input_qubit_mapping_array: + file_obj.write(struct.pack("!I", i)) + for i in final_layout_array: + file_obj.write(struct.pack("!I", i)) + + +def _read_layout(file_obj, circuit): + header = formats.LAYOUT._make( + struct.unpack(formats.LAYOUT_PACK, file_obj.read(formats.LAYOUT_SIZE)) + ) + if not header.exists: + return + registers = { + name: QuantumRegister(len(v[1]), name) + for name, v in _read_registers_v4(file_obj, header.extra_registers)["q"].items() + } + initial_layout = None + initial_layout_virtual_bits = [] + for _ in range(header.initial_layout_size): + virtual_bit = formats.INITIAL_LAYOUT_BIT._make( + struct.unpack( + formats.INITIAL_LAYOUT_BIT_PACK, + file_obj.read(formats.INITIAL_LAYOUT_BIT_SIZE), + ) + ) + if virtual_bit.index == -1 and virtual_bit.register_size == -1: + qubit = Qubit() + else: + register_name = file_obj.read(virtual_bit.register_size).decode(common.ENCODE) + if register_name in registers: + qubit = registers[register_name][virtual_bit.index] + else: + register = next(filter(lambda x, name=register_name: x.name == name, circuit.qregs)) + qubit = register[virtual_bit.index] + initial_layout_virtual_bits.append(qubit) + if initial_layout_virtual_bits: + initial_layout = Layout.from_qubit_list(initial_layout_virtual_bits) + input_qubit_mapping = None + input_qubit_mapping_array = [] + for _ in range(header.input_mapping_size): + input_qubit_mapping_array.append( + struct.unpack("!I", file_obj.read(struct.calcsize("!I")))[0] + ) + if input_qubit_mapping_array: + input_qubit_mapping = {} + physical_bits = initial_layout.get_physical_bits() + for index, bit in enumerate(input_qubit_mapping_array): + input_qubit_mapping[physical_bits[bit]] = index + final_layout = None + final_layout_array = [] + for _ in range(header.final_layout_size): + final_layout_array.append(struct.unpack("!I", file_obj.read(struct.calcsize("!I")))[0]) + + if final_layout_array: + layout_dict = {circuit.qubits[bit]: index for index, bit in enumerate(final_layout_array)} + final_layout = Layout(layout_dict) + + circuit._layout = TranspileLayout(initial_layout, input_qubit_mapping, final_layout) + + def write_circuit(file_obj, circuit, metadata_serializer=None): """Write a single QuantumCircuit object in the file like object. @@ -830,6 +962,7 @@ def write_circuit(file_obj, circuit, metadata_serializer=None): # Write calibrations _write_calibrations(file_obj, circuit.calibrations, metadata_serializer) + _write_layout(file_obj, circuit) def read_circuit(file_obj, version, metadata_deserializer=None): @@ -947,5 +1080,6 @@ def read_circuit(file_obj, version, metadata_deserializer=None): f"as they weren't used in the circuit: {circ.name}", UserWarning, ) - + if version >= 8: + _read_layout(file_obj, circ) return circ diff --git a/qiskit/qpy/common.py b/qiskit/qpy/common.py index 174b299f7b8..9c8376b4bf8 100644 --- a/qiskit/qpy/common.py +++ b/qiskit/qpy/common.py @@ -20,7 +20,7 @@ from qiskit.qpy import formats -QPY_VERSION = 7 +QPY_VERSION = 8 ENCODE = "utf8" diff --git a/qiskit/qpy/formats.py b/qiskit/qpy/formats.py index 31cc32a9c40..e442adfafbb 100644 --- a/qiskit/qpy/formats.py +++ b/qiskit/qpy/formats.py @@ -261,3 +261,14 @@ MAP_ITEM = namedtuple("MAP_ITEM", ["key_size", "type", "size"]) MAP_ITEM_PACK = "!H1cH" MAP_ITEM_SIZE = struct.calcsize(MAP_ITEM_PACK) + +LAYOUT = namedtuple( + "LAYOUT", + ["exists", "initial_layout_size", "input_mapping_size", "final_layout_size", "extra_registers"], +) +LAYOUT_PACK = "!?iiiI" +LAYOUT_SIZE = struct.calcsize(LAYOUT_PACK) + +INITIAL_LAYOUT_BIT = namedtuple("INITIAL_LAYOUT_BIT", ["index", "register_size"]) +INITIAL_LAYOUT_BIT_PACK = "!ii" +INITIAL_LAYOUT_BIT_SIZE = struct.calcsize(INITIAL_LAYOUT_BIT_PACK) diff --git a/releasenotes/notes/qpy-layout-927ab34f2b47f4aa.yaml b/releasenotes/notes/qpy-layout-927ab34f2b47f4aa.yaml new file mode 100644 index 00000000000..35ce0ce4ca5 --- /dev/null +++ b/releasenotes/notes/qpy-layout-927ab34f2b47f4aa.yaml @@ -0,0 +1,12 @@ +--- +upgrade: + - | + The QPY format version emitted by :class:`~.qpy.dump` has increased to 8. + This new format version adds support for serializing the + :attr:`.QuantumCircuit.layout` attribute. +fixes: + - | + Fixed the :mod:`~qiskit.qpy` serialization of :attr:`.QuantumCircuit.layout` + attribue. Previously, the :attr:`~.QuantumCircuit.layout` attribute would + have been dropped when serializing a circuit to QPY. + Fixed `#10112 `__ diff --git a/test/python/qpy/test_circuit_load_from_qpy.py b/test/python/qpy/test_circuit_load_from_qpy.py index 4174e59c792..3684e8aacfe 100644 --- a/test/python/qpy/test_circuit_load_from_qpy.py +++ b/test/python/qpy/test_circuit_load_from_qpy.py @@ -16,12 +16,13 @@ from ddt import ddt, data -from qiskit.circuit import QuantumCircuit -from qiskit.providers.fake_provider import FakeHanoi +from qiskit.circuit import QuantumCircuit, QuantumRegister, Qubit +from qiskit.providers.fake_provider import FakeHanoi, FakeSherbrooke from qiskit.qpy import dump, load from qiskit.test import QiskitTestCase -from qiskit.transpiler import PassManager +from qiskit.transpiler import PassManager, TranspileLayout from qiskit.transpiler import passes +from qiskit.compiler import transpile class QpyCircuitTestCase(QiskitTestCase): @@ -35,6 +36,7 @@ def assert_roundtrip_equal(self, circuit): new_circuit = load(qpy_file)[0] self.assertEqual(circuit, new_circuit) + self.assertEqual(circuit.layout, new_circuit.layout) @ddt @@ -67,3 +69,102 @@ def test_rzx_calibration_echo(self, angle): rzx_qc = pass_manager.run(test_qc) self.assert_roundtrip_equal(rzx_qc) + + +@ddt +class TestLayout(QpyCircuitTestCase): + """Test circuit serialization for layout preservation.""" + + @data(0, 1, 2, 3) + def test_transpile_layout(self, opt_level): + """Test layout preserved after transpile.""" + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + qc.measure_all() + backend = FakeSherbrooke() + tqc = transpile(qc, backend, optimization_level=opt_level) + self.assert_roundtrip_equal(tqc) + + @data(0, 1, 2, 3) + def test_transpile_with_routing(self, opt_level): + """Test full layout with routing is preserved.""" + qc = QuantumCircuit(5) + qc.h(0) + qc.cx(0, 1) + qc.cx(0, 2) + qc.cx(0, 3) + qc.cx(0, 4) + qc.measure_all() + backend = FakeSherbrooke() + tqc = transpile(qc, backend, optimization_level=opt_level) + self.assert_roundtrip_equal(tqc) + + @data(0, 1, 2, 3) + def test_transpile_layout_explicit_None_final_layout(self, opt_level): + """Test layout preserved after transpile.""" + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + qc.measure_all() + backend = FakeSherbrooke() + tqc = transpile(qc, backend, optimization_level=opt_level) + tqc.layout.final_layout = None + self.assert_roundtrip_equal(tqc) + + def test_empty_layout(self): + """Test an empty layout is preserved correctly.""" + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + qc.measure_all() + qc._layout = TranspileLayout(None, None, None) + self.assert_roundtrip_equal(qc) + + @data(0, 1, 2, 3) + def test_custom_register_name(self, opt_level): + """Test layout preserved with custom register names.""" + qr = QuantumRegister(5, name="abc123") + qc = QuantumCircuit(qr) + qc.h(0) + qc.cx(0, 1) + qc.cx(0, 2) + qc.cx(0, 3) + qc.cx(0, 4) + qc.measure_all() + backend = FakeSherbrooke() + tqc = transpile(qc, backend, optimization_level=opt_level) + self.assert_roundtrip_equal(tqc) + + @data(0, 1, 2, 3) + def test_no_register(self, opt_level): + """Test layout preserved with no register.""" + qubits = [Qubit(), Qubit()] + qc = QuantumCircuit(qubits) + qc.h(0) + qc.cx(0, 1) + qc.measure_all() + backend = FakeSherbrooke() + tqc = transpile(qc, backend, optimization_level=opt_level) + # Manually validate to deal with qubit equality needing exact objects + qpy_file = io.BytesIO() + dump(tqc, qpy_file) + qpy_file.seek(0) + new_circuit = load(qpy_file)[0] + self.assertEqual(tqc, new_circuit) + initial_layout_old = tqc.layout.initial_layout.get_physical_bits() + initial_layout_new = new_circuit.layout.initial_layout.get_physical_bits() + for i in initial_layout_old: + self.assertIsInstance(initial_layout_old[i], Qubit) + self.assertIsInstance(initial_layout_new[i], Qubit) + if initial_layout_old[i]._register is not None: + self.assertEqual(initial_layout_new[i], initial_layout_old[i]) + else: + self.assertIsNone(initial_layout_new[i]._register) + self.assertIsNone(initial_layout_old[i]._index) + self.assertIsNone(initial_layout_new[i]._index) + self.assertEqual( + list(tqc.layout.input_qubit_mapping.values()), + list(new_circuit.layout.input_qubit_mapping.values()), + ) + self.assertEqual(tqc.layout.final_layout, new_circuit.layout.final_layout) diff --git a/test/qpy_compat/test_qpy.py b/test/qpy_compat/test_qpy.py index 54b1bce7e5e..cc74a3be0b6 100755 --- a/test/qpy_compat/test_qpy.py +++ b/test/qpy_compat/test_qpy.py @@ -574,6 +574,26 @@ def generate_open_controlled_gates(): return circuits +def generate_layout_circuits(): + """Test qpy circuits with layout set.""" + + from qiskit.transpiler.layout import TranspileLayout, Layout + + qr = QuantumRegister(3, "foo") + qc = QuantumCircuit(qr, name="GHZ with layout") + qc.h(0) + qc.cx(0, 1) + qc.swap(0, 1) + qc.cx(0, 2) + input_layout = {qr[index]: index for index in range(len(qc.qubits))} + qc._layout = TranspileLayout( + Layout(input_layout), + input_qubit_mapping=input_layout, + final_layout=Layout.from_qubit_list([qc.qubits[1], qc.qubits[0], qc.qubits[2]]), + ) + return [qc] + + def generate_circuits(version_parts): """Generate reference circuits.""" output_circuits = { @@ -611,6 +631,8 @@ def generate_circuits(version_parts): if version_parts >= (0, 24, 1): output_circuits["open_controlled_gates.qpy"] = generate_open_controlled_gates() output_circuits["controlled_gates.qpy"] = generate_controlled_gates() + if version_parts > (0, 24, 2): + output_circuits["layout.qpy"] = generate_layout_circuits() return output_circuits @@ -651,6 +673,15 @@ def assert_equal(reference, qpy, count, version_parts, bind=None): sys.stderr.write(msg) sys.exit(1) + if ( + version_parts >= (0, 24, 2) + and isinstance(reference, QuantumCircuit) + and reference.layout != qpy.layout + ): + msg = f"Circuit {count} layout mismatch {reference.layout} != {qpy.layout}\n" + sys.stderr.write(msg) + sys.exit(4) + # Don't compare name on bound circuits if bind is None and reference.name != qpy.name: msg = f"Circuit {count} name mismatch {reference.name} != {qpy.name}\n{reference}\n{qpy}"