Algorithm Search⌘K

Fürer's multiplication in Python

Integer multiplication in n log n 2^O(log* n) bit operations, improving Schönhage-Strassen.

File
furer_multiplication.py, 215 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
furer_multiplication.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/furer-multiplication/. The authors are listed in prov/V5N955.jsonl.

"""A toy structural model of Fürer's multiplication, in exact arithmetic.

The ring is R = Z_q[x]/(x^m + 1) with the prime q = 998244353 = 119 * 2^23 + 1,
in the spirit of the modular variant of De, Kurur, Saha and Saptharishi. In R
the element x is a principal 2m-th root of unity, and multiplying by a power of
x is a negacyclic rotation of the coefficients: a cheap shift. The FFT of length
N = K^L, K = 2m, uses a principal N-th root rho with rho^(N/K) = x, so that all
its inner DFTs of length K need shifts only. General products in R, the
expensive operations, are needed only for the twiddle factors between levels
and for the pointwise products. In Fürer's algorithm those products are
recursive calls; here they are done by schoolbook negacyclic convolution.
"""

from dataclasses import dataclass

Q = 998244353
GENERATOR = 3  # a primitive root modulo Q
TWO_ADICITY = 23

Elem = list[int]  # m coefficients modulo Q, lowest degree first


@dataclass
class Counts:
    """Operations in R: cheap shifts and expensive general products."""

    shifts: int = 0
    twiddle_products: int = 0
    pointwise_products: int = 0


def shift(a: Elem, e: int) -> Elem:
    """a * x^e in Z_q[x]/(x^m + 1): a negacyclic rotation."""
    m = len(a)
    e %= 2 * m
    sign = 1
    if e >= m:
        e -= m
        sign = -1
    out = [0] * m
    for i, c in enumerate(a):
        j = i + e
        if j < m:
            out[j] = (sign * c) % Q
        else:
            out[j - m] = (-sign * c) % Q
    return out


def ring_mul(a: Elem, b: Elem) -> Elem:
    """A general product in R, by schoolbook negacyclic convolution."""
    m = len(a)
    out = [0] * m
    for i, x in enumerate(a):
        if x:
            for j, y in enumerate(b):
                k = i + j
                if k < m:
                    out[k] = (out[k] + x * y) % Q
                else:
                    out[k - m] = (out[k - m] - x * y) % Q
    return out


def _add(a: Elem, b: Elem) -> Elem:
    return [(x + y) % Q for x, y in zip(a, b)]


def _sub(a: Elem, b: Elem) -> Elem:
    return [(x - y) % Q for x, y in zip(a, b)]


def _interpolate(xs: list[int], ys: list[int]) -> Elem:
    """The polynomial of degree < len(xs) with the values ys at the points xs, mod Q."""
    n = len(xs)
    coeffs = [0] * n
    for i in range(n):
        basis = [1]
        denom = 1
        for j in range(n):
            if j != i:
                basis = [(c1 - xs[j] * c0) % Q for c0, c1 in zip(basis + [0], [0] + basis)]
                denom = denom * (xs[i] - xs[j]) % Q
        scale = ys[i] * pow(denom, Q - 2, Q) % Q
        for k in range(n):
            coeffs[k] = (coeffs[k] + scale * basis[k]) % Q
    return coeffs


def shift_dft(vec: list[Elem], e: int, sign: int, counts: Counts) -> list[Elem]:
    """Radix-2 DFT with the root x^(sign * e), len(vec) * e = 2m: shifts only."""
    size = len(vec)
    if size == 1:
        return [vec[0]]
    even = shift_dft(vec[0::2], 2 * e, sign, counts)
    odd = shift_dft(vec[1::2], 2 * e, sign, counts)
    half = size // 2
    out: list[Elem] = [[]] * size
    for k in range(half):
        t = shift(odd[k], sign * e * k)
        counts.shifts += 1
        out[k] = _add(even[k], t)
        out[k + half] = _sub(even[k], t)
    return out


class FurerRing:
    """Precomputed data for FFTs of length N = (2m)^L over R = Z_q[x]/(x^m + 1)."""

    def __init__(self, m: int, levels: int):
        if m < 2 or m & (m - 1) or levels < 1:
            raise ValueError("m must be a power of two at least 2, and levels at least 1")
        self.m, self.K, self.L = m, 2 * m, levels
        self.N = self.K**levels
        if self.N.bit_length() - 1 > TWO_ADICITY:
            raise ValueError("N too large for this prime")
        omega = pow(GENERATOR, (Q - 1) // self.N, Q)  # a primitive N-th root in Z_q
        zeta = pow(omega, self.N // self.K, Q)  # a primitive 2m-th root in Z_q
        points = [pow(zeta, 2 * i + 1, Q) for i in range(m)]  # the roots of x^m + 1
        values = [pow(omega, 2 * i + 1, Q) for i in range(m)]
        self.rho = _interpolate(points, values)  # rho(zeta^(2i+1)) = omega^(2i+1)
        self.powers = [[1] + [0] * (m - 1)]
        for _ in range(1, self.N):
            self.powers.append(ring_mul(self.powers[-1], self.rho))

    def root_power(self, e: int) -> Elem:
        """rho^e for an integer e."""
        return self.powers[e % self.N]

    def fft(self, vec: list[Elem], stride: int, sign: int, counts: Counts) -> list[Elem]:
        """DFT of vec with the root rho^(sign * stride), where len(vec) * stride = N."""
        K, size = self.K, len(vec)
        if size == K:  # @line L6
            return shift_dft(vec, 1, sign, counts)
        J = size // K
        rows = []
        for n1 in range(J):  # @line L7
            inner = self.fft([vec[J * n2 + n1] for n2 in range(K)], self.N // K, sign, counts)
            for k1 in range(K):  # @line L8
                e = stride * n1 * k1
                if e % self.N:
                    inner[k1] = ring_mul(inner[k1], self.root_power(sign * e))
                    counts.twiddle_products += 1
            rows.append(inner)
        out: list[Elem] = [[]] * size
        for k1 in range(K):  # @line L9
            col = self.fft([rows[n1][k1] for n1 in range(J)], stride * K, sign, counts)
            for k2 in range(J):
                out[k1 + K * k2] = col[k2]
        return out  # @line L10


def expected_counts(K: int, L: int) -> tuple[int, int]:
    """(shifts, twiddle products) of one FFT of length N = K^L."""
    N = K**L
    shifts = (N // 2) * L * (K.bit_length() - 1)
    twiddles = (L - 1) * (K - 1) * (N // K) - N // K + 1
    return shifts, twiddles


def capacity(ring: FurerRing, b: int) -> int:
    """The largest operand length, in binary digits, that the ring can multiply."""
    return (ring.N // 2) * (ring.m // 2) * b


def choose_ring(length: int, m: int = 4, b: int = 8) -> FurerRing:
    """The smallest ring of the form (m, L) that holds operands of the given length."""
    levels = 1
    while ((2 * m) ** levels // 2) * (m // 2) * b < length:
        levels += 1
    return FurerRing(m, levels)


def furer_multiply(u: int, v: int, ring: FurerRing | None = None, b: int = 8, counts: Counts | None = None) -> int:
    """Return u * v for u, v >= 0 by the toy Fürer scheme with b-bit digits."""
    if u < 0 or v < 0:
        raise ValueError("operands must be nonnegative")
    counts = counts if counts is not None else Counts()
    length = max(u.bit_length(), v.bit_length(), 1)
    ring = ring or choose_ring(length, b=b)
    m, N, half = ring.m, ring.N, ring.m // 2
    if length > capacity(ring, b):
        raise ValueError("operands too long for this ring")
    if (N // 2) * half * ((1 << b) - 1) ** 2 >= Q:
        raise ValueError("product coefficients could wrap modulo Q")
    mask = (1 << b) - 1

    def pack(z: int) -> list[Elem]:  # @line L1
        out = []
        for _ in range(N):
            el = [0] * m
            for t in range(half):
                el[t] = z & mask
                z >>= b
            out.append(el)
        return out

    fa = ring.fft(pack(u), 1, 1, counts)  # @line L2
    fb = ring.fft(pack(v), 1, 1, counts)
    prod = []
    for x, y in zip(fa, fb):  # @line L3
        prod.append(ring_mul(x, y))
        counts.pointwise_products += 1
    back = ring.fft(prod, 1, -1, counts)  # @line L4
    inv_n = pow(N, Q - 2, Q)
    result = 0
    for j in range(N - 1, -1, -1):  # @line L5
        value = 0
        for t in range(m - 1, -1, -1):
            value = (value << b) + back[j][t] * inv_n % Q
        result = (result << (half * b)) + value
    return result
test_furer_multiplication.py 138 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/furer-multiplication/. The authors are listed in prov/V5N955.jsonl.

import random

import pytest

from furer_multiplication import (
    Q,
    Counts,
    FurerRing,
    capacity,
    expected_counts,
    furer_multiply,
    ring_mul,
    shift,
)

RINGS = {(m, L): FurerRing(m, L) for m, L in [(2, 1), (2, 2), (2, 3), (4, 1), (4, 2), (4, 3), (8, 1), (8, 2)]}


def naive_dft(vec, root):
    n = len(vec)
    m = len(vec[0])
    out = []
    power = [[1] + [0] * (m - 1)]
    for _ in range(1, n):
        power.append(ring_mul(power[-1], root))
    for k in range(n):
        acc = [0] * m
        for j, a in enumerate(vec):
            t = ring_mul(a, power[(j * k) % n])
            acc = [(p + r) % Q for p, r in zip(acc, t)]
        out.append(acc)
    return out


@pytest.mark.parametrize("key", sorted(RINGS))
def test_root_properties(key):
    ring = RINGS[key]
    m, K, N = ring.m, ring.K, ring.N
    one = [1] + [0] * (m - 1)
    assert ring.powers[N // K] == shift(one, 1)  # rho^(N/K) = x
    assert ring.powers[N // 2] == [Q - 1] + [0] * (m - 1)  # rho^(N/2) = -1
    assert ring_mul(ring.powers[N - 1], ring.rho) == one  # rho^N = 1


def test_shift_matches_ring_mul():
    rng = random.Random(11)
    for m in (2, 4, 8, 16):
        for _ in range(40):
            a = [rng.randrange(Q) for _ in range(m)]
            e = rng.randrange(-4 * m, 4 * m)
            xe = shift([1] + [0] * (m - 1), e)
            assert shift(a, e) == ring_mul(a, xe)


@pytest.mark.parametrize("key", [(2, 1), (2, 2), (4, 1), (4, 2), (8, 1)])
def test_fft_matches_naive_dft(key):
    ring = RINGS[key]
    rng = random.Random(sum(key))
    for sign in (1, -1):
        vec = [[rng.randrange(Q) for _ in range(ring.m)] for _ in range(ring.N)]
        root = ring.root_power(sign)
        assert ring.fft(vec, 1, sign, Counts()) == naive_dft(vec, root)


@pytest.mark.parametrize("key", sorted(RINGS))
def test_operation_counts(key):
    ring = RINGS[key]
    rng = random.Random(5)
    counts = Counts()
    vec = [[rng.randrange(Q) for _ in range(ring.m)] for _ in range(ring.N)]
    ring.fft(vec, 1, 1, counts)
    assert (counts.shifts, counts.twiddle_products) == expected_counts(ring.K, ring.L)
    assert counts.pointwise_products == 0


def test_example_from_the_page():
    counts = Counts()
    assert furer_multiply(0xBEEF, 0xCAFE, RINGS[(2, 2)], b=4, counts=counts) == 0x97660722
    assert (counts.shifts, counts.twiddle_products, counts.pointwise_products) == (96, 27, 16)


def test_table_rows():
    assert expected_counts(4, 2) == (32, 9)
    assert expected_counts(8, 3) == (2304, 833)
    assert expected_counts(8, 4) == (24576, 10241)
    assert expected_counts(16, 3) == (24576, 7425)


def test_edge_cases():
    for key, ring in RINGS.items():
        if ring.N > 64:
            continue
        for b in (1, 4, 8):
            if (ring.N // 2) * (ring.m // 2) * ((1 << b) - 1) ** 2 >= Q:
                continue
            top = (1 << capacity(ring, b)) - 1
            for u, v in [(0, 0), (0, top), (1, 1), (1, top), (top, top), (1 << (capacity(ring, b) - 1), top)]:
                assert furer_multiply(u, v, ring, b=b) == u * v


def test_random_against_python_ints():
    rng = random.Random(2007)
    checked = 0
    for key in [(2, 1), (2, 2), (4, 1), (4, 2), (8, 1)]:
        ring = RINGS[key]
        for b in (2, 5, 8):
            cap = capacity(ring, b)
            for _ in range(30):
                u = rng.getrandbits(rng.randrange(1, cap + 1))
                v = rng.getrandbits(rng.randrange(1, cap + 1))
                assert furer_multiply(u, v, ring, b=b) == u * v
                checked += 1
    for _ in range(100):
        u, v = rng.getrandbits(rng.randrange(1, 200)), rng.getrandbits(rng.randrange(1, 200))
        assert furer_multiply(u, v) == u * v
        checked += 1
    assert checked >= 500


def test_large_ring_once():
    ring = FurerRing(4, 3)
    rng = random.Random(4096)
    u, v = rng.getrandbits(capacity(ring, 8)), rng.getrandbits(capacity(ring, 8))
    assert furer_multiply(u, v, ring) == u * v


def test_rejects_bad_input():
    with pytest.raises(ValueError):
        furer_multiply(-1, 3)
    with pytest.raises(ValueError):
        furer_multiply(1 << 64, 3, RINGS[(2, 1)])
    with pytest.raises(ValueError):
        FurerRing(3, 1)
    with pytest.raises(ValueError):
        furer_multiply(1, 1, RINGS[(8, 2)], b=12)

What the next level needs

  • the tests did not run in this build

Read the full page about Fürer's multiplication