Skip to content

perf(codegen): elide the length computation of a statement-position push - #7600

Merged
proggeramlug merged 3 commits into
mainfrom
perf/discarded-push-length-2
Aug 7, 2026
Merged

perf(codegen): elide the length computation of a statement-position push#7600
proggeramlug merged 3 commits into
mainfrom
perf/discarded-push-length-2

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #7590/#7591 — the perf half that was deliberately kept out of the correctness fix.

What

arr.push(x) evaluates to the new length, computed by js_array_length — which is not a field read: it resolves Proxy arrays through the get trap and probes the registered-Set/Map side tables. A statement-position arr.push(x); discards that result, so push-heavy code pays an out-of-line runtime call per push for a number nobody reads (8–13% of push-heavy runs; json_pipeline's build_out loop is exactly this shape — 1 source-level .length but 458 emitted call sites, one inside the hot loop ⇒ 20M calls).

This PR elides the length computation when — and only when — the push is the statement's own expression.

Why this is safe now (and was not before #7591)

The gate is the mem::taken per-expression signal dispatch::lower_expr introduced in #7591. It reaches exactly the statement's own expression and never an operand, so n = arr.push(x), sink(a.push(10)), a.push(1) + 100, a.push(1) > 0 ? 7 : 9 all compute the real length. Gating on the raw ctx.discard_expr_value field is precisely the #7590 bug — it produced wrong values in all four of those positions when I first tried this as a perf change, which is what led to finding #7590 in the first place.

All eight push/push-spread return points receive the signal as a parameter, not a field read — handlers consult it after lowering their operands, by which point the field has been taken again.

Verified live in the IR

--trace llvm on a probe with a discarded loop push + one consumed push: the discarded push emits zero js_array_length calls; the consumed push exactly one per function specialization. (A green behavior test alone would be vacuous — the discarded form works whether or not the elision fires.)

Measurements

Interleaved A/B, each arm linked by its own compiler+runtime pair (separate target dirs, identical package sets, both runtimes current main):

Pure statement-position push loop (20M pushes, runtime-dependent bounds, escaping array):

arm best-of-6 per push
main 180 ms 9.0 ns
this PR 65 ms 3.25 ns

2.77× — more than the call itself: the out-of-line call was also blocking loop optimization around it. Both arms compute identical results.

json_pipeline (shipped workload, 200k records): 2,495 → 2,423 ms total (~3 %), output hash identical — modest because that workload is GC-bound (#7592); its statement-position out.push({...}) is exactly the elided shape.

Testing

test-files/test_array_push_expression_value.ts consumes the push value in call-argument, assignment, arithmetic, conditional, nested-push, object-element, spread, and boxed-array (runtime fall-through path) position, plus discarded forms that must keep pushing. Byte-identical to node --experimental-strip-types.

That matrix is the point (#7590's lesson): the discarded form kept working the entire time the original bug was live, so only a consuming test can catch a signal leak.

  • perry-codegen test suite: 8 targets, all green (671 tests in the main target), 0 failures.
  • Gap suite: running at time of filing; result will be posted as a comment.
  • cargo fmt --check, scripts/check_file_size.sh clean.

Summary by CodeRabbit

  • Performance

    • Improved generated code for discarded array.push() results by avoiding unnecessary length calculations.
    • Preserved accurate returned lengths when push() results are used.
  • Bug Fixes

    • Ensured array mutations continue to work correctly across standard, spread, object, and boxed arrays.
  • Tests

    • Added coverage for push() in assignments, conditions, arithmetic, function calls, and other expression contexts.

proggeramlug pushed a commit that referenced this pull request Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 seconds

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d115a86-cc4a-4799-85d0-09c31fb6650f

📥 Commits

Reviewing files that changed from the base of the PR and between f70beef and cf880a5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • CLAUDE.md
  • Cargo.toml
  • crates/perry-codegen/src/expr/array_push.rs
📝 Walkthrough

Walkthrough

Array push lowering now receives expression-discard state. Discarded pushes skip updated-length computation and return a zero placeholder. Consumed pushes preserve their new-length result across numeric, object, spread, boxed, and captured-array paths. Regression tests cover these contexts.

Changes

Array push discarded-result handling

Layer / File(s) Summary
Discard-state dispatch contract
crates/perry-codegen/src/expr/dispatch.rs, crates/perry-codegen/src/expr/array_push.rs
Array push dispatch passes the expression-discard flag to the lowering function.
Discard-aware array push lowering
crates/perry-codegen/src/expr/array_push.rs
Discarded pushes skip array-slot reloads and length computation across numeric, local, boxed, captured, fallback, and spread paths. Consumed pushes still return the computed length.
Expression-value regression coverage
test-files/test_array_push_expression_value.ts, changelog.d/7600-discarded-push-length-elision.md
Tests cover consumed push results, spread arguments, object arrays, boxed arrays, and discarded statement-position pushes. The changelog records the optimization and verification results.

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

Possibly related PRs

  • PerryTS/perry#6915: Both changes modify array push lowering, but this PR handles discarded-result length elision.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main optimization: removing unused length computation for statement-position pushes.
Description check ✅ Passed The description explains the change, rationale, safety, related issues, measurements, implementation coverage, and test results in sufficient detail.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/discarded-push-length-2

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/expr/array_push.rs`:
- Around line 28-49: Update emit_array_handle_length so value_discarded does not
bypass observable Proxy length access: ensure the push runtime helper performs
and preserves the required length lookup/trap before discarding the result, or
guard the optimization to proven ordinary arrays only. Add a regression case
using a Proxy length trap that records calls and throws, confirming the trap
still executes for statement-position push calls.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f94b3193-fc7d-43b8-b594-77c803e91828

📥 Commits

Reviewing files that changed from the base of the PR and between 72fa5b4 and f70beef.

📒 Files selected for processing (4)
  • changelog.d/7600-discarded-push-length-elision.md
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/dispatch.rs
  • test-files/test_array_push_expression_value.ts

Comment on lines +28 to 49
/// The expression's result: the new length per ES2024 `Array.prototype.push`.
///
/// `js_array_length` is NOT a field read — it resolves Proxy arrays through
/// the `get` trap and probes the registered-Set/Map side tables — and a
/// statement-position `arr.push(x);` discards its result, so on push-heavy
/// workloads it was 8–13% of the run computing a number nobody reads.
/// `value_discarded` is the `mem::take`n per-expression signal from
/// `dispatch::lower_expr` (#7590: it reaches exactly the statement's own
/// expression, never an operand — a consumed `n = arr.push(x)` always
/// computes the real length). When set, the placeholder constant is returned
/// without emitting the call.
fn emit_array_handle_length(
ctx: &mut FnCtx<'_>,
array_handle: &str,
value_discarded: bool,
) -> String {
if value_discarded {
return double_literal(0.0);
}
let blk = ctx.block();
let len_i32 = blk.call(I32, "js_array_length", &[(I64, array_handle)]);
blk.sitofp(I32, &len_i32, DOUBLE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve observable Proxy get traps.

Line 30 states that js_array_length resolves Proxy arrays through the get trap. Lines 44-45 skip that operation for discarded results. A discarded JavaScript result can skip only unobservable work. It cannot skip a trap that can run user code or throw.

Preserve the required Proxy behavior before returning the placeholder. Return the length from the push runtime helper, or elide the lookup only after proving that the receiver is an ordinary array. Add a regression case with a length trap that records calls and throws.

The PR objective requires Proxy behavior to be preserved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/array_push.rs` around lines 28 - 49, Update
emit_array_handle_length so value_discarded does not bypass observable Proxy
length access: ensure the push runtime helper performs and preserves the
required length lookup/trap before discarding the result, or guard the
optimization to proven ordinary arrays only. Add a regression case using a Proxy
length trap that records calls and throws, confirming the trap still executes
for statement-position push calls.

Ralph Küpper added 3 commits August 8, 2026 00:10
arr.push(x) evaluates to the new length, computed by js_array_length --
which is NOT a field read: it resolves Proxy arrays through the `get`
trap and probes the registered-Set/Map side tables. A statement-position
push discards that result, so push-heavy workloads spent 8-13% of the
run computing a number nobody reads (measured on push_cls; json_pipeline's
build_out loop is the same shape).

Gated on the mem::take'n per-expression signal from dispatch::lower_expr
(#7590/#7591): it reaches exactly the statement's own expression and
never an operand, so a consumed `n = arr.push(x)` always computes the
real length. All eight push/push-spread return points thread it as a
parameter -- reading the field there would reintroduce the #7590 leak in
a subtler form, since handlers consult it after lowering operands.

test_array_push_expression_value.ts consumes the value in call-argument,
assignment, arithmetic, conditional, nested-push, object-element, spread
and boxed-array position -- the discarded form keeps working no matter
what, so only a consuming test can catch a regression here (#7590's
lesson). Verified in traced IR: the discarded loop push emits zero
js_array_length calls, the consumed push exactly one per specialization.
@proggeramlug
proggeramlug force-pushed the perf/discarded-push-length-2 branch from f70beef to cf880a5 Compare August 7, 2026 22:17
@proggeramlug
proggeramlug merged commit aaea8b8 into main Aug 7, 2026
@proggeramlug
proggeramlug deleted the perf/discarded-push-length-2 branch August 7, 2026 22:17
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1342

IR mechanism confirmed on my own probe (discarded loop push + one consumed
push + one .length read): main emits 4 js_array_length calls, the PR
emits 2 — the 20M-iteration discarded push emits none, both consumed sites
keep theirs. Matrix test byte-identical to node.

The decisive sabotage is the one that recreates #7590, and the matrix test
catches it.
Regressing the gate to the leaking statement-scoped
ctx.discard_expr_value field produces exactly the original bug signature:

assign:WRONG got=0 want=2 ... obj_assign:WRONG got=0 want=2

That is the point of a consuming test matrix — the discarded form worked the
entire time #7590 was live, so only value-consuming positions can catch a
signal leak. The gate arriving as a parameter to all eight return points
(never a field read after operand lowering) is the discipline #7591's audit
asked for, applied.

My probe showed 1.08× rather than 2.77× — expected: it grows a 20M-element
array, so allocation/GC dominates, and #7602 just made the barrier half of that
cheaper. The 2.77× is the purpose-built pure-push shape; the IR census is the
load-bearing evidence and it is unambiguous.

Note for the record: the promised gap-suite comment was not posted (same as
#7596). My own validation stands in: full perry-codegen 677/0 and
perry-runtime 1851/0 suites, matrix test vs node, all four lint gates + fmt
clean. The elision is unreachable for Proxy receivers (the typed lowering
requires an array proof), so no observable trap behaviour changes.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gap suite result (as promised at filing)

Full run on this branch (which also carries the merged #7594/#7596 GC pacing changes, so this doubles as their post-merge gap verification — the run at #7596 filing time was voided by a deleted worktree):

  • No real regressions.
  • The one reported regression, test_gap_zlib_4917_level pass → compile_fail, is the documented host-local runtime-dir resolution flake, not a code regression: the missing js_zlib_*_raw_sync symbols are present in the freshly built libperry_stdlib.a (verified with nm on both A/B arms), and the same test compiles and matches node exactly when linked with PERRY_RUNTIME_DIR pinned to that archive.
  • 10 node_fail → parity_fail status changes are oracle-coverage transitions — tests the pinned Node (26.5.1, matching .node-version) now runs that it previously couldn't. They surface pre-existing Perry gaps, not changes in Perry behavior; the harness classifies them outside the regression gate for exactly this reason.
  • 1 improvement: test_gap_iterator_helpers_2874 parity_fail → pass.

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