Skip to content

fix(codegen): keep the high word of i128 constants in the split-module IR reader - #10566

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/10545-split-unit-wide-bigint-literals
Closed

proggeramlug wants to merge 2 commits into
mainfrom
fix/10545-split-unit-wide-bigint-literals

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

A BigInt literal wider than 64 bits changed value when its module was compiled as more than one codegen unit:
2n ** 70n === 1180591620717411303424n was false, the literal printed 0, and a 97-bit negative literal became a
different 64-bit number. Single-unit builds were correct, so nothing in the gap/parity corpus (all single-unit) could
see it, while large real modules split on their own — every split module containing a >64-bit BigInt literal silently
computed with the wrong constant (curve primes, group orders, field moduli).

Root cause

crates/perry-codegen/src/dialect/types.rs:299 (pre-fix):

BasicTypeEnum::IntType(t) => {
    let v: i128 = tok.parse().map_err(|_| anyhow!("bad integer `{tok}`"))?;
    t.const_int(v as u64, v < 0).into()
}

The in-process dialect reader — which builds every function of a split module natively through the LLVM C API, and is
the default only for split modules (native_emit::native_units_mode) — materialized every integer operand with
IntType::const_int, which takes a single 64-bit word. For an i128 operand that keeps only the low word
(v as u64), sign-extended when the literal was negative.

Every BigInt literal that fits in i128 lowers to exactly such operands: expr/mod.rs's SmallBigInt rep spells the
literal as an i128 constant and native_value/materialize.rs::box_small_bigint_i128_to_js_value splits it with
trunc i128 C to i64 / ashr i128 C, 64. With the low word only, 1180591620717411303424 (2^70) became 0 and
-98765432109876543210987654321 became -18444665141527514289. LLVM's own assembler (the single-unit text path, and
the external-clang path) reads the literal at arbitrary precision and then sign-/zero-extends it to the operand width,
which is why only split modules were wrong.

This is the same class as #8228/#8241: a form the closed-set reader gets wrong is invisible to every per-PR job,
because the reader only runs by default on modules big enough to split.

The fix

dialect/types.rs now builds integer constants with the assembler's semantics: widths ≤ 64 keep the existing
single-word path, and wider widths build both two's-complement words via const_int_arbitrary_precision. The
non-negative case parses as u128, so the full-width unsigned spelling LLVM also accepts is handled; an integer type
wider than i128 (which basic_type cannot name today) is refused loudly rather than silently truncated.

Tests (each fails on the baseline, 7661bc05fe)

  1. crates/perry/tests/issue_10545_split_unit_wide_bigint_literals.rs — compiles the issue repro with
    PERRY_CODEGEN_UNITS=2 and asserts Node 26.5.1's byte-exact output, after asserting both
    main_ts.unit{0,1}.native.ll exist (so a build that stopped splitting, or stopped routing split units through the
    reader, cannot pass vacuously). Baseline:
    left: "false false 0\n12345678901234567890 -18444665141527514289 false\n18446744073709551615 …"; with the fix it
    matches Node.
  2. dialect::tests::constant_operands_match_llvms_own_parse_on_typed_and_line_paths — a table of every constant
    operand form perry-codegen emits in a function body (i1…i128 including the spellings past a narrow type's signed
    range, nanbox::double_literal's output for each class it distinguishes, NaN-boxed tag words and a signalling NaN
    in hex, float, null/undef/poison/zeroinitializer, the ptrtoint constant expression, <2 x i64> and
    <4 x i32> literals) is built through both reader paths (typed FnStream::item and the line reader) and each
    is compared against LLVM's own parse of the same text, not against expectations written in the test. On the
    baseline only the i128 rows diverge (i128 1180591620717411303424i128 0,
    i128 170141183460469231731687303715884105727i128 18446744073709551615, …); everything else already matched,
    which is the audit the issue asked for.
  3. dialect::tests::wide_bigint_literal_words_survive_native_construction — drives the real emitter
    (compile_module over a HIR module with wide BigInt literals), asserts the i128 operand is still emitted, then
    re-builds the module through the reader and checks the words handed to js_bigint_from_i128_parts.
  4. test-files/test_gap_10545_split_unit_wide_bigint_literals.ts — the repro plus the 64-bit-boundary controls,
    the i128 edges in every radix, literals past i128 (secp256k1 p/n), and wide literals in closures, containers,
    class fields, default parameters and a loop. The gap harness compiles single units, so this pins the semantics
    rather than reproducing the bug; compiled by a baseline binary with PERRY_CODEGEN_UNITS=2 it diverges from Node
    on the first line and dies partway through with RangeError: Division by zero (a modulus literal truncated to 0).

Validation (Linux x64, Node 26.5.1, baseline 7661bc05fe)

gate result
cargo test --release -p perry-codegen --tests 1573 lib tests + all integration suites pass (0 failed)
cargo test --release -p perry --test issue_10545_… --test issue_10152_cgu_str_bytes 2 passed
scripts/run_lint_gates.sh (full, incl. compile tier) 82 of 83 pass; the one FAIL is Public benchmark evidence freshness (benchmarks/ci_public_baseline_check.py), which fails identically on a clean 7661bc05fe worktree — pre-existing
gap suite (run_gap_tests.sh, 820 tests) GAP_EXIT=0, snapshot OK, 6 known non-passing — the same six as the baseline run, no new failures; the new gap test passes
cargo fmt --all -- --check clean

Performance

Compile-time A/B of the compiler binary built from this same clone with and without the one-file change (both builds
bit-identical on rebuild), PERRY_CODEGEN_UNITS=2 --no-link, perf stat -e instructions, 3 runs, mean:

workload pre-fix with fix delta
1200-function module, no wide constants (objects byte-identical) 90.502 G 90.439 G −0.07 %
@noble/curves secp256k1 program (wide curve constants) 201.916 G 201.926 G +0.005 %

Runtime, BigInt-literal-heavy loop (300k iterations over >64-bit literals and a 256-bit modulus), perf stat, 3 runs,
mean instructions:

build instructions output
baseline, 1 unit 1.0875 G correct
with fix, 1 unit 1.0874 G correct
with fix, 2 units 1.0892 G (+0.16 % vs the 1-unit arm) correct
baseline, 2 units 0.9947 G wrong (constants truncated, so it is not a comparable program)

Node wall for the same loop: 0.10–0.14 s; the Perry binary's task-clock is ~0.125 s.

Package check (informational)

@noble/curves 2.2.0 + @noble/hashes 2.2.0, secp256k1 getPublicKey/sign/verify with a fixed secret key,
compiled with PERRY_CODEGEN_UNITS=2: still blocked, but by an unrelated defect that reproduces at 1 unit too
TypeError: param "allowInfinityPoint" is invalid: expected own property. Minimal repro (a closure created in an arrow
function sees the first call's parameter value on the second call):

function validate(object: any, fields: any = {}, optFields: any = {}) {
  function check(name: string, t: string, isOpt: boolean) { console.log(name, t, isOpt); }
  const iter = (f: any, isOpt: boolean) => Object.entries(f).forEach(([k, v]) => check(k, v as string, isOpt));
  iter(fields, false);
  iter(optFields, true);   // Perry prints isOpt=false here; Node prints true
}
validate({ x: 1 }, { x: "number" }, { a: "boolean" });

@noble/curves 1.2.0's ReferenceError: Cannot access 'wnaf' before initialization also persists on this build
(1 unit and 2 units). Neither is touched by this PR; both are separate issues.

Not verified

  • macOS/arm64: everything above was run on Linux x64 only.
  • The external-clang backend (PERRY_LLVM_INPROCESS=0) needs PERRY_LLVM_OPT on this host; with
    PERRY_LLVM_OPT=/usr/bin/opt-22 it compiles the repro correctly, which is what confirmed the reader (not codegen)
    as the culprit.
  • Constant forms that only appear in the module skeleton (c"…" byte strings, aggregate initializers, global
    initializers) are unchanged: LLVM's own parser reads the skeleton on every path.

Fixes #10545

Summary by CodeRabbit

  • Bug Fixes

    • Fixed incorrect values for BigInt literals wider than 64 bits when compiling split modules.
    • Preserved high-order bits for wide positive and negative BigInt literals across supported usage contexts.
  • Tests

    • Added coverage for wide BigInt literals across multiple bases, values, and runtime usage patterns.
    • Added regression checks for split-unit compilation and constant handling.

…e IR reader

The in-process dialect reader, which builds every function of a split
(multi-codegen-unit) module, materialized integer operands with
`IntType::const_int(v as u64, v < 0)`. That API takes a single 64-bit
word, so an `i128` operand kept only its low word. BigInt literals that fit
in i128 lower to exactly such operands (`NativeRep::SmallBigInt`), so in a
split module `1180591620717411303424n` (2^70) read back as `0n` and a
97-bit negative literal became a different 64-bit value, while single-unit
builds (LLVM's own text parser) were correct.

Widths above 64 bits now build both two's-complement words with
`const_int_arbitrary_precision`, matching the assembler's semantics; the
unsigned full-width spelling LLVM accepts is parsed too.

Tests: a dialect unit test that builds every constant operand form codegen
emits through the typed and the line paths and compares each against LLVM's
own parse of the same text (only the i128 forms diverged); a unit test that
lowers wide BigInt literals through the real emitter and checks the words
passed to js_bigint_from_i128_parts; an integration test compiling the issue
repro with PERRY_CODEGEN_UNITS=2 against Node's output; a gap test.
@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 9831f3e9-e55a-461f-a45f-72650c0ec247

📥 Commits

Reviewing files that changed from the base of the PR and between c8cf450 and 34543d0.

📒 Files selected for processing (5)
  • changelog.d/10566-split-unit-wide-bigint-literals.md
  • crates/perry-codegen/src/dialect/tests.rs
  • crates/perry-codegen/src/dialect/types.rs
  • crates/perry/tests/issue_10545_split_unit_wide_bigint_literals.rs
  • test-files/test_gap_10545_split_unit_wide_bigint_literals.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The change preserves high and low words for wide integer constants in the in-process dialect reader. It adds LLVM reader validation, emitter checks, split-module integration coverage, and BigInt gap cases through 256-bit values.

Changes

Wide integer constants

Layer / File(s) Summary
Integer constant materialization
crates/perry-codegen/src/dialect/types.rs
Integer constants are parsed at their operand width. Values up to 128 bits preserve both two's-complement words, while wider integer types are rejected.
Dialect constant validation
crates/perry-codegen/src/dialect/tests.rs
Tests cover emitted integer, floating-point, pointer, vector, and special constant forms through typed and line-based reader paths. Additional checks verify two-word construction for i128 BigInt values.
Split-module regression coverage
crates/perry/tests/issue_10545_split_unit_wide_bigint_literals.rs, test-files/test_gap_10545_split_unit_wide_bigint_literals.ts, changelog.d/10566-split-unit-wide-bigint-literals.md
Integration and gap tests cover wide positive and negative BigInt literals, multiple radices, boundary values, runtime usage forms, and split compilation through the in-process reader. The changelog records the fix and validation coverage.

Priority: ⬆️ High

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: High

Merge Risk: ⚪ Minimal · up to 34543

The split-module wide-BigInt fix preserves supported 128-bit constants without an actionable remaining regression risk.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: preserving the high word of i128 constants in the split-module IR reader.
Description check ✅ Passed The description is detailed and covers the summary, root cause, fix, related issue, tests, validation results, performance, and limitations. It does not use every template heading or include the check…
Linked Issues check ✅ Passed Issue #10545 requires correct wide BigInt values in split modules, preservation of high bits for positive and negative literals, parity with single-unit/Node behavior, and regression coverage. `int_co…
Out of Scope Changes check ✅ Passed The changes stay within issue #10545. The implementation fixes integer constant construction. The dialect, emitter, integration, and gap tests provide regression coverage for the same behavior. The ch…
Full details: Docstring Coverage

Explanation

Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 4 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10610 (v0.5.1594). All source commits preserve authorship; merged main matches the validated train exactly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BigInt literals wider than 64 bits are truncated in split (multi-unit) modules: 2n ** 70n === 1180591620717411303424n is false

1 participant