Algorithm Search⌘K

Ryu in Python

Always-correct shortest float printing using precomputed 128-bit multipliers.

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

"""Ryu: shortest round-trip decimal for binary64 (Adams 2018).

ryu(x) returns (digits, exponent) with digits * 10^exponent the shortest
decimal that reads back to x under round to nearest, ties to even, and the
closest such decimal to x when several have the same length. The 125-bit
multipliers are computed on demand instead of being stored in a table.
"""

import math
import struct

MANTISSA_BITS = 52
BIAS = 1023
POW5_INV_BITCOUNT = 125
POW5_BITCOUNT = 125


def pow5bits(e: int) -> int:
    """ceil(log2(5^e)) for e >= 1, and 1 for e = 0."""
    return ((e * 1217359) >> 19) + 1


def log10_pow2(e: int) -> int:
    """floor(log10(2^e)) for 0 <= e <= 1650."""
    return (e * 78913) >> 18


def log10_pow5(e: int) -> int:
    """floor(log10(5^e)) for 0 <= e <= 2620."""
    return (e * 732923) >> 20


def pow5_inv_split(q: int) -> int:
    """The table entry ceil(2^(pow5bits(q) - 1 + 125) / 5^q), a 125-bit multiplier."""
    return ((1 << (pow5bits(q) - 1 + POW5_INV_BITCOUNT)) // 5**q) + 1


def pow5_split(i: int) -> int:
    """The table entry 5^i normalized to exactly 125 bits (truncated)."""
    shift = pow5bits(i) - POW5_BITCOUNT
    return 5**i >> shift if shift >= 0 else 5**i << -shift


def pow5_factor(v: int) -> int:
    count = 0
    while v % 5 == 0:
        v //= 5
        count += 1
    return count


def decode(x: float) -> tuple[int, int, int]:
    """Return (sign, ieee_mantissa, ieee_exponent) of a binary64 value."""
    bits = struct.unpack("<Q", struct.pack("<d", x))[0]
    return bits >> 63, bits & ((1 << MANTISSA_BITS) - 1), (bits >> MANTISSA_BITS) & 0x7FF


def ryu(x: float) -> tuple[int, int]:
    """Shortest decimal (digits, exponent) for a finite positive float x."""
    if not (math.isfinite(x) and x > 0):
        raise ValueError("ryu expects a finite positive float")
    _, ieee_m, ieee_e = decode(x)
    if ieee_e == 0:  # @line L1
        e2, m2 = 1 - BIAS - MANTISSA_BITS - 2, ieee_m
    else:
        e2, m2 = ieee_e - BIAS - MANTISSA_BITS - 2, (1 << MANTISSA_BITS) | ieee_m
    accept_bounds = m2 % 2 == 0  # ties go to even, so even m2 owns its boundaries
    mv = 4 * m2
    mm_shift = 1 if (ieee_m != 0 or ieee_e <= 1) else 0
    lo_n, hi_n = mv - 1 - mm_shift, mv + 2  # interval [lo_n, hi_n] * 2^e2
    vm_tz = vr_tz = False
    if e2 >= 0:  # @line L2
        q = max(0, log10_pow2(e2) - (1 if e2 > 3 else 0))
        e10 = q
        k = POW5_INV_BITCOUNT + pow5bits(q) - 1
        j = -e2 + q + k
        mul = pow5_inv_split(q)
        vr, vp, vm = (mv * mul) >> j, (hi_n * mul) >> j, (lo_n * mul) >> j  # @line L3
        if q <= 21:  # @line L4
            if mv % 5 == 0:
                vr_tz = pow5_factor(mv) >= q
            elif accept_bounds:
                vm_tz = pow5_factor(lo_n) >= q
            else:
                vp -= 1 if pow5_factor(hi_n) >= q else 0  # @line L5
    else:
        q = max(0, log10_pow5(-e2) - (1 if -e2 > 1 else 0))  # @line L2
        e10 = q + e2
        i = -e2 - q
        k = pow5bits(i) - POW5_BITCOUNT
        j = q - k
        mul = pow5_split(i)
        vr, vp, vm = (mv * mul) >> j, (hi_n * mul) >> j, (lo_n * mul) >> j  # @line L3
        if q <= 1:  # @line L4
            vr_tz = True
            if accept_bounds:
                vm_tz = mm_shift == 1
            else:
                vp -= 1  # @line L5
        elif q < 63:
            vr_tz = mv & ((1 << q) - 1) == 0
    removed, last = 0, 0
    while vp // 10 > vm // 10:  # @line L6
        vm_tz = vm_tz and vm % 10 == 0
        vr_tz = vr_tz and last == 0
        last = vr % 10
        vr, vp, vm = vr // 10, vp // 10, vm // 10
        removed += 1
    if vm_tz and accept_bounds:  # @line L7
        while vm % 10 == 0:
            vr_tz = vr_tz and last == 0
            last = vr % 10
            vr, vp, vm = vr // 10, vp // 10, vm // 10
            removed += 1
    if vr_tz and last == 5 and vr % 2 == 0:  # @line L8
        last = 4  # an exact tie: round half to even
    out = vr + (1 if (vr == vm and not (accept_bounds and vm_tz)) or last >= 5 else 0)  # @line L9
    return out, e10 + removed  # @line L10


def ryu_str(x: float) -> str:
    """Format like '1.5e-7', '-0.0', 'inf'; the digits come from ryu."""
    if math.isnan(x):
        return "nan"
    sign = "-" if math.copysign(1.0, x) < 0 else ""
    if math.isinf(x):
        return sign + "inf"
    if x == 0:
        return sign + "0.0"
    digits, exp = ryu(abs(x))
    s = str(digits)
    mantissa = s[0] + ("." + s[1:] if len(s) > 1 else "")
    return f"{sign}{mantissa}e{exp + len(s) - 1}"
test_ryu.py 114 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ryu/. The authors are listed in prov/JH77DH.jsonl.

import math
import random
import struct
from decimal import Decimal
from fractions import Fraction

from ryu import BIAS, MANTISSA_BITS, decode, pow5_inv_split, pow5_split, pow5bits, ryu, ryu_str


def from_bits(b: int) -> float:
    return struct.unpack("<d", struct.pack("<Q", b))[0]


def reference(x: float) -> tuple[int, int]:
    """Shortest, then closest (ties to even digits), decimal in the rounding interval, in exact rationals."""
    v = Fraction(x)
    lo = (v + Fraction(math.nextafter(x, 0.0))) / 2
    hi = (v + Fraction(math.nextafter(x, math.inf))) / 2
    inclusive = decode(x)[1] % 2 == 0

    def inside(y: Fraction) -> bool:
        return lo <= y <= hi if inclusive else lo < y < hi

    k = len(str(int(hi))) + 1 if hi >= 1 else 1
    while True:
        unit = Fraction(10) ** k
        d0 = math.floor(v / unit)
        cands = [d for d in (d0 - 1, d0, d0 + 1, d0 + 2) if d > 0 and inside(d * unit)]
        if cands:
            best = min(cands, key=lambda d: (abs(d * unit - v), d % 2))
            while best % 10 == 0:
                best //= 10
                k += 1
            return best, k
        k -= 1


def from_repr(x: float) -> tuple[int, int]:
    t = Decimal(repr(x)).normalize().as_tuple()
    return int("".join(map(str, t.digits))), t.exponent


def test_examples():
    assert ryu(0.3) == (3, -1)
    assert ryu(1.0) == (1, 0)
    assert ryu(5e-324) == (5, -324)
    assert ryu(1.7976931348623157e308) == (17976931348623157, 292)
    assert ryu(2.0**-1017) == (7120236347223045, -322)
    assert ryu(1e23) == (1, 23)
    assert ryu(2.0**60) == (1152921504606847, 3)
    assert ryu_str(0.1) == "1e-1" and ryu_str(-2.5) == "-2.5e0" and ryu_str(-0.0) == "-0.0"


def test_against_exact_reference():
    rng = random.Random(2018)
    for _ in range(800):
        x = from_bits(rng.getrandbits(63))
        if math.isfinite(x) and x > 0:
            assert ryu(x) == reference(x), x


def test_round_trip_and_repr():
    rng = random.Random(1)
    for _ in range(25000):
        x = from_bits(rng.getrandbits(63))
        if not (math.isfinite(x) and x > 0):
            continue
        d, e = ryu(x)
        assert float(f"{d}e{e}") == x
        assert (d, e) == from_repr(x)


def test_edge_values():
    vals = [5e-324, 1e-323, 2.2250738585072014e-308, 2.225073858507201e-308, 1.0, 2.0, 0.5]
    vals += [2.0**k for k in range(-1074, 1024, 7)]
    vals += [math.nextafter(2.0**k, 0.0) for k in range(-1020, 1024, 11)]
    vals += [float(10**k) for k in range(0, 23)] + [10.0**-k for k in range(1, 30)]
    vals += [float(n) for n in range(1, 400)] + [9007199254740993.0, 2.0**53 + 2.0]
    for x in vals:
        assert ryu(x) == reference(x), x


def test_multiplier_floors_are_exact():
    # the 125-bit table entries give the exact floor of v * 2^e2 / 10^e10
    rng = random.Random(3)
    for _ in range(1500):
        x = from_bits(rng.getrandbits(63))
        if not (math.isfinite(x) and x > 0):
            continue
        _, m, e = decode(x)
        e2 = (1 if e == 0 else e) - BIAS - MANTISSA_BITS - 2
        mv = 4 * ((1 << MANTISSA_BITS) | m if e else m)
        if e2 >= 0:
            q = max(0, ((e2 * 78913) >> 18) - (e2 > 3))
            j = -e2 + q + 125 + pow5bits(q) - 1
            got, e10 = (mv * pow5_inv_split(q)) >> j, q
        else:
            q = max(0, ((-e2 * 732923) >> 20) - (-e2 > 1))
            i = -e2 - q
            j = q - (pow5bits(i) - 125)
            got, e10 = (mv * pow5_split(i)) >> j, q + e2
        assert got == math.floor(Fraction(mv) * Fraction(2) ** e2 / Fraction(10) ** e10)


def test_rejects_non_positive():
    for bad in (0.0, -1.0, math.inf, math.nan):
        try:
            ryu(bad)
        except ValueError:
            continue
        raise AssertionError(bad)

What the next level needs

  • the tests did not run in this build

Read the full page about Ryu