Skip to content

Commit 0a7690d

Browse files
mtreinishElePT
andauthored
Add Qiskit native QPY ParameterExpression serialization (#13356)
* 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>
1 parent 1b35e8b commit 0a7690d

File tree

9 files changed

+744
-47
lines changed

9 files changed

+744
-47
lines changed

qiskit/circuit/parameter.py

+4
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ def __init__(
8787
self._hash = hash((self._parameter_keys, self._symbol_expr))
8888
self._parameter_symbols = {self: symbol}
8989
self._name_map = None
90+
self._qpy_replay = []
91+
self._standalone_param = True
9092

9193
def assign(self, parameter, value):
9294
if parameter != self:
@@ -172,3 +174,5 @@ def __setstate__(self, state):
172174
self._hash = hash((self._parameter_keys, self._symbol_expr))
173175
self._parameter_symbols = {self: self._symbol_expr}
174176
self._name_map = None
177+
self._qpy_replay = []
178+
self._standalone_param = True

qiskit/circuit/parameterexpression.py

+167-34
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
"""
1515

1616
from __future__ import annotations
17+
18+
from dataclasses import dataclass
19+
from enum import IntEnum
1720
from typing import Callable, Union
1821

1922
import numbers
@@ -30,12 +33,86 @@
3033
ParameterValueType = Union["ParameterExpression", float]
3134

3235

36+
class _OPCode(IntEnum):
37+
ADD = 0
38+
SUB = 1
39+
MUL = 2
40+
DIV = 3
41+
POW = 4
42+
SIN = 5
43+
COS = 6
44+
TAN = 7
45+
ASIN = 8
46+
ACOS = 9
47+
EXP = 10
48+
LOG = 11
49+
SIGN = 12
50+
GRAD = 13
51+
CONJ = 14
52+
SUBSTITUTE = 15
53+
ABS = 16
54+
ATAN = 17
55+
RSUB = 18
56+
RDIV = 19
57+
RPOW = 20
58+
59+
60+
_OP_CODE_MAP = (
61+
"__add__",
62+
"__sub__",
63+
"__mul__",
64+
"__truediv__",
65+
"__pow__",
66+
"sin",
67+
"cos",
68+
"tan",
69+
"arcsin",
70+
"arccos",
71+
"exp",
72+
"log",
73+
"sign",
74+
"gradient",
75+
"conjugate",
76+
"subs",
77+
"abs",
78+
"arctan",
79+
"__rsub__",
80+
"__rtruediv__",
81+
"__rpow__",
82+
)
83+
84+
85+
def op_code_to_method(op_code: _OPCode):
86+
"""Return the method name for a given op_code."""
87+
return _OP_CODE_MAP[op_code]
88+
89+
90+
@dataclass
91+
class _INSTRUCTION:
92+
op: _OPCode
93+
lhs: ParameterValueType | None
94+
rhs: ParameterValueType | None = None
95+
96+
97+
@dataclass
98+
class _SUBS:
99+
binds: dict
100+
op: _OPCode = _OPCode.SUBSTITUTE
101+
102+
33103
class ParameterExpression:
34104
"""ParameterExpression class to enable creating expressions of Parameters."""
35105

36-
__slots__ = ["_parameter_symbols", "_parameter_keys", "_symbol_expr", "_name_map"]
106+
__slots__ = [
107+
"_parameter_symbols",
108+
"_parameter_keys",
109+
"_symbol_expr",
110+
"_name_map",
111+
"_qpy_replay",
112+
"_standalone_param",
113+
]
37114

38-
def __init__(self, symbol_map: dict, expr):
115+
def __init__(self, symbol_map: dict, expr, *, _qpy_replay=None):
39116
"""Create a new :class:`ParameterExpression`.
40117
41118
Not intended to be called directly, but to be instantiated via operations
@@ -54,6 +131,11 @@ def __init__(self, symbol_map: dict, expr):
54131
self._parameter_keys = frozenset(p._hash_key() for p in self._parameter_symbols)
55132
self._symbol_expr = expr
56133
self._name_map: dict | None = None
134+
self._standalone_param = False
135+
if _qpy_replay is not None:
136+
self._qpy_replay = _qpy_replay
137+
else:
138+
self._qpy_replay = []
57139

58140
@property
59141
def parameters(self) -> set:
@@ -69,8 +151,14 @@ def _names(self) -> dict:
69151

70152
def conjugate(self) -> "ParameterExpression":
71153
"""Return the conjugate."""
154+
if self._standalone_param:
155+
new_op = _INSTRUCTION(_OPCode.CONJ, self)
156+
else:
157+
new_op = _INSTRUCTION(_OPCode.CONJ, None)
158+
new_replay = self._qpy_replay.copy()
159+
new_replay.append(new_op)
72160
conjugated = ParameterExpression(
73-
self._parameter_symbols, symengine.conjugate(self._symbol_expr)
161+
self._parameter_symbols, symengine.conjugate(self._symbol_expr), _qpy_replay=new_replay
74162
)
75163
return conjugated
76164

@@ -117,6 +205,7 @@ def bind(
117205
self._raise_if_passed_unknown_parameters(parameter_values.keys())
118206
self._raise_if_passed_nan(parameter_values)
119207

208+
new_op = _SUBS(parameter_values)
120209
symbol_values = {}
121210
for parameter, value in parameter_values.items():
122211
if (param_expr := self._parameter_symbols.get(parameter)) is not None:
@@ -143,7 +232,12 @@ def bind(
143232
f"(Expression: {self}, Bindings: {parameter_values})."
144233
)
145234

146-
return ParameterExpression(free_parameter_symbols, bound_symbol_expr)
235+
new_replay = self._qpy_replay.copy()
236+
new_replay.append(new_op)
237+
238+
return ParameterExpression(
239+
free_parameter_symbols, bound_symbol_expr, _qpy_replay=new_replay
240+
)
147241

148242
def subs(
149243
self, parameter_map: dict, allow_unknown_parameters: bool = False
@@ -175,6 +269,7 @@ def subs(
175269
for p in replacement_expr.parameters
176270
}
177271
self._raise_if_parameter_names_conflict(inbound_names, parameter_map.keys())
272+
new_op = _SUBS(parameter_map)
178273

179274
# Include existing parameters in self not set to be replaced.
180275
new_parameter_symbols = {
@@ -192,8 +287,12 @@ def subs(
192287
new_parameter_symbols[p] = symbol_type(p.name)
193288

194289
substituted_symbol_expr = self._symbol_expr.subs(symbol_map)
290+
new_replay = self._qpy_replay.copy()
291+
new_replay.append(new_op)
195292

196-
return ParameterExpression(new_parameter_symbols, substituted_symbol_expr)
293+
return ParameterExpression(
294+
new_parameter_symbols, substituted_symbol_expr, _qpy_replay=new_replay
295+
)
197296

198297
def _raise_if_passed_unknown_parameters(self, parameters):
199298
unknown_parameters = parameters - self.parameters
@@ -231,7 +330,11 @@ def _raise_if_parameter_names_conflict(self, inbound_parameters, outbound_parame
231330
)
232331

233332
def _apply_operation(
234-
self, operation: Callable, other: ParameterValueType, reflected: bool = False
333+
self,
334+
operation: Callable,
335+
other: ParameterValueType,
336+
reflected: bool = False,
337+
op_code: _OPCode = None,
235338
) -> "ParameterExpression":
236339
"""Base method implementing math operations between Parameters and
237340
either a constant or a second ParameterExpression.
@@ -253,7 +356,6 @@ def _apply_operation(
253356
A new expression describing the result of the operation.
254357
"""
255358
self_expr = self._symbol_expr
256-
257359
if isinstance(other, ParameterExpression):
258360
self._raise_if_parameter_names_conflict(other._names)
259361
parameter_symbols = {**self._parameter_symbols, **other._parameter_symbols}
@@ -266,10 +368,26 @@ def _apply_operation(
266368

267369
if reflected:
268370
expr = operation(other_expr, self_expr)
371+
if op_code in {_OPCode.RSUB, _OPCode.RDIV, _OPCode.RPOW}:
372+
if self._standalone_param:
373+
new_op = _INSTRUCTION(op_code, self, other)
374+
else:
375+
new_op = _INSTRUCTION(op_code, None, other)
376+
else:
377+
if self._standalone_param:
378+
new_op = _INSTRUCTION(op_code, other, self)
379+
else:
380+
new_op = _INSTRUCTION(op_code, other, None)
269381
else:
270382
expr = operation(self_expr, other_expr)
271-
272-
out_expr = ParameterExpression(parameter_symbols, expr)
383+
if self._standalone_param:
384+
new_op = _INSTRUCTION(op_code, self, other)
385+
else:
386+
new_op = _INSTRUCTION(op_code, None, other)
387+
new_replay = self._qpy_replay.copy()
388+
new_replay.append(new_op)
389+
390+
out_expr = ParameterExpression(parameter_symbols, expr, _qpy_replay=new_replay)
273391
out_expr._name_map = self._names.copy()
274392
if isinstance(other, ParameterExpression):
275393
out_expr._names.update(other._names.copy())
@@ -291,6 +409,13 @@ def gradient(self, param) -> Union["ParameterExpression", complex]:
291409
# If it is not contained then return 0
292410
return 0.0
293411

412+
if self._standalone_param:
413+
new_op = _INSTRUCTION(_OPCode.GRAD, self, param)
414+
else:
415+
new_op = _INSTRUCTION(_OPCode.GRAD, None, param)
416+
qpy_replay = self._qpy_replay.copy()
417+
qpy_replay.append(new_op)
418+
294419
# Compute the gradient of the parameter expression w.r.t. param
295420
key = self._parameter_symbols[param]
296421
expr_grad = symengine.Derivative(self._symbol_expr, key)
@@ -304,7 +429,7 @@ def gradient(self, param) -> Union["ParameterExpression", complex]:
304429
parameter_symbols[parameter] = symbol
305430
# If the gradient corresponds to a parameter expression then return the new expression.
306431
if len(parameter_symbols) > 0:
307-
return ParameterExpression(parameter_symbols, expr=expr_grad)
432+
return ParameterExpression(parameter_symbols, expr=expr_grad, _qpy_replay=qpy_replay)
308433
# If no free symbols left, return a complex or float gradient
309434
expr_grad_cplx = complex(expr_grad)
310435
if expr_grad_cplx.imag != 0:
@@ -313,81 +438,89 @@ def gradient(self, param) -> Union["ParameterExpression", complex]:
313438
return float(expr_grad)
314439

315440
def __add__(self, other):
316-
return self._apply_operation(operator.add, other)
441+
return self._apply_operation(operator.add, other, op_code=_OPCode.ADD)
317442

318443
def __radd__(self, other):
319-
return self._apply_operation(operator.add, other, reflected=True)
444+
return self._apply_operation(operator.add, other, reflected=True, op_code=_OPCode.ADD)
320445

321446
def __sub__(self, other):
322-
return self._apply_operation(operator.sub, other)
447+
return self._apply_operation(operator.sub, other, op_code=_OPCode.SUB)
323448

324449
def __rsub__(self, other):
325-
return self._apply_operation(operator.sub, other, reflected=True)
450+
return self._apply_operation(operator.sub, other, reflected=True, op_code=_OPCode.RSUB)
326451

327452
def __mul__(self, other):
328-
return self._apply_operation(operator.mul, other)
453+
return self._apply_operation(operator.mul, other, op_code=_OPCode.MUL)
329454

330455
def __pos__(self):
331-
return self._apply_operation(operator.mul, 1)
456+
return self._apply_operation(operator.mul, 1, op_code=_OPCode.MUL)
332457

333458
def __neg__(self):
334-
return self._apply_operation(operator.mul, -1)
459+
return self._apply_operation(operator.mul, -1, op_code=_OPCode.MUL)
335460

336461
def __rmul__(self, other):
337-
return self._apply_operation(operator.mul, other, reflected=True)
462+
return self._apply_operation(operator.mul, other, reflected=True, op_code=_OPCode.MUL)
338463

339464
def __truediv__(self, other):
340465
if other == 0:
341466
raise ZeroDivisionError("Division of a ParameterExpression by zero.")
342-
return self._apply_operation(operator.truediv, other)
467+
return self._apply_operation(operator.truediv, other, op_code=_OPCode.DIV)
343468

344469
def __rtruediv__(self, other):
345-
return self._apply_operation(operator.truediv, other, reflected=True)
470+
return self._apply_operation(operator.truediv, other, reflected=True, op_code=_OPCode.RDIV)
346471

347472
def __pow__(self, other):
348-
return self._apply_operation(pow, other)
473+
return self._apply_operation(pow, other, op_code=_OPCode.POW)
349474

350475
def __rpow__(self, other):
351-
return self._apply_operation(pow, other, reflected=True)
476+
return self._apply_operation(pow, other, reflected=True, op_code=_OPCode.RPOW)
352477

353-
def _call(self, ufunc):
354-
return ParameterExpression(self._parameter_symbols, ufunc(self._symbol_expr))
478+
def _call(self, ufunc, op_code):
479+
if self._standalone_param:
480+
new_op = _INSTRUCTION(op_code, self)
481+
else:
482+
new_op = _INSTRUCTION(op_code, None)
483+
new_replay = self._qpy_replay.copy()
484+
new_replay.append(new_op)
485+
return ParameterExpression(
486+
self._parameter_symbols, ufunc(self._symbol_expr), _qpy_replay=new_replay
487+
)
355488

356489
def sin(self):
357490
"""Sine of a ParameterExpression"""
358-
return self._call(symengine.sin)
491+
return self._call(symengine.sin, op_code=_OPCode.SIN)
359492

360493
def cos(self):
361494
"""Cosine of a ParameterExpression"""
362-
return self._call(symengine.cos)
495+
return self._call(symengine.cos, op_code=_OPCode.COS)
363496

364497
def tan(self):
365498
"""Tangent of a ParameterExpression"""
366-
return self._call(symengine.tan)
499+
return self._call(symengine.tan, op_code=_OPCode.TAN)
367500

368501
def arcsin(self):
369502
"""Arcsin of a ParameterExpression"""
370-
return self._call(symengine.asin)
503+
return self._call(symengine.asin, op_code=_OPCode.ASIN)
371504

372505
def arccos(self):
373506
"""Arccos of a ParameterExpression"""
374-
return self._call(symengine.acos)
507+
return self._call(symengine.acos, op_code=_OPCode.ACOS)
375508

376509
def arctan(self):
377510
"""Arctan of a ParameterExpression"""
378-
return self._call(symengine.atan)
511+
return self._call(symengine.atan, op_code=_OPCode.ATAN)
379512

380513
def exp(self):
381514
"""Exponential of a ParameterExpression"""
382-
return self._call(symengine.exp)
515+
return self._call(symengine.exp, op_code=_OPCode.EXP)
383516

384517
def log(self):
385518
"""Logarithm of a ParameterExpression"""
386-
return self._call(symengine.log)
519+
return self._call(symengine.log, op_code=_OPCode.LOG)
387520

388521
def sign(self):
389522
"""Sign of a ParameterExpression"""
390-
return self._call(symengine.sign)
523+
return self._call(symengine.sign, op_code=_OPCode.SIGN)
391524

392525
def __repr__(self):
393526
return f"{self.__class__.__name__}({str(self)})"
@@ -455,7 +588,7 @@ def __deepcopy__(self, memo=None):
455588

456589
def __abs__(self):
457590
"""Absolute of a ParameterExpression"""
458-
return self._call(symengine.Abs)
591+
return self._call(symengine.Abs, _OPCode.ABS)
459592

460593
def abs(self):
461594
"""Absolute of a ParameterExpression"""

0 commit comments

Comments
 (0)