Algorithm Search⌘K

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
reproducible_summation.pyRawGitHub
# 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