Speed up LinearQubitOperator matvec#1417
Conversation
_matvec applied each Pauli term by recursively splitting the amplitude vector into sublists and reassembling it. Apply each term instead as a signed permutation of the amplitudes: the X and Y qubits give an index xor mask, the Y and Z qubits give the indices whose bit parity sets the sign, and each Y adds a factor of 1j. This is a pure implementation change with identical results.
There was a problem hiding this comment.
Code Review
This pull request refactors the _matvec method in LinearQubitOperator to use bitwise operations and masks, introducing a new _bit_parity helper function to apply operators as signed permutations instead of recursively splitting vectors. The review feedback points out that using 1j ** (y_count % 4) introduces numerical inaccuracies due to floating-point approximations and suggests using a lookup table [1, 1j, -1, -1j][y_count % 4] to keep the real and imaginary parts exact.
| else: | ||
| z_mask ^= bit | ||
|
|
||
| amplitudes = coefficient * (1j ** (y_count % 4)) * vec |
There was a problem hiding this comment.
Using 1j ** (y_count % 4) introduces numerical inaccuracies (spurious imaginary parts of order 1j ** 2 evaluates to (-1+1.2246467991473532e-16j) instead of exactly -1. In quantum simulation, keeping real/imaginary parts exact is important to avoid accumulating numerical noise.
Using a pre-defined lookup table like [1, 1j, -1, -1j][y_count % 4] is 100% exact and also faster.
| amplitudes = coefficient * (1j ** (y_count % 4)) * vec | |
| amplitudes = coefficient * [1, 1j, -1, -1j][y_count % 4] * vec |
There was a problem hiding this comment.
Thanks, took the table. One note for the thread: 1j ** (y_count % 4) is actually already exact here since the exponent is a Python int, so CPython evaluates it by repeated multiplication rather than the polar form the example uses. The table makes that explicit though, and keeps it exact if the exponent ever comes through as a numpy scalar.
The phase contributed by the Y factors is always one of 1, 1j, -1, -1j. Index a small table by y_count % 4 instead of evaluating 1j ** (y_count % 4). Same values, and it stays exact if the exponent is ever a numpy integer rather than a Python int.
Second of the operator hot-path findings from #1407; the first is #1415.
LinearQubitOperator._matvecapplies aQubitOperatorto a state vector, and itis the inner loop behind
generate_linear_qubit_operatorand the eigensolversbuilt on top of it. For each term it took the input vector, split it into
2**kpieces to reach the first non-identity qubit, split each of those in half, applied
the 2x2 Pauli to the pairs, and concatenated everything back, once per Pauli in
the term. That is a lot of Python-level list building and array copying for what
is, per term, a single reindexing of the amplitudes.
A Pauli string acts on the state vector as a signed permutation, so it can be
written directly on the flat index. Number the amplitudes
0 .. 2**n - 1and putqubit
qat bitn - 1 - q(the ordering the split version already used, and theone
qubit_operator_sparseuses). Then for one term:cmoves toc ^ x_mask;cis set,that is
(-1)raised to the parity ofpopcount(c & z_mask);1j.So the term becomes
out[idx ^ x_mask] += coeff * 1j**(y_count % 4) * sign * x,where
signis a vector of +-1 from the bit parity andidx ^ x_maskis apermutation of the indices, which is what makes the fancy-indexed
+=safe. Themasks are cheap integer folds over the term and the per-amplitude work is a few
vectorized numpy operations with no intermediate lists.
This is an implementation change only. Public behavior is unchanged: complex
output, inputs of shape
(dim,)or(dim, 1), the identity term, operatorsdeclared on more qubits than they act on, and the empty operator all behave as
before. Only the serial
_matvecis touched;ParallelLinearQubitOperatorisleft alone.
The parity of
popcountuses a small xor-fold helper on uint64 rather thannumpy.bitwise_count, because the project still supports numpy 1.26 (themax_compat CI job pins
numpy==1.26.4) andbitwise_countneeds numpy 2.0.Correctness
linear_qubit_operator_test.pypasses unchanged. On top of that I compared thenew matvec against
qubit_operator_sparse(op) @ xover random operators forn = 2..12(mixed X/Y/Z, term counts including the empty and identity-onlyoperators), real and complex
x, shapes(dim,)and(dim, 1), andn_qubitsoversized by 2, plus a
scipy.sparse.linalg.eigshrun checked against denseeigenvalues. Worst absolute differences:
black and mypy are clean on the file.
Motivation
This is what #499 asked for. That issue called for a better
_matvec, measuredthat one was worth 80-100x at system sizes of 16 qubits and up, and noted that
ParallelLinearQubitOperatorcould then be deleted as obsolete once the serialpath was fast. The sketch there routed each Pauli through Cirq's
apply_unitary; no port ever landed and the issue was closed for inactivity in2025. This PR gets the same win with plain numpy on the flat index, so it adds
no dependency, and the 120x at 16 qubits below bears out the issue's estimate.
The parallel path is also where the deadlock possibility in #1405 lives, and
with the serial matvec this fast there is rarely a reason to reach for it.
Removing it, as #499 suggested, would be a separate change; this PR fixes
neither issue.
Benchmarks
Random QubitOperators with mixed X/Y/Z terms applied to a complex state vector:
Measured on my laptop (Intel Core Ultra 5 225, Windows, Python 3.12, numpy 2.5).
I ran the base/patch comparison twice in alternation with fixed seeds, taking
medians over three runs at n = 16 and single runs at n = 18 and 20 since the old
code takes minutes there; the per-round speedups agreed within a few percent, so
the table pools both rounds.