Skip to content

fix(runtime): a built-in callee receives the primitive, not a ToObject wrapper - #9800

Draft
proggeramlug wants to merge 4 commits into
PerryTS:mainfrom
proggeramlug:perf/builtin-callee-no-toobject
Draft

fix(runtime): a built-in callee receives the primitive, not a ToObject wrapper#9800
proggeramlug wants to merge 4 commits into
PerryTS:mainfrom
proggeramlug:perf/builtin-callee-no-toobject

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Stacked on #9794 and #9795.

What

ECMA-262 §10.3.1: a built-in function's [[Call]] does not run
OrdinaryCallBindThis. It receives thisArg unchanged and performs whatever
coercion it needs itself — which every thunk in primitive_proto_thunks
already does, accepting the raw primitive before it looks for a wrapper
payload (string_receiver_or_throw, number_receiver_or_throw, …).

call_primitive_closure_value boxed for them anyway. For a string receiver
that ToObject wrapper materialises an own index property per UTF-16 code
unit, so a builtin with a String.prototype thunk but no native dispatch arm
costs O(receiver length) allocations per call.

#9795 fixed exactly one such name. This closes the class: only a sloppy user
callee
is owed the wrapper now, which is the distinction the spec draws.

Why it is worth landing even though the rig shows no delta

After #9795, [gc-primitive-dispatch] reports zero trips through the
fallback on the claude-code TUI — the native tower answers every method name
that workload calls, so this change removes nothing there and the rig table
is expected to be flat within noise. Its value is that the next missing arm
costs a lookup instead of a wrapper per character, and that the boxing was a
spec divergence in its own right (String.prototype.at.call("abc", 0) and
friends must observe the primitive, not a wrapper).

I would rather land it on the spec argument plus the class-of-bug argument than
claim a number it does not produce.

Test

builtin_prototype_method_closures_are_distinguishable_from_ordinary_ones pins
the discriminator in both directions — a String.prototype method closure
reads as a built-in, a closure the runtime merely allocated does not. Without
the negative case the predicate could be "always true" and still pass, which
would silently strip the wrapper from every sloppy user callee.

Behavioural coverage for the sloppy-user-callee half needs real JS closures and
a populated globalThis, which the runtime unit-test thread does not have; it
comes from the extended JS gates (label applied).

cargo test -p perry-runtime --release -- --test-threads=1: 3156 passed,
0 failed.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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

Draft on purpose: the candidate carrying it is still building. It will be marked ready once the rig table for a binary with #9794 + #9795 + this change is posted here and confirms no regression (a flat CPU/footprint delta is the expected and acceptable outcome — see the PR body for why).

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Re-measured on current main. Candidate cc_gc4 = rebased stack #9794 + #9795 + #9800 (full compile, object cache missed because main's HIR moved); reference cc_base_new; node arm same session; every run under measure_lock.sh.

| arm | 400 cpu s | 400 idle12 cpu s | 3300 cpu s | 3300 idle12 cpu s | typing cpu r2 | echo p90 ms | turn r2 cpu s | FP settled 400 MB | FP end-turn 400 MB | peak RSS 400 MB | FP settled 3300 MB | peak RSS 3300 MB | startup s |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| gc4 | 6.74/5.65/5.77 | 2.34 | 19.13 | 6.93 | 0.8 | 48 | 1.26 | 342/375/358 | 396 | 527.53125 | 454 | 583.3125 | 3.47 |
| node | 0.32/0.3/0.24 | 0.01 | 0.55 | 0.01 | 0.11 | 2 | 0.07 | 169/331/175 | 169 | 364.875 | 220 | 412.546875 | 1.92 |
| basenew | 7.67/7.0/6.75 | 6.51 | 79.3 | 11.7 | 1.18 | 24 | 0.87 | 612/570/554 | 501 | 637.140625 | 844 | 1297.625 | 1.78 |

Primitive-method fallback counter on the candidate:

(no [gc-primitive-dispatch] line emitted: nothing reached the primitive-method fallback)

Allocation-site categories, streamed turn:


=== d_gc4/turn.diag  sampled total 195 MB (top-30 sites cover 93 MB = 48%)
  by-type MB: {'array': 71, 'string': 64, 'object': 36, 'closure': 8, 'object_meta': 3, 'set': 0, 'promise': 0, 'error': 0, 'map': 0}
      31.9 MB  34.4% of covered  proxy/ordinary property set (keys+slots arrays)
      22.3 MB  24.1% of covered  other
      14.4 MB  15.5% of covered  iterator result objects
       8.8 MB   9.5% of covered  string concat
       4.4 MB   4.7% of covered  regex construction
       3.7 MB   4.0% of covered  for-in key arrays
       3.7 MB   4.0% of covered  property set: keys array clone/grow
       3.4 MB   3.7% of covered  to_string / native method call

…l origins, allocation-site sampling

Three instruments for the cc-perf campaign, all inert unless asked for.

`PERRY_GC_DIAG=1` gains the lines that say WHY the collector ran:
`[gc-trigger]` (every predicate input at each decision site), `[gc-full]`
(the arm behind each synchronous full mark-sweep, counted per site),
`[gc-budgeted] start/done` (steps, per-phase step time, root-scan share),
`[gc-charge]` (mutator-assist / synchronous-full time per calling site,
resolved to JS display names) and `[gc-survival]` (per copying minor, the
root that first reached each surviving byte — shadow stack, native stack
map, named scanner, remembered set by old-parent type — with transitive
reach charged to the originating root through a parallel worklist origin
vector).

`PERRY_ALLOC_SITE_SAMPLE=<bytes>` samples the arena allocation sites byte-
proportionally across the runtime allocators AND the codegen inline bump
path (the mirrored inline block limit is capped at one interval while
sampling, so the fast path returns to the runtime once per interval).

The survival test is sabotage-checked: disabling the drain propagation
charges the 40 elements to `worklist_drain` and the test fails on that row.
The knob's OFF state and magnitude parse are pinned next to the other GC
knobs. `gc_diag_enabled()` gets the per-thread test override the census
already has, so the diag paths are testable without touching the process
environment.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
@proggeramlug
proggeramlug force-pushed the perf/builtin-callee-no-toobject branch from 0857acc to 99f2e38 Compare September 5, 2026 15:09
@proggeramlug

Copy link
Copy Markdown
Contributor Author

The A/B this PR never had: same binary, one branch, both arms

/tmp/cc_gc5 carries this change behind PERRY_BUILTIN_NO_TOOBJECT, so both
arms are the SAME binary and differ by nothing but the builtin_callee term in
call_primitive_closure_value. Reference cc_base_new (main 1d63fa91f, the
commit this stack was based on) and the node arm ran in the same session, all
runs interleaved under one measure_lock hold, every run load-stamped (11–22
for every row).

400-character streamed reply, three repeats per arm

arm turn CPU (s) CPU in next 12 s peak RSS (MB) settled footprint (MB)
base 7.20 / 6.50 / 6.73 4.81 / 4.92 / 4.46 633 / 651 / 571 556 / 473 / 390
gate 0 5.36 / 5.64 / 5.28 1.91 / 3.69 / 3.62 540 / 536 / 541 358 / 376 / 377
gate 1 5.31 / 5.35 / 5.36 3.62 / 3.00 / 1.98 543 / 541 / 535 379 / 376 / 357
node 0.29 / 0.28 0.01 370 / 373 328 / 330

3300-character streamed reply, two repeats per arm

arm turn CPU (s) CPU in next 12 s peak RSS (MB) settled footprint (MB)
base 54.06 / 39.10 11.59 / 11.75 1000 / 1294 847 / 833
gate 0 17.38 / 17.60 11.78 / 11.94 575 / 564 430 / 421
gate 1 16.38 / 17.44 11.88 / 11.86 585 / 586 454 / 466
node 0.43 0.01 402 214

Typing + short turn (timed_turn, n=1 per arm)

arm startup (s) typing CPU r2 turn CPU r2 echo p90 r2 (ms) r3 turn CPU idle 10 s CPU
base 2.21 1.53 0.94 33 56.49 5.91
gate 0 2.17 0.80 1.00 23 20.24 1.02
gate 1 2.14 0.92 1.67 29 17.66 0.77
node 1.30 0.09 0.05 2 0.18 0.01

Verdict: flat, exactly as the PR body predicted. Every gate-0/gate-1 pair
is inside its own arm's spread, in both directions and on both metrics.

Why it is flat, proved rather than assumed

PERRY_GC_DIAG=1 on a 400-character reply, both arms:

[gc-primitive-dispatch] minor: iter_result_objects=167946
(no builtin_callee_primitive_calls line — never emitted)
(no string_wrappers line — never emitted)
(no primitive-method fallback histogram — never emitted)

The counter this branch carries increments at the fork itself, before the
gate, so it counts the population the change converts regardless of which arm
is running. It is never emitted, so zero calls reach the built-in-callee fork
during a claude-code reply
and no String wrapper is minted at all. The
change therefore removes nothing on this workload and cannot regress it — which
also settles the one row that reads worse (3300 settled footprint 454/466
against 430/421): the two arms executed the same code, so that difference is
run-to-run variance in a metric the campaign documents as bimodal, not an effect
of this diff.

Behaviour, on a workload that DOES reach the fork

Two offline probes compiled with the same binary and run in both arms plus node.

Probe A, 51 cases — reflection and coercion around boxed strings
(hasOwnProperty, propertyIsEnumerable, Object.keys, getOwnPropertyNames,
spread, in, Object.prototype.toString.call, Object(str)[i],
getOwnPropertyDescriptor, sloppy/strict user callees, writes through this,
fn.call/fn.apply with a string thisArg, Number/Boolean/BigInt/Symbol
receivers): byte-identical between the two arms, and byte-identical to node.

Probe B, 46 cases, chosen to FORCE the fork — direct calls to the built-ins
least likely to have a native dispatch arm (anchor, big, blink, bold,
fixed, fontcolor, fontsize, italics, link, small, strike, sub,
sup, toWellFormed, isWellFormed, localeCompare, normalize,
toLocale{Upper,Lower}Case, the Object.prototype inheritors, and the same
set again on a 200-character receiver): byte-identical between the arms, and
identical to node on 45 of 46. The one differing line is identical in BOTH arms
and is a separate pre-existing defect (a dynamically-keyed property read on a
primitive — const k = "charAt"; typeof "abc"[k] is "undefined" in perry and
"function" in node, while "abc"[k](1) returns "b"); it is filed
separately and is not this change.

Probe B also moves the counters the way the mechanism predicts:

gate 0:  string_wrappers=4  index_properties=17   builtin_callee_primitive_calls=3
gate 1:  string_wrappers=3  index_properties=12   builtin_callee_primitive_calls=3

Three calls reach the fork; one of them has a five-character string receiver;
with the change on, that wrapper and its five own index properties are gone and
nothing else moves. That is the smallest possible complete demonstration: same
population counted in both arms, one wrapper removed, output unchanged.

Status

The metric condition is met and the mechanism is proved, so the reason this is
still a draft is now only the stack: it sits on #9794 and #9795, which I have
just rebased onto current main (c7361c87c) after they went CONFLICTING.
I will take it out of draft as soon as the rebased stack's checks are green
there is nothing left to measure on it.

Provenance: cc_gc5 is a runtime-only branch on 1d63fa91f, so it links the
same emitted-JS object as cc_base_new; the codegen id derived from
perry-codegen + perry-hir is identical (22c732a67c78a2ac) for both.

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

Ralph Küpper added 3 commits September 5, 2026 18:13
…iptors on the primitive-string path

Three allocations a JS program can never observe, found with the
allocation-site sampler (`PERRY_ALLOC_SITE_SAMPLE`) on the compiled
claude-code TUI, where they are the largest attributed source of garbage in
both the streaming turn and the render pass that follows it.

1. A one-ASCII-character string is now the canonical per-thread header.
   `js_string_char_at` minted a fresh 32-byte heap string per character read,
   and everything that walks a string a character at a time goes through it:
   `s[i]`, `charAt`, string spread, the String-wrapper index installer. There
   are 128 possible contents. The table has the same residency contract as the
   small-integer string table next to it (longlived arena, `refcount = 0` so it
   is never mutated in place, pinned out of the young generation) and rides
   that table's existing root scanner rather than registering a 96th one.

2. Runtime-internal constant property names resolve through the intern table.
   The `globalThis` builtin lookup, `x.constructor`, `toString` resolution and
   primitive-method dispatch each built a fresh heap string for a literal name
   on every call; `js_get_global_this_builtin_value` alone accounted for 133 MB
   of the 990 MB one 3300-character reply allocates. `string::canonical_key`
   routes them through the content-keyed per-thread table that
   `js_string_materialize_to_heap` already uses, which is also what the
   property read/write fast paths require of a key.

3. A `String` wrapper no longer stores a property descriptor per character.
   ECMA-262 §10.4.3 gives every in-range index of a String exotic object
   `{ writable: false, enumerable: true, configurable: false }` — a fact of the
   class and the boxed length, not per-object state — so `get_property_attrs`
   answers it from the wrapper's payload. Storing it cost, per boxed character,
   a Rust `String`, a `PROPERTY_DESCRIPTORS` entry only a full collection's
   dead-owner prune could reclaim, an owner-index entry, and one program-wide
   `prop_plan_epoch_bump()`. A sloppy method call on a string primitive boxes
   its receiver, so the TUI paid all of it per rendered line. A real stored
   descriptor still wins, so `Object.freeze`/`defineProperty` on a wrapper are
   unchanged.

`PERRY_GC_DIAG=1` also gains `[gc-primitive-dispatch]`: which
`<Builtin>.prototype.<method>` names reach the primitive-method fallback, how
often, and how many wrapper index properties they cost — the counter that says
whether a boxing fix ran.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
`codePointAt` had a `String.prototype` thunk but no arm in the native
string-method dispatch, so every call fell through to
`call_primitive_builtin_prototype_method`: resolve
`globalThis.String.prototype.codePointAt`, clone that closure to rebind `this`,
and — the thunk not being registered strict — run `ToObject` on the receiver,
minting a `String` wrapper whose own index properties are one per UTF-16 code
unit.

The new `[gc-primitive-dispatch]` counter says how much that cost: on the
compiled claude-code TUI, `codePointAt` is the ONLY method name that reaches
the fallback at all, and it reaches it 99,008 times per 400-character streamed
reply — 99,008 `globalThis` lookups, 99,008 closure clones and 99,008 String
wrappers, because grapheme-aware text measurement calls it once per character.

The arm is the sibling of `charCodeAt` one line above it and reads the receiver
the same way. The test asserts the WRAPPER COUNT rather than the return value:
the fallback computes the same number, so an answer-only test would pass with
the arm deleted. A positive control pins that the counter can move.
…t wrapper

ECMA-262 §10.3.1: a BUILT-IN function's [[Call]] does not run
OrdinaryCallBindThis. It receives `thisArg` unchanged and performs whatever
coercion it needs itself — which every thunk in `primitive_proto_thunks`
already does, accepting the raw primitive BEFORE it looks for a wrapper
payload (`string_receiver_or_throw`, `number_receiver_or_throw`, ...).

`call_primitive_closure_value` boxed for them anyway, and for a string receiver
the `ToObject` wrapper materialises an own index property per UTF-16 code unit.
The `codePointAt` arm (PerryTS#9761) showed what one method name reaching this path
costs: 99,008 wrappers per 400-character claude-code reply. This closes the
class rather than the instance — the next builtin with a prototype thunk but no
native dispatch arm costs a lookup, not a wrapper per character.

Only a sloppy USER callee still gets the wrapper. `builtin_closure_length` is
the registry that separates the two, and the test pins both directions: a
`String.prototype` method closure reads as a built-in, a closure the runtime
merely allocated does not — without the negative case the predicate could be
"always true" and still pass.
@proggeramlug
proggeramlug force-pushed the perf/builtin-callee-no-toobject branch from a88f81a to 32f150b Compare September 5, 2026 16:14
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Changelog fragment renamed to carry this PR's number, after checking the gate's
source rather than the symptom. scripts/check_changeset_fragment.sh is
stricter and looser than "the name must be <PR>-<slug>" in two ways worth
recording, because they change what a red lint column means:

  • The hard failure is no added fragment matching ^changelog\.d/[0-9]+-[^/]+\.md$
    at all. A fragment without a numeric prefix — which is what these branches had
    — does not match, so the job reports "adds no changelog.d fragment" even
    though a fragment is right there in the diff. That is the failure mode to look
    for, and it reads nothing like a naming problem.
  • A fragment with the wrong number is only a ::warning:: and passes, by
    design: the script's own comment explains that a strict rule would block
    backfills and stacked PRs, which is exactly the shape this stack has.
  • 0000- is a separate hard failure, and an edited (rather than added)
    fragment does not satisfy the gate at all.

Verified the rename against the gate directly (changeset_verdict on this PR's
file list returns 0 with no warning) and ran --self-test, which passes.

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

@jdalton jdalton left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review of 32f150b22349f9da6886f7180ec13da0a7e4d671 (2026-09-07).

Blocking rooting bug in the added codePointAt arm: js_string_code_point_at(s_ptr, arg_i32(0)) keeps the pre-coercion raw receiver pointer while arg_i32 can execute user valueOf and trigger moving GC. This function already has s_handle/receiver_string() and the adjacent slice arm explicitly coerces first and re-fetches the receiver for this reason. Compute the index first, then call the helper with receiver_string(). Add a dynamically dispatched, heap-string codePointAt({valueOf(){ /* allocating/collecting work */ return 1; }}) regression under forced evacuation and assert the coercion runs once. The new arm must not inherit the adjacent older raw-pointer shape.

Validation scope: source/diff inspection; I have not run this PR's build or test suite locally.

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

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants