fix(codegen): stop typing BigInt-capable bitwise results as int32 - #10540
proggeramlug wants to merge 2 commits into
Conversation
`&` `|` `^` `<<` `>>` compute a BigInt from two BigInt operands, but the compiler assumed every bitwise result is an int32 Number: - HIR typed `a & b` over unknown operands `Number` (lower_types.rs, value_types.rs), so `stable_local_type_proof` vouched for it. - The integer-local proofs (integer_locals.rs judge, int_valued_ta_locals.rs) admitted every bitwise write, so `const x = a & b` took an int32 slot and `ToInt32`'d the BigInt result to `0`; a `bigint`-typed binding reached a call as `fptosi` of its box. - `Number(a & b)` elided `js_number_coerce` for any bitwise operand (bigint_set.rs), returning the BigInt unchanged. Each site now requires an operand that provably is not a BigInt. The not-BigInt fixpoint is exposed as `NotBigIntFacts` and computed ahead of the integer-local proofs. With the int32 slot no longer covering unproven operands, those operators take the existing guarded numeric diamond (tag test, inline `ToInt32 <op> ToInt32`, BigInt-aware helper on the cold arm).
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (17)
Included review availability: Your plan provides up to 8 included reviews per hour; 2 remain after this review. 📝 WalkthroughWalkthroughThe compiler now preserves BigInt results for ChangesBigInt bitwise result handling
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant HIR
participant NotBigIntFacts
participant Collectors
participant Codegen
HIR->>NotBigIntFacts: classify operand facts
NotBigIntFacts->>Collectors: provide bitwise result proofs
Collectors->>Codegen: select integer or dynamic representation
Codegen->>Codegen: lower guarded or direct bitwise operation
Merge Risk: ⚪ Minimal · up to The changed BigInt bitwise paths and mixed-operand behavior are consistently covered, with no actionable merge risk identified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 28.32% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 173 functions across 16 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. Comment |
|
Landed via merge train #10559 (v0.5.1592). All source commits preserve authorship; merged main matches the validated train exactly. |
Summary
&|^<<>>compute a BigInt when both operands are BigInts, but three layers of the compiler assumed every bitwise result is an int32 Number. For BigInt operands whose type is not statically known (untyped params,BigInt(...)initializers, property/element reads, arithmetic results):const x = a & bread back as0.Number(a & b)returned the BigInt unchanged.bigint-typed result reached a call asfptosiof its box (number:-2147483648).(_2n << k) * _2nthrewCannot mix BigInt and other types.This is @noble/hashes'
fromBig(Number((n >> _32n) & U32_MASK64) | 0withU32_MASK64 = BigInt(2 ** 32 - 1)). It made sha384/sha512/blake2b/blake2s/argon2 produce wrong digests, and sha3/keccak throw at module load.Root cause
A bitwise expression is a Number only when an operand provably is not a BigInt (a mixed pair throws). Every site below skipped that check:
crates/perry-hir/src/lower_types.rs:434typed the five operatorsNumberunless an operand was alreadyBigInt.crates/perry-hir/src/analysis/value_types.rs:1682(infer_binary_type) returnedNumberunconditionally. That type became the binding'sstable_local_type_proof.collect_integer_let_ids,is_int32_producing_expr) accepts every bitwise init, by design optimistically. But the disqualification judgeint32_producing_deps(crates/perry-codegen/src/collectors/integer_locals.rs:763) accepted them too, so nothing pruned them.const x = a & btherefore took an int32 slot, and the storeToInt32'd the correctjs_dynamic_bitandresult to0.collectors/int_valued_ta_locals.rs(rule 1 and the additive arm) had the same unconditional arm. The int32 slot also made the local "numeric" for later consumers, which is how abigintvalue reachedshow$spec_i32(fptosi x).Number()elision.number_coerce_operand_is_already_primitive_number(crates/perry-codegen/src/expr/bigint_set.rs:37) returnedtruefor any bitwise operand, sojs_number_coercewas dropped.Fix
Type::is_non_bigint_primitive()(Number/Int32/Boolean/String). The five operators inferBigIntfor BigInt operands,Numberwhen either operand is a non-BigInt primitive, andAnyotherwise.>>>staysNumber.collectors/not_bigint_locals.rs: the existing not-BigInt fixpoint is exposed asNotBigIntFacts, withbitwise_result_is_number(e). It is computed once incollect_type_facts, ahead of the integer-local proofs, and reused fornot_bigint_locals, so it is not computed twice.integer_locals.rsjudge andint_valued_ta_locals.rs: a BigInt-capable bitwise write is int-producing only whenNotBigIntFactsproves it, or when an operand is itself an integer candidate (recording that operand's deps). Admission stays optimistic; the judge prunes.bigint_set.rs:Number(a op b)is elided only with anis_provably_not_bigintoperand.expr/binary.rs: removing the int32 slot for unproven operands would have lefta ^ bas an unconditionaljs_dynamic_bitxorcall. The bitwise operators now take the existinglower_guarded_numeric_arithdiamond that-/*//already use: tag-test both operands, inlineToInt32 <op> ToInt32(shift count masked to 5 bits,toint32_wrapfor NaN/Infinity), and the BigInt-aware helper on the cold arm. It is gated by the existingPERRY_GUARDED_ARITHandPERRY_INLINE_NONBIGINT_BITWISEknobs, both already in the object-cache key. Proven-Number operands still take the unguarded inline path as before.Tests (fail on baseline 7661bc0, pass here)
test-files/test_gap_10418_bigint_bitwise_typing.ts: the issue's matrix.BigInt(),bigintparam, untyped param, module literal / moduleBigInt(), property and element (literal andBigInt()),(p + _0n), inlineBigInt(), and mixed literal/unknown.typeof,String,* 2n,===,f(x),Number(x),letbinding, the same on the bare expression, plus a closure.+ - * ** %on three shapes,~, and compound assignment.Number(n & M)withM = BigInt(2**32-1)(untyped and typed)._2n << (c1 - _1n - _1n)prelude andNumber(q.y & _1n).fromBig/split/toBig64-bit round trip over the SHA-512 constants.>>> 0, a hash loop.run_parity_tests.sh --filter test_gap_10418: baseline 0%, this branch 100%).perry-hir:value_types_tests::bitwise_result_is_number_only_with_a_non_bigint_operand, andtests/shape_inference.rs::bitwise_over_possible_bigints_is_not_inferred_number(source level).perry-codegen:hir_facts::tests::bigint_capable_bitwise_init_is_not_an_integer_local,int_valued_ta_locals::tests::bigint_capable_bitwise_write_is_not_an_i32_write, andexpr::bigint_bitwise_tests(noalloca i32for possibly-BigInt consts and one kept fora & 3;Number(a & b)keepsjs_number_coerceandNumber(a & 255)does not; unproven operands get the guarded int32 arm with a masked shift count).Validation
All run on perrybuilder (Linux x64) against baseline 7661bc0 (prebuilt reference build of the same commit).
cargo test --release -p perry-hir --testscargo test --release -p perry-codegen --teststests/native_proof_regressions.rs, the IR-grep suite namingjs_dynamic_bit*)scripts/run_lint_gates.sh(full, incl. compile tier:-D warningscheck, clippy, API-docs drift)benchmarks/ci_public_baseline_check.py("public artifact benchmark inputs changed") fails identically on a 7661bc0 snapshot.scripts/check_file_size.sh,cargo fmt --check,check_test_registration.pyPERRY_SKIP_BUILD=1 ./scripts/run_gap_tests.sh)GAP_EXIT=0, 814 pass / 6 fail of 820. The 6 are exactly the baseline's 6 known mismatches (2159_defineproperty_class_prototype,2514_settracesigint,json_lazy_defineproperty_index,perfhooks_3088…,prop_plan_cache_invalidation,v8_2_3680plus). No new failures.Package check: @noble/hashes 2.2.0 (
compilePackages,PERRY_NO_AUTO_OPTIMIZE=1), each hash on a 3000-byte and a 300-byte input, diffed against Node 26.5.1:TypeError: Cannot convert a BigInt value to a number at split / keccakPat module load.IR (
--trace llvm, no auto-optimize). The proven-Number fast path keeps its inline int32 ops:compress, baseline and fix emit the sameand i32/xor i32/shl i32/lshr i32counts (26/26/20/22).js_dynamic_bit*calls are now tag-guarded, and their Numbers compute inline.xorshift,fmix32,mix) gain the same guarded inline arm.test-filesand 50benchmarksprograms that use bitwise operators, against the baseline. No IR changed apart from declaration order in 2 multi-module tests, i.e. no proven-Number site lost its fast path. The guarded diamond then deliberately changes only the bitwise ops whose operands are unproven.Perf.
perf stat -e instructions, 3 runs each,PERRY_NO_AUTO_OPTIMIZE=1both arms, outputs identical to Node:ops.mix(a, b)via dynamic dispatch ×20Mbenchmarks/bench_bitwise.tsNumber(n & M)/(_2n << k) * _2n) ×300kSpread across runs is ≤0.02 % on every row. Compile time for the 1031-line gap test is unchanged (≈27 s vs ≈22–26 s uncached,
PERRY_NO_AUTO_OPTIMIZE=1, loaded host).Not verified / notes
PERRY_NO_AUTO_OPTIMIZE=1on both arms. The baseline tree is read-only, so an auto-optimize A/B was not possible there. Wall time vs Node on these microbenchmarks is far behind Node on both arms (e.g. SHA-256 1.8 s vs 0.23 s on a host at load ~100). That gap exists on the baseline too and this change does not move it.toint32_wrap, which emitsfjcvtzsthere.ReferenceError: Cannot access 'wnaf' before initializationinweierstrassPoints(esm/abstract/weierstrass.js:382referencesconst wnafdeclared at line 514, afterPoint.BASE = new Point(...)). This is a separate TDZ bug; a minimal class/const shape did not reproduce it. ethers was not checked.===against a computed value isfalse.PERRY_CODEGEN_UNITS=2withfunction f() { const x = 2n ** 70n; return x === 1180591620717411303424n; }printsfalseon the baseline too. The gap test uses small operands and stays one unit, so it avoids this.Fixes #10418
Summary by CodeRabbit