Algorithm Search⌘K

Kulisch accumulator in Python

A fixed-point register wide enough to hold any sum of floats exactly, rounding only once at the end.

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

"""A Kulisch long accumulator for binary64 sums and dot products.

The register holds an integer V in two's complement over LIMBS 64-bit limbs,
and represents V * 2^-OFFSET. OFFSET = 2148 covers the smallest product of two
subnormals (2^-1074 * 2^-1074); the top covers products below 2^2048, plus
91 guard bits so that up to 2^91 products of any size cannot overflow. Every addition is
exact; rounding happens once, in round().
"""

import math

LIMB = 64
LIMBS = 67  # 4288 bits
OFFSET = 2148
MASK = (1 << LIMB) - 1
WIDTH = LIMB * LIMBS


def decompose(x: float) -> tuple[int, int]:
    """Return (m, e) with x = m * 2^e exactly, m a signed integer of at most 53 bits."""
    if x == 0.0:
        return 0, 0
    f, e = math.frexp(x)  # x = f * 2^e, 0.5 <= |f| < 1
    m = int(f * 2**53)
    return m, e - 53


class KulischAccumulator:
    def __init__(self) -> None:
        self.limbs = [0] * LIMBS
        self.nan = False
        self.pos_inf = False
        self.neg_inf = False

    def _special(self, v: float) -> bool:
        if math.isnan(v):
            self.nan = True
        elif math.isinf(v):
            if v > 0:
                self.pos_inf = True
            else:
                self.neg_inf = True
        else:
            return False
        return True

    def add_product(self, a: float, b: float) -> None:
        """R <- R + a*b, exactly."""
        if not (math.isfinite(a) and math.isfinite(b)):
            self._special(a * b)  # IEEE semantics: inf * 0 is NaN
            return
        ma, ea = decompose(a)
        mb, eb = decompose(b)
        m, e = ma * mb, ea + eb  # @line L1
        if m == 0:
            return
        s = e + OFFSET  # @line L2
        i, chunk = s // LIMB, abs(m) << (s % LIMB)  # @line L3
        limbs = self.limbs
        if m > 0:
            carry = 0
            while chunk or carry:  # @line L4
                if i >= LIMBS:
                    break  # wraps modulo 2^WIDTH (only after about 2^91 additions)
                t = limbs[i] + (chunk & MASK) + carry  # @line L5
                limbs[i], carry = t & MASK, t >> LIMB
                chunk >>= LIMB
                i += 1
        else:
            borrow = 0
            while chunk or borrow:  # @line L4
                if i >= LIMBS:
                    break
                t = limbs[i] - (chunk & MASK) - borrow  # @line L5
                limbs[i], borrow = t & MASK, 1 if t < 0 else 0
                chunk >>= LIMB
                i += 1

    def add(self, x: float) -> None:
        """R <- R + x, exactly."""
        self.add_product(x, 1.0)

    def value(self) -> int:
        """The signed integer V; the exact value is V * 2^-OFFSET."""
        v = 0
        for limb in reversed(self.limbs):
            v = (v << LIMB) | limb
        return v - (1 << WIDTH) if v >> (WIDTH - 1) else v

    def round(self) -> float:
        """The exact contents rounded to nearest binary64, ties to even."""
        if self.nan or (self.pos_inf and self.neg_inf):
            return math.nan
        if self.pos_inf or self.neg_inf:
            return math.inf if self.pos_inf else -math.inf
        v = self.value()  # @line L6
        if v == 0:
            return 0.0
        sign, a = (-1 if v < 0 else 1), abs(v)
        k = a.bit_length()  # @line L7
        # exponent of the leading bit is k - 1 - OFFSET; keep 53 bits, but not below 2^-1074
        drop = max(k - 53, OFFSET - 1074)
        if drop <= 0:
            return sign * math.ldexp(a, -OFFSET)
        q, r = a >> drop, a & ((1 << drop) - 1)
        half = 1 << (drop - 1)
        if r > half or (r == half and q & 1):  # @line L8
            q += 1
        e = drop - OFFSET
        if q.bit_length() + e > 1024:
            return sign * math.inf
        return sign * math.ldexp(q, e)  # @line L9


def exact_sum(xs) -> float:
    acc = KulischAccumulator()
    for x in xs:
        acc.add(x)
    return acc.round()


def exact_dot(xs, ys) -> float:
    acc = KulischAccumulator()
    for x, y in zip(xs, ys):
        acc.add_product(x, y)
    return acc.round()
test_kulisch_accumulator.py 90 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/kulisch-accumulator/. The authors are listed in prov/CV0X1C.jsonl.

import math
import random
from fractions import Fraction

from kulisch_accumulator import OFFSET, KulischAccumulator, exact_dot, exact_sum

MAX = 1.7976931348623157e308


def rnd(x: Fraction) -> float:
    try:
        return float(x)  # correctly rounded in CPython
    except OverflowError:
        return math.inf if x > 0 else -math.inf


def test_examples():
    assert exact_sum([1e100, 1.0, -1e100]) == 1.0
    assert exact_sum([0.1] * 10) == 1.0
    assert exact_sum([1.0, 2.0**-60, -1.0]) == 2.0**-60
    assert exact_sum([5e-324, 5e-324]) == 1e-323
    assert exact_sum([MAX, MAX, -MAX]) == MAX  # the intermediate 2*MAX is held exactly
    assert exact_dot([1e300, 1e300], [1e300, -1e300]) == 0.0
    assert exact_dot([1e-160, 3.0], [1e-160, 0.0]) == rnd(Fraction(1e-160) ** 2)  # a subnormal result
    assert exact_sum([]) == 0.0


def test_random_sums_and_dots_against_fractions():
    rng = random.Random(1)
    for _ in range(200):
        xs = [math.ldexp(rng.uniform(-1, 1), rng.randint(-1074, 1020)) for _ in range(40)]
        ys = [math.ldexp(rng.uniform(-1, 1), rng.randint(-1074, 1020)) for _ in range(40)]
        assert exact_sum(xs) == rnd(sum(map(Fraction, xs)))
        assert exact_dot(xs, ys) == rnd(sum(Fraction(x) * Fraction(y) for x, y in zip(xs, ys)))


def test_ill_conditioned_sums():
    rng = random.Random(2)
    for _ in range(200):
        xs = [rng.uniform(-1, 1) * 10.0 ** rng.randint(-30, 30) for _ in range(30)]
        xs += [-x for x in xs[:25]] + [rng.uniform(-1e-20, 1e-20)]
        rng.shuffle(xs)
        assert exact_sum(xs) == rnd(sum(map(Fraction, xs)))


def test_order_independent():
    rng = random.Random(3)
    xs = [math.ldexp(rng.uniform(-1, 1), rng.randint(-200, 200)) for _ in range(300)]
    ref = exact_sum(xs)
    for _ in range(5):
        rng.shuffle(xs)
        assert exact_sum(xs) == ref


def test_exact_register_contents():
    rng = random.Random(4)
    acc, total = KulischAccumulator(), Fraction(0)
    for _ in range(500):
        a = math.ldexp(rng.uniform(-1, 1), rng.randint(-1074, 1023))
        b = math.ldexp(rng.uniform(-1, 1), rng.randint(-1074, 1023 - max(0, math.frexp(a)[1])))
        acc.add_product(a, b)
        total += Fraction(a) * Fraction(b)
        assert Fraction(acc.value(), 2**OFFSET) == total


def test_long_carry_and_borrow():
    acc = KulischAccumulator()
    acc.add(-(2.0**-1074))  # register becomes all ones: borrow through every limb
    assert acc.value() == -(1 << (OFFSET - 1074))
    acc.add(2.0**-1074)  # carry back through every limb
    assert acc.value() == 0 and all(limb == 0 for limb in acc.limbs)


def test_rounding_ties_and_overflow():
    assert exact_sum([1.0, 2.0**-53]) == 1.0  # tie, to even
    assert exact_sum([1.0, 2.0**-53, 2.0**-1074]) == 1.0 + 2.0**-52  # just above the tie
    assert exact_sum([1.0 + 2.0**-52, 2.0**-53]) == 1.0 + 2.0**-51  # tie, to even (up)
    assert exact_sum([MAX, MAX]) == math.inf
    assert exact_sum([MAX, 2.0**970]) == math.inf  # rounds to 2^1024
    assert exact_sum([MAX, 2.0**969]) == MAX


def test_special_values():
    assert math.isnan(exact_sum([1.0, math.nan]))
    assert math.isnan(exact_sum([math.inf, -math.inf]))
    assert exact_sum([math.inf, 1e308, 1e308]) == math.inf
    assert math.isnan(exact_dot([math.inf], [0.0]))

What the next level needs

  • the tests did not run in this build

Read the full page about Kulisch accumulator