Skip to content

Commit c9e3926

Browse files
$O(N^3)$ Pauli-flow finding algorithm (#337)
Introduce the `O(N^3)` Pauli flow-finding algorithm presented in [1] which improves the previous version with `O(N^5)` complexity [2]. Since a Pauli flow in an open graph without Pauli measurements defines a gflow, this algorithm also improves the previous implementation of the gflow-finding algorithm with `O(N^4)` complexity [3]. The implementation proposed here considers the suggestion in [1] to eliminate the rows that are identically 0 in the order-demand matrix to accelerate the flow-finding routine in open graphs with nI ≠ n O. Finally, the new algorithm solves the issue discussed in qat-inria#17 , whereby the previous flow-finding functions could return a correction function containing input nodes in the codomain, which is not allowed by the definition of gflow or Pauli flow. In summary: - The core functions of the algorithm are encapsulated in the new module `graphix.find_pflow`, while `graphix.gflow` and related modules are adapted accordingly to avoid modifying the current API in this PR. - The module `graphix.linalg` has been renamed `graphix._linalg` and updated with more efficient functions for gaussian elimination, back substitution, kernel-finding and right-inverse. New implementation is GF2-specialized and, where appropriate, just-in-time compiled with numba. The dependence on `sympy` and `galois` is dropped. Tests are simplified and updated accordingly, and module is now typed. - `pytest-benchmark` is incorporated to allow for easier benchmarking between different commits (courtesy of @thierry-martinez). Additional comments --------------------- - This PR addresses the same issue as #294, but is otherwise unrelated. - The module `graphix.gflow` will be restructured in a future PR. The implementation exhibits the cubic scaling as described in [1]. See https://gitlab.inria.fr/muldemol/pflow-on3-scaling.git for a benchmarking code. References ----------- [1] Mitosek and Backens, 2024 (arXiv:2410.23439) [2] Simmons, 2021 (arXiv:2109.05654) [3] Backens et al., Quantum 5, 421 (2021) --------- Co-authored-by: Thierry Martinez <[email protected]>
1 parent c0f7d16 commit c9e3926

20 files changed

+1901
-961
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,4 @@ docs/source/benchmarks
167167
docs/source/gallery
168168
graphix/_version.py
169169
docs/source/sg_execution_times.rst
170+
/.benchmarks/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- #337: New module `graphix.find_pauliflow` with the $O(N^3)$
13+
Pauli-flow finding algorithm introduced in Mitosek and Backens, 2024
14+
(arXiv:2410.23439).
15+
1216
- #332: New class `StandardizedPattern` that contains the decomposed
1317
parts of a standardized pattern.
1418

@@ -30,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3034

3135
### Changed
3236

37+
- #337: Dropped dependence on `sympy` and `galois`.
38+
3339
- #332: `Pattern.extract_graph` now returns a networkx graph.
3440

3541
- #220, #332: `Pattern.extract_graph`, `Pattern.extract_isolated_nodes`,

graphix/_linalg.py

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
r"""Performant module for linear algebra on :math:`\mathbb F_2` field."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import numba as nb
8+
import numpy as np
9+
import numpy.typing as npt
10+
11+
if TYPE_CHECKING:
12+
from typing import Self
13+
14+
15+
class MatGF2(npt.NDArray[np.uint8]):
16+
r"""Custom implementation of :math:`\mathbb F_2` matrices. This class specializes `:class:np.ndarray` to the :math:`\mathbb F_2` field with increased efficiency."""
17+
18+
def __new__(cls, data: npt.ArrayLike, copy: bool = True) -> Self:
19+
"""Instantiate new `MatGF2` object.
20+
21+
Parameters
22+
----------
23+
data : array
24+
Data in array
25+
copy : bool
26+
Optional, defaults to `True`. If `False` and if possible, data
27+
is not copied.
28+
29+
Return
30+
-------
31+
MatGF2
32+
"""
33+
arr = np.array(data, dtype=np.uint8, copy=copy)
34+
return super().__new__(cls, shape=arr.shape, dtype=arr.dtype, buffer=arr)
35+
36+
def mat_mul(self, other: MatGF2 | npt.NDArray[np.uint8]) -> MatGF2:
37+
r"""Multiply two matrices.
38+
39+
Parameters
40+
----------
41+
other : array
42+
Matrix that right-multiplies `self`.
43+
44+
Returns
45+
-------
46+
MatGF2
47+
Matrix product `self` @ `other` in :math:`\mathbb F_2`.
48+
49+
Notes
50+
-----
51+
This function is a wrapper over :func:`_mat_mul_jit` which is a just-time compiled implementation of the matrix multiplication in :math:`\mathbb F_2`. It is more efficient than `galois.GF2.__matmul__` when the matrix `self` is sparse.
52+
The implementation assumes that the arguments have the right dimensions.
53+
"""
54+
if self.ndim != 2 or other.ndim != 2:
55+
raise ValueError(
56+
"`mat_mul` method only supports two-dimensional arrays. Use `np.matmul(self, other) % 2` instead."
57+
)
58+
if self.shape[1] != other.shape[0]:
59+
raise ValueError(
60+
f"Dimension mismatch. Attempted to multiply `self` with shape {self.shape} and `other` with shape {other.shape}"
61+
)
62+
63+
return MatGF2(_mat_mul_jit(self, other), copy=False)
64+
65+
def compute_rank(self) -> np.intp:
66+
"""Get the rank of the matrix.
67+
68+
Returns
69+
-------
70+
int : int
71+
Rank of the matrix.
72+
"""
73+
mat_a = self.row_reduction(copy=True)
74+
return np.count_nonzero(mat_a.any(axis=1))
75+
76+
def right_inverse(self) -> MatGF2 | None:
77+
r"""Return any right inverse of the matrix.
78+
79+
Returns
80+
-------
81+
rinv : MatGF2
82+
Any right inverse of the matrix.
83+
or `None`
84+
If the matrix does not have a right inverse.
85+
86+
Notes
87+
-----
88+
Let us consider a matrix :math:`A` of size :math:`(m \times n)`. The right inverse is a matrix :math:`B` of size :math:`(n \times m)` s.t. :math:`AB = I` where :math:`I` is the identity matrix.
89+
- The right inverse only exists if :math:`rank(A) = m`. Therefore, it is necessary but not sufficient that :math:`m ≤ n`.
90+
- The right inverse is unique only if :math:`m=n`.
91+
"""
92+
m, n = self.shape
93+
if m > n:
94+
return None
95+
96+
ident = np.eye(m, dtype=np.uint8)
97+
aug = np.hstack([self.data, ident]).view(MatGF2)
98+
red = aug.row_reduction(ncols=n, copy=False) # Reduced row echelon form
99+
100+
# Check that rank of right block is equal to the number of rows.
101+
# We don't use `MatGF2.compute_rank()` to avoid row-reducing twice.
102+
if m != np.count_nonzero(red[:, :n].any(axis=1)):
103+
return None
104+
rinv = np.zeros((n, m), dtype=np.uint8).view(MatGF2)
105+
106+
for i, row in enumerate(red):
107+
j = np.flatnonzero(row)[0] # Column index corresponding to the leading 1 in row `i`.
108+
rinv[j, :] = red[i, n:]
109+
110+
return rinv
111+
112+
def null_space(self) -> MatGF2:
113+
r"""Return the null space of the matrix.
114+
115+
Returns
116+
-------
117+
MatGF2
118+
The rows of the basis matrix are the basis vectors that span the null space. The number of rows of the basis matrix is the dimension of the null space.
119+
120+
Notes
121+
-----
122+
This implementation appear to be more efficient than `:func:galois.GF2.null_space`.
123+
"""
124+
m, n = self.shape
125+
126+
ident = np.eye(n, dtype=np.uint8)
127+
ref = np.hstack([self.T, ident]).view(MatGF2)
128+
ref.gauss_elimination(ncols=m, copy=False)
129+
row_idxs = np.flatnonzero(~ref[:, :m].any(axis=1)) # Row indices of the 0-rows in the first block of `ref`.
130+
131+
return ref[row_idxs, m:].view(MatGF2)
132+
133+
def gauss_elimination(self, ncols: int | None = None, copy: bool = True) -> MatGF2:
134+
"""Return row echelon form (REF) by performing Gaussian elimination.
135+
136+
Parameters
137+
----------
138+
n_cols : int (optional)
139+
Number of columns over which to perform Gaussian elimination. The default is `None` which represents the number of columns of the matrix.
140+
141+
copy : bool (optional)
142+
If `True`, the REF matrix is copied into a new instance, otherwise `self` is modified. Defaults to `True`.
143+
144+
Returns
145+
-------
146+
mat_ref : MatGF2
147+
The matrix in row echelon form.
148+
"""
149+
ncols_value = self.shape[1] if ncols is None else ncols
150+
mat_ref = MatGF2(self) if copy else self
151+
152+
return MatGF2(_elimination_jit(mat_ref, ncols=ncols_value, full_reduce=False), copy=False)
153+
154+
def row_reduction(self, ncols: int | None = None, copy: bool = True) -> MatGF2:
155+
"""Return row-reduced echelon form (RREF) by performing Gaussian elimination.
156+
157+
Parameters
158+
----------
159+
n_cols : int (optional)
160+
Number of columns over which to perform Gaussian elimination. The default is `None` which represents the number of columns of the matrix.
161+
162+
copy : bool (optional)
163+
If `True`, the RREF matrix is copied into a new instance, otherwise `self` is modified. Defaults to `True`.
164+
165+
Returns
166+
-------
167+
mat_ref: MatGF2
168+
The matrix in row-reduced echelon form.
169+
"""
170+
ncols_value = self.shape[1] if ncols is None else ncols
171+
mat_ref = self.copy() if copy else self
172+
173+
return MatGF2(_elimination_jit(mat_ref, ncols=ncols_value, full_reduce=True), copy=False)
174+
175+
176+
def solve_f2_linear_system(mat: MatGF2, b: MatGF2) -> MatGF2:
177+
r"""Solve the linear system (LS) `mat @ x == b`.
178+
179+
Parameters
180+
----------
181+
mat : MatGF2
182+
Matrix with shape `(m, n)` containing the LS coefficients in row echelon form (REF).
183+
b : MatGF2
184+
Matrix with shape `(m,)` containing the constants column vector.
185+
186+
Returns
187+
-------
188+
x : MatGF2
189+
Matrix with shape `(n,)` containing the solutions of the LS.
190+
191+
Notes
192+
-----
193+
This function is not integrated in `:class: graphix.linalg.MatGF2` because it does not perform any checks on the form of `mat` to ensure that it is in REF or that the system is solvable.
194+
"""
195+
return MatGF2(_solve_f2_linear_system_jit(mat, b), copy=False)
196+
197+
198+
@nb.njit("uint8[::1](uint8[:,::1], uint8[::1])")
199+
def _solve_f2_linear_system_jit(
200+
mat_data: npt.NDArray[np.uint8], b_data: npt.NDArray[np.uint8]
201+
) -> npt.NDArray[np.uint8]:
202+
"""See docstring of `:func:solve_f2_linear_system` for details."""
203+
m, n = mat_data.shape
204+
x = np.zeros(n, dtype=np.uint8)
205+
206+
# Find first row that is all-zero
207+
for i in range(m):
208+
for j in range(n):
209+
if mat_data[i, j] == 1:
210+
break # Row is not zero → go to next row
211+
else:
212+
m_nonzero = i # No break: this row is all-zero
213+
break
214+
else:
215+
m_nonzero = m
216+
217+
# Backward substitution from row m_nonzero - 1 to 0
218+
for i in range(m_nonzero - 1, -1, -1):
219+
# Find first non-zero column in row i
220+
pivot = -1
221+
for j in range(n):
222+
if mat_data[i, j] == 1:
223+
pivot = j
224+
break
225+
226+
# Sum x_k for k such that mat_data[i, k] == 1
227+
acc = 0
228+
for k in range(pivot, n):
229+
if mat_data[i, k] == 1:
230+
acc ^= x[k]
231+
232+
x[pivot] = b_data[i] ^ acc
233+
234+
return x
235+
236+
237+
@nb.njit("uint8[:,::1](uint8[:,::1], uint64, boolean)")
238+
def _elimination_jit(mat_data: npt.NDArray[np.uint8], ncols: int, full_reduce: bool) -> npt.NDArray[np.uint8]:
239+
r"""Return row echelon form (REF) or row-reduced echelon form (RREF) by performing Gaussian elimination.
240+
241+
Parameters
242+
----------
243+
mat_data : npt.NDArray[np.uint8]
244+
Matrix to be gaussian-eliminated.
245+
n_cols : int
246+
Number of columns over which to perform Gaussian elimination.
247+
full_reduce : bool
248+
Flag determining the operation mode. Output is in RREF if `True`, REF otherwise.
249+
250+
Returns
251+
-------
252+
mat_data: npt.NDArray[np.uint8]
253+
The matrix in row(-reduced) echelon form.
254+
255+
Notes
256+
-----
257+
Adapted from `:func: galois.FieldArray.row_reduction`, which renders the matrix in row-reduced echelon form (RREF) and specialized for :math:`\mathbb F_2`.
258+
259+
Row echelon form (REF):
260+
1. All rows having only zero entries are at the bottom.
261+
2. The leading entry of every nonzero row is on the right of the leading entry of every row above.
262+
3. (1) and (2) imply that all entries in a column below a leading coefficient are zeros.
263+
4. It's the result of Gaussian elimination.
264+
265+
For matrices over :math:`\mathbb F_2` the only difference between REF and RREF is that elements above a leading 1 can be non-zero in REF but must be 0 in RREF.
266+
"""
267+
m, n = mat_data.shape
268+
p = 0 # Pivot
269+
270+
for j in range(ncols):
271+
# Find a pivot in column `j` at or below row `p`.
272+
for i in range(p, m):
273+
if mat_data[i, j] == 1:
274+
break # `i` is a row with a pivot
275+
else:
276+
continue # No break: column `j` does not have a pivot below row `p`.
277+
278+
# Swap row `p` and `i`. The pivot is now located at row `p`.
279+
if i != p:
280+
for k in range(n):
281+
mat_data[i, k], mat_data[p, k] = mat_data[p, k], mat_data[i, k]
282+
283+
if full_reduce:
284+
# Force zeros BELOW and ABOVE the pivot by xor-ing with the pivot row
285+
for k in range(m):
286+
if mat_data[k, j] == 1 and k != p:
287+
for l in range(n):
288+
mat_data[k, l] ^= mat_data[p, l]
289+
else:
290+
# Force zeros BELOW the pivot by xor-ing with the pivot row
291+
for k in range(p + 1, m):
292+
if mat_data[k, j] == 1:
293+
for l in range(n):
294+
mat_data[k, l] ^= mat_data[p, l]
295+
296+
p += 1
297+
if p == m:
298+
break
299+
300+
return mat_data
301+
302+
303+
@nb.njit("uint8[:,::1](uint8[:,::1], uint8[:,::1])", parallel=True)
304+
def _mat_mul_jit(m1: npt.NDArray[np.uint8], m2: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]:
305+
"""See docstring of `:func:MatGF2.__matmul__` for details."""
306+
m, l = m1.shape
307+
_, n = m2.shape
308+
309+
res = np.zeros((m, n), dtype=np.uint8)
310+
311+
for i in nb.prange(m):
312+
for k in nb.prange(l):
313+
if m1[i, k] == 1:
314+
for j in range(n):
315+
res[i, j] = np.bitwise_xor(res[i, j], m2[k, j])
316+
317+
return res

0 commit comments

Comments
 (0)