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.

The mul 1x2 coverts the important case of 64-bit ints.

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>
@serhiy-storchaka

Copy link
Copy Markdown
Member

The row 0 loop no longer has a SIGCHECK(), and for size_a == 1 it is the only signal check in x_mul(). A significant part of the gain comes from this.

PGO+LTO build, a separate build for every variant, min of 4 interleaved rounds:

case base PR PR + SIGCHECK
1x2 18.8 ns 15.6 ns (1.21x) 16.7 ns (1.13x)
1x10 21.9 ns 17.9 ns (1.22x) 19.2 ns (1.14x)
2x10 27.7 ns 23.9 ns (1.16x) 24.6 ns (1.13x)
1x2000 901 ns 807 ns (1.12x) 820 ns (1.10x)

The gain is smaller than in the PR description, which looks measured without PGO.

Larger sizes are dominated by the code layout and tell us nothing. Between 55x55 and 71x71 the measured speedup of this PR jumps from 1.23x to 1.02x between the neighbour sizes, and two of my builds which differ only in a branch not taken for these sizes differ by 9%.

With the signal check restored the gain is about 1.10x, and only for size_a == 1. It varies greatly depending on the size, and a large part of the difference can be caused by the code layout, which is unreliable. This is too complex for such a small gain.

@eendebakpt

Copy link
Copy Markdown
Contributor Author

@serhiy-storchaka Agreed the code layout can interfere with the benchmarks. The avoided sigcheck is indeed part of the gain. In #157744 I address the sigcheck, If that is merged I will revisit this PR (maybe indeed the gains turn out to be too small)

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.

2 participants