Skip to content

A non-strict function called with a string receiver installs one own property per character — 38% of a claude-code render window, 16,000x node #9810

Closed

Description

@proggeramlug

Measured during the cc performance campaign on the combined candidate, and it is now the largest single item in the profile — larger than the collector, with 0.0 % of it inside a GC frame.

The cost

call_primitive_closure_value is 38.3 % of the sampled window and js_boxed_string_new 23.6 %. The mechanism is js_boxed_string_new → install_string_wrapper_indices: it installs one own property per code unit — a shape transition, a set_builtin_property_attrs entry (into descriptor_state, the table an allocation census independently measured at 284 MB/turn), and roughly three allocations, per character, per call.

Offline controls, 20,000 calls, perry vs node

case perry node
sloppy method, 200-char receiver 15,963–29,342 ms 0 ms
fn.call(200-char string) 16,886 ms 1 ms
fn.apply(...) 22,524 ms 4 ms
strict callee 10 ms, flat in length —
Number receiver 21 ms —
bound function 1 ms —
Object(str) 7,575 ms 1 ms

So the cost is specific to a non-strict callee receiving a string receiver, and it scales with the length of the string. Built-in String.prototype methods are native thunks and do not box, which is why this hides until an application calls its own sloppy-mode helper with a string this.

Why the work is unnecessary

The read path already computes index properties on demand (get_field_get/get_field_by_name_tail.rs:1326 → string_index_value). The eagerly installed ones exist only so reflection sees them (#3987), and that issue's own doc comment says "typical new String receivers are short".

Two fixes, each with a sibling already in-tree

  1. A USES_THIS body flag. perry-hir already computes closure_uses_this; a spare bit beside STRICT would let call_primitive_closure_value skip boxing entirely for a callee that never reads this. This removes the whole path for the common case rather than making it cheaper.
  2. Lazy index materialisation on the wrapper, so the properties exist when reflected upon and cost nothing otherwise — the read path already has the machinery.

Both are runtime/HIR changes; neither needs a codegen ABI change.

Suggested falsifier for whoever takes it

The offline control above is the direct one: fn.call(long string) must fall from ~17 s to the strict-callee figure (~10 ms, flat in length), and install_string_wrapper_indices must disappear from the sampled profile of a claude-code render window. If a fix moves the microbenchmark but not the window, the attribution above is wrong and should be re-derived before anything lands.

Activity

  1. proggeramlug commented on Sep 5, 2026

    @proggeramlug
    ContributorAuthor

    Boundary note, so two efforts do not collide or undo each other.

    PR #9800 (open, draft) already covers the built-in-callee half of this. ECMA-262 §10.3.1: a built-in function's [[Call]] does not run OrdinaryCallBindThis, and every thunk in primitive_proto_thunks already accepts the raw primitive before it looks for a wrapper payload — call_primitive_closure_value was boxing for them anyway. That PR is being measured now with an env gate for a same-binary A/B.

    This issue is the general case: any non-strict user function receiving a string receiver, where the two proposed fixes are a USES_THIS body flag (perry-hir already computes closure_uses_this; a spare bit beside STRICT) and lazy index materialisation on the wrapper. Both this and #9800 touch call_primitive_closure_value and the wrapper path, so whoever takes this should expect #9800 to land under them or alongside, and should read its diff first.

    Useful existing instrument: PR #9794 adds a [gc-primitive-dispatch] counter that made a sibling defect visible — String.prototype.codePointAt had a prototype thunk but no native dispatch arm, so text measurement fell into the boxing path 99,008 times per 400-character reply, and the counter going from 99,008 calls to never being emitted was the mechanism proof. The same counter is the natural way to prove a fix here rather than relying on the microbenchmark alone.

    Also worth carrying into any fix: the read path already computes index properties on demand (get_field_by_name_tail.rs:1326 → string_index_value), so the eagerly installed ones exist only for reflection (#3987), whose own doc comment says typical new String receivers are short.

    https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

  2. proggeramlug commented on Sep 5, 2026

    @proggeramlug
    ContributorAuthor

    There is already a runtime counter that gives this a mechanism proof on the real workload, not just the microbenchmark — worth knowing before anyone starts, because the falsifier above ("must disappear from the sampled profile of a render window") is otherwise only checkable with sample.

    What exists today. [gc-primitive-dispatch], in crates/perry-runtime/src/gc/diag_sites.rs, behind PERRY_GC_DIAG=1. It is the instrument that made the codePointAt fallback visible (99,008 boxing calls per 400-character reply, one method name). It ships in #9794, which is open and not draft, so it lands with that PR. Two lines are directly relevant here:

    • string_wrappers=<n> index_properties=<total> — incremented in install_string_wrapper_indices (builtins/formatting/boxed_primitives.rs), so it counts every wrapper actually materialised and the total number of own index properties installed, whatever the entry point (sloppy user callee, Function.prototype.call/apply, Object(str), explicit new String). That is exactly the population this issue is about, counted on the cc render window rather than a loop. A fix for A non-strict function called with a string receiver installs one own property per character — 38% of a claude-code render window, 16,000x node #9810 must drive index_properties to ~0 there; if it moves the microbenchmark and not this line, the attribution is wrong.
    • names=… calls=… receiver_chars=… plus a top-20 histogram keyed "<Builtin>.prototype.<method>" — the primitive-method fallback by method name, with the receiver's UTF-16 length summed per name. This is what says which names reach the fallback and how long their receivers are.

    The small change that would make it split by entry point. #9800's measurement branch (meas/toobject-ab, commit 6f1e58f5d, measurement-only and never part of a PR) adds builtin_callee_primitive_call(recv_chars) at the fork in call_primitive_closure_value, printed as builtin_callee_primitive_calls=<n> wrapper_index_properties_avoided=<chars>. It counts the built-in-callee half — #9800's population. The complement is ~10 lines in the same place: call_primitive_closure_value already has both strict_callee and builtin_callee in hand, so a second counter on the !strict_callee && !builtin_callee arm counts #9810's population (calls, and receiver chars = properties that will be installed) before any fix exists. That lets you state the expected delta in advance and check it after, on cc, per minor.

    Take it if it helps — I am not touching call_primitive_closure_value beyond #9800's built-in-callee fork, so there is no collision in adding it.

    Two usage notes. The counters are cumulative and reported at every minor collection and at exit; a harness that ends the process with SIGKILL loses the exit report, so read the last per-minor line instead. And PERRY_GC_DIAG=1 itself costs CPU, so counter runs must be separate from timing runs.

    Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

  3. proggeramlug commented on Sep 6, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by #9814, landed on main via merge train #9863. The train merged as its own branch, so the close-keyword never fired — closing manually.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions