Skip to content

gh-157742: Speed up int multiplication by not zeroing the whole result in x_mul() - #157743

Open
eendebakpt wants to merge 4 commits into
python:mainfrom
eendebakpt:longobject-xmul-memset
Open

eendebakpt wants to merge 4 commits into
python:mainfrom
eendebakpt:longobject-xmul-memset

Conversation

@eendebakpt

@eendebakpt eendebakpt commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

x_mul() (schoolbook multiplication in Objects/longobject.c) starts by memset-ing all digits of the result to zero, then accumulates row by row with carry += *pz + *pb++ * f.

The zeroing is not needed for the non-squaring path. Row 0 can store its size_b + 1 digits outright, and row i only reads digits that row i - 1 already wrote; each carry slot z[i + size_b] is written exactly once before it is ever read. So no digit needs to start out zero, and the per-row if (carry) *pz += carry becomes a plain store.

This matters most for the common big × small case (size_a == 1). Karatsuba and lopsided multiplication call x_mul() for their base cases, so they gain a little too.

Benchmarks

Benchmark main branch
mul 1x2 38.0 ns 27.9 ns: 1.36x faster
mul 1x10 40.0 ns 31.3 ns: 1.28x faster
mul 2x10 51.6 ns 43.3 ns: 1.19x faster
mul 10x10 158 ns 148 ns: 1.07x faster
mul 60x60 2.58 us not significant
mul 1x2000 1.28 us 1.20 us: 1.07x faster
mul 2x2000 (lopsided) 2.45 us 2.37 us: 1.03x faster
mul 500x500 (karatsuba) 95.0 us 91.4 us: 1.04x faster
mul 2000x2000 (karatsuba) 876 us 846 us: 1.04x faster
Geometric mean (ref) 1.11x faster
pyperformance pidigits 157 ms 154 ms: 1.02x faster
Benchmark script
import pyperf
runner = pyperf.Runner()
CASES = [("mul 1x2", 1, 2), ("mul 1x10", 1, 10), ("mul 2x10", 2, 10),
         ("mul 10x10", 10, 10), ("mul 60x60", 60, 60), ("mul 1x2000", 1, 2000),
         ("mul 2x2000 (lopsided)", 2, 2000), ("mul 500x500 (karatsuba)", 500, 500),
         ("mul 2000x2000 (karatsuba)", 2000, 2000)]
for name, na, nb in CASES:
    setup = (f"import random; r = random.Random(1234); "
             f"a = r.getrandbits({na*30-1}) | (1 << {na*30-2}); "
             f"b = r.getrandbits({nb*30-1}) | (1 << {nb*30-2})")
    runner.timeit(name, stmt="a*b", setup=setup)

eendebakpt and others added 3 commits September 16, 2026 16:21
x_mul() memset()s the entire product to zero before the gradeschool loop
runs, but the i == 0 pass stores z[0:size_b+1] outright rather than
accumulating into it, and every later pass only reads digits that the
previous pass has already written.  Only the top size_a digits -- the
carry positions -- actually have to start out zeroed.

k_mul() arranges for size_a <= size_b before calling x_mul(), so this
replaces a memset of size_a + size_b digits with one of size_a digits.
The win is largest exactly where the old code wasted the most: for a
single-digit multiplier the zeroing pass was as long as the entire
multiplication.

Peeling the i == 0 pass out of the loop also drops the redundant load of
*pz from the carry dependency chain.

The a == b squaring path is unchanged and keeps the full memset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each schoolbook pass stores its final carry into z[i+size_b], a digit no
earlier pass has touched, so it can be a plain store rather than an
addition onto a pre-zeroed digit.  With that, no digit of the result
needs zeroing at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.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