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