Skip to content

gh-100687: Reduce frequency of overallocation in x_add() - #158002

Open
eendebakpt wants to merge 1 commit into
python:mainfrom
eendebakpt:longobject-xadd-exact-alloc
Open

eendebakpt wants to merge 1 commit into
python:mainfrom
eendebakpt:longobject-xadd-exact-alloc

Conversation

@eendebakpt

Copy link
Copy Markdown
Contributor

Extends #100688 by @mdickinson. x_add() allocated a digit for the carry out of the top digit on every call; in most cases that digit is zero. In this PR we avoid the overallocation and the normalize in most of the cases.

Memory

Resident bytes per result for a list of one million sums, PGO+LTO build. A dropped digit changes the pymalloc size class every fourth digit count, so only those sizes shrink; the others are unchanged.

Sum of main PR
2-digit operands 48.1 B 32.0 B −33%
6-digit operands 64.1 B 48.1 B −25%
10-digit operands 80.0 B 64.1 B −20%
3-digit operands 47.8 B 47.9 B same size class

Speed

PGO+LTO build. Small and medium additions get faster because the result is returned without long_normalize(); for 100 digits and more the extra top-digit test shows as 1 to 3% and the saved call no longer matters.

Case main PR
a + b, 3 to 10 digits, both operand classes 20.5 to 23.6 ns 1.04x to 1.05x faster
a + b, 10x2 digits 23.0 ns 1.06x to 1.07x faster
(-a) + (-b), 5 digits (same sign, still x_add()) 21.7 ns 1.04x faster
a + b, 100 and 1000 digits 70 ns, 575 ns 1.03x and 1.01x slower
a - b, 5 and 10 digits (x_sub(), control) 23.8 ns unchanged
fib(300) 6.59 us 1.04x faster
math.factorial(200) 1.11 us 1.01x slower
Geometric mean, 20 cases 1.02x faster
Benchmark scripts

Timing: python bench_xadd.py -o out.json --affinity CPU under each build, then python -m pyperf compare_to --table. Memory: python bench_memory.py under each build (the a + b rows).

"""pyperf timings for the x_add() exact allocation (gh-100687).

All operands are positive unless the case says otherwise, so `a + b` goes
through x_add() and `a - b` through x_sub(), which this change leaves alone.
"randbits" operands have a bit length uniform within their digit range;
"full" operands have the top bit of their top digit set.
"""
import pyperf


def make(r, cls, n):
    if cls == "full":
        return r.getrandbits(n * 30 - 1) | (1 << (n * 30 - 2))
    if cls == "randbits":
        return r.getrandbits(r.randrange(30 * (n - 1) + 1, 30 * n + 1)) | (1 << (30 * (n - 1)))
    raise ValueError(cls)


def setup(cls, na, nb, neg=False):
    return (f"import random; r = random.Random(1); from __main__ import make; "
            f"a = make(r, '{cls}', {na}); b = make(r, '{cls}', {nb})"
            + ("; a = -a; b = -b" if neg else ""))


def main():
    runner = pyperf.Runner()
    sizes = [(2, 2), (3, 3), (5, 5), (10, 10), (10, 2), (100, 100), (1000, 1000)]
    for cls in ("full", "randbits"):
        for na, nb in sizes:
            runner.timeit(f"a + b, {cls}, {na}x{nb} digits", "a + b", setup=setup(cls, na, nb))
    runner.timeit("(-a) + (-b), randbits, 5x5 digits (x_add)", "a + b", setup=setup("randbits", 5, 5, neg=True))
    runner.timeit("a - b, randbits, 5x5 digits (x_sub, unchanged)", "a - b", setup=setup("randbits", 5, 5))
    runner.timeit("a - b, randbits, 10x10 digits (x_sub, unchanged)", "a - b", setup=setup("randbits", 10, 10))
    runner.timeit("fib(300)", "fib(300)", setup="from __main__ import fib")
    runner.timeit("factorial(200)", "math.factorial(200)", setup="import math")


def fib(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a


if __name__ == "__main__":
    main()
"""Memory used by lists of int results: python bench_memory.py [CASE]

Without CASE, runs every case in a fresh subprocess of the same interpreter
and prints max RSS delta per result object.  sys.getsizeof() cannot see the
difference (it reports the digit count, which long_normalize already trims),
so the resident size is measured instead.
"""
import random
import resource
import subprocess
import sys

N = 1_000_000
CASES = {
    # name: (op, class, na, nb)
    "a + b, full, 2x2":        ("+", "full", 2, 2),
    "a + b, full, 6x6":        ("+", "full", 6, 6),
    "a + b, full, 10x10":      ("+", "full", 10, 10),
    "a + b, uniform, 2x2":     ("+", "uniform", 2, 2),
    "a + b, decimal, 3x3":     ("+", "decimal", 3, 3),
    "a * b, full, 1x2":        ("*", "full", 1, 2),
    "a * b, uniform, 1x2":     ("*", "uniform", 1, 2),
    "a * b, decimal, 1x2":     ("*", "decimal", 1, 2),
    "a * b, uniform, 2x5":     ("*", "uniform", 2, 5),
    "a * b, decimal, 2x5":     ("*", "decimal", 2, 5),
    "a * b, uniform, 3x3":     ("*", "uniform", 3, 3),
    "a * b, decimal, 3x3":     ("*", "decimal", 3, 3),
    "a * b, uniform, 5x5":     ("*", "uniform", 5, 5),
    "a * b, decimal, 5x5":     ("*", "decimal", 5, 5),
    "a * b, decimal, 10x10":   ("*", "decimal", 10, 10),
    "a * b, randbits, 1x2":    ("*", "randbits", 1, 2),
    "a * b, randbits, 2x2":    ("*", "randbits", 2, 2),
    "a * b, randbits, 2x5":    ("*", "randbits", 2, 5),
    "a * b, randbits, 3x3":    ("*", "randbits", 3, 3),
    "a * b, randbits, 5x5":    ("*", "randbits", 5, 5),
    "a * b, randbits, 10x10":  ("*", "randbits", 10, 10),
    "a + b, randbits, 2x2":    ("+", "randbits", 2, 2),
    "a + b, randbits, 6x6":    ("+", "randbits", 6, 6),
}


def make(r, cls, n):
    if cls == "full":
        return r.getrandbits(n * 30 - 1) | (1 << (n * 30 - 2))
    if cls == "uniform":
        top = r.randrange(1, 1 << 30)
        return (top << (30 * (n - 1))) | r.getrandbits(30 * (n - 1))
    if cls == "randbits":
        # bit length uniform within the n-digit range: the top digit has
        # 1..30 bits, as ints in real programs do
        return r.getrandbits(r.randrange(30 * (n - 1) + 1, 30 * n + 1)) | (1 << (30 * (n - 1)))
    if cls == "decimal":
        k = (n * 30 - 3) * 30103 // 100000
        return 10 ** k + r.randrange(10 ** (k // 2))
    raise ValueError(cls)


def run_case(name):
    op, cls, na, nb = CASES[name]
    r = random.Random(1)
    # distinct operand pairs so results are not cached / shared
    xs = [make(r, cls, na) for _ in range(1000)]
    ys = [make(r, cls, nb) for _ in range(1000)]
    keep = []
    rss0 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    if op == "+":
        for i in range(N):
            keep.append(xs[i % 1000] + ys[(i * 7) % 1000])
    else:
        for i in range(N):
            keep.append(xs[i % 1000] * ys[(i * 7) % 1000])
    rss1 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    # subtract the list itself (8 bytes per slot, allocated with over-growth)
    list_bytes = sys.getsizeof(keep)
    per_obj = ((rss1 - rss0) * 1024 - list_bytes) / N
    digits = sum((abs(v).bit_length() + 29) // 30 for v in keep[:10000]) / 10000
    print(f"{per_obj:.1f} {digits:.2f}")


if __name__ == "__main__":
    if len(sys.argv) > 1:
        run_case(sys.argv[1])
    else:
        print(f"{'case':26s}{'bytes/obj':>10s}{'digits':>8s}")
        for name in CASES:
            out = subprocess.run([sys.executable, __file__, name],
                                 capture_output=True, text=True, check=True).stdout.split()
            print(f"{name:26s}{float(out[0]):10.1f}{float(out[1]):8.2f}")

Generated with Claude Code

x_add() allocated a digit for the carry out of the top digit on every
call. In most cases that digit is zero and long_normalize() drops it
from the digit count, but the allocation keeps its size, so the result
uses more memory than its value needs for as long as it lives.

Allocate the carry digit only when the top digits of the operands sum to
at least PyLong_MASK, the only case in which a carry out of the top digit
is possible. When no carry digit was allocated the top digit of the
result is at least the top digit of the larger operand, so the result is
already normalized and is returned directly.

This applies to same-sign additions and opposite-sign subtractions, which
go through x_add(); x_sub() is unchanged.

Extends PR 100688 by Mark Dickinson, which contributed the size test and
the tests.

Co-authored-by: Mark Dickinson <dickinsm@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant