Algorithm Search⌘K

Schönhage-Strassen multiplication in Python

FFT-based multiplication in O(n log n log log n) bit operations.

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

"""Schönhage-Strassen multiplication modulo 2^n + 1, after Brent and Zimmermann's
Algorithm FFTMulMod, with a negacyclic convolution of length K = 2^k over the
ring Z / (2^n' + 1), where 2 is a root of unity.

Elements of the rings are Python ints used only as bit strings: additions,
subtractions, shifts and masks. Every product of two ring elements goes through
the recursion, and the base case is schoolbook multiplication on limb arrays
in radix 2^8. The public functions take and return limb arrays.
"""


class Stats:
    def __init__(self) -> None:
        self.max_depth = 0
        self.base_products = 0
        self.levels: dict[int, list[tuple[int, int, int]]] = {}  # depth -> (n, K, n')


def _to_limbs(x: int, w: int = 8) -> list[int]:
    out = []
    while x:
        out.append(x & ((1 << w) - 1))
        x >>= w
    return out


def _from_limbs(d: list[int], w: int = 8) -> int:
    v = 0
    for x in reversed(d):
        v = (v << w) | x
    return v


def _schoolbook(a: list[int], b: list[int], w: int = 8) -> list[int]:
    mask = (1 << w) - 1
    c = [0] * (len(a) + len(b))
    for i, x in enumerate(a):
        carry = 0
        for j, y in enumerate(b):
            t = c[i + j] + x * y + carry
            c[i + j] = t & mask
            carry = t >> w
        c[i + len(b)] = carry
    return c


def _reduce(x: int, m: int) -> int:
    """x mod 2^m + 1 for 0 <= x, by folding: 2^m = -1."""
    mod = (1 << m) + 1
    mask = (1 << m) - 1
    sign = 1
    acc = 0
    while x:
        acc += sign * (x & mask)
        x >>= m
        sign = -sign
    while acc < 0:
        acc += mod
    while acc >= mod:
        acc -= mod
    return acc


def mul2exp(x: int, s: int, m: int) -> int:
    """x * 2^s mod 2^m + 1, for 0 <= x <= 2^m, by a shift and one subtraction."""
    mod = (1 << m) + 1
    s %= 2 * m
    if s >= m:  # 2^m = -1
        x = (mod - x) % mod
        s -= m
    t = x << s
    lo, hi = t & ((1 << m) - 1), t >> m
    r = lo - hi
    return r + mod if r < 0 else r


def _fft(a: list[int], e: int, m: int) -> list[int]:
    """DFT of a over Z/(2^m + 1) with the root omega = 2^e, len(a) a power of 2.

    Recursive radix-2; every twiddle multiplication is mul2exp.
    """
    K = len(a)
    if K == 1:
        return list(a)
    mod = (1 << m) + 1
    even = _fft(a[0::2], 2 * e, m)
    odd = _fft(a[1::2], 2 * e, m)
    out = [0] * K
    for j in range(K // 2):
        t = mul2exp(odd[j], j * e, m)
        u = even[j]
        out[j] = (u + t) % mod
        out[j + K // 2] = (u - t) % mod
    return out


def _choose(n: int) -> int:
    """The number k of the split into K = 2^k pieces: about half the length of n."""
    k = max(1, (n.bit_length() - 1) // 2)
    while n % (1 << k):
        k -= 1
    return k


def _next_size(M: int, k: int) -> int:
    """The smallest n' >= 2M + k + 1 that is a multiple of K = 2^k and of a
    power of two near its own square root, so that the next level can split it."""
    need = 2 * M + k + 1
    j = max(k, (need.bit_length() + 1) // 2)
    step = 1 << j
    return -(-need // step) * step


def mulmod(A: int, B: int, n: int, threshold: int = 64, st: Stats | None = None, depth: int = 0) -> int:
    """A * B mod 2^n + 1 for residues 0 <= A, B <= 2^n."""
    mod = (1 << n) + 1
    if st is not None:
        st.max_depth = max(st.max_depth, depth)
    if A == 1 << n:  # A = -1
        return (mod - B) % mod
    if B == 1 << n:
        return (mod - A) % mod
    k = _choose(n)
    K = 1 << k  # @line L2: K pieces of M binary digits
    M = n // K
    n2 = _next_size(M, k)  # @line L3: n' >= 2M + k + 1, a multiple of K
    if n <= threshold or n2 >= n:  # @line L1: base case, also when the pieces would not be smaller
        if st is not None:
            st.base_products += 1
        p = _from_limbs(_schoolbook(_to_limbs(A), _to_limbs(B)))
        return _reduce(p, n)
    if st is not None:
        st.levels.setdefault(depth, []).append((n, K, n2))
    mask = (1 << M) - 1
    a = [(A >> (j * M)) & mask for j in range(K)]
    b = [(B >> (j * M)) & mask for j in range(K)]
    t = n2 // K  # theta = 2^t, theta^K = 2^n' = -1
    a = [mul2exp(a[j], j * t, n2) for j in range(K)]  # @line L4: weights theta^j
    b = [mul2exp(b[j], j * t, n2) for j in range(K)]
    fa = _fft(a, 2 * t, n2)  # @line L5: two forward transforms, omega = theta^2
    fb = _fft(b, 2 * t, n2)
    fc = [mulmod(x, y, n2, threshold, st, depth + 1) for x, y in zip(fa, fb)]  # @line L6: K products mod 2^n' + 1
    c = _fft(fc, -2 * t, n2)  # @line L7: inverse transform, omega^(-1)
    mod2 = (1 << n2) + 1
    out = 0
    for j in range(K):
        cj = mul2exp(c[j], -(k + j * t), n2)  # @line L8: divide by K theta^j
        if cj >= (j + 1) << (2 * M):  # @line L9: the value is negative
            cj -= mod2
        out += cj << (j * M)  # @line L10: C = sum c_j 2^(jM)
    return out % mod


def ssa_multiply(a: list[int], b: list[int], w: int = 8, threshold: int = 64, st: Stats | None = None) -> list[int]:
    """Product of two limb arrays in radix 2^w, as len(a) + len(b) limbs.

    The product is computed modulo 2^n + 1 for an n at least the length of the
    product in binary digits, so the residue is the product itself.
    """
    if w not in (4, 8, 16):
        raise ValueError("w must be 4, 8 or 16")
    L = (len(a) + len(b)) * w
    if L == 0:
        return []
    x, y = _from_limbs(a, w), _from_limbs(b, w)
    j = (L + 1).bit_length() // 2
    n = -(-(L + 1) // (1 << j)) << j  # n > L, a multiple of 2^j with j about half the length of L
    p = mulmod(x, y, n, threshold, st)
    out = _to_limbs(p, w)
    return out + [0] * (len(a) + len(b) - len(out))
test_schonhage_strassen_multiplication.py 108 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/schonhage-strassen-multiplication/. The authors are listed in prov/XRQ0DB.jsonl.

import math
import random

import pytest

from schonhage_strassen_multiplication import Stats, _fft, mul2exp, mulmod, ssa_multiply


def limbs(x: int, w: int, n: int) -> list[int]:
    return [(x >> (w * i)) & ((1 << w) - 1) for i in range(n)]


def value(d: list[int], w: int) -> int:
    v = 0
    for x in reversed(d):
        v = (v << w) | x
    return v


def test_example() -> None:
    n2, K, t = 12, 4, 3
    mod2 = (1 << n2) + 1
    a, b = [4, 3, 2, 1], [8, 7, 6, 5]
    aw = [mul2exp(a[j], j * t, n2) for j in range(K)]
    bw = [mul2exp(b[j], j * t, n2) for j in range(K)]
    assert aw == [4, 24, 128, 512] and bw == [8, 56, 384, 2560]
    fa, fb = _fft(aw, 2 * t, n2), _fft(bw, 2 * t, n2)
    assert fa == [668, 1420, 3693, 2429] and fb == [3008, 3248, 1873, 97]
    fc = [x * y % mod2 for x, y in zip(fa, fb)]
    assert fc == [1814, 3035, 1253, 2084]
    c = _fft(fc, -2 * t, n2)
    assert c == [4089, 1152, 2045, 4067]
    cj = [mul2exp(c[j], -(2 + j * t), n2) for j in range(K)]
    assert cj == [4095, 36, 56, 60]
    signed = [x - mod2 if x >= (j + 1) << 8 else x for j, x in enumerate(cj)]
    assert signed == [-2, 36, 56, 60]
    assert sum(v << (4 * j) for j, v in enumerate(signed)) % 65537 == 64059 == 4660 * 22136 % 65537
    st = Stats()
    assert mulmod(4660, 22136, 16, 8, st) == 64059
    assert st.levels[0] == [(16, 4, 12)]


def test_mul2exp() -> None:
    rng = random.Random(1)
    for m in (1, 2, 5, 12, 64, 100):
        mod = (1 << m) + 1
        for _ in range(100):
            x, s = rng.randrange(mod), rng.randrange(-5 * m, 5 * m)
            assert mul2exp(x, s, m) == x * pow(2, s, mod) % mod
        assert mul2exp(1 << m, 1, m) == mod - 2


def test_mulmod_random_and_edges() -> None:
    rng = random.Random(2)
    sizes = {1: list(range(1, 65)), 32: list(range(1, 200)) + [256, 1000], 64: [100, 256, 1000, 3000, 4096]}
    for th, ns in sizes.items():
        for n in ns:
            mod = (1 << n) + 1
            cases = [(0, 0), (1, 1 << n), (1 << n, 1 << n), ((1 << n) - 1, (1 << n) - 1), (1 << n, 5 % mod)]
            cases += [(rng.randrange(mod), rng.randrange(mod)) for _ in range(3)]
            for A, B in cases:
                assert mulmod(A, B, n, th) == A * B % mod


@pytest.mark.parametrize("w", [4, 8])
def test_limbs_random(w: int) -> None:
    rng = random.Random(10 + w)
    for _ in range(80):
        m, n = rng.randint(1, 90), rng.randint(1, 90)
        x, y = rng.randrange(1 << (w * m)), rng.randrange(1 << (w * n))
        out = ssa_multiply(limbs(x, w, m), limbs(y, w, n), w, 32)
        assert len(out) == m + n
        assert value(out, w) == x * y


@pytest.mark.parametrize("w", [4, 8])
def test_limbs_edges(w: int) -> None:
    B = 1 << w
    for n in (1, 2, 3, 16, 64 // w * 4, 100 // w * 4):
        top = B**n - 1
        for x in (0, 1, top, B ** (n - 1)):
            for y in (0, 1, top, B ** (n - 1), top - 1):
                assert value(ssa_multiply(limbs(x, w, n), limbs(y, w, n), w, 32), w) == x * y
    assert ssa_multiply([], [], w) == []


def test_large_and_depth() -> None:
    rng = random.Random(4)
    for N, depth in ((1 << 10, 2), (1 << 12, 2)):
        x, y = rng.randrange(1 << N), rng.randrange(1 << N)
        st = Stats()
        assert value(ssa_multiply(limbs(x, 8, N // 8), limbs(y, 8, N // 8), 8, 64, st), 8) == x * y
        assert st.max_depth == depth
        assert st.max_depth <= math.log2(math.log2(2 * N)) + 1
    st = Stats()
    N = 1 << 14
    x, y = rng.randrange(1 << N), rng.randrange(1 << N)
    assert value(ssa_multiply(limbs(x, 8, N // 8), limbs(y, 8, N // 8), 8, 64, st), 8) == x * y
    assert (st.levels[0][0], st.levels[1][0], st.levels[2][0]) == ((33024, 128, 640), (640, 16, 96), (96, 8, 32))
    assert st.base_products == 16384 and st.max_depth == 3


def test_bad_radix() -> None:
    with pytest.raises(ValueError):
        ssa_multiply([1], [1], 3)

What the next level needs

  • the tests did not run in this build

Read the full page about Schönhage-Strassen multiplication