Skip to content

perf(runtime): skip the arg-combine Vec for no-partial-args bound callbacks (−4.6% CPU) - #10818

Closed
proggeramlug wants to merge 2 commits into
mainfrom
perf/callback-dispatch
Closed

proggeramlug wants to merge 2 commits into
mainfrom
perf/callback-dispatch

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

dispatch_bound_function — the entry every js_closure_call<N> routes a .bind()-created callback through — unconditionally allocated, populated and freed a Vec<f64> on every call, including for .bind(thisArg) with no partial-applied args at all.

That no-partial-args shape is the plain method reference: arr.forEach(obj.method.bind(obj)). And it is precisely the shape direct.rs cannot hoist out of a loop — resolve_direct_func_ptr returns None for BOUND_FUNCTION_FUNC_PTR (direct.rs:70), deliberately — so it re-pays the allocate-copy-free on every element.

js_function_bind already leaves the bound-args capture slot null whenever bound_arg_count == 0, so the case is free to detect. The fix skips the Vec and hands js_native_call_value the caller's own args slice; only actual partial application (.bind(obj, extra)) still builds a combined buffer.

This lever's specific hazard, checked rather than assumed

A previous attempt in this area won on instruction count and cost 54% CPU. Removing an allocation is exactly that shape, so instructions alone were never going to be sufficient evidence here.

Measured both, under a measurement mutex, after waiting for host load to fall below 8:

shape base this branch Δ
bound callback — instructions/call 2,795 2,676 −118 (−4.3%)
bound callback — CPU seconds (N=400k, best-of-7) 5.04 4.81 −4.6%
non-bound callback — CPU (control) 0.27 0.27 identical
partial-bind, loop-local closure, bare loop ~0

CPU agrees with instructions rather than contradicting them, and the non-bound control is live and flat — which is what makes the comparison readable rather than merely green. An earlier wall-clock attempt was taken under load averages of 60–117 on 10 cores and resolved nothing; that run is not the evidence here.

GC safety: the old copy was waste, not protection

The obvious objection is that copying into a Vec put the values in memory the collector cannot move, and passing the caller's pointer through removes that. Traced all 9 call sites: every one hands dispatch_bound_function either a Rust-stack [f64; N] array or a fresh Rust Vec copy — the direct callers pass &[arg0, …] literals at each arity, and the one non-literal path (dispatch_registered_call) has eight call sites that each build let args = [arg0, arg1, …]. The only from_raw_parts uses under closure/ are capture arrays and UTF-8 method names, never the args slice. No path hands it GC-managed memory.

The narrower claim, stated rather than glossed: a stack array of NaN-boxed values is not a GC root either, and neither was the Vec. If a collection moves an object inside js_native_call_value, neither buffer is rewritten, and the conservative native-stack scan is diagnostic-only by default. This change neither introduces nor fixes that — rooting semantics are unchanged.

Empty-args behaviour is preserved exactly: empty maps to a null pointer, as an empty combined did, rather than the non-null-but-dangling pointer an empty slice's as_ptr() would give.

What was investigated and found already optimal

The rest of the callback path is not a gap. A direct arrow inlined into forEach, a loop-called closure local, and a loop-called callback parameter all reach fast, largely branch-predictable paths — via js_closure_resolve_arrow_direct_call, entry-resolved once per function, plus direct.rs's per-loop hoisting for array-iteration builtins and codegen's early_branches.rs fast tiers. The bound-function entry is the one shape excluded from that hoisting, which is why it was the one carrying avoidable per-call work.

Validation

  • Gap test byte-identical to node 26.5.1 through the compiled binary — this binding across arrow/function/bound, arguments, extra and missing arguments, length, a throwing callback's stack, recursion, and a callback closing over a loop variable.
  • cargo test -p perry-runtime --lib: 4,115 passed, 0 failed.
  • GC stress, both seeds, with real copying minors (retired_set=#9), no faults, output byte-identical.
  • run_lint_gates.sh 84/85 — the one failure is the known-red public-baseline step. cargo fmt --all -- --check clean.

Summary by CodeRabbit

  • Performance

    • Improved callback execution for bound functions without additional bound arguments, reducing unnecessary processing and improving efficiency in common cases.
    • Preserved partial-application behavior while tightening handling for callbacks with extra bound arguments.
  • Bug Fixes

    • Improved consistency across callback invocation patterns, including bound callbacks, closures, recursion, missing or extra arguments, and receiverless calls.
  • Tests

    • Added comprehensive coverage for callback dispatch, this binding, argument handling, throwing callbacks, and closure behavior.

Ralph Küpper added 2 commits September 20, 2026 15:41
…s bound functions

dispatch_bound_function unconditionally built a Vec<f64>, copied the
call-time args into it, and freed it before every Function.prototype.bind
call -- even for the overwhelmingly common .bind(thisArg) method-reference
shape with no partial-applied arguments, where js_function_bind already
leaves the bound-args capture null. That shape is exactly what direct.rs's
per-loop dispatch hoisting excludes (BoundFunction is deliberately not
resolved by resolve_direct_func_ptr), so a bound method used as an
arr.forEach callback paid this allocation on every element.
@coderabbitai

coderabbitai Bot commented Sep 20, 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: 22125e38-138d-4457-badd-f914f39dac9c

📥 Commits

Reviewing files that changed from the base of the PR and between b9ba951 and 301aca4.

📒 Files selected for processing (3)
  • changelog.d/10818-callback-bound-dispatch-skip-vec-alloc.md
  • crates/perry-runtime/src/closure/dispatch/bound.rs
  • test-files/test_gap_callback_dispatch_shapes.ts

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


📝 Walkthrough

Walkthrough

The bound-function dispatcher now forwards caller arguments directly when no partial arguments are bound. Partial application still combines bound and caller arguments. New tests cover callback dispatch, binding, argument handling, exceptions, recursion, and closure capture.

Changes

Bound callback dispatch

Layer / File(s) Summary
Direct argument forwarding
crates/perry-runtime/src/closure/dispatch/bound.rs, changelog.d/10818-callback-bound-dispatch-skip-vec-alloc.md
dispatch_bound_function skips the combined Vec<f64> for .bind(thisArg) calls without partial arguments. Partial binds still build a combined buffer. The changelog records measurements and safety analysis.
Callback dispatch shape coverage
test-files/test_gap_callback_dispatch_shapes.ts
Tests cover direct callbacks, callback frames and loops, bound methods, partial arguments, and repeated bound callbacks.
Function call semantics coverage
test-files/test_gap_callback_dispatch_shapes.ts
Tests cover this, arguments, function length, exceptions, recursion, and loop-variable closure behavior.

Priority: ⬇️ Low

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

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant CallbackCaller
  participant dispatch_bound_function
  participant js_native_call_value
  CallbackCaller->>dispatch_bound_function: pass caller args
  dispatch_bound_function->>js_native_call_value: forward caller args when no partial args are bound
  dispatch_bound_function->>js_native_call_value: pass combined bound and caller args for partial application
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.41% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 2 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 runtime optimization and reports its measured performance impact.
Description check ✅ Passed The description clearly covers the implementation, rationale, safety analysis, benchmarks, regression coverage, and validation results. It does not use the template headings or explicitly provide a re…
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 29.41% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 17 functions across 2 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 242 (#10830) as v0.5.1621e2a0839074.

Eight PRs travelled together because their file sets are disjoint — 30 files, +1,514/−101, zero overlap. Validated as one tree: ten cheap gates, -D warnings across all targets, five pinned artifacts byte-identical before and after, seven unit suites with an empty failing set, both compiler-output suites at failed_workloads=[], repsel_census rc=0, and a 250-fixture sweep with one area per PR (class 84, string 50, object 40, map 21, stream 18, bind 14, url 12, regex 11) — zero unexplained regressions.

Two of the eight needed a fix before they could land, both made in the train rather than bounced back.

#10816 bound sep_jv unconditionally in string/split.rs while reading it only inside #[cfg(feature = "regex-engine")], so RUSTFLAGS="-D warnings" cargo check -p perry --bins failed. Worth knowing why this is invisible in normal review: a one-invocation whole-workspace build unifies cargo features, so the regex engine is always on and the binding always read — only the per-package command, one of six run_lint_gates.sh derives, sees it. Same family as cargo check --lib not compiling cfg(test) code. Gated behind the feature that reads it; lim_jv on the next line was checked separately and is genuinely used outside the block.

#10817 added 15 dispatch entries without regenerating the docs, so the API-docs-drift check failed. Regenerated from a built binary: 2855 → 2870, exactly your 15, with perry.d.ts correctly unchanged at 2026 since those rows are dispatch-table rather than public surface. it_manifest_consistency passes on the assembled tree, which is the stronger signal — a green drift check only proves the files match the binary; that suite proves the manifest is internally consistent.

For future PRs in this area: scripts/regen_api_docs.sh hardcodes <worktree>/target/release/perry and, with that binary absent, regenerates from nothing and leaves both files truncated. A real regeneration moves the header counts and leaves the tail intact — worth checking the tail, not just the count.

One more thing, aimed at whoever cuts the next PR here: verify() flagged an exponential-backoff manifest entry in #10817 as missing from the train. That was correct — train 240 removed the binding, and restoring the entry would have failed manifest sync. main is moving several times an hour at the moment, so a PR cut against a base more than a few hours old is worth rebasing before review rather than after.

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