Reproducible floating-point summation in Python
Gives bitwise identical sums regardless of the order of summation or the parallel decomposition.
- File
reproducible_summation.py, 122 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/reproducible-summation/. The authors are listed in prov/R755YZ.jsonl.
"""Order-independent summation by pre-rounding to fixed boundaries.
reproducible_sum(xs, K) returns the same binary64 result for every
permutation of xs (and for every split of xs into parts that are summed
separately and merged with merge_partials). The method is the K-fold
extraction of Demmel and Nguyen, with the boundary chosen from max |x_i|
and n so that every extracted part sums exactly.
"""
import math
P = 53 # binary64 precision
_MIN_EXP = -1021 # M >= 2^-1021 keeps the unit M * 2^-p >= 2^-1074
def _ceil_log2(v):
"""Smallest integer t with v <= 2^t, for v > 0 (a float or an int)."""
m, e = math.frexp(v)
return e - 1 if m == 0.5 else e
def boundaries(m, n, K):
"""The K boundaries M_1 > M_2 > ... used for max |x_i| = m and length n."""
c = _ceil_log2(n) + 1 # 2^c >= 2n
a = max(_ceil_log2(m) + c, _MIN_EXP)
out = []
for _ in range(K):
if a > 1023:
raise OverflowError("max |x_i| too large for this n")
if a < _MIN_EXP:
if out and out[-1] == math.ldexp(1.0, _MIN_EXP):
out.append(0.0) # residuals are now 0 or +-2^-1074: take them whole
break
a = _MIN_EXP # one level with unit 2^-1074
out.append(math.ldexp(1.0, a))
a += c - P
return out
def fold_partials(xs, Ms):
"""Exact per-level sums T_j of the extracted parts (order independent)."""
T = [0.0] * len(Ms) # @line L3
for x in xs: # @line L4
v = x # @line L5
for j, M in enumerate(Ms): # @line L6
q = (M + v) - M # @line L7
v = v - q # @line L8
T[j] += q # @line L9
return T
def _combine(T):
s = 0.0
for t in reversed(T): # @line L10
s = t + s
return s
def _special(xs):
"""Order-independent result for inputs with inf or nan, else None."""
pos = neg = nan = False
for x in xs:
if math.isnan(x):
nan = True
elif x == math.inf:
pos = True
elif x == -math.inf:
neg = True
if nan or (pos and neg):
return math.nan
if pos:
return math.inf
if neg:
return -math.inf
return None
def reproducible_sum(xs, K=3):
"""Sum xs so that the result does not depend on the order of xs."""
xs = list(xs)
sp = _special(xs)
if sp is not None:
return sp
m = max((abs(x) for x in xs), default=0.0) # @line L1
if m == 0.0:
return 0.0
Ms = boundaries(m, len(xs), K) # @line L2
return _combine(fold_partials(xs, Ms))
def merge_partials(parts, K=3):
"""Sum a list of lists as if concatenated, summing each part separately.
Each part only needs the global max and the global length, which is the
pattern of a parallel reduction: one max-reduction, then one sum.
"""
parts = [list(p) for p in parts]
flat = [x for p in parts for x in p]
sp = _special(flat)
if sp is not None:
return sp
m = max((abs(x) for x in flat), default=0.0)
if m == 0.0:
return 0.0
Ms = boundaries(m, len(flat), K)
totals = [0.0] * len(Ms)
for p in parts:
for j, t in enumerate(fold_partials(p, Ms)):
totals[j] += t # exact, see the proof
return _combine(totals)
if __name__ == "__main__":
import random
rng = random.Random(1)
xs = [rng.uniform(-1, 1) * 10 ** rng.randint(-8, 8) for _ in range(1000)]
ys = xs[:]
rng.shuffle(ys)
print(sum(xs), sum(ys), reproducible_sum(xs), reproducible_sum(ys), math.fsum(xs))
test_reproducible_summation.py 119 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/reproducible-summation/. The authors are listed in prov/R755YZ.jsonl.
import itertools
import math
import random
from fractions import Fraction
from reproducible_summation import (P, boundaries, fold_partials, merge_partials,
reproducible_sum)
def gamma(k):
u = Fraction(1, 2 ** P)
return k * u / (1 - k * u)
def data(rng, n, spread):
return [rng.uniform(-1, 1) * 10.0 ** rng.randint(-spread, spread) for _ in range(n)]
def test_small_example():
xs = [1e16, 1.0, -1e16, 1.0]
for p in itertools.permutations(xs):
assert reproducible_sum(p) == 2.0
def test_permutation_invariance():
rng = random.Random(11)
for trial in range(30):
xs = data(rng, rng.randint(1, 400), rng.randint(0, 20))
want = reproducible_sum(xs)
for _ in range(5):
rng.shuffle(xs)
assert reproducible_sum(xs) == want
def test_partition_invariance():
rng = random.Random(12)
for trial in range(30):
xs = data(rng, 300, 12)
want = reproducible_sum(xs)
cuts = sorted(rng.sample(range(1, 300), rng.randint(1, 8)))
parts = [xs[i:j] for i, j in zip([0] + cuts, cuts + [300])]
assert merge_partials(parts) == want
def test_levels_are_exact():
rng = random.Random(13)
for trial in range(40):
xs = data(rng, rng.randint(1, 200), 15)
m = max(abs(x) for x in xs)
Ms = boundaries(m, len(xs), 3)
T = fold_partials(xs, Ms)
# Recompute with exact arithmetic: each extraction and each T_j is exact.
exact = [Fraction(0)] * len(Ms)
tail = Fraction(0)
for x in xs:
v = x
for j, M in enumerate(Ms):
q = (M + v) - M
assert Fraction(v) - Fraction(q) == Fraction(v - q)
assert M == 0.0 or abs(v) <= M / (2 * len(xs))
exact[j] += Fraction(q)
v = v - q
tail += Fraction(v)
assert [Fraction(t) for t in T] == exact
assert sum(exact) + tail == sum(Fraction(x) for x in xs)
def test_error_bound():
rng = random.Random(14)
for K in (1, 2, 3):
for trial in range(30):
n = rng.randint(1, 500)
xs = data(rng, n, 10)
m = max(abs(x) for x in xs)
Ms = boundaries(m, n, K)
T = fold_partials(xs, Ms)
S = sum(Fraction(x) for x in xs)
err = abs(Fraction(reproducible_sum(xs, K)) - S)
bound = n * Fraction(Ms[-1]) / 2 ** P + gamma(len(T) - 1) * sum(abs(Fraction(t)) for t in T)
assert err <= bound
def test_three_folds_usually_correctly_rounded():
rng = random.Random(15)
hits = 0
for trial in range(200):
xs = data(rng, 100, 6)
hits += reproducible_sum(xs) == math.fsum(xs)
assert hits >= 190
def test_tiny_inputs_reach_the_bottom_level():
rng = random.Random(16)
for trial in range(20):
xs = [rng.randint(-2 ** 40, 2 ** 40) * 5e-324 for _ in range(rng.randint(1, 300))]
Ms = boundaries(max(abs(x) for x in xs) or 5e-324, len(xs), 3)
assert Ms[-1] == 0.0
assert reproducible_sum(xs) == math.fsum(xs)
def test_special_values():
assert reproducible_sum([]) == 0.0
assert reproducible_sum([0.0, -0.0]) == 0.0
assert reproducible_sum([1.0, math.inf]) == math.inf
assert math.isnan(reproducible_sum([math.inf, -math.inf, 1.0]))
assert math.isnan(reproducible_sum([1.0, math.nan]))
assert reproducible_sum([5e-324, 5e-324]) == 1e-323
def test_overflow_guard():
try:
reproducible_sum([1e308, 1e308])
except OverflowError:
pass
else:
raise AssertionError("expected OverflowError")
What the next level needs
- the tests did not run in this build
Read the full page about Reproducible floating-point summation