Skip to content

Make per-call nested function instantiation allocation-free#2684

Merged
lahma merged 5 commits into
sebastienros:mainfrom
lahma:perf-nested-fn-instantiation
Jul 14, 2026
Merged

Make per-call nested function instantiation allocation-free#2684
lahma merged 5 commits into
sebastienros:mainfrom
lahma:perf-nested-fn-instantiation

Conversation

@lahma

@lahma lahma commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Every call of a function whose body declares inner functions re-instantiates those functions (spec-required — each call's closures capture that call's environment). ETW profiling shows the instantiation chain (FunctionDeclarationInstantiationInstantiateFunctionObjectScriptFunction..ctor) at 4.6% of program time on closure-producing workloads. This makes each instantiation cheaper — instantiating a named sloppy nested function drops from ~7 side allocations (~250–300 B) to zero (only the ScriptFunction object itself remains, +16 B for two new lazy fields).

Five commits:

  1. Lazy name/length/prototype descriptors — a shared pending-descriptor sentinel materializes the real PropertyDescriptor on first read (sharing actual descriptors across instances is provably unsafe: ValidateAndApplyPropertyDescriptor mutates them in place). The sentinel's value accessors throw so a leak fails loudly. The per-definition-constant function name JsString is cached on JintFunctionDefinition.
  2. Lazy sloppy-mode arguments/caller poison descriptors — a plain sloppy function no longer creates any property dictionary at all; delete-then-redefine demotes to dictionary preserving key-resurrection order; initial key order unchanged; %ThrowTypeError% resolution stays deferred exactly as before.
  3. FDI hoisting-loop trimsFunctionsToInitialize becomes a readonly record-struct array (was LinkedList) with the binding Key precomputed at BuildState; the intrinsic lookup is hoisted out of the loop; SetFunctionName's per-call JsString allocation is skipped for named declarations.
  4. Pre-size the FDI var-binding dictionary (mirrors the adjacent lexical-declarations pattern) instead of growing per insert.
  5. Materialize the pending prototype in [[Construct]] only when consulted.

Functional verification beyond suites: 77 checks (descriptor shapes before/after calls via getOwnPropertyDescriptor, key order, delete/redefine/freeze, loop-closures, NFE recursion, generators/async, Proxy, bind, with, class) — output byte-identical to a pristine main build.

Benchmarks (default jobs, adjacent A/B on latest main)

Benchmark main PR Δ
FunctionDeclarationAlloc DeclareMany 61.98 µs / 168.79 KB 52.57 µs / 129.73 KB −15.2% time, −23.1% alloc
FunctionDeclarationAlloc ConstructEach 202.07 µs / 418.79 KB 199.68 µs / 395.35 KB −1.2% time, −5.6% alloc
ClosureCallBenchmarks (5 rows) neutral (SloppyEmptyClosureCall settled by launchCount-5: 25.49 ±0.82 → 25.00 ±0.39 ms, allocations byte-identical)
SunSpider recursion rows (top-level functions — lane not exercised) neutral

Gates

  • Jint.Tests: 3,569 (net10.0) + 3,506 (net472) passed, 0 failed
  • Jint.Tests.PublicInterface: green both TFMs (descriptor surface exercised)
  • Jint.Tests.CommonScripts: green both TFMs
  • Test262: 99,429 passed, 0 failed (run twice)

🤖 Generated with Claude Code

@lahma
lahma enabled auto-merge (squash) July 14, 2026 07:16
@lahma
lahma force-pushed the perf-nested-fn-instantiation branch from 69c35be to aa59c18 Compare July 14, 2026 08:09
lahma and others added 5 commits July 14, 2026 11:24
Every instantiation of a script function (nested function declarations
re-instantiate on each call of their enclosing function) eagerly allocated
a JsString for the name, a name PropertyDescriptor, a length
LazyPropertyDescriptor and, via MakeConstructor, a prototype
LazyPropertyDescriptor. Descriptors cannot be shared across instances
(DefineOwnProperty mutates them in place), so instead a shared pending
sentinel now marks each property as existing-but-not-materialized and
GetOwnProperty swaps in the real descriptor on first read. The name
JsString is cached once per JintFunctionDefinition. Behavior (descriptor
shapes, key order, delete/redefine semantics) is unchanged; the sentinel's
value accessors throw so an unmaterialized leak fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A non-strict, non-arrow, non-generator, non-async ScriptFunction eagerly
allocated a ThrowerPropertyDescriptor, a caller PropertyDescriptor and the
property dictionary holding them on every instantiation. The two
restricted properties now live in dedicated ScriptFunction fields (the
same mechanism as name/length/prototype) initialized to the shared pending
sentinel and materialized on first read, so a plain sloppy function
instantiates with no dictionary and no descriptor allocations at all.
A deleted-then-redefined restricted property demotes to the dictionary,
preserving the previous end-of-key-order resurrection semantics; the
initial enumeration order (length, name, prototype, arguments, caller)
is unchanged. MakeMethod now removes the pair via the cached JsStrings
instead of allocating fresh ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FunctionDeclarationInstantiation re-runs its function-hoisting loop on
every call of a declaration-bearing function. The hoisted declarations now
live in a plain array of (declaration, Key) pairs on the shared State
instead of a LinkedList, so the per-call loop stops chasing list nodes and
stops re-hashing each function name into a Key; the Function intrinsic is
resolved once per call instead of per declaration. InstantiateFunctionObject
also skips the SetFunctionName call for named declarations — the function's
(pending) own name was already established at construction, so the call was
a guaranteed early-return that still allocated a JsString for the name
argument on every instantiation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The no-fixed-slots var-initialization loop in FunctionDeclarationInstantiation
inserted bindings one CreateMutableBindingAndInitialize at a time, cutting the
hybrid dictionary's list over to hash storage mid-loop on every call of a
declaration-heavy function (such environments escape and are rebuilt per call).
EnsureCapacity once up front, mirroring the lexical-declarations path below.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The newTarget != this arm resolves the prototype through the newTarget
instead, so a pending descriptor can stay pending there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma force-pushed the perf-nested-fn-instantiation branch from aa59c18 to 7567550 Compare July 14, 2026 08:24
@lahma
lahma merged commit b7fe37f into sebastienros:main Jul 14, 2026
4 checks passed
@lahma
lahma deleted the perf-nested-fn-instantiation branch July 14, 2026 08:35
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