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