Algorithm Search⌘K

Scaled remainder tree in Python

Subquadratic radix conversion using a tree of scaled remainders.

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

"""Scaled remainder tree: x mod m_1, ..., x mod m_k by multiplications only.

A remainder tree pushes x mod Q down the product tree of the moduli, with one
division per node. The scaled tree pushes the fraction frac(x / Q) instead,
as a fixed-point number Y / 2^p with p = bitlength(Q) + G binary digits. For
Q = Q_c * Q_s, frac(x / Q_c) = frac(Q_s * frac(x / Q)), so each child costs
one product and a truncation. At a leaf m, x mod m = ceil(m * Y / 2^p) mod m.
All truncations round down, which keeps the error one-sided; G guard digits
with 2^G >= depth + 1 make every leaf exact.

Integers are Python ints; Stats counts the products and their sizes.
"""

import math


class Stats:
    def __init__(self) -> None:
        self.products = 0
        self.product_digits = 0  # the sum of the lengths of the larger factors
        self.divisions = 0
        self.depth = 0


def _mul(a: int, b: int, st: Stats | None) -> int:
    if st is not None:
        st.products += 1
        st.product_digits += max(a.bit_length(), b.bit_length())
    return a * b


class Node:
    __slots__ = ("q", "left", "right")

    def __init__(self, q: int, left: "Node | None" = None, right: "Node | None" = None) -> None:
        self.q = q
        self.left = left
        self.right = right


def product_tree(ms: list[int], st: Stats | None = None) -> Node:
    """The balanced product tree of the moduli, built bottom up."""
    if not ms:
        raise ValueError("need at least one modulus")
    if any(m < 1 for m in ms):
        raise ValueError("moduli must be positive")
    level = [Node(m) for m in ms]
    while len(level) > 1:
        nxt = []
        for i in range(0, len(level) - 1, 2):
            a, b = level[i], level[i + 1]
            nxt.append(Node(_mul(a.q, b.q, st), a, b))
        if len(level) % 2:
            nxt.append(level[-1])
        level = nxt
    return level[0]


def height(t: Node) -> int:
    if t.left is None:
        return 0
    return 1 + max(height(t.left), height(t.right))


def _descend(t: Node, Y: int, p: int, G: int, out: list[int], st: Stats | None, depth: int) -> None:
    if st is not None:
        st.depth = max(st.depth, depth)
    if t.left is None:  # @line L6: a leaf: x mod m = ceil(m Y / 2^p) mod m
        m = t.q
        out.append(((_mul(m, Y, st) + (1 << p) - 1) >> p) % m)
        return
    for c, s in ((t.left, t.right), (t.right, t.left)):  # @line L4: for each child c with sibling s
        pc = c.q.bit_length() + G
        Z = _mul(s.q, Y, st) & ((1 << p) - 1)  # @line L5: frac(Q_s * Y / 2^p), then keep p_c digits
        _descend(c, Z >> (p - pc), pc, G, out, st, depth + 1)


def scaled_remainders(x: int, ms: list[int], st: Stats | None = None, tree: Node | None = None) -> list[int]:
    """[x mod m for m in ms] for an integer x >= 0 and moduli m >= 1."""
    if x < 0:
        raise ValueError("x must be nonnegative")
    t = tree if tree is not None else product_tree(ms, st)  # @line L1: the product tree
    d = height(t)
    G = max(1, math.ceil(math.log2(d + 1)))  # @line L2: guard digits, 2^G >= depth + 1
    p = t.q.bit_length() + G
    if st is not None:
        st.divisions += 1
    Y = ((x % t.q) << p) // t.q  # @line L3: Y = floor(frac(x / P) 2^p), the one division
    out: list[int] = []
    _descend(t, Y, p, G, out, st, 0)
    return out  # @line L7


def remainder_tree(x: int, ms: list[int], st: Stats | None = None, tree: Node | None = None) -> list[int]:
    """The unscaled remainder tree, one division per node, for comparison."""
    t = tree if tree is not None else product_tree(ms, st)
    out: list[int] = []

    def go(v: Node, r: int) -> None:
        if st is not None:
            st.divisions += 1
        r %= v.q
        if v.left is None:
            out.append(r)
            return
        go(v.left, r)
        go(v.right, r)

    go(t, x)
    return out


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


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


def scaled_remainders_limbs(x: list[int], ms: list[list[int]], w: int = 8) -> list[list[int]]:
    """The same on limb arrays in radix 2^w; each remainder has as many limbs as its modulus."""
    rs = scaled_remainders(from_limbs(x, w), [from_limbs(m, w) for m in ms])
    return [to_limbs(r, w) + [0] * (len(m) - len(to_limbs(r, w))) for r, m in zip(rs, ms)]
test_scaled_remainder_tree.py 131 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/scaled-remainder-tree/. The authors are listed in prov/T3AFQR.jsonl.

import math
import random

import pytest

import scaled_remainder_tree as srt
from scaled_remainder_tree import (
    Stats,
    from_limbs,
    height,
    product_tree,
    remainder_tree,
    scaled_remainders,
    scaled_remainders_limbs,
    to_limbs,
)


def test_random_against_python():
    rng = random.Random(1)
    for _ in range(1500):
        k = rng.randint(1, 40)
        ms = [rng.getrandbits(rng.randint(1, 64)) or 1 for _ in range(k)]
        x = rng.getrandbits(rng.randint(0, 2500))
        want = [x % m for m in ms]
        assert scaled_remainders(x, ms) == want
        assert remainder_tree(x, ms) == want


def test_edge_cases():
    cases = [
        (0, [1]),
        (0, [5, 7]),
        (5, [1, 1, 1]),
        (12345, [12345]),
        (12345 * 7 * 11, [7, 11, 12345]),  # all remainders zero
        ((1 << 200) - 1, [2, 4, 8, (1 << 64) - 1, 1 << 63]),
        (10**50, [10**k for k in range(1, 12)]),
        (2**127 - 1, [3] * 33),  # many equal moduli, height 6
    ]
    for x, ms in cases:
        assert scaled_remainders(x, ms) == [x % m for m in ms]
    rng = random.Random(2)
    for _ in range(300):  # x a multiple of P minus small values: fractions near 0 and 1
        ms = [rng.randint(1, 1000) for _ in range(rng.randint(1, 20))]
        P = math.prod(ms)
        for x in (P, P - 1, P + 1, 3 * P, 3 * P - 1):
            assert scaled_remainders(x, ms) == [x % m for m in ms]
    with pytest.raises(ValueError):
        scaled_remainders(5, [])
    with pytest.raises(ValueError):
        scaled_remainders(5, [3, 0])
    with pytest.raises(ValueError):
        scaled_remainders(-1, [3])


def test_guard_digits_are_needed():
    def run(x, ms, G):
        t = product_tree(ms)
        p = t.q.bit_length() + G
        out = []
        srt._descend(t, ((x % t.q) << p) // t.q, p, G, out, None, 0)
        return out

    rng = random.Random(3)
    bad = {0: 0, 1: 0}
    for _ in range(300):
        ms = [rng.getrandbits(rng.randint(1, 16)) or 1 for _ in range(rng.randint(2, 64))]
        x = rng.getrandbits(rng.randint(0, 800))
        want = [x % m for m in ms]
        for G in (0, 1):
            bad[G] += run(x, ms, G) != want
        d = height(product_tree(ms))
        G = max(1, math.ceil(math.log2(d + 1)))
        assert run(x, ms, G) == want
    assert bad[0] > 150 and bad[1] > 150


def test_worked_example():
    x, ms = 123456, [7, 11, 13, 17]
    t = product_tree(ms)
    assert (t.q, t.left.q, t.right.q, height(t)) == (17017, 77, 221, 2)
    p = 17
    Y = ((x % 17017) << p) // 17017
    assert x % 17017 == 4337 and Y == 33405
    assert 221 * Y == 7382505 and (221 * Y) % (1 << 17) == 42473 and 42473 >> 8 == 165
    assert (77 * Y) % (1 << 17) == 81817 and 81817 >> 7 == 639
    rows = []
    for parent, pp, sib_q, m in ((165, 9, 11, 7), (165, 9, 7, 11), (639, 10, 17, 13), (639, 10, 13, 17)):
        pl = m.bit_length() + 2
        Z = (sib_q * parent) % (1 << pp)
        Yl = Z >> (pp - pl)
        rows.append((pl, Z, Yl, -((-m * Yl) >> pl) % m))
    assert rows == [(5, 279, 17, 4), (6, 131, 16, 3), (6, 623, 38, 8), (7, 115, 14, 2)]
    st = Stats()
    assert scaled_remainders(x, ms, st) == [4, 3, 8, 2]
    assert (st.products, st.divisions) == (13, 1)


def test_counts_and_depth():
    rng = random.Random(4)
    for k in (1, 2, 3, 5, 8, 64, 100, 257):
        ms = [rng.getrandbits(32) | 1 for _ in range(k)]
        st = Stats()
        scaled_remainders(rng.getrandbits(32 * k), ms, st)
        assert st.products == 4 * k - 3 and st.divisions == 1
        assert st.depth == height(product_tree(ms)) == math.ceil(math.log2(k))
        t = product_tree(ms)
        st2 = Stats()
        scaled_remainders(rng.getrandbits(32 * k), ms, st2, tree=t)
        assert st2.products == 3 * k - 2


@pytest.mark.parametrize("w", [4, 8])
def test_limbs_against_python(w):
    rng = random.Random(10 + w)
    for _ in range(400):
        ms = [[rng.randrange(1 << w) for _ in range(rng.randint(1, 6))] for _ in range(rng.randint(1, 20))]
        for m in ms:
            if from_limbs(m, w) == 0:
                m[0] = 1
        x = [rng.randrange(1 << w) for _ in range(rng.randint(0, 80))]
        X = from_limbs(x, w)
        rs = scaled_remainders_limbs(x, ms, w)
        for r, m in zip(rs, ms):
            assert from_limbs(r, w) == X % from_limbs(m, w)
            assert len(r) == len(m) and all(0 <= d < (1 << w) for d in r)
    assert to_limbs(0, w) == []

What the next level needs

  • the tests did not run in this build

Read the full page about Scaled remainder tree