Skip to content

perf: inline number-to-string, fix own-override flag arming for Map/Set, regex scratch GC pressure, faster randomUUID - #11643

Open
proggeramlug wants to merge 9 commits into
mainfrom
claude/zealous-noether-453kqt
Open

proggeramlug wants to merge 9 commits into
mainfrom
claude/zealous-noether-453kqt

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Performance fixes for four issues. No version bump, and no changes to any JS-callable native function's signature. Every measured workload's output is byte-identical to Node 26.5.1.

Changes

#10762: number-to-string

  • String(n), `${n}`, n.toString() and "" + n on a numeric operand now build a small integer's SSO text inline at the call site. The runtime call is kept on a cold arm. The digit split is fixed-point and exact for every value under 100000 (expr/number_to_string_inline.rs).
  • const s = String(n), `${n}` and "" + n (a + with a string-literal operand) now record a runtime-derived String proof. Before this, s.charCodeAt(i) on such a local fell to the generic method site.
  • The inline charCodeAt now reads an ASCII SSO receiver's byte directly from the value. Before, it materialized a heap copy through the intern table (~175 instructions).
  • Result: String(n) / `${n}` consumed by charCodeAt go from 449/462 to 169 instr/op; a negative integer from 644 to 199.

#10697: string-keyed Map

  • Root cause: lazy globalThis population armed PERRY_OWN_NAMED_PROP_INSTALLED, because installing statics on %TypedArray%, Array.prototype.constructor and Number.parseFloat goes through the exotic-store gauntlet. After that, every proven Map/Set/Date builtin call paid ~600 instructions of hasOwn.
  • The runtime's own builtin definitions now arm that flag only for Map/Set/Date (or unreadable) owners. The universal dispatcher reads a separate internal flag that every install still arms, so its behavior is unchanged.
  • Small-map lookups now answer a bit-identical key of any type from the inlined hot lane.
  • Result: count-by-category (get-or-default + set, 4 constant keys) goes from 1,866 to 373 instr/op, and plain m.get(k) from 947 to 206.

#11549: regex scratch counted as GC pressure

  • The lent regex scratch cell now grows past 32 registers (capped at 4096).
  • Operation-scoped perex_memory::Buffer bytes are counted as transient, via the new gc_note_external_transient_alloc/_free. They no longer feed the released-bytes term that scheduled full collections.
  • To keep peak RSS flat once those phantom full collections stop, the scavenge nursery now starts at ¼ of its base (4 MB). It climbs back through the existing debounced rule when survivor influx exceeds 4%, and returns to the floor below 1%. The perf(object): remove the derivable object_type and field_count header words (56 B -> 48 B) [HELD: #8157 refuted; footprint-coupled residual + new #8094 guard cost] #8122 census now seeds at half the starting cap. The GC doc is updated.
  • Result: dotenv/parse −31.4% instructions with peak RSS 55.2 → 52.1 MB; moment/parse_format −20.5% instructions with RSS roughly flat (65.2/65.2/101.6 → 62.7/69.6/98.0 MB at 5k/20k/80k iterations).

#10523: randomUUID

Four changelog.d/11643-* fragments have the details and measurements.

Related issue

Refs #10762, #10697, #11549, #10523

Test plan

Regression tests fail without their fix; each was sabotage-checked by reverting the fix locally:

  • a_search_over_thirty_two_registers_borrows_the_lent_scratch: 64/64 searches took the owned path before, 0 now.
  • regex_scratch_buffers_do_not_count_as_released_external_pressure: 8,192 bytes of released pressure before, 0 now.
  • the_nursery_starts_at_a_floor_and_follows_survivor_influx
  • a_builtin_install_on_an_intrinsic_does_not_arm_the_guard: 272 arms before, 0 now; installs onto Maps still arm.
  • a_small_map_answers_an_identical_key_without_the_cold_path: 8/8 lookups went to the cold path before, 0 now.
  • number_to_string_inline_tests (3 tests)
  • hyphenated_layout_is_exact

Gap tests added: test_gap_10762_inline_number_to_string, test_gap_10697_own_override_after_global_population, test_gap_11549_regex_large_register_scratch.

Suites run locally, with branch results compared against main:

  • RUST_TEST_THREADS=1 cargo test -p perry-runtime --lib: 4709 passed, 0 failed.
  • cargo test -p perry-codegen --lib: 1790 passed.
  • perry-uuid: 4 passed.
  • Five existing tenuring/trigger tests now power on at the new starting cap, with their intent kept. Three threshold tests pin the base cap explicitly.
  • Gap suite A/B, fast mode, Node 26.5.1 oracle, 1,032 tests. I ran it once with the main compiler and once with the branch compiler on identical test sets. I left out the 105 ext-routed tests, because each one builds a ~0.7 GB runtime variant per arm and there wasn't disk space for that. Main has 11 non-pass tests and the branch has the same 11, so there are no new failures. Two further branch-only compile failures were harness artifacts: the worktree's auto-built archive was stamped with the WIP commit, not the binary's tree. Both (test_gap_10623_implicit_ctor_native_super, test_gap_6558_webassembly_graceful_fail) pass through the harness with a build of the pushed commit.
  • Lint scripts run locally and passing: check_file_size, addr_class_inventory, gc_runtime_root_holders (4 new holder verdicts, and the PASS1_MARKED pin re-audited for gc/policy.rs), check_gc_doc_claims, local_binding_type_audit, registry_lifetime_check, runtime_abi_check --check-native, and the others in the lint job. cargo fmt --check is clean.
  • Not run locally: the whole-workspace RUSTFLAGS=-D warnings check (not enough disk here). The changed crates' lib and test builds introduced no new warnings.
  • Worth running run-extended-tests / gc-ratchet. The nursery starting cap is a default-pacing change. I checked it on dotenv, moment, a keep-one-in-ten retaining loop (within ±1.5% of main's total instructions at 2M–4M iterations, 22–27% below it at 0.5–1M) and an allocation-heavy string loop (−10%), but not on the ratchet corpus.

Not addressed:

  • Under the generational collector, moment's RSS keeps growing with N on main as well as on this branch; it stays flat with PERRY_GEN_GC=0. This is pre-existing and deserves its own issue.
  • "" + x, where x is declared number but holds an object with both valueOf and toString, prints the toString result where Node uses valueOf. The behavior is identical on main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vagu5YeePM8twRt4LkpaZ3

Summary by CodeRabbit

  • Performance Improvements

    • Accelerated number-to-string conversion for common integers across string conversion, templates, and concatenation, and optimized ASCII character reads from short strings.
    • Improved lookups in small maps with identical keys and reduced overhead in UUID generation.
    • Improved regex performance for patterns with many capture groups.
    • Adjusted garbage collection to respond to changing memory usage.
  • Bug Fixes

    • Improved handling of built-in property installations and instance-level overrides, including overrides applied after global properties are populated.

#10762: String(n), `${n}`, n.toString() and "" + n on a number operand
build a small integer's SSO text inline at the call site (fixed-point
digit split, exact for every value under 100000), with the runtime call
on a cold arm. `const s = String(n)` / `${n}` / "" + n (a + with a string
literal) now record a runtime-derived String proof, so s.charCodeAt and
the other string lowerings no longer fall to the generic method site, and
the inline charCodeAt reads an ASCII SSO receiver's byte from the value
instead of materializing a heap copy.

#10697: populating globalThis installed builtins onto intrinsics through
the exotic-store gauntlet and armed PERRY_OWN_NAMED_PROP_INSTALLED, which
sent every proven Map/Set/Date builtin call through the ~600-instruction
hasOwn predicate. The runtime's own builtin definitions now arm it only
for Map/Set/Date (or unreadable) owners; the universal dispatcher reads a
separate flag every install still arms. Small-map lookups also answer a
bit-identical key of any type from the inlined hot lane.

#11549: the lent regex scratch cell grows past 32 registers, and
operation-scoped regex Buffers are accounted as transient external bytes,
so a large pattern in a loop no longer feeds released-bytes pressure into
full collections. The scavenge nursery now powers on at a quarter of its
base and climbs back on survivor influx, which keeps peak RSS flat now
that the phantom fulls are gone; the #8122 census seeds at half the
power-on cap.

#10523: the UUID formatter writes through a constant position table and
the stdlib copies its bytes without re-validating them as UTF-8.

No version bump.
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 1c4cb263-daae-4cea-9bbd-63b8e27f98be

📥 Commits

Reviewing files that changed from the base of the PR and between 38433c4 and 954c629.

📒 Files selected for processing (3)
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-runtime/src/object/mod.rs
  • scripts/gc_runtime_root_holders.json

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The pull request updates numeric string conversion, small-Map lookup, builtin install tracking, regex scratch and garbage collection, and UUID formatting. It adds supporting tests, documentation, and changelog entries.

Changes

Inline number-to-string and character access

Layer / File(s) Summary
Numeric conversion lowering
crates/perry-codegen/src/expr/*, crates/perry-codegen/src/lower_call/property_get/number_string.rs, crates/perry-codegen/src/lower_string_concat.rs, crates/perry-codegen/src/type_analysis/refine.rs, test-files/test_gap_10762_inline_number_to_string.ts, changelog.d/11643-inline-number-to-string.md
Proven numeric operands in the supported range use inline conversion; other values retain runtime fallbacks. Type analysis recognizes additional string-producing initializers. Tests cover conversion forms, boundaries, and runtime values that differ from their annotations.
ASCII SSO character access
crates/perry-codegen/src/lower_string_method/char_code_at.rs, crates/perry-codegen/src/expr/number_to_string_inline_tests.rs, test-files/test_gap_10762_inline_number_to_string.ts
charCodeAt reads in-range ASCII bytes directly from SSO payloads. Other SSO cases use the slow path. Tests cover fast-path lowering and character access cases.

Small-Map lookup

Layer / File(s) Summary
Small-Map identity lookup
crates/perry-runtime/src/map.rs, crates/perry-runtime/src/map/string_key.rs, changelog.d/11643-map-own-override-flag-builtin-installs.md
Small maps check identity-matching keys of any type in the hot lane. Unresolved non-number keys continue to the cold path. Tests count cold lookups for identity matches, content-equal keys, and misses.

Builtin own-override tracking

Layer / File(s) Summary
Builtin install guard tracking
crates/perry-runtime/src/object/*, test-files/test_gap_10697_own_override_after_global_population.ts, changelog.d/11643-map-own-override-flag-builtin-installs.md
Builtin-definition installs on readable owners other than Map, Set, or Date do not arm the emitted-guard flag. Install history remains recorded separately. Install call sites pass owner addresses, and tests cover builtin installs and later instance overrides.

Regex scratch and garbage collection

Layer / File(s) Summary
Regex scratch reuse and transient accounting
crates/perry-runtime/src/regex/*, crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/tests/runtime_roots/perex_*, test-files/test_gap_11549_regex_large_register_scratch.ts, changelog.d/11643-regex-scratch-gc-pressure.md
Lent regex scratch grows to support up to 4096 registers. Operation-scoped buffers use transient external-byte accounting. Tests cover scratch reuse, owned-buffer use, and live-byte accounting.
Nursery cap and allocation census
crates/perry-runtime/src/gc/tenuring.rs, crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs, crates/perry-runtime/src/gc/tests/triggers.rs, docs/src/internals/garbage-collector.md, scripts/gc_runtime_root_holders.json, changelog.d/11643-regex-scratch-gc-pressure.md
The nursery cap starts at one quarter of its base and adjusts through debounced influx thresholds. The allocation census seed point is half the power-on cap. Tests and documentation describe the updated cap behavior.

UUID formatting

Layer / File(s) Summary
Fixed-layout UUID bytes
crates/perry-uuid/src/lib.rs, crates/perry-stdlib/src/crypto/random.rs, changelog.d/11643-random-uuid-format.md
Hyphenated writes hexadecimal digits at fixed positions and exposes its byte array. UUID string creation uses those bytes directly. Tests check the formatted layout and byte equivalence.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Refactor

Suggested reviewers: claude

Merge Risk: ⚪ Minimal · up to 954c6

The selected changes are mergeable after normal checks; no unresolved issue is established.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 954c6

Several shared behaviors change, but the examined controls remain in place and no exploitable regression was established. Broader integration behavior remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The security-relevant exposure is runtime-wide behavior of compiled code using guarded builtin calls and regex or allocation-heavy workloads, rather than a newly exposed code-generation entrypoint.

Trust Boundaries and Controls

  • observed — The general method dispatcher uses the install-history flag, while the emitted fast-path guard uses the more selective flag. This preserves a check for user-defined own methods despite the narrower builtin-install policy.

Resilience and Maintainability Implications

  • observed — Allocation and drop move the regex transient live-byte count in opposite directions; the accounting change does not itself trigger collection while a caller holds heap views.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.51% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 82 functions across 30 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 summarizes the four main performance and correctness changes in the pull request. It is long but remains specific and relevant.
Description check ✅ Passed The description includes the required Summary, Changes, Related issue, Test plan, Screenshots / output, and Checklist sections. It provides detailed scope, measurements, test results, and clearly iden…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

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

✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • 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.

… user

Without the feature the regex module is compiled out and both functions
were dead code, which the -D warnings workspace check rejects. Re-pins
PASS1_MARKED's gc/policy.rs source hash.

Copy link
Copy Markdown
Contributor Author

lint is red at the public-baseline step (benchmarks/ci_public_baseline_check.py: "public artifact benchmark inputs changed"). This failure doesn't come from this PR:

  • The PR touches none of SOURCE_PATHS/HARNESS_PATHS in benchmarks/public_baseline.py (no Cargo.toml, no benchmarks/ edits).
  • The same check exits 2 on current main (5bd0aba9) and on each of the previous 8 main commits that touched Cargo.toml, back to at least 5a6310af. The fingerprint normalizes the workspace version, so the drift comes from real manifest changes on main, such as the perex dependency bumps in d9ec9e17/094b3cf1.

No fix exists to port. The artifact has to be regenerated with ./benchmarks/run_public_baseline.sh on the quiet benchmark host (~2 h), which this PR can't do. I searched open PRs and didn't find one that regenerates it. The rest of this PR's checks are still running, and I'll handle anything they turn up.


Generated by Claude Code

…r-453kqt

# Conflicts:
#	scripts/gc_runtime_root_holders.json
…r-453kqt

# Conflicts:
#	scripts/gc_runtime_root_holders.json
…r-453kqt

# Conflicts:
#	scripts/gc_runtime_root_holders.json

Copy link
Copy Markdown
Contributor Author

gap-suite (4) and gap-suite (6) are red on 954c6292, each with one regression. Neither comes from this PR; main has the same failures.

  • test_gap_11499_class_object_static_write
  • test_gap_10480_define_property_generic_descriptor_accessors

Cause: a class static setter receives the class object instead of the assigned value. For example, static set sv(v) { C._v = v * 10 } followed by C.sv = 2 leaves C._v as NaN. I reproduced this with a local perry-dev build of main at 41de9c5f, which is this PR's merge base, with none of this PR's changes. Both tests fail there exactly as they do on this branch, and a build from before #11651 prints 20. The regression came in with that main range (most likely #11651, "class static accessors are accessor properties of the class function object"). This PR does not touch class statics or accessor dispatch.

Fix: none that I've verified yet. #11667 reworks static storage, but I haven't confirmed that it fixes the setter argument. Once a fix lands on main, I'll merge main in again. lint still fails only on the stale public baseline, as in my earlier comment.


Generated by Claude Code

…r-453kqt

# Conflicts:
#	scripts/gc_runtime_root_holders.json

Copy link
Copy Markdown
Contributor Author

e2e-scoped is red on cbab4afa, but not because of this PR. The failure is in scripts/ci_e2e_scope.py --self-test, which runs before any suite:

these crates/perry-codegen/tests/*.rs suites are in neither SOURCE_SUITE_MAP nor SUITE_EXCLUSIONS: typed_array_update_lowering

main's #11666 (b0bf0ae7) added crates/perry-codegen/tests/typed_array_update_lowering.rs without registering it. The self-test exits 1 on main at b0bf0ae7 alone, so every PR that merges current main fails here. main has no fix yet, and one isn't in this PR's scope, so I'm not widening the diff for it.

Proposed patch for main; it's one line, and I checked that the self-test prints ok with it:

--- a/scripts/ci_e2e_scope.py
+++ b/scripts/ci_e2e_scope.py
@@ _CODEGEN_SUITES
     "typed_array_rmw_8692",
+    "typed_array_update_lowering",
     "typed_shape_declared_at_allocation",

Once this lands on main, I'll merge main in again. The other two known red checks are unchanged: lint's stale public baseline, and the two static-setter gap regressions from main described above.


Generated by Claude Code

Resolve scripts/gc_runtime_root_holders.json by taking main's PASS1_MARKED
text, re-appending this branch's #11549 audit note, and pinning
gc/policy.rs to the merged file (it differs from main only by this
branch's audited hunks).

Port own_override_builtin_install_tests to main's #11654 closure ABI:
js_closure_alloc now takes a static JsFunctionInfo, and bodies take the
#11637 receiver parameter.

Copy link
Copy Markdown
Contributor Author

self-test-checkers fails on 2635a8b7, but the failure is on main, not in this PR. The failing step is scripts/check_thread_locals.py:

crates/perry-runtime/src/async_hooks.rs: lost raw thread_local! declarations (7 recorded, 6 found)
crates/perry-runtime/src/node_stream_constructors.rs: lost raw thread_local! declarations (3 recorded, 2 found)

This PR doesn't touch either file or the ratchet. main removed those declarations without re-recording scripts/thread_local_cold_allowlist.json. The check exits 1 on main at both 6ce52044 and the current head d7df6e75, and I haven't seen a fix for it.

Proposed patch for main: run scripts/check_thread_locals.py --update. It only lowers counts, and the check then reports OK: 521 hot declarations.

-  "_hot_declarations": 528,
+  "_hot_declarations": 521,
-    "crates/perry-runtime/src/async_hooks.rs": 7,
+    "crates/perry-runtime/src/async_hooks.rs": 6,
-    "crates/perry-runtime/src/node_stream_constructors.rs": 3,
+    "crates/perry-runtime/src/node_stream_constructors.rs": 2,

Once this lands on main, I'll merge main in again. e2e-scoped should now be green, since main's #11675 registered the suite. lint still fails on the stale public baseline, and the gap suite still fails on main's static-setter bug. Both are covered in my earlier comments.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

lint now fails in a second step, scripts/class_id_collisions.py, reporting CLASS_ID mirror drift:

  • 0x00741c77 in crates/perry-runtime/src/object/static_shapes_tests.rs:225
  • 0x7d018001 in crates/perry-runtime/src/object/class_registry/state.rs:1345

This PR doesn't cause it; the drift is on main:

  • CI runs lint on this PR merged with current main.
  • The script passes on this branch's head, 2635a8b7.
  • It exits 1 on main at d7df6e75 alone.
  • This PR doesn't touch either file.

I haven't found a fix on main yet. Once one lands, I'll merge main in again. The public-baseline step in the same job still fails, as in my earlier comment.


Generated by Claude Code

…r-453kqt

# Conflicts:
#	scripts/gc_runtime_root_holders.json

Copy link
Copy Markdown
Contributor Author

On 4fceef9c the static-setter tests pass now that main's #11672 is merged in. Three new gap regressions showed up in their place, and none of them comes from this PR. I reproduced all three on a local perry-dev build of main alone at 7fa094cb, which is the commit this PR merges:

test shard main @ 7fa094cb without this PR
test_gap_sloppy_this_bound_once 3 throws Cannot assign to read only property 'extra' of object '#<Object>' from mutate.call(1), where Node (strict ESM via the root "type": "module") throws Cannot create property 'extra' on number '1'; this branch's build prints the identical wrong error
test_gap_node_redis_from_source 3 FAILED: #<perry:private-member:64:#validateOptions> is not a function instead of the command transcript
test_gap_mongodb_from_source 5 prints the version line, then Uncaught (in promise) TypeError: @@iterator is not a function in MongoClient parseOptions, exit 1

All three look like fallout from main's recent receiver and class-static reworks (#11637, #11667 and #11679). None of them touches code this PR changes. The two newer main commits (#11682, #11683) don't look like fixes for them. I'll merge main again once fixes land. The earlier main-side failures I reported are unchanged: the lint public baseline, lint CLASS_ID mirror drift, and the self-test-checkers thread-local ratchet.


Generated by Claude Code

This branch has not been deployed

No deployments
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