Skip to content

perf(runtime): every function object carries a shape (every-shape part 1) - #11580

Merged
proggeramlug merged 13 commits into
mainfrom
perf-function-shapes
Sep 27, 2026
Merged

proggeramlug merged 13 commits into
mainfrom
perf-function-shapes

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #11489. Review after it merges; the diff here is this PR's 12 commits only. Refs #10502, #11394.

What

Every function object now carries a real ShapeId, like any other object. The function header is capcount | ShapeId @+4 | code @+8 | props @+16, a 24-byte header.

With a shape, the questions the runtime used to answer by walking side tables become one compare:

  • fn.bind/call/apply are decided by the prototype slot the shape names.
  • A bind result is born with one tag-checked allocation, with its length in a capture.
  • The accessor and deleted-key probes on a base-shaped function answer from its shape.
  • The inherited-read cache and the method arm handle exotic receivers with one compare.
  • is_closure_ptr proves arena ownership through the page-class table.
  • The base Function ShapeId is read in place instead of copied out of its cell. That copy alone was about 3% of Zod.

Evidence

Measured n=5 interleaved on a release build, each arm linking its own runtime, against base d548daaaf (#11489). Spread was at most 0.38%, and output matches node in every arm.

tsc instr Zod instr fn.bind per call peak RSS tsc / Zod
base (#11489) 79.566G 1.6093G 9,148 320 / 89 MB
this PR 78.582G (−1.24%) 1.1152G (−30.70%) 3,537 309 / 69 MB
  • Real code: a counting build shows all 81,600 of Zod's bind calls take the new path.
  • Runtime suite, --test-threads=1: 4,676 passing vs 4,665 on base. Both arms share one failure, a DNS test that fails on the build host's network.
  • Codegen: 1,771 passed, 0 failed.
  • run_lint_gates.sh: 2 of 108 fail, the Windows xwin check (not installed on the host) and public-benchmark freshness, which fails on main too. This includes -D warnings, clippy, API docs and GC root-dominance. cargo fmt --check is clean.
  • Thread-exit gate: 0 problems. The deleted closure side tables are removed from its inventory.
  • Sabotage: 5 of 5 targeted breakages go red.
  • Gap suite: measured on part 2 (perf(runtime): a function's own properties live in the function object (every-shape part 2) #11581), which contains this PR: 1,083 pass against base's 1,079, with 0 regressions. This part alone was clean on the previous base, except for one timing-sensitive worker test.

Summary by CodeRabbit

  • New Features
    • Function objects now support shape-aware property handling and dispatch for bind, call, and apply.
    • Bound functions retain their computed length when it falls within the supported range.
  • Performance
    • Reported instruction counts decreased for Zod, TypeScript, and fn.bind.
  • Bug Fixes
    • Function identification and property handling now work correctly as functions gain or lose properties.

@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 88521d3e-18a2-44bf-9689-582a60e53d5a

📥 Commits

Reviewing files that changed from the base of the PR and between e18e175 and a86c2bc.

📒 Files selected for processing (5)
  • crates/perry-codegen/src/target_layout.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/shapes.rs
  • crates/perry-runtime/src/object/shapes_store.rs
  • crates/perry-runtime/src/object/tests.rs

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


📝 Walkthrough

Walkthrough

Function closures now carry ShapeIds in an expanded header. The runtime assigns base function shapes and moves closures with additional own state to a dictionary shape. Closure checks use GC headers. Function-method dispatch handles bind, call, and apply, and bound functions store eligible lengths in a capture.

Changes

Function Object Runtime

Layer / File(s) Summary
Closure layout and function shapes
crates/perry-abi/src/lib.rs, crates/perry-codegen/src/target_layout.rs, crates/perry-ffi/src/types.rs, crates/perry-runtime/src/closure/alloc.rs, crates/perry-runtime/src/closure/shape.rs, crates/perry-runtime/src/object/shapes*.rs, crates/perry-runtime/src/object/proto_validity.rs, crates/perry-runtime/src/object/global_this/..., crates/perry-runtime/src/gc/...
Closure headers include a ShapeId and properties pointer. The runtime assigns base shapes by function body kind, allocates exotic shape IDs, and assigns intrinsic prototype serials.
Closure validation and property-state updates
crates/perry-codegen/src/expr/method_site.rs, crates/perry-codegen/src/lower_call/early_branches.rs, crates/perry-runtime/src/closure/..., crates/perry-runtime/src/object/..., crates/perry-runtime/src/json/..., scripts/*
Codegen and runtime closure checks use GC header type and forwarding bits. Closure property and prototype changes notify function-shape tracking. Closure consumers and tests use the updated header and validation paths.
Function method dispatch and bound functions
crates/perry-runtime/src/closure/dispatch/..., crates/perry-runtime/src/object/native_call_method/..., crates/perry-runtime/src/object/native_module/..., crates/perry-runtime/src/object/global_this.rs, test-files/test_gap_function_shape_methods.ts
Function-shape dispatch checks the receiver shape and Function prototype slot before routing bind, call, or apply. Bound closures store eligible lengths in a capture, which metadata lookup can read.
Layout checks and behavior fixtures
crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs, crates/perry-codegen/src/target_layout.rs, crates/perry-runtime/src/closure/shape.rs, test-files/test_gap_function_shape_methods.ts, changelog.d/11580-function-objects-carry-a-shape.md
Tests cover closure layout, shape transitions, and function-method behavior. The changelog records the implementation summary and reported instruction counts.

Priority: ➖ Normal

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

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant NativeCallMethod
  participant FunctionShapeDispatch
  participant CommonMethodDispatch
  participant ClosureAllocator
  NativeCallMethod->>FunctionShapeDispatch: pass receiver, method name, and arguments
  FunctionShapeDispatch->>CommonMethodDispatch: dispatch bind, call, or apply when the prototype slot matches
  CommonMethodDispatch->>ClosureAllocator: allocate bound closure for bind
Loading

Suggested reviewers: claude

Merge Risk: ⚪ Minimal · up to a86c2

No actionable risk remains in the reviewed changes; the PR is mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to a86c2

The change spans several core runtime behaviors. The reviewed dispatch path retains checks on object ownership, function shape, and prototype method identity, but the breadth of the change and incomplete comparison with the stacked base warrant design review.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The affected trust decision is within the runtime handling of function-valued objects and their properties, rather than a newly identified external service or privilege boundary.

Trust Boundaries and Controls

  • observed — A shape word alone does not authorize the inspected native method fast path: it also requires closure validation and a recognized method value from the Function prototype.

Resilience and Maintainability Implications

  • inferred — A malformed shape word on an otherwise valid closure would be treated as base-shaped by the release-time predicate, potentially skipping accessor handling. The inspected normal creation and transition paths do not show how such a word could arise.

Hardening Proposals

  • proposed — Consider enforcing valid Function or FunctionDictionary shape identity in release builds at the shared classification seam, particularly if additional closure-shape transitions are introduced.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 150 functions across 50 files.
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.
Title check ✅ Passed The title clearly identifies the main change: adding shapes to every runtime function object. It is concise and specific.
Description check ✅ Passed The description explains the implementation, performance goals, related issues, and test evidence. It does not use the template headings or include the formal checklist, but it covers the required inf…
✨ 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
🛠️ 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.

Ralph Küpper added 13 commits September 27, 2026 23:56
A closure is now {capture_count u32, ShapeId u32, func_ptr, props} with the
ShapeId at payload +4, the word an ObjectHeader keeps its ShapeId in. The
CLOSURE_MAGIC payload word is gone: what a cell IS comes from its GcHeader
type byte, and is_closure_ptr proves ownership (tracked header) behind a
one-load exotic-band prefilter on the +4 word.

Function shapes are minted in a new EXOTIC ShapeId band that no own-slot
site word accepts. A closure is born with the base Function shape of its
body kind (proto_id = the intrinsic serial of Function.prototype /
%AsyncFunction.prototype% / %(Async)GeneratorFunction.prototype%, reserved
1..4 and assigned at creation) and leaves it for the shared
FunctionDictionary shape at every funnel that installs something the base
shape does not describe (a non-intrinsic key, a symbol key, an accessor, a
delete, a recorded [[Prototype]]).
…he shape names

A method call on a function object that is still on its base Function shape
compares the receiver's +4 word with this agent's base ShapeId (ownership
then proven by the tracked GC header), reads the key's data slot on the
Function.prototype that shape names, and — when that slot's VALUE is still
the intrinsic bind/call/apply closure — runs exactly what the by-name tower
runs for those names (now one unit, dispatch_function_proto_method), before
any of the tower's probes. A patched slot, an accessor, a missing key or a
FunctionDictionary receiver declines to the unchanged full path.
… in a capture

js_function_bind wrote its four captures through the per-slot setters, so
every bind installed a per-object layout mask (4 >= the mask threshold) and
a builtin-closure-length metadata entry, both of which every minor then
pruned. The bound closure now has a fifth capture holding its .length (read
back through builtin_closure_length, the one reader every .length path uses),
its captures are installed in one step (closure_install_boxed_captures: raw
stores, GC_LAYOUT_UNKNOWN when any word can be a pointer — the #7630 state
for a payload a mask cannot improve on — and one newborn barrier), and the
length is resolved from the rooted target before the closure exists. A
non-u32 length stays an own property, as before.
…-class table

The tracked resolver range search measured 17% of a fn.length read. An arena
cell is proven by classify_heap_generation (the page-class table, the proof the
pre-shape predicate used); only a cell in no arena takes the malloc-registry
resolver. builtin_closure_length consults its table before the bind-capture
check, so a non-bound function pays no extra probe.
…d-key probes from its shape

Every accessor installer (set_accessor_descriptor, install_fresh_accessor_property,
set_builtin_accessor_descriptor) and every delete now moves a closure to
FunctionDictionary, so a closure still on its base Function shape provably has
neither: closure_get_dynamic_prop skips both side-table probes for it. The bound
length re-check is the header byte only (callers proved the closure).
…e method arm on one compare

A function now shows a ShapeId at +4, so the inherited-read cache hashed and
probed for it before failing; it declines an exotic-band id up front. The
function-shape method arm tests the band before loading the agent base id, so a
Map/array/object receiver leaves on one compare. closure_on_base_shape is one
compare against the FunctionDictionary id.
- a test's unsafe block around a now-safe call;
- trusted_box_callback_tests reads the capture offset from perry-abi
  (CLOSURE_HEADER_SIZE, 24) instead of the old literal 16;
- the two JSON-reviver copied-minor tests register the object model's
  production scanners. The copying-nursery guard takes the thread's registry
  away; without the shape-table scanner a copied minor left the parse's shape
  families under from-space addresses, and once the nursery reused one the
  post-minor prune retired the live object's shape ("b" vanished from
  {"a":[{"b":"c"}]}). Which address is reused depends on cell sizes, so the
  24-byte closure header exposed a gap that was already there.
…ut of its cell

Every closure birth and every function-receiver test asks for one of the
agent's base Function ShapeIds (or the FunctionDictionary id). base_slot
copied the whole five-id array out of its thread-local cell per call, and
showed up at ~3% of Zod's instructions. It now reads the one element in
place and keeps the mint on a cold path. Zod (perrymaster, real
release profile, this series' part 2): 1,160.9M -> 1,145.9M instructions
(-1.3%); fn.bind per op 3,732 -> 3,605.
@proggeramlug
proggeramlug merged commit 790a402 into main Sep 27, 2026
55 of 57 checks passed
@proggeramlug
proggeramlug deleted the perf-function-shapes branch September 27, 2026 23:39
proggeramlug pushed a commit that referenced this pull request Sep 28, 2026
…ure births)

Since the last regeneration, closure birth mints a shape descriptor
(#11580/#11581): js_closure_alloc -> birth_shape_for_body -> mint ->
shape_descriptor_ensure_with_holes -> keys_attrs, and keys_attrs'
forwarded-keys arm calls clean_arr_ptr, whose full resolver
force-materializes a lazy JSON array (a reparse that reaches
js_object_set_field_by_name's indirect call). The graph therefore demotes
js_closure_alloc{,_init,_singleton,_with_captures_singleton},
js_closure_unbind_this, js_console_log_as_closure, js_domain_{bind,intercept}
and js_v8_promise_hook_register from AllocOnly, and (macOS) four ThrowOnly
callers, to Reenters. No symbol lost Leaf; the S2 fast hits stay Leaf. In
default codegen AllocOnly and ThrowOnly are already non-leaf, so emitted
code is unchanged. Twelve new js_stdlib_install_* registration exports come
out Leaf.

Tables are the gc-call-effects jobs' own regenerations (run 36380097760)
from refs/pull/11565/merge c9a053f, which is tree-identical to this
branch's parent.
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