Algorithm Search⌘K

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
winograd_fourier_transform.pyRawGitHub
# 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