Skip to content

JSBigInt::parseInt: O(n) fast path for power-of-two radix - #353

Open
robobun wants to merge 1 commit into
mainfrom
robobun/bigint-parse-pow2-linear
Open

JSBigInt::parseInt: O(n) fast path for power-of-two radix#353
robobun wants to merge 1 commit into
mainfrom
robobun/bigint-parse-pow2-linear

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

BigInt("0x…") / BigInt("0b…") / BigInt("0o…") parse time is O(n²) in the string length. JSBigInt::parseInt routes every radix through a loop that, for each lengthLimitForBigInt32-character group, calls multiplyAdd(resultVector.span(), multiplier, digit, …) over the full preallocated digit vector: n/k groups × O(n) = O(n²).

The output direction is already linear (toStringBasePowerOfTwo). V8 has FromStringBasePowerOfTwo for the parse direction.

bun 1.4.0              50k      100k     200k hex chars
BigInt("0x"+s)         26.8ms   125.5ms  362ms    (×4 per doubling)
toString(16)           -        -        4ms

node v26.3.0           0.1ms    0.3ms    0.4ms

Fix

For power-of-two radix, each character maps to exactly ctz(radix) bits. Pack them directly into the digit vector from least-significant char to most-significant, carrying bits across 64-bit digit boundaries for octal. No multiplication. Mirrors toStringBasePowerOfTwo.

Inputs short enough to fit in int32 fall through to the existing path so they keep producing BigInt32 where applicable. Invalid characters and the maxLength cap throw the same errors as before.

Result

debug+ASAN:

              before       after
50k hex       1097ms       1.9ms
100k hex      4365ms       3.4ms
200k hex      17405ms      5.8ms

Correctness verified by roundtripping BigInt(prefix+s).toString(radix) === s across hex/binary/octal at all alignment boundaries (1..70 chars for octal to exercise the 3-bit span across 64-bit digit boundaries), and by comparing against digit-by-digit r = r*radix + d reference construction.

Bun-side test: test/js/bun/jsc/bigint-parse.test.ts (in the companion bun PR).

The generic parse loop calls multiplyAdd over the full preallocated
digit vector for every lengthLimitForBigInt32-sized group of input
characters (n/k groups times O(n) multiplyAdd = O(n^2)). For a
power-of-two radix (0b/0o/0x string prefixes, and literal parsing for
radix 2/4/8/16/32) no multiplication is needed at all: each character
is a fixed number of bits that can be packed directly into the digit
vector. This mirrors toStringBasePowerOfTwo, which is already linear
in the output direction.

200k hex chars: ~360ms -> ~1ms (release), ~17s -> ~6ms (debug+ASAN).
Short inputs that fit in an int32 fall through to the existing path
so they keep producing BigInt32 where applicable.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fbbc8b25-3611-434b-b366-03693b12b4e1

📥 Commits

Reviewing files that changed from the base of the PR and between d103ebd and 4b51ec6.

📒 Files selected for processing (1)
  • Source/JavaScriptCore/runtime/JSBigInt.cpp

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs, but this adds ~60 lines of bit-packing logic to a core JS runtime path (BigInt string parsing), so it's worth a human look.

What was reviewed:

  • Traced the carry arithmetic across 64-bit digit boundaries for radix 8 (bitsInDigit 63→66→2, 62→65→1, 61→64→0 cycle) — shifts stay in-range and bits are preserved.
  • Checked digitIndex never exceeds numDigits (writes = ⌈totalBits/digitBits⌉ exactly), and that a zero top digit from a split MSB is stripped by normalize() in tryCreateFromImpl.
  • Confirmed leading-zero skip and the initialLength > lengthLimitForBigInt32 guard keep short inputs on the BigInt32-producing path; error paths match the generic loop.
Extended reasoning...

Overview

Adds an O(n) fast path inside JSBigInt::parseInt for power-of-two radixes (2/4/8/16/32), gated behind USE(BUN_JSC_ADDITIONS). Instead of the existing multiply-add loop (O(n²) over the preallocated digit vector), it packs each character's ctz(radix) bits directly into the Digit vector from LSB to MSB, carrying overflow bits across 64-bit boundaries. Short inputs (initialLength <= lengthLimitForBigInt32) fall through to the existing path so BigInt32 results are unchanged. The result is handed to the same tryCreateFromImpl used by the generic path, which normalizes trailing zero digits.

Security risks

None identified. Input is a bounded std::span<const CharType>; numDigits is capped against maxLength before resultVector.fill; digitIndex is bounded by numDigits (verified: total writes equal ⌈initialLength·bitsPerChar / digitBits⌉ in both the exact-multiple and remainder cases); all shifts are by amounts strictly less than the Digit width (max bitsInDigit before OR is digitBits-1, max right-shift is bitsPerChar-1 ≤ 4). No raw pointer arithmetic beyond what the existing path already does.

Level of scrutiny

High. This is a hot, spec-observable primitive (BigInt(string)) and the bit-carry logic — particularly the octal case where 3-bit chunks straddle 64-bit boundaries — is easy to get subtly wrong. It's confined to Bun builds via the #if guard, and the PR describes roundtrip + reference-construction tests in a companion bun PR, but the correctness of the arithmetic itself deserves human eyes rather than bot approval.

Other factors

I stepped through the carry cycle for radix 8 by hand and it checks out; leading zeros are stripped before initialLength is computed so numDigits isn't inflated, and any residual zero top digit (e.g. octal MSB '1' whose high bits spill into a new digit as 0) is handled by normalize() inside tryCreateFromImpl. Error handling (invalid char → SyntaxError, oversize → OOM) mirrors the generic path. Nothing looks wrong — deferring purely on complexity/criticality grounds.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
4b51ec68 autobuild-preview-pr-353-4b51ec68 2026-07-26 08:34:43 UTC

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