From 0a7690d5a1a5b38b29d521bd253e5c44babcbbfd Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Wed, 6 Nov 2024 13:01:13 -0500 Subject: [PATCH] Add Qiskit native QPY ParameterExpression serialization (#13356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Qiskit native QPY ParameterExpression serialization With the release of symengine 0.13.0 we discovered a version dependence on the payload format used for serializing symengine expressions. This was worked around in #13251 but this is not a sustainable solution and only works for symengine 0.11.0 and 0.13.0 (there was no 0.12.0). While there was always the option to use sympy to serialize the underlying symbolic expression (there is a `use_symengine` flag on `qpy.dumps` you can set to `False` to do this) the sympy serialzation has several tradeoffs most importantly is much higher runtime overhead. To solve the issue moving forward a qiskit native representation of the parameter expression object is necessary for serialization. This commit bumps the QPY format version to 13 and adds a new serialization format for ParameterExpression objects. This new format is a serialization of the API calls made to ParameterExpression that resulted in the creation of the underlying object. To facilitate this the ParameterExpression class is expanded to store an internal "replay" record of the API calls used to construct the ParameterExpression object. This internal list is what gets serialized by QPY and then on deserialization the "replay" is replayed to reconstruct the expression object. This is a different approach to the previous QPY representations of the ParameterExpression objects which instead represented the internal state stored in the ParameterExpression object with the symbolic expression from symengine (or a sympy copy of the expression). Doing this directly in Qiskit isn't viable though because symengine's internal expression tree is not exposed to Python directly. There isn't any method (private or public) to walk the expression tree to construct a serialization format based off of it. Converting symengine to a sympy expression and then using sympy's API to walk the expression tree is a possibility but that would tie us to sympy which would be problematic for #13267 and #13131, have significant runtime overhead, and it would be just easier to rely on sympy's native serialization tools. The tradeoff with this approach is that it does increase the memory overhead of the `ParameterExpression` class because for each element in the expression we have to store a record of it. Depending on the depth of the expression tree this also could be a lot larger than symengine's internal representation as we store the raw api calls made to create the ParameterExpression but symengine is likely simplifying it's internal representation as it builds it out. But I personally think this tradeoff is worthwhile as it ties the serialization format to the Qiskit objects instead of relying on a 3rd party library. This also gives us the flexibility of changing the internal symbolic expression library internally in the future if we decide to stop using symengine at any point. Fixes #13252 * Remove stray comment * Add format documentation * Add release note * Add test and fix some issues with recursive expressions * Add int type for operands * Add dedicated subs test * Pivot to stack based postfix/rpn deserialization This commit changes how the deserialization works to use a postfix stack based approach. Operands are push on the stack and then popped off based on the operation being run. The result of the operation is then pushed on the stack. This handles nested objects much more cleanly than the recursion based approach because we just keep pushing on the stack instead of recursing, making the accounting much simpler. After the expression payload is finished being processed there will be a single value on the stack and that is returned as the final expression. * Apply suggestions from code review Co-authored-by: Elena Peña Tapia <57907331+ElePT@users.noreply.github.com> * Change DERIV to GRAD * Change side kwarg to r_side * Change all the v4s to v13s * Correctly handle non-commutative operations This commit fixes a bug with handling the operand order of subtraction, division, and exponentiation. These operations are not commutative but the qpy deserialization code was treating them as such. So in cases where the argument order was reversed qpy was trying to flip the operands around for code simplicity and this would result in incorrect behavior. This commit fixes this by adding explicit op codes for the reversed sub, div, and pow and preserving the operand order correctly in these cases. * Fix lint --------- Co-authored-by: Elena Peña Tapia <57907331+ElePT@users.noreply.github.com> --- qiskit/circuit/parameter.py | 4 + qiskit/circuit/parameterexpression.py | 201 +++++++++-- qiskit/qpy/__init__.py | 155 +++++++++ qiskit/qpy/binary_io/value.py | 318 +++++++++++++++++- qiskit/qpy/common.py | 2 +- qiskit/qpy/formats.py | 7 + qiskit/qpy/interface.py | 6 +- .../notes/add-qpy-v13-3b22ae33045af6c1.yaml | 33 ++ test/python/qpy/test_circuit_load_from_qpy.py | 65 ++++ 9 files changed, 744 insertions(+), 47 deletions(-) create mode 100644 releasenotes/notes/add-qpy-v13-3b22ae33045af6c1.yaml diff --git a/qiskit/circuit/parameter.py b/qiskit/circuit/parameter.py index c7a8228dd463..8723445cdef4 100644 --- a/qiskit/circuit/parameter.py +++ b/qiskit/circuit/parameter.py @@ -87,6 +87,8 @@ def __init__( self._hash = hash((self._parameter_keys, self._symbol_expr)) self._parameter_symbols = {self: symbol} self._name_map = None + self._qpy_replay = [] + self._standalone_param = True def assign(self, parameter, value): if parameter != self: @@ -172,3 +174,5 @@ def __setstate__(self, state): self._hash = hash((self._parameter_keys, self._symbol_expr)) self._parameter_symbols = {self: self._symbol_expr} self._name_map = None + self._qpy_replay = [] + self._standalone_param = True diff --git a/qiskit/circuit/parameterexpression.py b/qiskit/circuit/parameterexpression.py index 16b691480d26..fe786762c096 100644 --- a/qiskit/circuit/parameterexpression.py +++ b/qiskit/circuit/parameterexpression.py @@ -14,6 +14,9 @@ """ from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum from typing import Callable, Union import numbers @@ -30,12 +33,86 @@ ParameterValueType = Union["ParameterExpression", float] +class _OPCode(IntEnum): + ADD = 0 + SUB = 1 + MUL = 2 + DIV = 3 + POW = 4 + SIN = 5 + COS = 6 + TAN = 7 + ASIN = 8 + ACOS = 9 + EXP = 10 + LOG = 11 + SIGN = 12 + GRAD = 13 + CONJ = 14 + SUBSTITUTE = 15 + ABS = 16 + ATAN = 17 + RSUB = 18 + RDIV = 19 + RPOW = 20 + + +_OP_CODE_MAP = ( + "__add__", + "__sub__", + "__mul__", + "__truediv__", + "__pow__", + "sin", + "cos", + "tan", + "arcsin", + "arccos", + "exp", + "log", + "sign", + "gradient", + "conjugate", + "subs", + "abs", + "arctan", + "__rsub__", + "__rtruediv__", + "__rpow__", +) + + +def op_code_to_method(op_code: _OPCode): + """Return the method name for a given op_code.""" + return _OP_CODE_MAP[op_code] + + +@dataclass +class _INSTRUCTION: + op: _OPCode + lhs: ParameterValueType | None + rhs: ParameterValueType | None = None + + +@dataclass +class _SUBS: + binds: dict + op: _OPCode = _OPCode.SUBSTITUTE + + class ParameterExpression: """ParameterExpression class to enable creating expressions of Parameters.""" - __slots__ = ["_parameter_symbols", "_parameter_keys", "_symbol_expr", "_name_map"] + __slots__ = [ + "_parameter_symbols", + "_parameter_keys", + "_symbol_expr", + "_name_map", + "_qpy_replay", + "_standalone_param", + ] - def __init__(self, symbol_map: dict, expr): + def __init__(self, symbol_map: dict, expr, *, _qpy_replay=None): """Create a new :class:`ParameterExpression`. Not intended to be called directly, but to be instantiated via operations @@ -54,6 +131,11 @@ def __init__(self, symbol_map: dict, expr): self._parameter_keys = frozenset(p._hash_key() for p in self._parameter_symbols) self._symbol_expr = expr self._name_map: dict | None = None + self._standalone_param = False + if _qpy_replay is not None: + self._qpy_replay = _qpy_replay + else: + self._qpy_replay = [] @property def parameters(self) -> set: @@ -69,8 +151,14 @@ def _names(self) -> dict: def conjugate(self) -> "ParameterExpression": """Return the conjugate.""" + if self._standalone_param: + new_op = _INSTRUCTION(_OPCode.CONJ, self) + else: + new_op = _INSTRUCTION(_OPCode.CONJ, None) + new_replay = self._qpy_replay.copy() + new_replay.append(new_op) conjugated = ParameterExpression( - self._parameter_symbols, symengine.conjugate(self._symbol_expr) + self._parameter_symbols, symengine.conjugate(self._symbol_expr), _qpy_replay=new_replay ) return conjugated @@ -117,6 +205,7 @@ def bind( self._raise_if_passed_unknown_parameters(parameter_values.keys()) self._raise_if_passed_nan(parameter_values) + new_op = _SUBS(parameter_values) symbol_values = {} for parameter, value in parameter_values.items(): if (param_expr := self._parameter_symbols.get(parameter)) is not None: @@ -143,7 +232,12 @@ def bind( f"(Expression: {self}, Bindings: {parameter_values})." ) - return ParameterExpression(free_parameter_symbols, bound_symbol_expr) + new_replay = self._qpy_replay.copy() + new_replay.append(new_op) + + return ParameterExpression( + free_parameter_symbols, bound_symbol_expr, _qpy_replay=new_replay + ) def subs( self, parameter_map: dict, allow_unknown_parameters: bool = False @@ -175,6 +269,7 @@ def subs( for p in replacement_expr.parameters } self._raise_if_parameter_names_conflict(inbound_names, parameter_map.keys()) + new_op = _SUBS(parameter_map) # Include existing parameters in self not set to be replaced. new_parameter_symbols = { @@ -192,8 +287,12 @@ def subs( new_parameter_symbols[p] = symbol_type(p.name) substituted_symbol_expr = self._symbol_expr.subs(symbol_map) + new_replay = self._qpy_replay.copy() + new_replay.append(new_op) - return ParameterExpression(new_parameter_symbols, substituted_symbol_expr) + return ParameterExpression( + new_parameter_symbols, substituted_symbol_expr, _qpy_replay=new_replay + ) def _raise_if_passed_unknown_parameters(self, parameters): unknown_parameters = parameters - self.parameters @@ -231,7 +330,11 @@ def _raise_if_parameter_names_conflict(self, inbound_parameters, outbound_parame ) def _apply_operation( - self, operation: Callable, other: ParameterValueType, reflected: bool = False + self, + operation: Callable, + other: ParameterValueType, + reflected: bool = False, + op_code: _OPCode = None, ) -> "ParameterExpression": """Base method implementing math operations between Parameters and either a constant or a second ParameterExpression. @@ -253,7 +356,6 @@ def _apply_operation( A new expression describing the result of the operation. """ self_expr = self._symbol_expr - if isinstance(other, ParameterExpression): self._raise_if_parameter_names_conflict(other._names) parameter_symbols = {**self._parameter_symbols, **other._parameter_symbols} @@ -266,10 +368,26 @@ def _apply_operation( if reflected: expr = operation(other_expr, self_expr) + if op_code in {_OPCode.RSUB, _OPCode.RDIV, _OPCode.RPOW}: + if self._standalone_param: + new_op = _INSTRUCTION(op_code, self, other) + else: + new_op = _INSTRUCTION(op_code, None, other) + else: + if self._standalone_param: + new_op = _INSTRUCTION(op_code, other, self) + else: + new_op = _INSTRUCTION(op_code, other, None) else: expr = operation(self_expr, other_expr) - - out_expr = ParameterExpression(parameter_symbols, expr) + if self._standalone_param: + new_op = _INSTRUCTION(op_code, self, other) + else: + new_op = _INSTRUCTION(op_code, None, other) + new_replay = self._qpy_replay.copy() + new_replay.append(new_op) + + out_expr = ParameterExpression(parameter_symbols, expr, _qpy_replay=new_replay) out_expr._name_map = self._names.copy() if isinstance(other, ParameterExpression): out_expr._names.update(other._names.copy()) @@ -291,6 +409,13 @@ def gradient(self, param) -> Union["ParameterExpression", complex]: # If it is not contained then return 0 return 0.0 + if self._standalone_param: + new_op = _INSTRUCTION(_OPCode.GRAD, self, param) + else: + new_op = _INSTRUCTION(_OPCode.GRAD, None, param) + qpy_replay = self._qpy_replay.copy() + qpy_replay.append(new_op) + # Compute the gradient of the parameter expression w.r.t. param key = self._parameter_symbols[param] expr_grad = symengine.Derivative(self._symbol_expr, key) @@ -304,7 +429,7 @@ def gradient(self, param) -> Union["ParameterExpression", complex]: parameter_symbols[parameter] = symbol # If the gradient corresponds to a parameter expression then return the new expression. if len(parameter_symbols) > 0: - return ParameterExpression(parameter_symbols, expr=expr_grad) + return ParameterExpression(parameter_symbols, expr=expr_grad, _qpy_replay=qpy_replay) # If no free symbols left, return a complex or float gradient expr_grad_cplx = complex(expr_grad) if expr_grad_cplx.imag != 0: @@ -313,81 +438,89 @@ def gradient(self, param) -> Union["ParameterExpression", complex]: return float(expr_grad) def __add__(self, other): - return self._apply_operation(operator.add, other) + return self._apply_operation(operator.add, other, op_code=_OPCode.ADD) def __radd__(self, other): - return self._apply_operation(operator.add, other, reflected=True) + return self._apply_operation(operator.add, other, reflected=True, op_code=_OPCode.ADD) def __sub__(self, other): - return self._apply_operation(operator.sub, other) + return self._apply_operation(operator.sub, other, op_code=_OPCode.SUB) def __rsub__(self, other): - return self._apply_operation(operator.sub, other, reflected=True) + return self._apply_operation(operator.sub, other, reflected=True, op_code=_OPCode.RSUB) def __mul__(self, other): - return self._apply_operation(operator.mul, other) + return self._apply_operation(operator.mul, other, op_code=_OPCode.MUL) def __pos__(self): - return self._apply_operation(operator.mul, 1) + return self._apply_operation(operator.mul, 1, op_code=_OPCode.MUL) def __neg__(self): - return self._apply_operation(operator.mul, -1) + return self._apply_operation(operator.mul, -1, op_code=_OPCode.MUL) def __rmul__(self, other): - return self._apply_operation(operator.mul, other, reflected=True) + return self._apply_operation(operator.mul, other, reflected=True, op_code=_OPCode.MUL) def __truediv__(self, other): if other == 0: raise ZeroDivisionError("Division of a ParameterExpression by zero.") - return self._apply_operation(operator.truediv, other) + return self._apply_operation(operator.truediv, other, op_code=_OPCode.DIV) def __rtruediv__(self, other): - return self._apply_operation(operator.truediv, other, reflected=True) + return self._apply_operation(operator.truediv, other, reflected=True, op_code=_OPCode.RDIV) def __pow__(self, other): - return self._apply_operation(pow, other) + return self._apply_operation(pow, other, op_code=_OPCode.POW) def __rpow__(self, other): - return self._apply_operation(pow, other, reflected=True) + return self._apply_operation(pow, other, reflected=True, op_code=_OPCode.RPOW) - def _call(self, ufunc): - return ParameterExpression(self._parameter_symbols, ufunc(self._symbol_expr)) + def _call(self, ufunc, op_code): + if self._standalone_param: + new_op = _INSTRUCTION(op_code, self) + else: + new_op = _INSTRUCTION(op_code, None) + new_replay = self._qpy_replay.copy() + new_replay.append(new_op) + return ParameterExpression( + self._parameter_symbols, ufunc(self._symbol_expr), _qpy_replay=new_replay + ) def sin(self): """Sine of a ParameterExpression""" - return self._call(symengine.sin) + return self._call(symengine.sin, op_code=_OPCode.SIN) def cos(self): """Cosine of a ParameterExpression""" - return self._call(symengine.cos) + return self._call(symengine.cos, op_code=_OPCode.COS) def tan(self): """Tangent of a ParameterExpression""" - return self._call(symengine.tan) + return self._call(symengine.tan, op_code=_OPCode.TAN) def arcsin(self): """Arcsin of a ParameterExpression""" - return self._call(symengine.asin) + return self._call(symengine.asin, op_code=_OPCode.ASIN) def arccos(self): """Arccos of a ParameterExpression""" - return self._call(symengine.acos) + return self._call(symengine.acos, op_code=_OPCode.ACOS) def arctan(self): """Arctan of a ParameterExpression""" - return self._call(symengine.atan) + return self._call(symengine.atan, op_code=_OPCode.ATAN) def exp(self): """Exponential of a ParameterExpression""" - return self._call(symengine.exp) + return self._call(symengine.exp, op_code=_OPCode.EXP) def log(self): """Logarithm of a ParameterExpression""" - return self._call(symengine.log) + return self._call(symengine.log, op_code=_OPCode.LOG) def sign(self): """Sign of a ParameterExpression""" - return self._call(symengine.sign) + return self._call(symengine.sign, op_code=_OPCode.SIGN) def __repr__(self): return f"{self.__class__.__name__}({str(self)})" @@ -455,7 +588,7 @@ def __deepcopy__(self, memo=None): def __abs__(self): """Absolute of a ParameterExpression""" - return self._call(symengine.Abs) + return self._call(symengine.Abs, _OPCode.ABS) def abs(self): """Absolute of a ParameterExpression""" diff --git a/qiskit/qpy/__init__.py b/qiskit/qpy/__init__.py index 950b78fc421d..b6f7420ccfdf 100644 --- a/qiskit/qpy/__init__.py +++ b/qiskit/qpy/__init__.py @@ -97,6 +97,8 @@ will be able to load all released format versions of QPY (up until ``QPY_VERSION``). +.. _qpy_compatibility: + QPY Compatibility ================= @@ -157,6 +159,24 @@ * - Qiskit (qiskit-terra for < 1.0.0) version - :func:`.dump` format(s) output versions - :func:`.load` maximum supported version (older format versions can always be read) + * - 1.3.0 + - 10, 11, 12, 13 + - 13 + * - 1.2.4 + - 10, 11, 12 + - 12 + * - 1.2.3 (yanked) + - 10, 11, 12 + - 12 + * - 1.2.2 + - 10, 11, 12 + - 12 + * - 1.2.1 + - 10, 11, 12 + - 12 + * - 1.2.0 + - 10, 11, 12 + - 12 * - 1.1.0 - 10, 11, 12 - 12 @@ -322,6 +342,141 @@ by ``num_circuits`` in the file header). There is no padding between the circuits in the data. +.. _qpy_version_13: + +Version 13 +---------- + +Version 13 added a native Qiskit serialization representation for :class:`.ParameterExpression`. +Previous QPY versions relied on either ``sympy`` or ``symengine`` to serialize the underlying symbolic +expression. Starting in Version 13, QPY now represents the sequence of API calls used to create the +:class:`.ParameterExpression`. + +The main change in the serialization format is in the :ref:`qpy_param_expr_v3` payload. The +``expr_size`` bytes following the head now contain an array of ``PARAM_EXPR_ELEM_V13`` structs. The +intent is for this array to be read one struct at a time, where each struct describes one of the +calls to make to reconstruct the :class:`.ParameterExpression`. + +PARAM_EXPR_ELEM_V13 +~~~~~~~~~~~~~~~~~~~ + +The struct format is defined as: + +.. code-block:: c + + struct { + unsigned char op_code; + char lhs_type; + char lhs[16]; + char rhs_type; + char rhs[16]; + } PARAM_EXPR_ELEM_V13; + +The ``op_code`` field is used to define the operation added to the :class:`.ParameterExpression`. +The value can be: + +.. list-table:: PARAM_EXPR_ELEM_V13 op code values + :header-rows: 1 + + * - ``op_code`` + - :class:`.ParameterExpression` method + * - 0 + - :meth:`~.ParameterExpression.__add__` + * - 1 + - :meth:`~.ParameterExpression.__sub__` + * - 2 + - :meth:`~.ParameterExpression.__mul__` + * - 3 + - :meth:`~.ParameterExpression.__truediv__` + * - 4 + - :meth:`~.ParameterExpression.__pow__` + * - 5 + - :meth:`~.ParameterExpression.sin` + * - 6 + - :meth:`~.ParameterExpression.cos` + * - 7 + - :meth:`~.ParameterExpression.tan` + * - 8 + - :meth:`~.ParameterExpression.arcsin` + * - 9 + - :meth:`~.ParameterExpression.arccos` + * - 10 + - :meth:`~.ParameterExpression.exp` + * - 11 + - :meth:`~.ParameterExpression.log` + * - 12 + - :meth:`~.ParameterExpression.sign` + * - 13 + - :meth:`~.ParameterExpression.gradient` + * - 14 + - :meth:`~.ParameterExpression.conjugate` + * - 15 + - :meth:`~.ParameterExpression.subs` + * - 16 + - :meth:`~.ParameterExpression.abs` + * - 17 + - :meth:`~.ParameterExpression.arctan` + * - 255 + - NULL + +The ``NULL`` value of 255 is only used to fill the op code field for +entries that are not actual operations but indicate recursive definitions. +Then the ``lhs_type`` and ``rhs_type`` fields are used to describe +the operand types and can be one of the following UTF-8 encoded +characters: + +.. list-table:: PARAM_EXPR_ELEM_V13 operand type values + :header-rows: 1 + + * - Value + - Type + * - ``n`` + - ``None`` + * - ``p`` + - :class:`.Parameter` + * - ``f`` + - ``float`` + * - ``c`` + - ``complex`` + * - ``i`` + - ``int`` + * - ``s`` + - Recursive :class:`.ParameterExpression` definition start + * - ``e`` + - Recursive :class:`.ParameterExpression` definition stop + * - ``u`` + - substitution + +If the type value is ``f`` ,``c`` or ``i``, the corresponding ``lhs`` or `rhs`` +field widths are 128 bits each. In the case of floats, the literal value is encoded as a double +with 0 padding, while complex numbers are encoded as real part followed by imaginary part, +taking up 64 bits each. For ``i`, the value is encoded as a 64 bit signed integer with 0 padding +for the full 128 bit width. ``n`` is used to represent a ``None`` and typically isn't directly used +as it indicates an argument that's not used. For ``p`` the data is the UUID for the +:class:`.Parameter` which can be looked up in the symbol map described in the +``map_elements`` outer :ref:`qpy_param_expr_v3` payload. If the type value is +``s`` this marks the start of a a new recursive section for a nested +:class:`.ParameterExpression`. For example, in the following snippet there is an inner ``expr`` +contained in ``final_expr``, constituting a nested expression:: + + from qiskit.circuit import Parameter + + x = Parameter("x") + y = Parameter("y") + z = Parameter("z") + + expr = (x + y) / 2 + final_expr = z**2 + expr + +When ``s`` is encountered, this indicates that until an ``e` struct is reached, the next structs +are used for a recursive definition. For both +``s`` and ``e`` types, the data values are not used, and always set to 0. The type value +of ``u`` is used to represent a substitution call. This is only used for ``lhs_type`` +and is always paired with an ``rhs_type`` of ``n``. The data value is the size in bytes of +a :ref:`qpy_mapping` encoded mapping of :class:`.Parameter` names to their value for the +:meth:`~.ParameterExpression.subs` call. The mapping data is immediately following the +struct, and the next struct starts immediately after the mapping data. + .. _qpy_version_12: Version 12 diff --git a/qiskit/qpy/binary_io/value.py b/qiskit/qpy/binary_io/value.py index 5b82e14d15cd..9799fdf3f459 100644 --- a/qiskit/qpy/binary_io/value.py +++ b/qiskit/qpy/binary_io/value.py @@ -15,6 +15,7 @@ from __future__ import annotations import collections.abc +import io import struct import uuid @@ -25,7 +26,12 @@ from qiskit.circuit import CASE_DEFAULT, Clbit, ClassicalRegister from qiskit.circuit.classical import expr, types from qiskit.circuit.parameter import Parameter -from qiskit.circuit.parameterexpression import ParameterExpression +from qiskit.circuit.parameterexpression import ( + ParameterExpression, + op_code_to_method, + _OPCode, + _SUBS, +) from qiskit.circuit.parametervector import ParameterVector, ParameterVectorElement from qiskit.qpy import common, formats, exceptions, type_keys @@ -50,20 +56,132 @@ def _write_parameter_vec(file_obj, obj): file_obj.write(name_bytes) -def _write_parameter_expression(file_obj, obj, use_symengine, *, version): - if use_symengine: - expr_bytes = obj._symbol_expr.__reduce__()[1][0] +def _encode_replay_entry(inst, file_obj, version, r_side=False): + inst_type = None + inst_data = None + if inst is None: + inst_type = "n" + inst_data = b"\x00" + elif isinstance(inst, Parameter): + inst_type = "p" + inst_data = inst.uuid.bytes + elif isinstance(inst, complex): + inst_type = "c" + inst_data = struct.pack("!dd", inst.real, inst.imag) + elif isinstance(inst, float): + inst_type = "f" + inst_data = struct.pack("!Qd", 0, inst) + elif isinstance(inst, int): + inst_type = "i" + inst_data = struct.pack("!Qq", 0, inst) + elif isinstance(inst, ParameterExpression): + if not r_side: + entry = struct.pack( + formats.PARAM_EXPR_ELEM_V13_PACK, + 255, + "s".encode("utf8"), + b"\x00", + "n".encode("utf8"), + b"\x00", + ) + else: + entry = struct.pack( + formats.PARAM_EXPR_ELEM_V13_PACK, + 255, + "n".encode("utf8"), + b"\x00", + "s".encode("utf8"), + b"\x00", + ) + file_obj.write(entry) + _write_parameter_expression_v13(file_obj, inst, version) + if not r_side: + entry = struct.pack( + formats.PARAM_EXPR_ELEM_V13_PACK, + 255, + "e".encode("utf8"), + b"\x00", + "n".encode("utf8"), + b"\x00", + ) + else: + entry = struct.pack( + formats.PARAM_EXPR_ELEM_V13_PACK, + 255, + "n".encode("utf8"), + b"\x00", + "e".encode("utf8"), + b"\x00", + ) + file_obj.write(entry) + inst_type = "n" + inst_data = b"\x00" else: - from sympy import srepr, sympify + raise exceptions.QpyError("Invalid parameter expression type") + return inst_type, inst_data + + +def _encode_replay_subs(subs, file_obj, version): + with io.BytesIO() as mapping_buf: + subs_dict = {k.name: v for k, v in subs.binds.items()} + common.write_mapping( + mapping_buf, mapping=subs_dict, serializer=dumps_value, version=version + ) + data = mapping_buf.getvalue() + entry = struct.pack( + formats.PARAM_EXPR_ELEM_V13_PACK, + subs.op, + "u".encode("utf8"), + struct.pack("!QQ", len(data), 0), + "n".encode("utf8"), + b"\x00", + ) + file_obj.write(entry) + file_obj.write(data) + return subs.binds + + +def _write_parameter_expression_v13(file_obj, obj, version): + symbol_map = {} + for inst in obj._qpy_replay: + if isinstance(inst, _SUBS): + symbol_map.update(_encode_replay_subs(inst, file_obj, version)) + continue + lhs_type, lhs = _encode_replay_entry(inst.lhs, file_obj, version) + rhs_type, rhs = _encode_replay_entry(inst.rhs, file_obj, version, True) + entry = struct.pack( + formats.PARAM_EXPR_ELEM_V13_PACK, + inst.op, + lhs_type.encode("utf8"), + lhs, + rhs_type.encode("utf8"), + rhs, + ) + file_obj.write(entry) + return symbol_map - expr_bytes = srepr(sympify(obj._symbol_expr)).encode(common.ENCODE) +def _write_parameter_expression(file_obj, obj, use_symengine, *, version): + extra_symbols = None + if version < 13: + if use_symengine: + expr_bytes = obj._symbol_expr.__reduce__()[1][0] + else: + from sympy import srepr, sympify + + expr_bytes = srepr(sympify(obj._symbol_expr)).encode(common.ENCODE) + else: + with io.BytesIO() as buf: + extra_symbols = _write_parameter_expression_v13(buf, obj, version) + expr_bytes = buf.getvalue() + symbol_table_len = len(obj._parameter_symbols) + if extra_symbols: + symbol_table_len += 2 * len(extra_symbols) param_expr_header_raw = struct.pack( - formats.PARAMETER_EXPR_PACK, len(obj._parameter_symbols), len(expr_bytes) + formats.PARAMETER_EXPR_PACK, symbol_table_len, len(expr_bytes) ) file_obj.write(param_expr_header_raw) file_obj.write(expr_bytes) - for symbol, value in obj._parameter_symbols.items(): symbol_key = type_keys.Value.assign(symbol) @@ -89,6 +207,49 @@ def _write_parameter_expression(file_obj, obj, use_symengine, *, version): file_obj.write(elem_header) file_obj.write(symbol_data) file_obj.write(value_data) + if extra_symbols: + for symbol in extra_symbols: + symbol_key = type_keys.Value.assign(symbol) + # serialize key + if symbol_key == type_keys.Value.PARAMETER_VECTOR: + symbol_data = common.data_to_binary(symbol, _write_parameter_vec) + else: + symbol_data = common.data_to_binary(symbol, _write_parameter) + # serialize value + value_key, value_data = dumps_value( + symbol, version=version, use_symengine=use_symengine + ) + + elem_header = struct.pack( + formats.PARAM_EXPR_MAP_ELEM_V3_PACK, + symbol_key, + value_key, + len(value_data), + ) + file_obj.write(elem_header) + file_obj.write(symbol_data) + file_obj.write(value_data) + for symbol in extra_symbols.values(): + symbol_key = type_keys.Value.assign(symbol) + # serialize key + if symbol_key == type_keys.Value.PARAMETER_VECTOR: + symbol_data = common.data_to_binary(symbol, _write_parameter_vec) + else: + symbol_data = common.data_to_binary(symbol, _write_parameter) + # serialize value + value_key, value_data = dumps_value( + symbol, version=version, use_symengine=use_symengine + ) + + elem_header = struct.pack( + formats.PARAM_EXPR_MAP_ELEM_V3_PACK, + symbol_key, + value_key, + len(value_data), + ) + file_obj.write(elem_header) + file_obj.write(symbol_data) + file_obj.write(value_data) class _ExprWriter(expr.ExprVisitor[None]): @@ -334,6 +495,141 @@ def _read_parameter_expression_v3(file_obj, vectors, use_symengine): return ParameterExpression(symbol_map, expr_) +def _read_parameter_expression_v13(file_obj, vectors, version): + data = formats.PARAMETER_EXPR( + *struct.unpack(formats.PARAMETER_EXPR_PACK, file_obj.read(formats.PARAMETER_EXPR_SIZE)) + ) + + payload = file_obj.read(data.expr_size) + + symbol_map = {} + for _ in range(data.map_elements): + elem_data = formats.PARAM_EXPR_MAP_ELEM_V3( + *struct.unpack( + formats.PARAM_EXPR_MAP_ELEM_V3_PACK, + file_obj.read(formats.PARAM_EXPR_MAP_ELEM_V3_SIZE), + ) + ) + symbol_key = type_keys.Value(elem_data.symbol_type) + + if symbol_key == type_keys.Value.PARAMETER: + symbol = _read_parameter(file_obj) + elif symbol_key == type_keys.Value.PARAMETER_VECTOR: + symbol = _read_parameter_vec(file_obj, vectors) + else: + raise exceptions.QpyError(f"Invalid parameter expression map type: {symbol_key}") + + elem_key = type_keys.Value(elem_data.type) + binary_data = file_obj.read(elem_data.size) + if elem_key == type_keys.Value.INTEGER: + value = struct.unpack("!q", binary_data) + elif elem_key == type_keys.Value.FLOAT: + value = struct.unpack("!d", binary_data) + elif elem_key == type_keys.Value.COMPLEX: + value = complex(*struct.unpack(formats.COMPLEX_PACK, binary_data)) + elif elem_key in (type_keys.Value.PARAMETER, type_keys.Value.PARAMETER_VECTOR): + value = symbol._symbol_expr + elif elem_key == type_keys.Value.PARAMETER_EXPRESSION: + value = common.data_from_binary( + binary_data, + _read_parameter_expression_v13, + vectors=vectors, + ) + else: + raise exceptions.QpyError(f"Invalid parameter expression map type: {elem_key}") + symbol_map[symbol] = value + with io.BytesIO(payload) as buf: + return _read_parameter_expr_v13(buf, symbol_map, version, vectors) + + +def _read_parameter_expr_v13(buf, symbol_map, version, vectors): + param_uuid_map = {symbol.uuid: symbol for symbol in symbol_map if isinstance(symbol, Parameter)} + name_map = {str(v): k for k, v in symbol_map.items()} + data = buf.read(formats.PARAM_EXPR_ELEM_V13_SIZE) + stack = [] + while data: + expression_data = formats.PARAM_EXPR_ELEM_V13._make( + struct.unpack(formats.PARAM_EXPR_ELEM_V13_PACK, data) + ) + # LHS + if expression_data.LHS_TYPE == b"p": + stack.append(param_uuid_map[uuid.UUID(bytes=expression_data.LHS)]) + elif expression_data.LHS_TYPE == b"f": + stack.append(struct.unpack("!Qd", expression_data.LHS)[1]) + elif expression_data.LHS_TYPE == b"n": + pass + elif expression_data.LHS_TYPE == b"c": + stack.append(complex(*struct.unpack("!dd", expression_data.LHS))) + elif expression_data.LHS_TYPE == b"i": + stack.append(struct.unpack("!Qq", expression_data.LHS)[1]) + elif expression_data.LHS_TYPE == b"s": + data = buf.read(formats.PARAM_EXPR_ELEM_V13_SIZE) + continue + elif expression_data.LHS_TYPE == b"e": + data = buf.read(formats.PARAM_EXPR_ELEM_V13_SIZE) + continue + elif expression_data.LHS_TYPE == b"u": + size = struct.unpack_from("!QQ", expression_data.LHS)[0] + subs_map_data = buf.read(size) + with io.BytesIO(subs_map_data) as mapping_buf: + mapping = common.read_mapping( + mapping_buf, deserializer=loads_value, version=version, vectors=vectors + ) + stack.append({name_map[k]: v for k, v in mapping.items()}) + else: + raise exceptions.QpyError( + "Unknown ParameterExpression operation type {expression_data.LHS_TYPE}" + ) + # RHS + if expression_data.RHS_TYPE == b"p": + stack.append(param_uuid_map[uuid.UUID(bytes=expression_data.RHS)]) + elif expression_data.RHS_TYPE == b"f": + stack.append(struct.unpack("!Qd", expression_data.RHS)[1]) + elif expression_data.RHS_TYPE == b"n": + pass + elif expression_data.RHS_TYPE == b"c": + stack.append(complex(*struct.unpack("!dd", expression_data.RHS))) + elif expression_data.RHS_TYPE == b"i": + stack.append(struct.unpack("!Qq", expression_data.RHS)[1]) + elif expression_data.RHS_TYPE == b"s": + data = buf.read(formats.PARAM_EXPR_ELEM_V13_SIZE) + continue + elif expression_data.RHS_TYPE == b"e": + data = buf.read(formats.PARAM_EXPR_ELEM_V13_SIZE) + continue + else: + raise exceptions.QpyError( + f"Unknown ParameterExpression operation type {expression_data.RHS_TYPE}" + ) + if expression_data.OP_CODE == 255: + continue + method_str = op_code_to_method(_OPCode(expression_data.OP_CODE)) + if expression_data.OP_CODE in {0, 1, 2, 3, 4, 13, 15, 18, 19, 20}: + rhs = stack.pop() + lhs = stack.pop() + # Reverse ops for commutative ops, which are add, mul (0 and 2 respectively) + # op codes 13 and 15 can never be reversed and 18, 19, 20 + # are the reversed versions of non-commuative operations + # so 1, 3, 4 and 18, 19, 20 handle this explicitly. + if ( + not isinstance(lhs, ParameterExpression) + and isinstance(rhs, ParameterExpression) + and expression_data.OP_CODE in {0, 2} + ): + if expression_data.OP_CODE == 0: + method_str = "__radd__" + elif expression_data.OP_CODE == 2: + method_str = "__rmul__" + stack.append(getattr(rhs, method_str)(lhs)) + else: + stack.append(getattr(lhs, method_str)(rhs)) + else: + lhs = stack.pop() + stack.append(getattr(lhs, method_str)()) + data = buf.read(formats.PARAM_EXPR_ELEM_V13_SIZE) + return stack.pop() + + def _read_expr( file_obj, clbits: collections.abc.Sequence[Clbit], @@ -664,13 +960,17 @@ def loads_value( if type_key == type_keys.Value.PARAMETER_EXPRESSION: if version < 3: return common.data_from_binary(binary_data, _read_parameter_expression) - else: + elif version < 13: return common.data_from_binary( binary_data, _read_parameter_expression_v3, vectors=vectors, use_symengine=use_symengine, ) + else: + return common.data_from_binary( + binary_data, _read_parameter_expression_v13, vectors=vectors, version=version + ) if type_key == type_keys.Value.EXPRESSION: return common.data_from_binary( binary_data, diff --git a/qiskit/qpy/common.py b/qiskit/qpy/common.py index 8d8a57b7404f..c2585d5be4d3 100644 --- a/qiskit/qpy/common.py +++ b/qiskit/qpy/common.py @@ -25,7 +25,7 @@ from qiskit.qpy import formats, exceptions -QPY_VERSION = 12 +QPY_VERSION = 13 QPY_COMPATIBILITY_VERSION = 10 ENCODE = "utf8" diff --git a/qiskit/qpy/formats.py b/qiskit/qpy/formats.py index a48a9ea777fa..7696cae94e2b 100644 --- a/qiskit/qpy/formats.py +++ b/qiskit/qpy/formats.py @@ -259,6 +259,13 @@ PARAMETER_PACK = "!H16s" PARAMETER_SIZE = struct.calcsize(PARAMETER_PACK) +# PARAMETEREXPRESSION_ENTRY +PARAM_EXPR_ELEM_V13 = namedtuple( + "PARAM_EXPR_ELEM_V13", ["OP_CODE", "LHS_TYPE", "LHS", "RHS_TYPE", "RHS"] +) +PARAM_EXPR_ELEM_V13_PACK = "!Bc16sc16s" +PARAM_EXPR_ELEM_V13_SIZE = struct.calcsize(PARAM_EXPR_ELEM_V13_PACK) + # COMPLEX COMPLEX = namedtuple("COMPLEX", ["real", "imag"]) COMPLEX_PACK = "!dd" diff --git a/qiskit/qpy/interface.py b/qiskit/qpy/interface.py index 688064625fb7..c56630fa2b10 100644 --- a/qiskit/qpy/interface.py +++ b/qiskit/qpy/interface.py @@ -133,9 +133,9 @@ def dump( used as the ``cls`` kwarg on the `json.dump()`` call to JSON serialize that dictionary. use_symengine: If True, all objects containing symbolic expressions will be serialized using symengine's native mechanism. This is a faster serialization alternative, - but not supported in all platforms. Please check that your target platform is supported - by the symengine library before setting this option, as it will be required by qpy to - deserialize the payload. For this reason, the option defaults to False. + but not supported in all platforms. This flag only has an effect if the emitted QPY format + version is 10, 11, or 12. For QPY format version >= 13 (which is the default starting in + Qiskit 1.3.0) this flag is no longer used. version: The QPY format version to emit. By default this defaults to the latest supported format of :attr:`~.qpy.QPY_VERSION`, however for compatibility reasons if you need to load the generated QPY payload with an older diff --git a/releasenotes/notes/add-qpy-v13-3b22ae33045af6c1.yaml b/releasenotes/notes/add-qpy-v13-3b22ae33045af6c1.yaml new file mode 100644 index 000000000000..3cc80adc0b5c --- /dev/null +++ b/releasenotes/notes/add-qpy-v13-3b22ae33045af6c1.yaml @@ -0,0 +1,33 @@ +--- +features_qpy: + - | + Added a new QPY format version 13 that adds a Qiskit native representation + of :class:`.ParameterExpression` objects. +issues: + - | + When using QPY formats 10, 11, or 12 there is a dependency on the version + of ``symengine`` installed in the payload for serialized + :class:`.ParamerExpression` if there is mismatched version of the installed + ``symengine`` package between the environment that generated the payload with + :func:`.qpy.dump` and the installed version that is trying to load the payload + with :func:`.qpy.load`. If this is encountered you will need to install the + symengine version from the error message emitted to load the payload. QPY + format version >= 13 (or < 10) will not have this issue and it is recommended + if you're serializing :class:`.ParameterExpression` objects as part of your + circuit or any :class:`.ScheduleBlock` objects you use version 13 to avoid + this issue in the future. +upgrade_qpy: + - | + The :func:`.qpy.dump` function will now emit format version 13 by default. + This means payloads generated with this function by default will only + be compatible with Qiskit >= 1.3.0. If you need for the payload to be + loaded by a older version of Qiskit you can use the ``version`` flag on + :func:`.qpy.dump` to emit a version compatible with earlier releases of + Qiskit. You can refer to :ref:`qpy_compatibility` for more details on this. +# security: +# - | +# Add security notes here, or remove this section. All of the list items in +# this section are combined when the release notes are rendered, so the text +# needs to be worded so that it does not depend on any information only +# available in another section, such as the prelude. This may mean repeating +# some details. diff --git a/test/python/qpy/test_circuit_load_from_qpy.py b/test/python/qpy/test_circuit_load_from_qpy.py index e909f7ced455..8890a45ffe9e 100644 --- a/test/python/qpy/test_circuit_load_from_qpy.py +++ b/test/python/qpy/test_circuit_load_from_qpy.py @@ -306,6 +306,71 @@ def test_compatibility_version_roundtrip(self): qc.measure_all() self.assert_roundtrip_equal(qc, version=QPY_COMPATIBILITY_VERSION) + def test_nested_params_subs(self): + """Test substitution works.""" + qc = QuantumCircuit(1) + a = Parameter("a") + b = Parameter("b") + expr = a + b + expr = expr.subs({b: a}) + qc.ry(expr, 0) + self.assert_roundtrip_equal(qc) + + def test_all_the_expression_ops(self): + """Test a circuit with an expression that uses all the ops available.""" + qc = QuantumCircuit(1) + a = Parameter("a") + b = Parameter("b") + c = Parameter("c") + d = Parameter("d") + + expression = (a + b.sin() / 4) * c**2 + final_expr = ( + (expression.cos() + d.arccos() - d.arcsin() + d.arctan() + d.tan()) / d.exp() + + expression.gradient(a) + + expression.log() + - a.sin() + - b.conjugate() + ) + final_expr = final_expr.abs() + final_expr = final_expr.subs({c: a}) + + qc.rx(final_expr, 0) + self.assert_roundtrip_equal(qc) + + def test_rpow(self): + """Test rpow works as expected""" + qc = QuantumCircuit(1) + a = Parameter("A") + b = Parameter("B") + expr = 3.14159**a + expr = expr**b + expr = 1.2345**expr + qc.ry(expr, 0) + self.assert_roundtrip_equal(qc) + + def test_rsub(self): + """Test rsub works as expected""" + qc = QuantumCircuit(1) + a = Parameter("A") + b = Parameter("B") + expr = 3.14159 - a + expr = expr - b + expr = 1.2345 - expr + qc.ry(expr, 0) + self.assert_roundtrip_equal(qc) + + def test_rdiv(self): + """Test rdiv works as expected""" + qc = QuantumCircuit(1) + a = Parameter("A") + b = Parameter("B") + expr = 3.14159 / a + expr = expr / b + expr = 1.2345 / expr + qc.ry(expr, 0) + self.assert_roundtrip_equal(qc) + class TestUseSymengineFlag(QpyCircuitTestCase): """Test that the symengine flag works correctly."""