Skip to content

Avoid deepcopy in SymbolicOperator arithmetic#1415

Open
blazingphoenix7 wants to merge 2 commits into
quantumlib:mainfrom
blazingphoenix7:avoid-deepcopy-in-operator-arithmetic
Open

Avoid deepcopy in SymbolicOperator arithmetic#1415
blazingphoenix7 wants to merge 2 commits into
quantumlib:mainfrom
blazingphoenix7:avoid-deepcopy-in-operator-arithmetic

Conversation

@blazingphoenix7

Copy link
Copy Markdown
Contributor

This is the patch offered in #1407.

SymbolicOperator.__add__, __sub__, __mul__ and accumulate each start by
copying the operand with copy.deepcopy and then mutate the copy in place with
the matching __iadd__ / __isub__ / __imul__. deepcopy walks the whole
terms dictionary and recursively copies every key and every value.

None of that recursion buys anything here. A term key is a tuple of
(index, action) tuples, and a coefficient is an int, float, complex or sympy
expression. All of those are immutable, so the copy only needs to be independent
at the dictionary level: the in-place operators rebind self.terms[term] and add
or remove keys, they never mutate a key or a coefficient object in place. A plain
dict(self.terms) is therefore enough to make a result that is safe to mutate
without touching the original.

This adds a small _clone helper that builds an empty instance and copies the
terms dictionary into it, and uses it in the four spots that called deepcopy. For
operators with many terms it's a good deal cheaper than the deep copy, and the
out-of-place guarantee (a + b leaves both a and b unchanged) is the same as
before.

Tests

The existing symbolic_operator_test.py already covers this: the add, subtract,
multiply and divide tests each assert the operation is done out of place, and
qubit_operator_test.py exercises accumulate. I checked that every changed line
is hit by the current suite, and ops/operators/, utils/ and
transforms/opconversions/ pass. black and mypy are clean on the file.

Benchmarks

Random QubitOperators over 30 qubits, both operands the same size:

terms    op         before     after    speedup
 1000    A + B     17.1 ms    0.49 ms       35x
 1000    A - B     16.7 ms    0.47 ms       35x
 1000    2.0 * A   16.5 ms    0.19 ms       89x
 5000    A + B     85.8 ms    2.5 ms        35x
 5000    A - B     85.2 ms    2.6 ms        33x
 5000    2.0 * A   83.6 ms    0.99 ms       84x
20000    A + B      348 ms    11.8 ms       30x
20000    A - B      347 ms    11.9 ms       29x
20000    2.0 * A    339 ms    4.7 ms        72x

Measured on my laptop (Intel Core Ultra 5 225, Windows, Python 3.12, numpy 2.5).
Times are medians over 25 repetitions after a warmup. I ran the whole
base/patch comparison twice in alternation with fixed seeds and the speedups
agreed within about 10 percent, so the table pools both rounds.

__add__, __sub__, __mul__ and accumulate copied the operand with
copy.deepcopy before mutating it in place. Term keys are tuples of
tuples and coefficients are numbers or sympy expressions, all
immutable, so copying the terms dictionary is enough to keep the
result independent of the operand. Add a small _clone helper that does
this and use it in place of deepcopy.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces expensive copy.deepcopy calls with a custom _clone method in SymbolicOperator to improve performance. The review feedback points out that in the accumulate method, cloning cls.zero() when start is None is redundant and suggests an optimized ternary expression instead.

def accumulate(cls, operators, start=None):
"""Sums over SymbolicOperators."""
total = copy.deepcopy(start or cls.zero())
total = (start or cls.zero())._clone()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When start is None, start or cls.zero() evaluates to cls.zero(), which is a newly created empty operator. Calling _clone() on it is redundant and adds unnecessary overhead. Using an explicit is not None check avoids this redundant clone and is also safer if start is an empty operator that might evaluate to falsy.

Suggested change
total = (start or cls.zero())._clone()
total = start._clone() if start is not None else cls.zero()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The falsy case can't actually come up since SymbolicOperator doesn't define __bool__ or __len__, but the explicit check reads better anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant