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}"