Skip to content

perf(codegen): collapse the class-field GET tower to one exit - #10189

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:size/t3-class-field-get-one-exit
Closed

proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:size/t3-class-field-get-one-exit

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Problem

The monomorphic class-field GET tower (property_get.rs, class_field_get.*) emits per site the #5093 inline precheck plus a guard call, a guard-pass branch, a nullish diamond with its own js_throw_type_error_property_access block, and one or two js_object_get_field_by_name_f64 fallbacks: ~88 pre-RS4GC IR instructions, 3 runtime call sites, 7 blocks, and 6 RS4GC relocation reloads per site. In @babel/parser that is 13,636 sites, 27 % of the module's IR; every call is a statepoint, so the code and .perry_gcmap scale with call sites × live GC values.

Change

Keep the inline precheck and the class_field_get.fast slot load (boxed and raw-f64 variants, with the raw-f64 bookkeeping) byte-for-byte; replace the guard call / fallback / nullish throw / by-name lookup arms with one call to the existing js_class_field_get_ic, which runs the same guard, does the fast load on a pass, records the fallback, throws on nullish and falls back to by-name — the contract the full-outline path already used. The key-handle load sinks from the entry block into the cold miss block (its only remaining consumer). class_field_inline_guard.rs is untouched; the runtime crate's non-test code is byte-identical to base.

Per site: 88 → 47 IR instructions, 3 → 1 runtime calls, 7 → 4 blocks, 6 → 1 relocation reloads. One unplanned hot-path win: class_field_get.fast used to be reachable from the guard call, i.e. across a statepoint, so RS4GC put two relocation reloads (14 instructions) on the hit path; it is now reachable only from the precheck and carries none.

raw_f64.rs gains ("ClassFieldGet", "js_class_field_get_ic") in the dynamic-fallback record set. Tests that used the retired symbols as the witness for "a guarded class-field read was emitted here" now accept either witness; the three negative assertions ("must not rebuild the field IC diamond") gained the new symbol so they cannot pass green on a still-guarded body. New: class_field_get_shape_tests (one exit, boxed and raw-f64, IC operands) and four runtime tests for js_class_field_get_ic (boxed slot, raw-f64 slot, fallback + by-name, nullish throw).

Out of scope, deliberately: the class-field SET tower (T4); class_field_get.shape_proven_load and the loop-preheader raw-f64 load (bare loads, nothing to collapse); the number-context tower in property_get/helpers.rs (its fallback pipes through js_number_coerce, a semantics decision).

Evidence (x86-64, perrymaster, base = b5a82cf)

Size, perry compile --platform bun --no-link with the corpus flags:

specimen .text before after Δ .perry_gcmap before → after
@babel/parser 17,640,473 16,389,647 −7.09 % 809,047 → 792,749 (−2.0 %)
prettier/plugins/flow.mjs 38,113,543 38,102,068 −0.03 % 1,545,587 → 1,544,935
@babel/types validators/generated 22,453,450 22,453,450 0 (no class-field reads) unchanged

Runtime A/B, 16 workloads, 5 interleaved rounds, walltime + peak RSS + instructions retired: instructions geomean 0.9997× (largest increase +0.02 %, at the ±0.02 % noise floor of a base-vs-base control); peak RSS between −2.0 % and +0.2 % (floor ±0.6 %); walltime geomean +0.6 % with interp.ts (class-heavy, ~4 s) at −13.5 %; program outputs identical on every workload (the bench_* programs print their own timings; all checksums match).

Cache decisions unchanged: PERRY_TYPED_FEEDBACK_TRACE on interp.ts shows 273 sites with identical per-site guard-pass / guard-fail / fallback counters (817,155,616 / 202,359,184 / 202,359,184) in both arms.

Gap-suite subset (class, field, this, method, getter, private, static, inherit): 14 pre-existing failures identical in both arms, zero new; one timeout flake reproduced on the base arm too.

Tests

cargo test --release -p perry-codegen: lib 1489 passed / 0 failed / 1 ignored, 25 integration binaries green. RUST_TEST_THREADS=1 cargo test --release -p perry-runtime: 3681 passed, 1 failed — native_stack::tests::stack_top_respects_custom_thread_stack_sizes, which fails identically on a pristine build of base on that Linux host (pre-existing; CI runs the suite on macOS). cargo fmt --all -- --check, scripts/check_file_size.sh, scripts/check_test_registration.py: clean.

Ralph Küpper added 2 commits September 13, 2026 13:16
The monomorphic `this.field` / `obj.field` tower in expr/property_get.rs emitted
four runtime call sites per site -- `js_typed_feedback_class_field_get_guard`
when the PerryTS#5093 inline pre-check missed, `js_throw_type_error_property_access`
for a nullish receiver, and one or two `js_object_get_field_by_name_f64` by-name
lookups -- spread over
class_field_get.{fast,fallback,merge,throw_nullish,fallback_lookup}. In
@babel/parser that is 13,636 sites and 27 % of the module's IR, and because
every call is a statepoint the .perry_gcmap scaled with it too.

Keep the pre-check and the fast slot load byte-identical, and replace the four
miss arms with ONE call to `js_class_field_get_ic` -- the runtime helper the
PerryTS#5391 path-2 full outline a few lines above already calls. It runs the same
guard, reads the same slot at the same header-relative offset on a pass, throws
the same nullish TypeError (PerryTS#7153 put that check there for exactly this
equivalence) and falls back to `js_object_get_field_by_name_f64`, recording the
fallback itself under the same `typed_feedback_enabled()` gate codegen used.

A plain number is self-boxing under NaN-boxing, so a `requires_raw_f64` site
reads the helper's return exactly as the old phi read the by-name fallback's --
neither arm converted. The key-handle load/bitcast/mask also sinks into the cold
miss block, its only remaining consumer.

Measured (fixture with one boxed field, two raw-f64 fields, a read on a bare
`Named` parameter, reads on `this`, and a raw-f64 read in a loop):

  * per site: 88 -> 47 IR instructions, 3 runtime calls -> 1, 7 -> 4 blocks;
  * the `class_field_inline.deref` pre-check is instruction-for-instruction
    identical (SSA numbering shifts by 3 because the entry block lost the sunk
    key load);
  * the `class_field_get.fast` block LOSES its two RS4GC relocation reloads (14
    instructions, 2 `call i64 asm "gc-leaf-function"`): it used to be reachable
    from the guard call, i.e. across a statepoint, and is now reachable only
    from the pre-check, which crosses none. The inline hit path is strictly
    shorter than before;
  * @babel/parser .text 17,640,473 -> 16,389,647 (-7.09 %), .perry_gcmap
    809,047 -> 792,749 (-2.01 %), O0-fallback units unchanged.

Runtime behaviour is unchanged: on benchmarks/tls-budget/interp.ts the
typed-feedback trace reports the same 273 sites with identical guard_passes
(817,155,616), guard_failures (202,359,184) and fallback_calls (202,359,184),
zero per-site differences, and identical program output.

The raw-f64 site's dynamic-fallback native-value record now names
`js_class_field_get_ic`; that pair is ADDED to verify/raw_f64.rs rather than
replacing the by-name pair, which verify/tests.rs still pins.

Tests: a new cargo-test-visible codegen module pins the one-exit shape (the
pre-check branches INTO the fast load, the miss arm is exactly one call, the
three retired blocks are gone, and the three retired symbols are absent as CALL
FORMS -- a bare substring is satisfied by the `declare` line alone). Four
runtime tests cover `js_class_field_get_ic`'s arms, which had none. Five
existing assertions that used the retired symbols as the proxy for "a guarded
class-field read was emitted" are re-pointed: the positives accept either
witness (other lowerings still call the old symbols), and the negatives gain the
IC as an excluded symbol so they cannot pass on a still-guarded body.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 pushed a commit that referenced this pull request Sep 13, 2026
(cherry picked from commit eb4bee5)
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10211 (rebase-merged; main 64f5249ac0, tree identical to the train), cherry-picked onto 26ed55cb74 with the version bump to 0.5.1557. Validation (including the full gap suite) and CI attribution are in #10211.

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