Skip to content

Allocate with headroom when BigDigits spills to the heap - #355

Merged
cuviper merged 5 commits into
rust-num:mainfrom
samuelcolvin:push-headroom
Aug 21, 2026
Merged

Allocate with headroom when BigDigits spills to the heap#355
cuviper merged 5 commits into
rust-num:mainfrom
samuelcolvin:push-headroom

Conversation

@samuelcolvin

@samuelcolvin samuelcolvin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

The below description is written with AI, but I've researched this at length manually, and I think on balance this change smoothes out the changes. See pydantic/jiter#264 (comment) for a severe example which I chanced upon.


BigDigits::push moves from Inline to Heap with [*x, y].to_vec(), which allocates capacity exactly 2, so growth then runs 2 -> 4 -> 8. Before #307 the first allocation came from Vec::push, whose minimum non-zero capacity is 4 for BigDigit at either digit width, so growth ran 4 -> 8. Values that grow past one digit do one more realloc than they did in 0.4.6, and each growth step happens one push earlier.

Measured with the benchmarks this PR adds, Apple M3 Max, rustc 1.97.1-nightly:

bench before after
scalar_accumulate_2_digits 14.40 ns 18.00 ns
scalar_accumulate_3_digits 49.15 ns 21.50 ns
scalar_accumulate_4_digits 54.15 ns 25.46 ns
scalar_accumulate_6_digits 88.73 ns 60.49 ns
scalar_accumulate_20_digits 253.35 ns 213.80 ns

The 2-digit case gets slower. A value that reaches two digits and stops now takes a 32-byte allocation instead of 16, and nothing at the transition distinguishes it from a value that keeps growing. That measurement is macOS system malloc; 16 and 32 bytes may fall in the same chunk size class on glibc.

Found from jiter, which parses integers as repeated x *= 10^18; x += chunk. Its massive_ints_array benchmark, 1000 integers of ~60 decimal digits or 4 BigDigits, is 46-55% slower on 0.4.8 than on 0.4.6, and returns to 0.4.6 timings with this change.

`BigDigits::push` moved from `Inline` to `Heap` with `[*x, y].to_vec()`,
which allocates capacity exactly 2, so growth then ran 2 -> 4 -> 8.
`Vec::push` starts at 4 for `BigDigit` at either width, which is what the
pre-rust-num#307 code got. Values growing past one digit did one more realloc
than before, with each growth step one push earlier.

Adds benchmarks for accumulating a decimal integer 18 digits at a time,
bracketing the Inline -> Heap transition.
@samuelcolvin
samuelcolvin marked this pull request as ready for review August 18, 2026 19:42
@samuelcolvin

Copy link
Copy Markdown
Contributor Author

cc @cuviper who originally implemented #307.

Comment thread src/big_digit.rs Outdated
// Capacity 2 here would make growth run 2 -> 4 -> 8, one realloc more than
// `Vec::push` does on its own: its minimum non-zero capacity is 4 for
// `BigDigit`, at either digit width.
let mut xs = Vec::with_capacity(4);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put this 4 in a constant and cite the upstream min here:
https://github.com/rust-lang/rust/blob/e71c0f1e3395b10a8c331317be1a5c107bdf7b2e/library/alloc/src/raw_vec/mod.rs#L153-L166

Then we should also consider this in other methods that affect capacity, like shrink, normalize, extend, etc. ... even from_slice, but from_vec can leave its allocation alone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. (except shrink and normalize which look a bit more complicated)

Review feedback: `4` is `RawVec::MIN_NON_ZERO_CAP`, so give it that name and
link to where upstream defines it rather than leaving the value bare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLR7FhfVNh2Qmd8WS7NwHD
@samuelcolvin

Copy link
Copy Markdown
Contributor Author

@cuviper could you at least allow CI to run on this.

It would be great to get this merged and released - it would be great to reverse the ~55% performance regression introduced in 0.4.7.

@cuviper

cuviper commented Aug 20, 2026

Copy link
Copy Markdown
Member

it would be great to reverse the ~55% performance regression introduced in 0.4.7.

Let's not overstate this -- only your new microbenchmarks show so much difference. The rest of our benchmarks were mostly improved by the change, some drastically. And your own CI showed 12-18% regression in only 3 out of 70 benchmarks, the rest unchanged.

That said, I do appreciate that you (and your AI) found this and provided a fix. Will it suffice to publish a 0.5.x update, or do you really need this in 0.4.x?

@samuelcolvin

Copy link
Copy Markdown
Contributor Author

And your own CI showed 12-18% regression in only 3 out of 70 benchmarks, the rest unchanged.

At the risk of continuing an argument that didn't need to start: it's a JSON parser, most tests don't touch big ints at all. We only found this because codspeed's ci tool caught the regression, and their AI pointed me in the right direction.

I think 55% is a fair description of the performance regression in this library's code in the worst case.


Anyway, 0.5.x is fine.

Thanks so much.

@cuviper

cuviper commented Aug 21, 2026

Copy link
Copy Markdown
Member

The 2-digit case gets slower. A value that reaches two digits and stops now takes a 32-byte allocation instead of 16, and nothing at the transition distinguishes it from a value that keeps growing. That measurement is macOS system malloc; 16 and 32 bytes may fall in the same chunk size class on glibc.

Can you bench this on macOS before #307? If the 2-digit result is similar to this PR, we're probably ok.

My Fedora (glibc) results for this PR are steady on the 2-digit case, as you predicted.

 scalar_accumulate_20_digits  203         193
 scalar_accumulate_2_digits   11          11
 scalar_accumulate_3_digits   43          11
 scalar_accumulate_4_digits   45          12
 scalar_accumulate_6_digits   70          57

@samuelcolvin

samuelcolvin commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Here are my results:

on main:

test scalar_accumulate_20_digits ... bench:         254.45 ns/iter (+/- 4.16)
test scalar_accumulate_2_digits  ... bench:          14.32 ns/iter (+/- 0.67)
test scalar_accumulate_3_digits  ... bench:          45.75 ns/iter (+/- 1.89)
test scalar_accumulate_4_digits  ... bench:          49.73 ns/iter (+/- 1.89)
test scalar_accumulate_6_digits  ... bench:          85.57 ns/iter (+/- 2.68)

on this branch:

test scalar_accumulate_20_digits ... bench:         219.94 ns/iter (+/- 3.46)
test scalar_accumulate_2_digits  ... bench:          17.11 ns/iter (+/- 1.07)
test scalar_accumulate_3_digits  ... bench:          20.72 ns/iter (+/- 1.51)
test scalar_accumulate_4_digits  ... bench:          23.73 ns/iter (+/- 0.65)
test scalar_accumulate_6_digits  ... bench:          58.69 ns/iter (+/- 1.56)

on num-bigint-0.4.6 tag:

test scalar_accumulate_20_digits ... bench:         217.61 ns/iter (+/- 6.22)
test scalar_accumulate_2_digits  ... bench:          18.44 ns/iter (+/- 0.67)
test scalar_accumulate_3_digits  ... bench:          21.31 ns/iter (+/- 1.04)
test scalar_accumulate_4_digits  ... bench:          26.36 ns/iter (+/- 1.98)
test scalar_accumulate_6_digits  ... bench:          60.57 ns/iter (+/- 2.82)

@cuviper

cuviper commented Aug 21, 2026

Copy link
Copy Markdown
Member

Thanks, that looks good!

@cuviper
cuviper added this pull request to the merge queue Aug 21, 2026
Merged via the queue into rust-num:main with commit 41f0b03 Aug 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants