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
# 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