Skip to content

refactor(bigint): read each from_octets limb with a u32be pattern - #4099

Open
bobzhang wants to merge 3 commits into
mainfrom
hongbo/bits-pattern-wins
Open

refactor(bigint): read each from_octets limb with a u32be pattern#4099
bobzhang wants to merge 3 commits into
mainfrom
hongbo/bits-pattern-wins

Conversation

@bobzhang

@bobzhang bobzhang commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

BigInt::from_octets's tail loop assembles each limb from byte_per_limb indexed byte reads plus constant shifts. Since RADIX_BIT_LEN = 32, that inner loop is exactly one big-endian 32-bit read, so it collapses to a single u32be bits pattern. Five lines become two.

Plus bigint/from_octets_bench_test.mbt so the result is reproducible.

Rebased. This PR originally also converted BytesView::to_uint_be/le and to_uint64_be/le to bits patterns. #4101 has since deleted those four functions outright (they were #deprecated + #doc(hidden) and had no remaining call sites), so that commit is obsolete and was dropped in the rebase. The measurements it carried are preserved at the bottom of this description for the record.

Why this site and not others

The pattern's cost is constructing a view, and the payoff scales with how many byte operations that view replaces:

shape result
whole view already in hand — no slice needed big win
slice amortized over 4+ bytes (u32/u64 at an offset) clear win — this PR
slice to replace only 2 byte reads (u16 at an offset) loses — view construction dominates

That last row is why builtin/bitstring.mbt's unsafe_extract_byte is not here. One site that regressed badly is split into #4100 and deliberately not in this PR.

Performance

moon bench --release, 3 runs per backend per revision, means reported.

BigInt::from_octets native wasm-gc js
n=1024 409.2 → 269.5 ns (−34%) 1.50 → 0.82 µs (−46%) 26.72 → 26.40 µs (−1%)
n=64 39.0 → 29.6 ns (−24%) 108.7 → 63.1 ns (−42%) 1.74 → 1.81 µs (+4%)

js is a genuine control: bigint_js.mbt carries its own from_octets, which this PR does not touch, and it duly did not move.

Review response — the hard-coded 32-bit read

Copilot flagged that the tail now hard-codes u32be while byte_per_limb is still derived from RADIX_BIT_LEN, and suggested guarding the fast path behind RADIX_BIT_LEN == 32 with the old shift-accumulate loop as a generic fallback.

The concern is right; the suggested remedy is not, because that fallback would not actually be generic. byte_per_limb is RADIX_BIT_LEN / 8, and the file only requires RADIX_BIT_LEN to be a multiple of 4 and at most 32. For the documented-legal widths 4, 12, 20 and 28 the division truncates and the old loop mis-decodes exactly as badly — it would trade a loud failure for a quiet one, and add a branch no test can ever reach (this PR's patch coverage is currently 100%).

So the assumption is pinned instead: a whitebox test asserts RADIX_BIT_LEN == 32, and the comment above the loop names it. Narrowing the constant now fails a test rather than silently mis-decoding octets.

The pin deliberately uses @test.assert_eq, not inspect. A snapshot would be rewritten by moon test --update, which would retire the guard at exactly the moment it is supposed to fire. Both halves are verified: temporarily setting the expected value to 16 fails with 32 != 16, and moon test --update leaves it failing rather than quietly rewriting it.

For what it's worth, the new code cannot silently mis-decode even without the test: at any byte_per_limb < 4 the first tail iteration slices fewer than four bytes and the guard! panics.

Test plan

  • moon test --target all — wasm 7444, wasm-gc 7445, js 7388, native 7361, zero failures
  • moon check --deny-warn clean
  • moon fmt and moon info --target wasm,wasm-gc,js,native produce no diff — no public signature is touched
  • Benchmarks run on native, wasm-gc and js
Dropped: BytesView converter measurements (superseded by #4101, which deleted these functions)

4096-byte sweep, moon bench --release:

native wasm-gc js
to_uint_be 8.61 → 4.28 µs (−50%) 17.96 → 11.13 µs (−38%) 8.72 → 7.89 µs (−10%)
to_uint_le 8.59 → 4.27 µs (−50%) 17.81 → 11.23 µs (−37%) 8.61 → 7.84 µs (−9%)
to_uint64_be 13.31 → 4.35 µs (−67%) 23.94 → 16.99 µs (−29%) 357.5 → 340.9 µs (−5%)
to_uint64_le 13.23 → 4.23 µs (−68%) 23.55 → 16.72 µs (−29%) 351.6 → 349.6 µs (−1%)

Signed-off-by: Codex CLI codex@openai.com

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Refactors a few multi-byte integer assembly sites to use MoonBit bits patterns (u32be/u32le/u64be/u64le) where benchmarking showed neutral-or-better performance across native/wasm-gc/js backends, and adds a reproducible benchmark for the BigInt::from_octets change.

Changes:

  • BytesView::to_uint_be/le and to_uint64_be/le now use bits-pattern matching instead of manual byte-shift assembly.
  • BigInt::from_octets tail limb construction now uses a single u32be read per limb (matching RADIX_BIT_LEN = 32 today).
  • Adds a benchmark test file for BigInt::from_octets performance reproducibility.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
builtin/bytesview.mbt Replaces manual byte shifting with u32be/u32le/u64be/u64le pattern reads in deprecated converters.
bigint/bigint_nonjs.mbt Refactors from_octets tail loop to a u32be pattern read per 32-bit limb.
bigint/from_octets_bench_test.mbt Adds benchmark tests for BigInt::from_octets on representative input sizes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread bigint/bigint_nonjs.mbt
Comment on lines 1470 to 1475
let byte_per_limb = RADIX_BIT_LEN / 8
// tail
// tail; each limb is exactly one big-endian RADIX_BIT_LEN-bit word
for i in 0..<div {
for j in 0..<byte_per_limb {
let bytes_idx = len - byte_per_limb - i * byte_per_limb + j
limbs[i] = (limbs[i] << 8) | input[bytes_idx].to_uint()
}
guard! input[len - byte_per_limb - i * byte_per_limb:] is [u32be(word), ..]
limbs[i] = word
}

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.

Good catch on the assumption, but I don't think the suggested remedy holds up — the fallback would not actually be generic.

byte_per_limb is RADIX_BIT_LEN / 8, and the file only requires RADIX_BIT_LEN to be a multiple of 4 and at most 32 (lines 79-80). So the legal widths are 4, 8, 12, ... 32, and for 4, 12, 20 and 28 that division truncates — the old shift-accumulate loop reads 0, 1, 2 or 3 bytes into a limb that is 4, 12, 20 or 28 bits wide and mis-decodes just as badly as a hard-coded u32be would. Restoring it as the "safe" arm would convert a loud failure into a quiet one, and add a branch no test can reach (patch coverage here is currently 100%).

So I pinned the assumption instead, in 09f2b3c:

  • bigint_nonjs_wbtest.mbt gets a test asserting RADIX_BIT_LEN == 32, with a comment explaining that from_octets's u32be read is what depends on it.
  • The comment above the tail loop now names the assumption and points at that test.

Narrowing the constant now fails a test rather than silently mis-decoding octets — which is the outcome you were after.

Worth adding: the new code cannot silently mis-decode even without the test. At any byte_per_limb < 4 the very first tail iteration slices fewer than four bytes, so guard! ... is [u32be(word), ..] fails and it aborts.

moon test --target all is green: wasm 7444, wasm-gc 7445, js 7388, native 7361.

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.

Follow-up: the pin now uses @test.assert_eq(RADIX_BIT_LEN, 32) rather than inspect(..., content="32") (ba03090).

inspect would have made the guard self-defeating. Its expected value is a snapshot, so a routine moon test --update after narrowing the constant would rewrite content="32" to the new value and the test would go green — retiring the guard at exactly the moment it is supposed to fire. assert_eq is not auto-updatable.

Verified both halves by temporarily setting the expected value to 16:

  • moon test fails with FAILED: 32 != 16, so the pin is live and not skipped by backend gating.
  • moon test --update leaves it failing and does not touch the source, confirming the durability claim.

@coveralls

coveralls commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 6167

Coverage decreased (-0.001%) to 90.681%

Details

  • Coverage decreased (-0.001%) from the base build.
  • Patch coverage: 2 of 2 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 17899
Covered Lines: 16231
Line Coverage: 90.68%
Coverage Strength: 332091.49 hits per line

💛 - Coveralls

bobzhang and others added 2 commits August 19, 2026 12:39
The inner loop was a 4-iteration big-endian shift-accumulate, which is
exactly one u32be read (RADIX_BIT_LEN = 32).

Measured: native -34% at n=1024 / -24% at n=64, wasm-gc -46% / -42%.
js is unaffected (bigint_js.mbt has its own from_octets).
Covers the limb-fill path at n=64 and n=1024 so the u32be conversion in
the previous commit is reproducible.

Signed-off-by: Codex CLI <codex@openai.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bobzhang
bobzhang force-pushed the hongbo/bits-pattern-wins branch from f43a8d3 to 09f2b3c Compare August 19, 2026 04:43
@bobzhang bobzhang changed the title refactor: use bits patterns where they win on every backend refactor(bigint): read each from_octets limb with a u32be pattern Aug 19, 2026
Addresses the review note that `from_octets`'s tail now hard-codes a
32-bit read while `byte_per_limb` is still derived from `RADIX_BIT_LEN`.

Rather than branch on the constant at runtime, the assumption is pinned
by a whitebox test and named in the comment above the loop: narrowing
`RADIX_BIT_LEN` now fails a test instead of silently mis-decoding.

The pin uses `@test.assert_eq`, not `inspect`. A snapshot would be
rewritten by `moon test --update`, which would retire the guard at
exactly the moment it is supposed to fire; `assert_eq` is not
auto-updatable. Verified both halves by temporarily setting the expected
value to 16: the test fails with `32 != 16`, and `moon test --update`
leaves it failing rather than rewriting it.

A runtime fallback was considered and rejected. Restoring the old
shift-accumulate loop as the "generic" arm would not actually be generic:
`byte_per_limb` is `RADIX_BIT_LEN / 8`, so for the documented-legal
widths 4, 12, 20 and 28 (the file only requires a multiple of 4, at most
32) that loop truncates and mis-decodes just as badly. It would trade a
loud failure for a quiet one, plus a permanently dead branch that no test
can reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bobzhang
bobzhang force-pushed the hongbo/bits-pattern-wins branch from 09f2b3c to ba03090 Compare August 19, 2026 05:40
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.

3 participants