Winograd Fourier transform algorithm in Python
Minimal-multiplication DFT for small lengths built from nested prime-factor decompositions.
- File
winograd_fourier_transform.py, 285 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/winograd-fourier-transform/. The authors are listed in prov/XCA95E.jsonl.
"""Winograd Fourier transform algorithm (WFTA).
Each short DFT of length q in {2, 3, 4, 5, 7, 8} is written as C diag(mu) A,
where A (pre-additions) and C (post-additions) use only additions and
subtractions and mu is a vector of constants. For n = q_1 ... q_d with pairwise
coprime factors, the Good-Thomas index maps turn the DFT of length n into a
d-dimensional DFT, which is the Kronecker product of the short ones; nesting
gives (C_1 x ... x C_d) diag(mu_1 x ... x mu_d) (A_1 x ... x A_d), so all
multiplications happen in one pointwise product of size m_1 ... m_d.
X_k = sum_j x_j w**(j*k); over C, w = exp(-2*pi*i/n); the inverse divides by n.
"""
from __future__ import annotations
import cmath
import math
from typing import Callable, Sequence
def _unit(r: int, n: int) -> complex:
"""Return exp(2*pi*i*r/n), exact when r/n is a multiple of 1/4."""
r %= n
if (4 * r) % n == 0:
return (1 + 0j, 1j, -1 + 0j, -1j)[4 * r // n]
return cmath.exp(2j * math.pi * r / n)
class Field:
"""C (p is None, w = exp(-2 pi i/n)) or Z/pZ with a given root w of order n."""
def __init__(self, n: int, w: int | None = None, p: int | None = None, sign: int = -1) -> None:
self.n, self.p, self.w, self.sign = n, p, w, sign
if p is not None:
if pow(w, n, p) != 1 or any(pow(w, n // r, p) == 1 for r in range(2, n + 1) if n % r == 0 and _is_prime(r)):
raise ValueError("w must have order exactly n modulo p")
def root(self, e: int):
"""w**e."""
if self.p is None:
return _unit(self.sign * e, self.n)
return pow(self.w, e % self.n, self.p)
def div(self, a, b: int):
if self.p is None:
return a / b
return a * pow(b, -1, self.p) % self.p
def inv(self, a):
if self.p is None:
return 1 / a
return pow(a, -1, self.p)
def red(self, a):
return a % self.p if self.p is not None else a
def _is_prime(r: int) -> bool:
return r > 1 and all(r % q for q in range(2, int(r**0.5) + 1))
# ---------------------------------------------------------------- short modules
# pre(x) -> list of length m, post(m) -> list of length q; constants(F, w) where
# w is a root of order q. C(e) = (w^e + w^-e)/2 and S(e) = (w^e - w^-e)/2.
def _pre2(x):
return [x[0] + x[1], x[0] - x[1]]
def _post2(m):
return [m[0], m[1]]
def _mu2(F, w):
return [1, 1]
def _pre3(x):
p, q = x[1] + x[2], x[1] - x[2]
return [x[0] + p, p, q]
def _post3(m):
b = m[0] + m[1]
return [m[0], b + m[2], b - m[2]]
def _mu3(F, w):
w2 = F.red(w * w)
C1, S1 = F.div(w + w2, 2), F.div(w - w2, 2)
return [1, C1 - 1, S1]
def _pre4(x):
t1, t2, t3, t4 = x[0] + x[2], x[0] - x[2], x[1] + x[3], x[1] - x[3]
return [t1 + t3, t1 - t3, t2, t4]
def _post4(m):
return [m[0], m[2] + m[3], m[1], m[2] - m[3]]
def _mu4(F, w):
return [1, 1, 1, w]
def _pre5(x):
t0, t1, q0, q1 = x[1] + x[4], x[2] + x[3], x[1] - x[4], x[2] - x[3]
s = t0 + t1
return [x[0] + s, s, t0 - t1, q0 + q1, q0, q1]
def _post5(m):
base = m[0] + m[1]
r0, r1 = base + m[2], base - m[2]
i0, i1 = m[3] + m[4], m[3] + m[5]
return [m[0], r0 + i0, r1 + i1, r1 - i1, r0 - i0]
def _mu5(F, w):
pw = [F.red(w**e) if F.p is not None else w**e for e in range(5)]
C = lambda e: F.div(pw[e % 5] + pw[-e % 5], 2)
S = lambda e: F.div(pw[e % 5] - pw[-e % 5], 2)
c0, c1, s0, s1 = C(1), C(2), S(1), S(2)
return [1, F.div(c0 + c1, 2) - 1, F.div(c0 - c1, 2), s1, s0 - s1, -(s0 + s1)]
def _pre7(x):
p1, q1 = x[1] + x[6], x[1] - x[6]
p3, q3 = x[3] + x[4], x[3] - x[4]
p2, q2 = x[2] + x[5], x[2] - x[5]
s = p1 + p2 + p3 # real part: cyclic convolution of (p1, p2, p3)
d0, d1, e = p1 - p2, p2 - p3, p1 - p3
s2 = q1 + q2 - q3 # imaginary part: (q1, q2, -q3) after the sign change
f0, f1, f2 = q1 - q2, q2 + q3, q1 + q3
return [x[0] + s, s, d0, d1, e, s2, f0, f1, f2]
def _post7(m):
base = m[0] + m[1]
w0, w1, w2 = m[2] - m[3], m[4] - m[2], m[3] - m[4]
r0, r1, r2 = base + w0, base + w1, base + w2
v0, v1, v2 = m[6] - m[7], m[8] - m[6], m[7] - m[8]
u0, u1, u2 = m[5] + v0, m[5] + v1, m[5] + v2
return [m[0], r0 + u0, r2 + u2, r1 - u1, r1 + u1, r2 - u2, r0 - u0]
def _mu7(F, w):
pw = [F.red(w**e) if F.p is not None else w**e for e in range(7)]
C = lambda e: F.div(pw[e % 7] + pw[-e % 7], 2)
S = lambda e: F.div(pw[e % 7] - pw[-e % 7], 2)
c = [C(1), C(3), C(2)] # C(g^e) with g = 3
t = [S(1), -S(3), S(2)] # S(g^e) times the signs (1, -1, 1)
H, T = sum(c), sum(t)
g = [ce - F.div(H, 3) for ce in c]
h = [te - F.div(T, 3) for te in t]
return [1, F.div(H, 3) - 1, g[0], g[1], g[0] + g[1], F.div(T, 3), h[0], h[1], h[0] + h[1]]
def _pre8(x):
s = [x[j] + x[j + 4] for j in range(4)]
d = [x[j] - x[j + 4] for j in range(4)]
t1, t2, t3, t4 = s[0] + s[2], s[0] - s[2], s[1] + s[3], s[1] - s[3]
return [t1 + t3, t1 - t3, t2, t4, d[0], d[2], d[1] - d[3], d[1] + d[3]]
def _post8(m):
a, b = m[4] + m[5], m[4] - m[5]
P, Q = m[6] + m[7], m[7] - m[6]
return [m[0], a + P, m[2] + m[3], b + Q, m[1], a - P, m[2] - m[3], b - Q]
def _mu8(F, w):
J = F.red(w * w)
c = F.red(w * F.inv(1 + J)) # a square root of 1/2
return [1, 1, 1, J, 1, J, c, F.red(c * J)]
MODULES: dict[int, tuple[Callable, Callable, Callable]] = {
2: (_pre2, _mu2, _post2),
3: (_pre3, _mu3, _post3),
4: (_pre4, _mu4, _post4),
5: (_pre5, _mu5, _post5),
7: (_pre7, _mu7, _post7),
8: (_pre8, _mu8, _post8),
}
def module_constants(q: int, F: Field, wq) -> list:
return [F.red(v) for v in MODULES[q][1](F, wq)]
# ---------------------------------------------------------------- nesting
def _along(a: list, shape: list[int], axis: int, fn: Callable, new: int) -> list:
"""Apply fn to every line of the row-major array a along axis; that axis gets length new."""
inner = math.prod(shape[axis + 1 :])
outer = math.prod(shape[:axis])
L = shape[axis]
out = [None] * (outer * new * inner)
for o in range(outer):
for i in range(inner):
base_in, base_out = o * L * inner + i, o * new * inner + i
y = fn([a[base_in + t * inner] for t in range(L)])
for t in range(new):
out[base_out + t * inner] = y[t]
shape[axis] = new
return out
def wfta(
x: Sequence,
factors: Sequence[int],
w: int | None = None,
p: int | None = None,
inverse: bool = False,
) -> list:
"""DFT of x, len(x) = prod(factors), the factors pairwise coprime and in MODULES."""
factors = list(factors)
n = math.prod(factors)
if len(x) != n:
raise ValueError("len(x) must be the product of the factors")
for a in range(len(factors)):
if factors[a] not in MODULES:
raise ValueError(f"no short module of length {factors[a]}")
for b in range(a + 1, len(factors)):
if math.gcd(factors[a], factors[b]) != 1:
raise ValueError("the factors must be pairwise coprime")
if inverse:
F = Field(n, None if p is None else pow(w, -1, p), p, sign=+1)
else:
F = Field(n, w, p)
if n == 1:
return [F.red(x[0])]
d = len(factors)
shape = list(factors)
a = [] # @line L1
for flat in range(n): # Ruritanian input map, j = sum_i j_i (n/q_i) mod n
rest, j = flat, 0
for i in range(d - 1, -1, -1):
rest, ji = divmod(rest, factors[i])
j += ji * (n // factors[i])
a.append(x[j % n])
mus = [module_constants(q, F, F.root(n // q)) for q in factors] # @line L2
ones = [[c == 1 for c in mu] for mu in mus]
sizes = [len(mu) for mu in mus]
for i, q in enumerate(factors): # @line L3
a = _along(a, shape, i, MODULES[q][0], sizes[i])
for flat in range(len(a)): # @line L4
rest, c, triv = flat, 1, True
for i in range(d - 1, -1, -1):
rest, t = divmod(rest, sizes[i])
c = F.red(c * mus[i][t])
triv = triv and ones[i][t]
if not triv: # products of ones only are skipped
a[flat] = F.red(a[flat] * c)
for i in range(d - 1, -1, -1): # @line L5
post = MODULES[factors[i]][2]
a = _along(a, shape, i, post, factors[i])
a = [F.red(v) for v in a]
X = [] # @line L6
for k in range(n):
flat = 0
for q in factors:
flat = flat * q + k % q
X.append(a[flat])
if inverse:
X = [F.div(v, n) for v in X]
return X
def counts(factors: Sequence[int]) -> tuple[int, int]:
"""(additions, multiplications) of wfta for this order of the factors."""
A = {2: 2, 3: 6, 4: 8, 5: 17, 7: 36, 8: 26}
M = {2: 2, 3: 3, 4: 4, 5: 6, 7: 9, 8: 8}
U = {2: 2, 3: 1, 4: 3, 5: 1, 7: 1, 8: 4} # constants equal to 1
adds = 0
for i, q in enumerate(factors):
adds += A[q] * math.prod(M[r] for r in factors[:i]) * math.prod(factors[i + 1 :])
mults = math.prod(M[q] for q in factors) - math.prod(U[q] for q in factors)
return adds, mults
test_winograd_fourier_transform.py 175 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/winograd-fourier-transform/. The authors are listed in prov/XCA95E.jsonl.
import cmath
import itertools
import math
import random
import pytest
from winograd_fourier_transform import MODULES, counts, wfta
# (prime, generator, usable module lengths)
FIELDS = [
(998244353, 3, (2, 4, 7, 8)), # p - 1 = 2^23 * 7 * 17
(2521, 17, (2, 3, 4, 5, 7, 8)), # p - 1 = 2520 = 8 * 9 * 5 * 7
(97, 5, (2, 3, 4, 8)),
(17, 3, (2, 4, 8)),
]
def dft_mod(x, w, p):
n = len(x)
pw = [pow(w, e, p) for e in range(n)]
return [sum(x[j] * pw[j * k % n] for j in range(n)) % p for k in range(n)]
def dft_c(x, sign=-1):
n = len(x)
pw = [cmath.exp(sign * 2j * math.pi * e / n) for e in range(n)]
return [sum(x[j] * pw[j * k % n] for j in range(n)) for k in range(n)]
def coprime_orders(lengths, limit):
out = []
for r in range(1, len(lengths) + 1):
for combo in itertools.combinations(lengths, r):
if all(math.gcd(a, b) == 1 for a, b in itertools.combinations(combo, 2)) and math.prod(combo) <= limit:
out.append(combo)
return out
def test_modules_against_direct_dft():
rng = random.Random(1)
for p, g, lengths in FIELDS:
for q in lengths:
w = pow(g, (p - 1) // q, p)
for _ in range(40):
x = [rng.randrange(p) for _ in range(q)]
assert wfta(x, (q,), w=w, p=p) == dft_mod(x, w, p)
def test_nested_all_orders_mod_p():
rng = random.Random(2)
for p, g, lengths in FIELDS:
for combo in coprime_orders(lengths, 210):
for order in itertools.permutations(combo):
n = math.prod(order)
w = pow(g, (p - 1) // n, p)
x = [rng.randrange(p) for _ in range(n)]
assert wfta(x, order, w=w, p=p) == dft_mod(x, w, p)
def test_length_840_mod_2521():
rng = random.Random(3)
w = pow(17, 3, 2521) # order 840
for order in [(8, 3, 5, 7), (7, 5, 3, 8)]:
x = [rng.randrange(2521) for _ in range(840)]
X = wfta(x, order, w=w, p=2521)
assert X == dft_mod(x, w, 2521)
assert wfta(X, order, w=w, p=2521, inverse=True) == x
class Count:
"""Integers mod p that count additions (with subtractions) and multiplications."""
p = 2521
adds = 0
mults = 0
def __init__(self, v):
self.v = v % Count.p
def _val(self, o):
return o.v if isinstance(o, Count) else o
def __add__(self, o):
Count.adds += 1
return Count(self.v + self._val(o))
__radd__ = __add__
def __sub__(self, o):
Count.adds += 1
return Count(self.v - self._val(o))
def __mul__(self, o):
Count.mults += 1
return Count(self.v * self._val(o))
__rmul__ = __mul__
def __mod__(self, m):
return self
def test_exact_operation_counts():
rng = random.Random(4)
table = {2: (2, 0), 3: (6, 2), 4: (8, 1), 5: (17, 5), 7: (36, 8), 8: (26, 4)}
for q, expect in table.items():
assert counts((q,)) == expect
orders = [o for c in coprime_orders((2, 3, 4, 5, 7, 8), 120) for o in itertools.permutations(c)]
for order in orders + [(8, 3, 5, 7), (5, 7, 3, 4)]:
n = math.prod(order)
w = pow(17, 2520 // n, 2521)
x = [Count(rng.randrange(2521)) for _ in range(n)]
Count.adds = Count.mults = 0
X = wfta(x, order, w=w, p=2521)
assert (Count.adds, Count.mults) == counts(order)
assert [v.v for v in X] == dft_mod([v.v for v in x], w, 2521)
# the multiplications are prod(m_i) minus the products of ones
assert counts((8, 3, 5, 7)) == (12450, 1292)
def test_complex_against_direct_dft():
rng = random.Random(5)
for combo in coprime_orders((2, 3, 4, 5, 7, 8), 840):
order = combo
n = math.prod(order)
x = [complex(rng.gauss(0, 1), rng.gauss(0, 1)) for _ in range(n)]
ref = dft_c(x)
X = wfta(x, order)
scale = math.sqrt(sum(abs(v) ** 2 for v in x))
tol = 16 * 2.2e-16 * math.log2(2 * n) * scale * math.sqrt(n)
assert max(abs(a - b) for a, b in zip(X, ref)) <= tol
y = wfta(X, order, inverse=True)
assert max(abs(a - b) for a, b in zip(x, y)) <= tol
def test_complex_inverse_matches_reference():
rng = random.Random(6)
x = [complex(rng.random(), rng.random()) for _ in range(105)]
ref = [v / 105 for v in dft_c(x, sign=+1)]
got = wfta(x, (3, 5, 7), inverse=True)
assert max(abs(a - b) for a, b in zip(got, ref)) < 1e-13
def test_edge_cases():
assert wfta([5], ()) == [5]
assert wfta([3, 1], (2,), w=16, p=17) == [4, 2]
w = pow(17, 2520 // 60, 2521)
assert wfta([0] * 60, (4, 3, 5), w=w, p=2521) == [0] * 60
assert wfta([1] + [0] * 59, (4, 3, 5), w=w, p=2521) == [1] * 60
assert wfta([7] * 60, (4, 3, 5), w=w, p=2521) == [420] + [0] * 59
X = wfta([1.0] + [0.0] * 104, (3, 5, 7))
assert max(abs(v - 1) for v in X) < 1e-14
X = wfta([2.0] * 56, (8, 7))
assert abs(X[0] - 112) < 1e-12 and max(abs(v) for v in X[1:]) < 1e-12
def test_bad_arguments():
with pytest.raises(ValueError):
wfta([0] * 8, (2, 4))
with pytest.raises(ValueError):
wfta([0] * 9, (9,))
with pytest.raises(ValueError):
wfta([0] * 5, (2, 3))
with pytest.raises(ValueError):
wfta([0] * 6, (2, 3), w=2, p=7) # 2 has order 3 modulo 7
def test_worked_example_mod_7():
assert wfta([3, 1, 4, 1, 5, 2], (2, 3), w=3, p=7) == [2, 1, 5, 1, 5, 4]
assert dft_mod([3, 1, 4, 1, 5, 2], 3, 7) == [2, 1, 5, 1, 5, 4]
assert set(MODULES) == {2, 3, 4, 5, 7, 8}
What the next level needs
- the tests did not run in this build
Read the full page about Winograd Fourier transform algorithm