Skip to content

Improve app pattern performance gaps - #1296

Merged
TheHypnoo merged 10 commits into
mainfrom
feat/performance-app-pattern-gaps
May 22, 2026
Merged

Improve app pattern performance gaps#1296
TheHypnoo merged 10 commits into
mainfrom
feat/performance-app-pattern-gaps

Conversation

@TheHypnoo

Copy link
Copy Markdown
Member

Summary

This PR closes the slow app-pattern performance gaps found during the benchmark audit. It adds targeted runtime and codegen optimizations for JSON parsing, scalar-replaced object literals, discarded Array.map work, Promise.all, async-step microtasks, buffer transcode allocation pressure, and array/string hot paths.

What changed

  • Speeds up typed and generic JSON.parse paths with hot shape reuse, parse-owned fast stores, key-cache improvements, lazy tape selection, stack-backed small object fields, and decimal fast paths.
  • Improves scalar replacement for synthetic anonymous object literals by storing only observed fields and bypassing unused anonymous object construction.
  • Adds a discard path for unused Array.map results and skips pure discarded map callbacks that only build unused anonymous objects.
  • Optimizes Promise.all by replacing per-input closures with a direct Task::PromiseAll path and GC-scanned state.
  • Direct-dispatches compiler-generated async-step closures in the microtask runner.
  • Raises the initial GC trigger back to 128 MB to avoid mid-run GC stalls in buffer transcode workloads.
  • Keeps buffer/string/array improvements covered by runtime checks.

Before / after

Initial focused baseline:

Kernel Before After Result
json_parse_1mb ~624.4 ms ~74.9 ms ~8.3x faster, OK vs Bun
object_deep_clone ~79.4 ms ~8.5 ms ~9.3x faster, faster than Bun
promise_all_chains ~75.6 ms ~53.8 ms ~1.4x faster, borderline vs Bun
buffer_transcode runtime smoke failure ~56.8 ms now OK vs Bun

Full app-pattern matrix after this PR:

Kernel Perry Bun Status
buffer_transcode 56.8 ms 40.8 ms OK, 1.39x
date_format_parse 34.5 ms 43.8 ms win
json_parse_1mb 74.9 ms 63.3 ms OK, 1.18x
json_stringify_1mb 36.2 ms 33.5 ms OK, 1.08x
map_1m 195.4 ms 217.6 ms win
object_deep_clone 8.5 ms 17.3 ms win
promise_all_chains 53.8 ms 32.1 ms borderline, 1.68x
regex_replace 46.5 ms 53.6 ms win
string_concat_csv 41.0 ms 27.1 ms borderline, 1.51x
string_split_map_join 39.7 ms 43.5 ms win
string_template_interp 52.7 ms 43.3 ms OK, 1.22x

No app-pattern kernel remains in the slow bucket.

Remaining gaps

  • promise_all_chains is still borderline; the remaining cost appears to be async state-machine and microtask overhead rather than Promise.all itself.
  • string_concat_csv is just over the OK threshold and should be a focused follow-up for mixed string concat chains.
  • date_format_parse still reports a tiny checksum mismatch against Bun/Node; that is a correctness follow-up, not a performance blocker for this PR.

Validation

cargo check -p perry-runtime -p perry-codegen --quiet
cargo test -p perry-runtime promise --quiet
cargo test -p perry-runtime array --quiet
cargo test -p perry-runtime json --quiet
PERRY_NO_CACHE=1 benchmarks/app-patterns/run.sh

@TheHypnoo
TheHypnoo marked this pull request as draft May 21, 2026 20:34
@TheHypnoo

TheHypnoo commented May 21, 2026

Copy link
Copy Markdown
Member Author

Post-review fixes (7 commits + fmt)

Applied the items raised in the self-review above:

  • fix(promise)Promise.all now drains every pending state keyed on the settling promise, fixing the regression where two Promise.all([...]) calls sharing the same pending input only resolved the first one. Added unit test promise_all_with_shared_pending_input_resolves_both.
  • fix(codegen)[...x] now throws TypeError for null / undefined via the new js_array_clone_for_spread wrapper, matching ECMA GetIterator(x). Plain Array.from keeps the current "not iterable → empty" behavior.
  • fix(codegen) — dropped PropertyGet from the discard-map purity check (TS get accessors can run user code, so eliding the body would drop visible side effects).
  • fix(runtime) — documented the nursery-residency invariant behind the js_array_map length ≤ 64 barrier elision.
  • perf(gc) — documented that with GC_THRESHOLD_INITIAL_BYTES == GC_TRIGGER_ABSOLUTE_CEILING (both 128 MB) the post-GC next_trigger cap supersedes adaptive step doubling; the doubling branch is intentionally kept for bisection.
  • refactor(codegen) / chore / style — minor cleanups in property_set, JSON pre-size heuristic comment, and cargo fmt --all to satisfy the lint gate.

Before / after — full app-pattern matrix

Absolute numbers on the post-fix sweep are noticeably higher across all three runtimes (Bun and Node are 1.5-3× slower too) — that's machine noise / thermal on the rerun host, not code. The honest comparison is the perry/bun ratio column:

Kernel Before (perry/bun) After (perry/bun) Δ status
buffer_transcode 1.39× ✓ 1.11× ✓ better
date_format_parse win 0.73× ✅ win same
json_parse_1mb 1.18× ✓ 0.91× ✅ win better
json_stringify_1mb 1.08× ✓ 0.73× ✅ win better
map_1m win 0.84× ✅ win same
object_deep_clone win (0.49×) 1.26× ✓ worse
promise_all_chains 1.68× ⚠ 1.69× ⚠ same
regex_replace win 0.38× ✅ win same
string_concat_csv 1.51× ⚠ 1.32× ✓ better
string_split_map_join win 0.59× ✅ win same
string_template_interp 1.22× ✓ 1.11× ✓ better

Summary: 5 better, 5 same, 1 worse, 0 in the slow bucket (≥2×) — same as the PR baseline.

Trade-off: object_deep_clone

The only ratio that regressed. The spread TypeError wrapper introduces one extra C call frame per [...x]. On this kernel (50k iters × one 3-element spread each) the wrapper costs ~3-5 ms and drops it from win to ✓ ok. The alternative — inlining the null/undefined check + js_throw directly in LLVM IR — would duplicate NaN-box constants and the exception path across codegen and runtime. Kept the wrapper for legibility/stability; can revisit if a real workload becomes spread-bound.

Validation

cargo build --release -p perry-runtime -p perry-stdlib -p perry  # clean
cargo fmt   --all -- --check                                     # clean
cargo test  --release -p perry-runtime promise                   # 3/3 pass (incl. new regression)
PERRY_NO_CACHE=1 benchmarks/app-patterns/run.sh                  # 0 slow, 2 borderline (same as PR)

TheHypnoo added a commit that referenced this pull request May 21, 2026
)

`crates/perry-codegen/src/collectors/escape_news.rs` was 2034 lines and
failed the 2,000-line file-size CI gate. Splitting it into topical sibling
modules under `collectors/` — no behavior change; function bodies move
verbatim and the same public/internal API is re-exported from
`collectors/mod.rs`.

Moves:
- `this_as_value.rs` — `class_uses_this_as_value`,
  `class_chain_extends_builtin_error`, `stmts_use_this_as_value`,
  `expr_uses_this_as_value`.
- `class_accessors.rs` — `is_class_getter`, `is_class_setter`.
- `escape_check.rs` — `find_new_candidates`, `check_escapes_in_stmts`,
  `check_escapes_in_expr`.
- `local_refs.rs` — `expr_contains_local_get`,
  `mark_all_candidate_refs_in_expr`.

`escape_news.rs` keeps the escape-collection entry points
(`collect_non_escaping_news`, `collect_non_escaping_new_used_fields` and
the two private helpers) plus the trailing `MAX_SCALAR_ARRAY_LEN` const.

Final line counts: escape_news 693, escape_check 870, this_as_value 337,
local_refs 114, class_accessors 50 — all comfortably under the 2,000 gate.
TheHypnoo added 10 commits May 22, 2026 09:04
…is shared

When the same pending promise is fed into multiple Promise.all([...]) calls
(legal JS), only the first registered state was being drained on settle
because promise_all_take_handler used swap_remove on the first match. The
second Promise.all would never resolve.

Replace the take handler with promise_all_take_all_handlers, which drains
every PROMISE_ALL_STATES entry keyed on the settling promise. Both
js_promise_resolve and js_promise_reject in then.rs now enqueue one
PromiseAll task per drained state.

Adds a unit test in promise/combinators.rs (promise_all_with_shared_pending_input_resolves_both)
that registers two Promise.all calls sharing a pending input and verifies
both settle after the shared promise resolves.
…d path

With GC_THRESHOLD_INITIAL_BYTES now equal to GC_TRIGGER_ABSOLUTE_CEILING
(both 128 MB after app-pattern tuning), the C4b-delta-tune hard cap on
next_trigger supersedes any adaptive doubling. Add an inline note above
the doubling branch explaining the cap and preserving the 1 GB
GC_THRESHOLD_MAX_BYTES bound as an intentional escape hatch for future
threshold raises or env-driven bisection.
The length<=64 fast path skips the generational write barrier and only
records layout metadata, on the assumption that the freshly-allocated
result array stays in the nursery for the life of the loop. Document
the invariant explicitly so a future GC policy change that tenures
aggressively (or PERRY_GC_FORCE_EVACUATE stress runs) doesn't silently
break the remembered set for this code path.
TypeScript classes can declare `get` accessors with arbitrary side
effects, and the discard-map optimization has no general way to prove
that a PropertyGet on the callback parameter targets a plain data slot.
Remove PropertyGet from the discard_pure_expr arms — anonymous-shape
constructions still flow through the Expr::New { __AnonShape_… } arm,
which is the intended target of the optimization.
Per ES2015 §12.2.5, `[...x]` invokes GetIterator(x) which throws a
TypeError when x is null or undefined. The single-spread fast path
previously routed straight through js_array_clone, which silently
returns an empty array for those inputs (kept for back-compat with
Array.from's not-iterable behavior).

Add a dedicated runtime entry point js_array_clone_for_spread that
inspects the NaN-box tag bits on the raw boxed value and throws a
TypeError("<receiver> is not iterable") for null/undefined before
forwarding to js_array_clone. Update the codegen single-spread arm
to call it. Array.from's behavior is unchanged.
The (target_has_slots, slot) tuple where target_has_slots was always
literally true was a noisy leftover from an earlier shape of this
branch. Collapse it to a plain Option<slot> lookup.
- escape_news.rs: clarify why the trailing `_ => {}` arm is kept after
  the explicit Expr-variant list — new allocating variants should be
  added explicitly above rather than relying on the fallthrough.
- json/parser.rs: explain the 96-bytes/object pre-size heuristic for
  arrays-of-objects (empirical average for small JSON objects with a
  handful of short string keys), plus the 16..16_384 clamp rationale.
)

`crates/perry-codegen/src/collectors/escape_news.rs` was 2034 lines and
failed the 2,000-line file-size CI gate. Splitting it into topical sibling
modules under `collectors/` — no behavior change; function bodies move
verbatim and the same public/internal API is re-exported from
`collectors/mod.rs`.

Moves:
- `this_as_value.rs` — `class_uses_this_as_value`,
  `class_chain_extends_builtin_error`, `stmts_use_this_as_value`,
  `expr_uses_this_as_value`.
- `class_accessors.rs` — `is_class_getter`, `is_class_setter`.
- `escape_check.rs` — `find_new_candidates`, `check_escapes_in_stmts`,
  `check_escapes_in_expr`.
- `local_refs.rs` — `expr_contains_local_get`,
  `mark_all_candidate_refs_in_expr`.

`escape_news.rs` keeps the escape-collection entry points
(`collect_non_escaping_news`, `collect_non_escaping_new_used_fields` and
the two private helpers) plus the trailing `MAX_SCALAR_ARRAY_LEN` const.

Final line counts: escape_news 693, escape_check 870, this_as_value 337,
local_refs 114, class_accessors 50 — all comfortably under the 2,000 gate.
@TheHypnoo
TheHypnoo force-pushed the feat/performance-app-pattern-gaps branch from dc4571f to 328ec5d Compare May 22, 2026 07:07
@TheHypnoo
TheHypnoo marked this pull request as ready for review May 22, 2026 07:07
@TheHypnoo
TheHypnoo merged commit ece6aa3 into main May 22, 2026
9 checks passed
@TheHypnoo
TheHypnoo deleted the feat/performance-app-pattern-gaps branch May 22, 2026 07:17
proggeramlug added a commit that referenced this pull request May 22, 2026
…sweep (#1414)

Rolls up 26 PRs that merged to main post-v0.5.1023 without version
bumps:

- node:crypto gap-fixes (#1386 #1393 #1394 #1402 #1405): randomInt,
  timingSafeEqual, getHashes/getCiphers, sha224/sha384, base64 digest,
  Buffer hash input, no-arg digest() → Buffer, pbkdf2Sync digest arg,
  scryptSync.
- node:perf_hooks (#1321 + #1328 #1342 coverage): performance + User
  Timing + PerformanceObserver native impl, granular node-suite +
  edge-case coverage.
- #1090 GC checkpoint runtime work (#1324).
- #1311 geisterhand on iOS (#1316 #1383 #1384 #1385).
- #1312 process.env.X (unset) is nullish undefined (#1314).
- #1319 thread-safety hardening for cross-thread runtime statics.
- #1322 exact-head GC evidence packet.
- #1323 wasm timers dispatch through mem_call bridge (#1329).
- #1317 node:timers/promises shadow-segfault fix (#1326).
- #1330 node:process suite (#1331).
- #1292 bcrypt.hash() returns String (#1307).
- #1293 fastify .json()/.body external-fastify dispatch (#1308).
- #1296 app pattern performance gaps.
- #1297 diagnostics_channel parity.
- #1301 iOS App Groups capability (#1313).
- #1318 #1325 os/methods/modern-methods static dispatch.
- #1315 expanded Node parity test coverage.
- #1382 ui-ios stdlib pump for async fetch.
- #1392 ui-wasm reactive state + setText (#1404).
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.

1 participant