Repository: Qiskit/qiskit

Issue:
QPY serialization fails for circuits with IfElse instructions
### Environment

- **Qiskit Terra version**: 0.20.0.dev0+01913f4
- **Python version**:  3.7
- **Operating system**: Mac OSX 11.6.1


### What is happening?

QPY serialization fails when attempting to serialize a circuit containing an `IfElseOp`.

### How can we reproduce the issue?

```
import qiskit as qk

qc = qk.QuantumCircuit(2, 2)
qc.h(0)
qc.measure(0, 0)

with qc.if_test((qc.clbits[0], True)):
    qc.x(1)
    
qc.measure(1,1)

from qiskit.circuit import qpy_serialization as qpy
import tempfile

with tempfile.TemporaryFile() as fp:
    qpy.dump(qc, fp)
    out = qpy.load(fp)
```

```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/var/folders/97/vkqk0hs17r5dnlc8dv5nmfxh0000gn/T/ipykernel_34451/736355381.py in <module>
      3 
      4 with tempfile.TemporaryFile() as fp:
----> 5     qpy.dump(qc, fp)
      6     out = qpy.load(fp)

~/q/qiskit-terra/qiskit/circuit/qpy_serialization.py in dump(circuits, file_obj)
   1423     file_obj.write(header)
   1424     for circuit in circuits:
-> 1425         _write_circuit(file_obj, circuit)
   1426 
   1427 

~/q/qiskit-terra/qiskit/circuit/qpy_serialization.py in _write_circuit(file_obj, circuit)
   1493     index_map["c"] = clbit_indices
   1494     for instruction in circuit.data:
-> 1495         _write_instruction(instruction_buffer, instruction, custom_instructions, index_map)
   1496     file_obj.write(struct.pack(CUSTOM_DEFINITION_HEADER_PACK, len(custom_instructions)))
   1497 

~/q/qiskit-terra/qiskit/circuit/qpy_serialization.py in _write_instruction(file_obj, instruction_tuple, custom_instructions, index_map)
   1223         else:
   1224             raise TypeError(
-> 1225                 f"Invalid parameter type {instruction_tuple[0]} for gate {type(param)},"
   1226             )
   1227         instruction_param_raw = struct.pack(INSTRUCTION_PARAM_PACK, type_key.encode("utf8"), size)

TypeError: Invalid parameter type Instruction(name='if_else', num_qubits=1, num_clbits=1, params=[<qiskit.circuit.quantumcircuit.QuantumCircuit object at 0x13c510750>, None]) for gate <class 'qiskit.circuit.quantumcircuit.QuantumCircuit'>,
```

### What should happen?

The circuit should be able to be serialized and de-serialized.

### Any suggestions?

_No response_


Discussion:
So there are 2 things we need to fix here. The first is to add `qiskit.circuit.controlflow` to the module lookup path for terra objects here: https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/qpy_serialization.py#L1122-L1125 and https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/qpy_serialization.py#L976-L983 so that qpy know about the control flow instructions. Otherwise it will try to treat the control flow instruction objects as custom instructions and make them instances of `Instruction` instead of the actual control flow object. The other piece of this is to allow circuits as a valid parameter type for instructions, here: https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/qpy_serialization.py#L1183-L1222 and https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/qpy_serialization.py#L934-L956 and we can just use https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/qpy_serialization.py#L1428 with a `BytesIO` buffer for the payload.

The only potential (mostly because I'm not as familiar with the control flow instructions) complication I can think of is if `IfElseOp(**params)` doesn't work we'll need to add a custom handling for it here: https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/qpy_serialization.py#L961-L991 in the deserializer. But, if we need more information from the object than what is serialized by default we'll probably need to define a custom serialization format for it (similar to what we did for the `PauliEvolutionGate` in #7374) so that we have all the necessary information in the qpy data to reconstruct the operation on the deserialization side.
`IfElseOp(**params)` will fail, because the first positional argument is the `condition`, which is stored separately on `Instruction`, not in `params`.  The same goes for `WhileLoopOp`, but `ForLoopOp` is in that form.

I guess we need to add an arbitrary serialisation hook here.  I think we want to avoid any possible arbitrary _de_serialisation hooks, so we could ask for a method that will return a 2-tuple `(args, kwargs)` that can be passed to the initialiser to reconstruct the state of the object, and then recursively serialise those?
Well we've had to do deserialization conditions before for instruction types which behave differently for weird reasons. `Barrier` and `Initialize` are the only two currently. We also serialize the condition attribute currently. So assuming the condition attribute is the same tuple of `(register/bit, value)` that other instructions have we wouldn't need to change anything on the serialization side and we can just add conditions like we do for barrier and initialize today. I think this might be preferable just because it's a smaller change for a backport and doesn't require defining a new type for the custom instruction table.

I think for the ever growing qpy v4 todo list (which already includes a global parameter table and pulse support) in 0.20.0 allowing an instruction class to define it's own `(args, kwargs)` for the constructor with something like a `__qpy_args__()` method for serialization is a good thing to do.
Having the condition is fine, the problem is that the constructor of `IfElseOp` and `WhileLoopOp` take `condition` as their first positional argument, and don't put `condition` in the `params` attribute.  The condition is the same tuple, but you do something like
```python
my_if = IfElseOp((creg, 1), true_body, false_body)
assert len(my_if.params) == 2
assert my_if.params[0] is true_body
assert my_if.params[1] is false_body
```
We can do it, but it'll need special handling for `IfElseOp` and `WhileLoopOp` when reconstructing the instances, to pass the condition positionally first (they error if there's no condition).

Also, can qpy currently serialise a parameter of `None`?  `IfElseOp` can have `None` as its second parameter if there isn't a false body.
Right, I just meant we have a place already for this kind of thing where we have to do that kind of custom deserialization construction. Since we had to do it for `Barrier` (which does `Barrier(num_qubits)` which isn't a parameter) and `Initialize` (which does `Initialize(params)` instead of `Initialize(*params)`. So for 0.19.4 I think if we do something like:
```
if gate_name in {"IfElseOp", "WhileLoopOp"}:
    gate_class(condition, *params)
```

around here: https://github.com/Qiskit/qiskit-terra/blob/main/qiskit/circuit/qpy_serialization.py#L986-L992 that fits in with an existing pattern and won't be too intrusive and seems like the simple path for a backport fix.

As for None, that's a good call we don't support that yet either. That's trivial to add as a supported param type, we can add that at the same time we do `QuantumCircuit` 

Resolving patch:
diff --git a/qiskit/circuit/qpy_serialization.py b/qiskit/circuit/qpy_serialization.py
--- a/qiskit/circuit/qpy_serialization.py
+++ b/qiskit/circuit/qpy_serialization.py
@@ -100,14 +100,80 @@
 by ``num_circuits`` in the file header). There is no padding between the
 circuits in the data.
 
-.. _version_3:
+.. _qpy_version_4:
+
+Version 4
+=========
+
+Version 4 is identical to :ref:`qpy_version_3` except that it adds 2 new type strings
+to the INSTRUCTION_PARAM struct, ``z`` to represent ``None`` (which is encoded as
+no data), ``q`` to represent a :class:`.QuantumCircuit` (which is encoded as
+a QPY circuit), ``r`` to represent a ``range`` of integers (which is encoded as
+a :ref:`qpy_range_pack`), and ``t`` to represent a ``tuple`` (which is encoded as
+defined by :ref:`qpy_tuple`). Additionally, version 4 changes the type of register
+index mapping array from ``uint32_t`` to ``int64_t``. If the values of any of the
+array elements are negative they represent a register bit that is not present in the
+circuit.
+
+The :ref:`qpy_registers` header format has also been updated to
+
+.. code-block:: c
+
+    struct {
+        char type;
+        _Bool standalone;
+        uint32_t size;
+        uint16_t name_size;
+        _bool in_circuit;
+    }
+
+which just adds the ``in_circuit`` field which represents whether the register is
+part of the circuit or not.
+
+.. _qpy_range_pack:
+
+RANGE
+-----
+
+A RANGE is a representation of a ``range`` object. It is defined as:
+
+.. code-block:: c
+
+    struct {
+        int64_t start;
+        int64_t stop;
+        int64_t step;
+    }
+
+.. _qpy_tuple:
+
+TUPLE
+-----
+
+A TUPLE is a reprentation of a ``tuple`` object. As tuples are just fixed length
+containers of arbitrary python objects their QPY can't fully represent any tuple,
+but as long as the contents in a tuple are other QPY serializable types for
+the INSTRUCTION_PARAM payload the ``tuple`` object can be serialized.
+
+A tuple instruction parameter starts with a header defined as:
+
+.. code-block:: c
+
+    struct {
+        uint64_t size;
+    }
+
+followed by ``size`` elements that are INSTRUCTION_PARAM payloads, where each of
+these define an element in the tuple.
+
+.. _qpy_version_3:
 
 Version 3
 =========
 
-Version 3 of the QPY format is identical to :ref:`version_2` except that it defines
+Version 3 of the QPY format is identical to :ref:`qpy_version_2` except that it defines
 a struct format to represent a :class:`~qiskit.circuit.library.PauliEvolutionGate`
-natively in QPY. To accomplish this the :ref:`custom_definition` struct now supports
+natively in QPY. To accomplish this the :ref:`qpy_custom_definition` struct now supports
 a new type value ``'p'`` to represent a :class:`~qiskit.circuit.library.PauliEvolutionGate`.
 Enties in the custom instructions tables have unique name generated that start with the
 string ``"###PauliEvolutionGate_"`` followed by a uuid string. This gate name is reservered
@@ -132,18 +198,18 @@
         uint64_t synthesis_size;
     }
 
-This is immediately followed by ``operator_count`` elements defined by the :ref:`pauli_sum_op`
+This is immediately followed by ``operator_count`` elements defined by the :ref:`qpy_pauli_sum_op`
 payload.  Following that we have ``time_size`` bytes representing the ``time`` attribute. If
 ``standalone_op`` is ``True`` then there must only be a single operator. The
 encoding of these bytes is determined by the value of ``time_type``. Possible values of
 ``time_type`` are ``'f'``, ``'p'``, and ``'e'``. If ``time_type`` is ``'f'`` it's a double,
 ``'p'`` defines a :class:`~qiskit.circuit.Parameter` object  which is represented by a
-:ref:`param_struct`, ``e`` defines a :class:`~qiskit.circuit.ParameterExpression` object
-(that's not a :class:`~qiskit.circuit.Parameter`) which is represented by a :ref:`param_expr`.
+:ref:`qpy_param_struct`, ``e`` defines a :class:`~qiskit.circuit.ParameterExpression` object
+(that's not a :class:`~qiskit.circuit.Parameter`) which is represented by a :ref:`qpy_param_expr`.
 Following that is ``synthesis_size`` bytes which is a utf8 encoded json payload representing
 the :class:`.EvolutionSynthesis` class used by the gate.
 
-.. _pauli_sum_op:
+.. _qpy_pauli_sum_op:
 
 SPARSE_PAULI_OP_LIST_ELEM
 -------------------------
@@ -165,9 +231,9 @@
 a :class:`~qiskit.circuit.Parameter`. This adds a new parameter type char ``'v'``
 to represent a :class:`~qiskit.circuit.ParameterVectorElement` which is now
 supported as a type string value for an INSTRUCTION_PARAM. The payload for these
-parameters are defined below as :ref:`param_vector`.
+parameters are defined below as :ref:`qpy_param_vector`.
 
-.. _param_vector:
+.. _qpy_param_vector:
 
 
 PARAMETER_VECTOR_ELEMENT
@@ -189,7 +255,7 @@
 which is immediately followed by ``vector_name_size`` utf8 bytes representing
 the parameter's vector name.
 
-.. _param_expr_v3:
+.. _qpy_param_expr_v3:
 
 
 PARAMETER_EXPR
@@ -199,7 +265,7 @@
 :class:`~qiskit.circuit.Parameter` and :class:`~qiskit.circuit.ParameterVectorElement`
 the payload for a :class:`~qiskit.circuit.ParameterExpression` needs to be updated
 to distinguish between the types. The following is the modified payload format
-which is mostly identical to the format in Version 1 and :ref:`version_2` but just
+which is mostly identical to the format in Version 1 and :ref:`qpy_version_2` but just
 modifies the ``map_elements`` struct to include a symbol type field.
 
 A PARAMETER_EXPR represents a :class:`~qiskit.circuit.ParameterExpression`
@@ -230,9 +296,9 @@
 for the element. If it's ``p`` it represents a :class:`~qiskit.circuit.Parameter`
 and if it's ``v`` it represents a :class:`~qiskit.circuit.ParameterVectorElement`.
 The map element struct is immediately followed by the symbol map key payload, if
-``symbol_type`` is ``p`` then it is followed immediately by a :ref:`param_struct`
+``symbol_type`` is ``p`` then it is followed immediately by a :ref:`qpy_param_struct`
 object (both the struct and utf8 name bytes) and if ``symbol_type`` is ``v``
-then the struct is imediately followed by :ref:`param_vector` (both the struct
+then the struct is imediately followed by :ref:`qpy_param_vector` (both the struct
 and utf8 name bytes). That is followed by ``size`` bytes for the
 data of the symbol. The data format is dependent on the value of ``type``. If
 ``type`` is ``p`` then it represents a :class:`~qiskit.circuit.Parameter` and
@@ -241,16 +307,16 @@
 and size will be 0 as the value will just be the same as the key. If
 ``type`` is ``f`` then it represents a double precision float. If ``type`` is
 ``c`` it represents a double precision complex, which is represented by the
-:ref:`complex`. Finally, if type is ``i`` it represents an integer which is an
+:ref:`qpy_complex`. Finally, if type is ``i`` it represents an integer which is an
 ``int64_t``.
 
-.. _version_2:
+.. _qpy_version_2:
 
 Version 2
 =========
 
 Version 2 of the QPY format is identical to version 1 except for the HEADER
-section is slightly different. You can refer to the :ref:`version_1` section
+section is slightly different. You can refer to the :ref:`qpy_version_1` section
 for the details on the rest of the payload format.
 
 HEADER
@@ -282,7 +348,7 @@
 :class:`~qiskit.circuit.Parameter`) which is represented by a PARAM_EXPR struct
 (see below).
 
-.. _version_1:
+.. _qpy_version_1:
 
 Version 1
 =========
@@ -313,9 +379,11 @@
 
 The METADATA field is a UTF8 encoded JSON string. After reading the HEADER
 (which is a fixed size at the start of the QPY file) and the ``name`` string
-you then read the`metadata_size`` number of bytes and parse the JSON to get
+you then read the ``metadata_size`` number of bytes and parse the JSON to get
 the metadata for the circuit.
 
+.. _qpy_registers:
+
 REGISTERS
 ---------
 
@@ -336,10 +404,16 @@
 
 Immediately following the REGISTER struct is the utf8 encoded register name of
 size ``name_size``. After the ``name`` utf8 bytes there is then an array of
-uint32_t values of size ``size`` that contains a map of the register's index to
+int64_t values of size ``size`` that contains a map of the register's index to
 the circuit's qubit index. For example, array element 0's value is the index
 of the ``register[0]``'s position in the containing circuit's qubits list.
 
+.. note::
+
+    Prior to QPY :ref:`qpy_version_4` the type of array elements was uint32_t. This was changed
+    to enable negative values which represent bits in the array not present in the
+    circuit
+
 The standalone boolean determines whether the register is constructed as a
 standalone register that was added to the circuit or was created from existing
 bits. A register is considered standalone if it has bits constructed solely
@@ -357,7 +431,7 @@
 ``qr`` would have ``standalone`` set to ``False``.
 
 
-.. _custom_definition:
+.. _qpy_custom_definition:
 
 CUSTOM_DEFINITIONS
 ------------------
@@ -457,19 +531,19 @@ class if it's defined in Qiskit. Otherwise it falls back to the custom
 The ``type`` field can be ``'i'``, ``'f'``, ``'p'``, ``'e'``, ``'s'``, ``'c'``
 or ``'n'`` which dictate the format. For ``'i'`` it's an integer, ``'f'`` it's
 a double, ``'s'`` if it's a string (encoded as utf8), ``'c'`` is a complex and
-the data is represented by the struct format in the :ref:`param_expr` section.
+the data is represented by the struct format in the :ref:`qpy_param_expr` section.
 ``'p'`` defines a :class:`~qiskit.circuit.Parameter` object  which is
-represented by a :ref:`param_struct` struct, ``e`` defines a
+represented by a :ref:`qpy_param_struct` struct, ``e`` defines a
 :class:`~qiskit.circuit.ParameterExpression` object (that's not a
-:class:`~qiskit.circuit.Parameter`) which is represented by a :ref:`param_expr`
-struct (on QPY format :ref:`version_3` the format is tweak slightly see:
-:ref:`param_expr_v3`), ``'n'`` represents an object from numpy (either an
+:class:`~qiskit.circuit.Parameter`) which is represented by a :ref:`qpy_param_expr`
+struct (on QPY format :ref:`qpy_version_3` the format is tweak slightly see:
+:ref:`qpy_param_expr_v3`), ``'n'`` represents an object from numpy (either an
 ``ndarray`` or a numpy type) which means the data is .npy format [#f2]_ data,
-and in QPY :ref:`version_3` ``'v'`` represents a
+and in QPY :ref:`qpy_version_3` ``'v'`` represents a
 :class:`~qiskit.circuit.ParameterVectorElement` which is represented by a
-:ref:`param_vector` struct.
+:ref:`qpy_param_vector` struct.
 
-.. _param_struct:
+.. _qpy_param_struct:
 
 PARAMETER
 ---------
@@ -487,7 +561,7 @@ class if it's defined in Qiskit. Otherwise it falls back to the custom
 which is immediately followed by ``name_size`` utf8 bytes representing the
 parameter name.
 
-.. _param_expr:
+.. _qpy_param_expr:
 
 PARAMETER_EXPR
 --------------
@@ -523,10 +597,10 @@ class if it's defined in Qiskit. Otherwise it falls back to the custom
 ``type`` is ``p`` then it represents a :class:`~qiskit.circuit.Parameter` and
 size will be 0, the value will just be the same as the key. If
 ``type`` is ``f`` then it represents a double precision float. If ``type`` is
-``c`` it represents a double precision complex, which is represented by :ref:`complex`.
+``c`` it represents a double precision complex, which is represented by :ref:`qpy_complex`.
 Finally, if type is ``i`` it represents an integer which is an ``int64_t``.
 
-.. _complex:
+.. _qpy_complex:
 
 COMPLEX
 -------
@@ -569,6 +643,7 @@ class if it's defined in Qiskit. Otherwise it falls back to the custom
 from qiskit.circuit import library
 from qiskit import circuit as circuit_mod
 from qiskit import extensions
+from qiskit.circuit import controlflow
 from qiskit.extensions import quantum_initializer
 from qiskit.version import __version__
 from qiskit.exceptions import QiskitError
@@ -641,10 +716,14 @@ class if it's defined in Qiskit. Otherwise it falls back to the custom
 
 
 # REGISTER binary format
+REGISTER_V4 = namedtuple("REGISTER", ["type", "standalone", "size", "name_size", "in_circuit"])
+REGISTER_V4_PACK = "!1c?IH?"
+REGISTER_V4_SIZE = struct.calcsize(REGISTER_V4_PACK)
 REGISTER = namedtuple("REGISTER", ["type", "standalone", "size", "name_size"])
 REGISTER_PACK = "!1c?IH"
 REGISTER_SIZE = struct.calcsize(REGISTER_PACK)
 
+
 # INSTRUCTION binary format
 INSTRUCTION = namedtuple(
     "INSTRUCTION",
@@ -706,6 +785,14 @@ class if it's defined in Qiskit. Otherwise it falls back to the custom
 SPARSE_PAULI_OP_LIST_ELEM = namedtuple("SPARSE_PAULI_OP_LIST_ELEMENT", ["size"])
 SPARSE_PAULI_OP_LIST_ELEM_PACK = "!Q"
 SPARSE_PAULI_OP_LIST_ELEM_SIZE = struct.calcsize(SPARSE_PAULI_OP_LIST_ELEM_PACK)
+# Range
+RANGE = namedtuple("RANGE", ["start", "stop", "step"])
+RANGE_PACK = "!qqq"
+RANGE_PACK_SIZE = struct.calcsize(RANGE_PACK)
+# Tuple
+TUPLE = namedtuple("TUPLE", ["num_elements"])
+TUPLE_PACK = "!Q"
+TUPLE_PACK_SIZE = struct.calcsize(TUPLE_PACK)
 
 
 def _read_header_v2(file_obj, version, vectors):
@@ -771,9 +858,27 @@ def _read_registers(file_obj, num_registers):
         bit_indices_raw = file_obj.read(struct.calcsize(REGISTER_ARRAY_PACK))
         bit_indices = list(struct.unpack(REGISTER_ARRAY_PACK, bit_indices_raw))
         if register[0].decode("utf8") == "q":
-            registers["q"][name] = (standalone, bit_indices)
+            registers["q"][name] = (standalone, bit_indices, True)
+        else:
+            registers["c"][name] = (standalone, bit_indices, True)
+    return registers
+
+
+def _read_registers_v4(file_obj, num_registers):
+    registers = {"q": {}, "c": {}}
+    for _reg in range(num_registers):
+        register_raw = file_obj.read(REGISTER_V4_SIZE)
+        register = struct.unpack(REGISTER_V4_PACK, register_raw)
+        name = file_obj.read(register[3]).decode("utf8")
+        standalone = register[1]
+        REGISTER_ARRAY_PACK = "!%sq" % register[2]
+        in_circuit = register[4]
+        bit_indices_raw = file_obj.read(struct.calcsize(REGISTER_ARRAY_PACK))
+        bit_indices = list(struct.unpack(REGISTER_ARRAY_PACK, bit_indices_raw))
+        if register[0].decode("utf8") == "q":
+            registers["q"][name] = (standalone, bit_indices, in_circuit)
         else:
-            registers["c"][name] = (standalone, bit_indices)
+            registers["c"][name] = (standalone, bit_indices, in_circuit)
     return registers
 
 
@@ -875,6 +980,53 @@ def _read_parameter_expression(file_obj):
     return ParameterExpression(symbol_map, expr)
 
 
+def _read_instruction_param(file_obj, version, vectors):
+    param_raw = file_obj.read(INSTRUCTION_PARAM_SIZE)
+    param = struct.unpack(INSTRUCTION_PARAM_PACK, param_raw)
+    data = file_obj.read(param[1])
+    type_str = param[0].decode("utf8")
+    param = None
+    if type_str == "i":
+        param = struct.unpack("<q", data)[0]
+    elif type_str == "f":
+        param = struct.unpack("<d", data)[0]
+    elif type_str == "c":
+        param = complex(*struct.unpack(COMPLEX_PACK, data))
+    elif type_str == "n":
+        container = io.BytesIO(data)
+        param = np.load(container)
+    elif type_str == "s":
+        param = data.decode("utf8")
+    elif type_str == "p":
+        container = io.BytesIO(data)
+        param = _read_parameter(container)
+    elif type_str == "e":
+        container = io.BytesIO(data)
+        if version < 3:
+            param = _read_parameter_expression(container)
+        else:
+            param = _read_parameter_expression_v3(container, vectors)
+    elif type_str == "v":
+        container = io.BytesIO(data)
+        param = _read_parameter_vec(container, vectors)
+    elif type_str == "z":
+        param = None
+    elif type_str == "q":
+        with io.BytesIO(data) as container:
+            param = _read_circuit(container, version)
+    elif type_str == "r":
+        param = range(*struct.unpack(RANGE_PACK, data))
+    elif type_str == "t":
+        with io.BytesIO(data) as container:
+            num_elements = struct.unpack(TUPLE_PACK, container.read(TUPLE_PACK_SIZE))[0]
+            param = tuple(
+                _read_instruction_param(container, version, vectors) for _ in range(num_elements)
+            )
+    else:
+        raise TypeError("Invalid parameter type: %s" % type_str)
+    return param
+
+
 def _read_instruction(file_obj, circuit, registers, custom_instructions, version, vectors):
     instruction_raw = file_obj.read(INSTRUCTION_SIZE)
     instruction = struct.unpack(INSTRUCTION_PACK, instruction_raw)
@@ -926,36 +1078,7 @@ def _read_instruction(file_obj, circuit, registers, custom_instructions, version
         cargs.append(clbit_indices[carg[1]])
     # Load Parameters
     for _param in range(num_params):
-        param_raw = file_obj.read(INSTRUCTION_PARAM_SIZE)
-        param = struct.unpack(INSTRUCTION_PARAM_PACK, param_raw)
-        data = file_obj.read(param[1])
-        type_str = param[0].decode("utf8")
-        param = None
-        if type_str == "i":
-            param = struct.unpack("<q", data)[0]
-        elif type_str == "f":
-            param = struct.unpack("<d", data)[0]
-        elif type_str == "c":
-            param = complex(*struct.unpack(COMPLEX_PACK, data))
-        elif type_str == "n":
-            container = io.BytesIO(data)
-            param = np.load(container)
-        elif type_str == "s":
-            param = data.decode("utf8")
-        elif type_str == "p":
-            container = io.BytesIO(data)
-            param = _read_parameter(container)
-        elif type_str == "e":
-            container = io.BytesIO(data)
-            if version < 3:
-                param = _read_parameter_expression(container)
-            else:
-                param = _read_parameter_expression_v3(container, vectors)
-        elif type_str == "v":
-            container = io.BytesIO(data)
-            param = _read_parameter_vec(container, vectors)
-        else:
-            raise TypeError("Invalid parameter type: %s" % type_str)
+        param = _read_instruction_param(file_obj, version, vectors)
         params.append(param)
     # Load Gate object
     gate_class = None
@@ -981,15 +1104,22 @@ def _read_instruction(file_obj, circuit, registers, custom_instructions, version
         gate_class = getattr(extensions, gate_name)
     elif hasattr(quantum_initializer, gate_name):
         gate_class = getattr(quantum_initializer, gate_name)
+    elif hasattr(controlflow, gate_name):
+        gate_class = getattr(controlflow, gate_name)
     else:
         raise AttributeError("Invalid instruction type: %s" % gate_name)
-    if gate_name == "Initialize":
-        gate = gate_class(params)
+    if gate_name in {"IfElseOp", "WhileLoopOp"}:
+        gate = gate_class(condition_tuple, *params)
     else:
-        if gate_name == "Barrier":
-            params = [len(qargs)]
-        gate = gate_class(*params)
-    gate.condition = condition_tuple
+        if gate_name == "Initialize":
+            gate = gate_class(params)
+        else:
+            if gate_name == "Barrier":
+                params = [len(qargs)]
+            elif gate_name in {"BreakLoopOp", "ContinueLoopOp"}:
+                params = [len(qargs), len(cargs)]
+            gate = gate_class(*params)
+        gate.condition = condition_tuple
     if label_size > 0:
         gate.label = label
     if not isinstance(gate, Instruction):
@@ -1115,6 +1245,77 @@ def _write_parameter_expression(file_obj, param):
         file_obj.write(data)
 
 
+def _write_instruction_parameter(file_obj, param):
+    container = io.BytesIO()
+    if isinstance(param, int):
+        type_key = "i"
+        data = struct.pack("<q", param)
+        size = struct.calcsize("<q")
+    elif isinstance(param, float):
+        type_key = "f"
+        data = struct.pack("<d", param)
+        size = struct.calcsize("<d")
+    elif isinstance(param, str):
+        type_key = "s"
+        data = param.encode("utf8")
+        size = len(data)
+    elif isinstance(param, ParameterVectorElement):
+        type_key = "v"
+        _write_parameter_vec(container, param)
+        container.seek(0)
+        data = container.read()
+        size = len(data)
+    elif isinstance(param, Parameter):
+        type_key = "p"
+        _write_parameter(container, param)
+        container.seek(0)
+        data = container.read()
+        size = len(data)
+    elif isinstance(param, ParameterExpression):
+        type_key = "e"
+        _write_parameter_expression(container, param)
+        container.seek(0)
+        data = container.read()
+        size = len(data)
+    elif isinstance(param, complex):
+        type_key = "c"
+        data = struct.pack(COMPLEX_PACK, param.real, param.imag)
+        size = struct.calcsize(COMPLEX_PACK)
+    elif isinstance(param, (np.integer, np.floating, np.ndarray, np.complexfloating)):
+        type_key = "n"
+        np.save(container, param)
+        container.seek(0)
+        data = container.read()
+        size = len(data)
+    elif param is None:
+        type_key = "z"
+        data = b""
+        size = len(data)
+    elif isinstance(param, QuantumCircuit):
+        type_key = "q"
+        _write_circuit(container, param)
+        data = container.getvalue()
+        size = len(data)
+    elif isinstance(param, range):
+        type_key = "r"
+        data = struct.pack(RANGE_PACK, param.start, param.stop, param.step)
+        size = len(data)
+    elif isinstance(param, tuple):
+        type_key = "t"
+        num_elements = len(param)
+        container.write(struct.pack(TUPLE_PACK, num_elements))
+        for item in param:
+            _write_instruction_parameter(container, item)
+        data = container.getvalue()
+        size = len(data)
+    else:
+        raise TypeError(f"Invalid parameter type {type(param)}")
+    instruction_param_raw = struct.pack(INSTRUCTION_PARAM_PACK, type_key.encode("utf8"), size)
+    file_obj.write(instruction_param_raw)
+    file_obj.write(data)
+    container.close()
+
+
 def _write_instruction(file_obj, instruction_tuple, custom_instructions, index_map):
     gate_class_name = instruction_tuple[0].__class__.__name__
     if (
@@ -1123,6 +1324,7 @@ def _write_instruction(file_obj, instruction_tuple, custom_instructions, index_m
             and not hasattr(circuit_mod, gate_class_name)
             and not hasattr(extensions, gate_class_name)
             and not hasattr(quantum_initializer, gate_class_name)
+            and not hasattr(controlflow, gate_class_name)
         )
         or gate_class_name == "Gate"
         or gate_class_name == "Instruction"
@@ -1179,55 +1381,7 @@ def _write_instruction(file_obj, instruction_tuple, custom_instructions, index_m
         file_obj.write(instruction_arg_raw)
     # Encode instruction params
     for param in instruction_tuple[0].params:
-        container = io.BytesIO()
-        if isinstance(param, int):
-            type_key = "i"
-            data = struct.pack("<q", param)
-            size = struct.calcsize("<q")
-        elif isinstance(param, float):
-            type_key = "f"
-            data = struct.pack("<d", param)
-            size = struct.calcsize("<d")
-        elif isinstance(param, str):
-            type_key = "s"
-            data = param.encode("utf8")
-            size = len(data)
-        elif isinstance(param, ParameterVectorElement):
-            type_key = "v"
-            _write_parameter_vec(container, param)
-            container.seek(0)
-            data = container.read()
-            size = len(data)
-        elif isinstance(param, Parameter):
-            type_key = "p"
-            _write_parameter(container, param)
-            container.seek(0)
-            data = container.read()
-            size = len(data)
-        elif isinstance(param, ParameterExpression):
-            type_key = "e"
-            _write_parameter_expression(container, param)
-            container.seek(0)
-            data = container.read()
-            size = len(data)
-        elif isinstance(param, complex):
-            type_key = "c"
-            data = struct.pack(COMPLEX_PACK, param.real, param.imag)
-            size = struct.calcsize(COMPLEX_PACK)
-        elif isinstance(param, (np.integer, np.floating, np.ndarray, np.complexfloating)):
-            type_key = "n"
-            np.save(container, param)
-            container.seek(0)
-            data = container.read()
-            size = len(data)
-        else:
-            raise TypeError(
-                f"Invalid parameter type {instruction_tuple[0]} for gate {type(param)},"
-            )
-        instruction_param_raw = struct.pack(INSTRUCTION_PARAM_PACK, type_key.encode("utf8"), size)
-        file_obj.write(instruction_param_raw)
-        file_obj.write(data)
-        container.close()
+        _write_instruction_parameter(file_obj, param)
 
 
 def _write_pauli_evolution_gate(file_obj, evolution_gate):
@@ -1414,7 +1568,7 @@ def dump(circuits, file_obj):
     header = struct.pack(
         FILE_HEADER_PACK,
         b"QISKIT",
-        3,
+        4,
         version_parts[0],
         version_parts[1],
         version_parts[2],
@@ -1454,38 +1608,88 @@ def _write_circuit(file_obj, circuit):
             global_phase_data = container.getvalue()
     else:
         raise TypeError("unsupported global phase type %s" % type(circuit.global_phase))
-    header_raw = HEADER_V2(
-        name_size=len(circuit_name),
-        global_phase_type=global_phase_type,
-        global_phase_size=len(global_phase_data),
-        num_qubits=circuit.num_qubits,
-        num_clbits=circuit.num_clbits,
-        metadata_size=metadata_size,
-        num_registers=num_registers,
-        num_instructions=num_instructions,
-    )
-    header = struct.pack(HEADER_V2_PACK, *header_raw)
-    file_obj.write(header)
-    file_obj.write(circuit_name)
-    file_obj.write(global_phase_data)
-    file_obj.write(metadata_raw)
+
     qubit_indices = {bit: index for index, bit in enumerate(circuit.qubits)}
     clbit_indices = {bit: index for index, bit in enumerate(circuit.clbits)}
-    if num_registers > 0:
-        for reg in circuit.qregs:
+    processed_qubit_indices = set()
+    processed_clbit_indices = set()
+
+    def process_qregs(buf, qregs, in_circuit=True):
+        for reg in qregs:
             standalone = all(bit._register is reg for bit in reg)
             reg_name = reg.name.encode("utf8")
-            file_obj.write(struct.pack(REGISTER_PACK, b"q", standalone, reg.size, len(reg_name)))
-            file_obj.write(reg_name)
-            REGISTER_ARRAY_PACK = "!%sI" % reg.size
-            file_obj.write(struct.pack(REGISTER_ARRAY_PACK, *(qubit_indices[bit] for bit in reg)))
-        for reg in circuit.cregs:
+            buf.write(
+                struct.pack(REGISTER_V4_PACK, b"q", standalone, reg.size, len(reg_name), in_circuit)
+            )
+            buf.write(reg_name)
+            REGISTER_ARRAY_PACK = "!%sq" % reg.size
+            bit_indices = []
+            for bit in reg:
+                bit_index = qubit_indices.get(bit, -1)
+                if bit_index in processed_qubit_indices:
+                    bit_index = -1
+                if bit_index >= 0:
+                    processed_qubit_indices.add(bit_index)
+                bit_indices.append(bit_index)
+            buf.write(struct.pack(REGISTER_ARRAY_PACK, *bit_indices))
+
+    def process_cregs(buf, cregs, in_circuit=True):
+        for reg in cregs:
             standalone = all(bit._register is reg for bit in reg)
             reg_name = reg.name.encode("utf8")
-            file_obj.write(struct.pack(REGISTER_PACK, b"c", standalone, reg.size, len(reg_name)))
-            file_obj.write(reg_name)
-            REGISTER_ARRAY_PACK = "!%sI" % reg.size
-            file_obj.write(struct.pack(REGISTER_ARRAY_PACK, *(clbit_indices[bit] for bit in reg)))
+            buf.write(
+                struct.pack(REGISTER_V4_PACK, b"c", standalone, reg.size, len(reg_name), in_circuit)
+            )
+            buf.write(reg_name)
+            REGISTER_ARRAY_PACK = "!%sq" % reg.size
+            bit_indices = []
+            for bit in reg:
+                bit_index = clbit_indices.get(bit, -1)
+                # If index already in circuit treat it as non-existent (since it indicates dual
+                # register membership and the bit is already being reconstructed
+                if bit_index in processed_clbit_indices:
+                    bit_index = -1
+                if bit_index >= 0:
+                    processed_clbit_indices.add(bit_index)
+                bit_indices.append(bit_index)
+            buf.write(struct.pack(REGISTER_ARRAY_PACK, *bit_indices))
+
+    with io.BytesIO() as reg_buf:
+        if num_registers > 0:
+            process_qregs(reg_buf, circuit.qregs)
+            process_cregs(reg_buf, circuit.cregs)
+        # process dangling registers (those not added to circuit)
+        qreg_set = set()
+        for bit in circuit.qubits:
+            if bit._register is not None and bit._register not in circuit.qregs:
+                qreg_set.add(bit._register)
+        process_qregs(reg_buf, qreg_set, in_circuit=False)
+        num_registers += len(qreg_set)
+        creg_set = set()
+        for bit in circuit.clbits:
+            if bit._register is not None and bit._register not in circuit.cregs:
+                creg_set.add(bit._register)
+        process_cregs(reg_buf, creg_set, in_circuit=False)
+        num_registers += len(creg_set)
+        # Write circuit header
+        header_raw = HEADER_V2(
+            name_size=len(circuit_name),
+            global_phase_type=global_phase_type,
+            global_phase_size=len(global_phase_data),
+            num_qubits=circuit.num_qubits,
+            num_clbits=circuit.num_clbits,
+            metadata_size=metadata_size,
+            num_registers=num_registers,
+            num_instructions=num_instructions,
+        )
+        header = struct.pack(HEADER_V2_PACK, *header_raw)
+        file_obj.write(header)
+        file_obj.write(circuit_name)
+        file_obj.write(global_phase_data)
+        file_obj.write(metadata_raw)
+        # Write header payload
+        file_obj.write(reg_buf.getvalue())
+
     instruction_buffer = io.BytesIO()
     custom_instructions = {}
     index_map = {}
@@ -1587,7 +1791,10 @@ def _read_circuit(file_obj, version):
     out_registers = {"q": {}, "c": {}}
     if num_registers > 0:
         circ = QuantumCircuit(name=name, global_phase=global_phase, metadata=metadata)
-        registers = _read_registers(file_obj, num_registers)
+        if version < 4:
+            registers = _read_registers(file_obj, num_registers)
+        else:
+            registers = _read_registers_v4(file_obj, num_registers)
 
         for bit_type_label, bit_type, reg_type in [
             ("q", Qubit, QuantumRegister),
@@ -1596,17 +1803,30 @@ def _read_circuit(file_obj, version):
             register_bits = set()
             # Add quantum registers and bits
             for register_name in registers[bit_type_label]:
-                standalone, indices = registers[bit_type_label][register_name]
+                standalone, indices, in_circuit = registers[bit_type_label][register_name]
+                indices_defined = [x for x in indices if x >= 0]
+                # If a register has no bits in the circuit skip it
+                if not indices_defined:
+                    continue
                 if standalone:
-                    start = min(indices)
+                    start = min(indices_defined)
                     count = start
                     out_of_order = False
                     for index in indices:
+                        if index < 0:
+                            out_of_order = True
+                            continue
                         if not out_of_order and index != count:
                             out_of_order = True
                         count += 1
                         if index in register_bits:
+                            # If we have a bit in the position already it's been
+                            # added by an earlier register in the circuit
+                            # otherwise it's invalid qpy
+                            if not in_circuit:
+                                continue
                             raise QiskitError("Duplicate register bits found")
+
                         register_bits.add(index)
 
                     num_reg_bits = len(indices)
@@ -1615,21 +1835,29 @@ def _read_circuit(file_obj, version):
                     reg = reg_type(num_reg_bits, register_name)
                     # If any bits from qreg are out of order in the circuit handle
                     # is case
-                    if out_of_order:
-                        sorted_indices = np.argsort(indices)
-                        for index in sorted_indices:
-                            pos = indices[index]
+                    if out_of_order or not in_circuit:
+                        for index, pos in sorted(
+                            enumerate(x for x in indices if x >= 0), key=lambda x: x[1]
+                        ):
                             if bit_type_label == "q":
                                 bit_len = len(circ.qubits)
                             else:
                                 bit_len = len(circ.clbits)
+                            if pos < bit_len:
+                                # If we have a bit in the position already it's been
+                                # added by an earlier register in the circuit
+                                # otherwise it's invalid qpy
+                                if not in_circuit:
+                                    continue
+                                raise QiskitError("Duplicate register bits found")
                             # Fill any holes between the current register bit and the
                             # next one
                             if pos > bit_len:
                                 bits = [bit_type() for _ in range(pos - bit_len)]
                                 circ.add_bits(bits)
                             circ.add_bits([reg[index]])
-                        circ.add_register(reg)
+                        if in_circuit:
+                            circ.add_register(reg)
                     else:
                         if bit_type_label == "q":
                             bit_len = len(circ.qubits)
@@ -1640,7 +1868,8 @@ def _read_circuit(file_obj, version):
                         if start > len(circ.qubits):
                             bits = [bit_type() for _ in range(start - bit_len)]
                             circ.add_bits(bit_len)
-                        circ.add_register(reg)
+                        if in_circuit:
+                            circ.add_register(reg)
                         out_registers[bit_type_label][register_name] = reg
                 else:
                     for index in indices:
@@ -1659,7 +1888,8 @@ def _read_circuit(file_obj, version):
                     else:
                         bits = [circ.clbits[i] for i in indices]
                     reg = reg_type(name=register_name, bits=bits)
-                    circ.add_register(reg)
+                    if in_circuit:
+                        circ.add_register(reg)
                     out_registers[bit_type_label][register_name] = reg
         # If we don't have sufficient bits in the circuit after adding
         # all the registers add more bits to fill the circuit


Test patch:
diff --git a/test/python/circuit/test_circuit_load_from_qpy.py b/test/python/circuit/test_circuit_load_from_qpy.py
--- a/test/python/circuit/test_circuit_load_from_qpy.py
+++ b/test/python/circuit/test_circuit_load_from_qpy.py
@@ -780,3 +780,110 @@ def test_parameter_vector_global_phase(self):
         qpy_file.seek(0)
         new_circuit = load(qpy_file)[0]
         self.assertEqual(qc, new_circuit)
+
+    def test_qpy_with_ifelseop(self):
+        """Test qpy serialization with an if block."""
+        qc = QuantumCircuit(2, 2)
+        qc.h(0)
+        qc.measure(0, 0)
+        with qc.if_test((qc.clbits[0], True)):
+            qc.x(1)
+        qc.measure(1, 1)
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
+    def test_qpy_with_ifelseop_with_else(self):
+        """Test qpy serialization with an else block."""
+        qc = QuantumCircuit(2, 2)
+        qc.h(0)
+        qc.measure(0, 0)
+        with qc.if_test((qc.clbits[0], True)) as else_:
+            qc.x(1)
+        with else_:
+            qc.y(1)
+        qc.measure(1, 1)
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
+    def test_qpy_with_while_loop(self):
+        """Test qpy serialization with a for loop."""
+        qc = QuantumCircuit(2, 1)
+
+        with qc.while_loop((qc.clbits[0], 0)):
+            qc.h(0)
+            qc.cx(0, 1)
+            qc.measure(0, 0)
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
+    def test_qpy_with_for_loop(self):
+        """Test qpy serialization with a for loop."""
+        qc = QuantumCircuit(2, 1)
+
+        with qc.for_loop(range(5)):
+            qc.h(0)
+            qc.cx(0, 1)
+            qc.measure(0, 0)
+            qc.break_loop().c_if(0, True)
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
+    def test_qpy_with_for_loop_iterator(self):
+        """Test qpy serialization with a for loop."""
+        qc = QuantumCircuit(2, 1)
+
+        with qc.for_loop(iter(range(5))):
+            qc.h(0)
+            qc.cx(0, 1)
+            qc.measure(0, 0)
+            qc.break_loop().c_if(0, True)
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
+    def test_standalone_register_partial_bit_in_circuit(self):
+        """Test qpy with only some bits from standalone register."""
+        qr = QuantumRegister(2)
+        qc = QuantumCircuit([qr[0]])
+        qc.x(0)
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
+    def test_nested_tuple_param(self):
+        """Test qpy with an instruction that contains nested tuples."""
+        inst = Instruction("tuple_test", 1, 0, [((((0, 1), (0, 1)), 2, 3), ("A", "B", "C"))])
+        qc = QuantumCircuit(1)
+        qc.append(inst, [0])
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
+
+    def test_empty_tuple_param(self):
+        """Test qpy with an instruction that contains an empty tuple."""
+        inst = Instruction("empty_tuple_test", 1, 0, [tuple()])
+        qc = QuantumCircuit(1)
+        qc.append(inst, [0])
+        qpy_file = io.BytesIO()
+        dump(qc, qpy_file)
+        qpy_file.seek(0)
+        new_circuit = load(qpy_file)[0]
+        self.assertEqual(qc, new_circuit)
diff --git a/test/qpy_compat/run_tests.sh b/test/qpy_compat/run_tests.sh
--- a/test/qpy_compat/run_tests.sh
+++ b/test/qpy_compat/run_tests.sh
@@ -14,6 +14,11 @@
 set -e
 set -x
 
+# Set fixed hash seed to ensure set orders are identical between saving and
+# loading.
+export PYTHONHASHSEED=$(python -S -c "import random; print(random.randint(1, 4294967295))")
+echo "PYTHONHASHSEED=$PYTHONHASHSEED"
+
 python -m venv qiskit_venv
 qiskit_venv/bin/pip install ../..
 
diff --git a/test/qpy_compat/test_qpy.py b/test/qpy_compat/test_qpy.py
--- a/test/qpy_compat/test_qpy.py
+++ b/test/qpy_compat/test_qpy.py
@@ -288,6 +288,58 @@ def generate_evolution_gate():
     return qc
 
 
+def generate_control_flow_circuits():
+    """Test qpy serialization with control flow instructions."""
+    from qiskit.circuit.controlflow import WhileLoopOp, IfElseOp, ForLoopOp
+
+    # If instruction
+    circuits = []
+    qc = QuantumCircuit(2, 2)
+    qc.h(0)
+    qc.measure(0, 0)
+    true_body = QuantumCircuit(1)
+    true_body.x(0)
+    if_op = IfElseOp((qc.clbits[0], True), true_body=true_body)
+    qc.append(if_op, [1])
+    qc.measure(1, 1)
+    circuits.append(qc)
+    # If else instruction
+    qc = QuantumCircuit(2, 2)
+    qc.h(0)
+    qc.measure(0, 0)
+    false_body = QuantumCircuit(1)
+    false_body.y(0)
+    if_else_op = IfElseOp((qc.clbits[0], True), true_body, false_body)
+    qc.append(if_else_op, [1])
+    qc.measure(1, 1)
+    circuits.append(qc)
+    # While loop
+    qc = QuantumCircuit(2, 1)
+    block = QuantumCircuit(2, 1)
+    block.h(0)
+    block.cx(0, 1)
+    block.measure(0, 0)
+    while_loop = WhileLoopOp((qc.clbits[0], 0), block)
+    qc.append(while_loop, [0, 1], [0])
+    circuits.append(qc)
+    # for loop range
+    qc = QuantumCircuit(2, 1)
+    body = QuantumCircuit(2, 1)
+    body.h(0)
+    body.cx(0, 1)
+    body.measure(0, 0)
+    body.break_loop().c_if(0, True)
+    for_loop_op = ForLoopOp(range(5), None, body=body)
+    qc.append(for_loop_op, [0, 1], [0])
+    circuits.append(qc)
+    # For loop iterator
+    qc = QuantumCircuit(2, 1)
+    for_loop_op = ForLoopOp(iter(range(5)), None, body=body)
+    qc.append(for_loop_op, [0, 1], [0])
+    circuits.append(qc)
+    return circuits
+
+
 def generate_circuits(version_str=None):
     """Generate reference circuits."""
     version_parts = None
@@ -318,6 +370,8 @@ def generate_circuits(version_str=None):
         output_circuits["parameter_vector_expression.qpy"] = [
             generate_parameter_vector_expression()
         ]
+    if version_parts >= (0, 19, 2):
+        output_circuits["control_flow.qpy"] = generate_control_flow_circuits()
 
     return output_circuits