From e81a6bdb9b694456d241c4f06da4d96334b199a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 10:04:31 +0200 Subject: [PATCH 1/4] perf(codegen,runtime): polymorphic property-read cache + `arr.length` short-circuit (#7753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tree-walking interpreter ran 12.3x Node — three times worse than any synthetic benchmark in the corpus, and the only program in it that resembles real software. GC was 1% of the run. The cause was that the per-site property cache holds exactly ONE entry, so a receiver with more than one shape misses on essentially every read and pays a full re-derivation of the receiver kind plus a linear keys scan. Two changes, one root cause: * `@perry_ic_N` grows from `[8 x i64]` to `[12 x i64]` — the MRU entry keeps its exact prior meaning, followed by four `(token, slot)` ways. The miss handler cascades the shape it evicts into a way instead of discarding it, and the emitted way compares sit inside the miss block above the call, so a monomorphic site's instruction sequence is unchanged. Ways accept keys-POINTER tokens (an ID-token-only way set never fills for object literals, and shipped that way it was a 6% REGRESSION), so they inherit #6080a and share word 2's epoch snapshot: a new epoch wipes every way and drops the evicted token. * The inline cache cannot cache `arr.length` by construction (#72 requires a GC_TYPE_OBJECT receiver), so every dynamic `.length` misses permanently and then walks an object ladder. On a variable lookup written `for (i = 0; i < names.length; i++)` that one read was 22% of total run time. The miss handler now answers it directly for a GC_TYPE_ARRAY receiver, via the same expression the by-name array arm already computes. interp.ts 3.96s -> 2.39s on the quiet M1 mini; all twelve protected benchmark floors hold; outputs byte-identical to node 26.5.1. Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- .../7753-polymorphic-property-read-cache.md | 153 +++++++ crates/perry-codegen/src/codegen/closure.rs | 5 +- crates/perry-codegen/src/codegen/entry.rs | 10 +- crates/perry-codegen/src/codegen/function.rs | 5 +- crates/perry-codegen/src/codegen/method.rs | 10 +- crates/perry-codegen/src/expr/mod.rs | 2 +- crates/perry-codegen/src/expr/property_get.rs | 2 +- .../src/expr/property_get/generic_dispatch.rs | 110 ++++- .../src/expr/property_get/tests.rs | 69 +++ .../src/node_submodules/tests.rs | 2 +- .../perry-runtime/src/object/field_get_set.rs | 4 +- .../src/object/field_get_set/ic_miss.rs | 422 +++++++++++++++++- .../perry-runtime/src/value/dynamic_object.rs | 2 +- 13 files changed, 761 insertions(+), 35 deletions(-) create mode 100644 changelog.d/7753-polymorphic-property-read-cache.md diff --git a/changelog.d/7753-polymorphic-property-read-cache.md b/changelog.d/7753-polymorphic-property-read-cache.md new file mode 100644 index 0000000000..a22105788f --- /dev/null +++ b/changelog.d/7753-polymorphic-property-read-cache.md @@ -0,0 +1,153 @@ +### perf(codegen, runtime): property reads resolve inline for polymorphic receivers, and `arr.length` stops walking the object ladder (#7753) + +A tree-walking interpreter (`gc-handoff/apps/interp.ts` — lexer, precedence-climbing +parser, recursive `let`, closures over environments; 189 statements, no dynamic +remainder) ran **3.96 s** against Node 26.5.1's 0.32 s. That is 12.3× Node, three +times worse than any synthetic benchmark in the corpus (all now 2.3–4.0×) and 2.3× +worse than scriptc. It is also the only program in the corpus that resembles real +software. It is now **2.39 s**, a 1.66× speedup, from two changes with a single root +cause: *Perry could not cache a property read whose receiver had more than one shape.* + +GC is not involved — 0.04 s of pause across the whole run, 38 minors, zero fulls. + +#### What the cost actually was + +Cutting the program down (`gc-handoff/bench/b_fib.ts`, FIB only) isolated **3.88 s of +the 3.96 s** into one interpreted program, and a symbolicated profile put **34% of it +in `js_object_get_field_ic_miss`** and the ladder it calls. The reason is structural: +the per-site cache is a **single entry**. `evalNode` dispatches on +`n.kind === "num" | "str" | "var" | "bin" | "if" | …`, so the receiver at those sites +cycles through five shapes and the one entry is wrong on essentially every read. Each +miss then re-derives the receiver kind from scratch — proxy band, closure magic, the +registered-buffer and typed-array registries (both behind thread-locals), the +accessors-in-use latch — and finishes with a linear scan of the keys array doing a +`js_string_equals` per key. + +This also explains the profile lines that made no sense on their face: +`typedarray::lookup_typed_array_kind` at 3.4% and `buffer::header::is_registered_buffer` +at 1.9% **in a program that uses neither typed arrays nor Buffers**. They were not a +separate problem; they were inside the miss handler. + +Three hypotheses were tested and refuted before this one, and the refutations are +worth keeping because each looked obviously right: + +* **String-keyed union tags are slow.** `js_jsvalue_equals` + `from_utf8` + `memcmp` + + `js_string_equals` came to 23% of the profile. Refuted: `bench/tag_str.ts` vs + `bench/tag_num.ts` are both 0.06 s. Converting the whole interpreter to numeric tags + (`bench/a_numkind.ts`) moved 3.88 s → 3.71 s — 4%. Most of that 23% was the *keys + scan inside the miss handler*, not the user's `===`. +* **Runtime-built heap strings compare slowly.** Refuted: `bench/streq_sub.ts` is 0.05 s + vs Node's 0.07. Interning every identifier at lex time (`bench/a_intern.ts`) moved + 3.88 s → 3.87 s, and numeric opcodes (`bench/a_numop.ts`) → 3.89 s. Neither is + measurable. +* **A megamorphic cliff at some shape count.** Refuted by a clean sweep holding the + loop body and array size fixed (`bench/meg{2..12}.ts`): a flat 3.0–3.7× at every + arity. Read as "no cliff, so not the cause" this is misleading — flat-and-already-3× + from **two** shapes onward is precisely the signature of a one-entry cache that + misses from the second shape on. The arity sweep could never show a cliff because + there is nothing to fall off. + +The discriminating probe was `bench/a_flatnode.ts`: the same interpreter with every AST +node built from one object shape, so every `n.*` read is monomorphic — recursion depth, +allocation count, string traffic and the environment chain all held constant. 3.88 s → +2.84 s. That put a number on the polymorphism itself and is what this change went after. + +#### Change 1 — the read cache gets polymorphic ways + +`@perry_ic_N` widens from `[8 x i64]` to `[12 x i64]`: the MRU entry `[token, slot, +epoch]` keeps its exact pre-existing meaning, followed by four `(token, slot)` ways and +a victim counter. On a miss the handler no longer discards the shape it evicts — it +cascades it into a way, so a site alternating between up to five shapes ends up with all +five resolvable inline. + +The emitted way compares live **inside the miss block, below the feedback records and +above the call**. A monomorphic site therefore executes the identical instruction +sequence it did before; the new work is reached only by a site that was already going to +call the runtime. Typed-feedback counters are recorded before the compares, so a way hit +still reports guard-fail + fallback-call exactly as it did when it was a real miss — the +heuristics see an unchanged signal (the site *is* polymorphic; only its cost changed). + +Two things had to be got right, and both were found by a test rather than by reasoning: + +* **The MRU token must be evicted from the ways when it is promoted.** Otherwise a way + permanently duplicates word 0 and a k-shape rotation only ever caches k−1 shapes. + `alternating_shapes_all_become_inline_resolvable` caught this. +* **The ways must accept keys-POINTER tokens, not only shape-ID tokens.** ID tokens + (#6804) need no epoch validation, which makes an ID-only way set look like the safe + design. It is also useless: a plain object literal is built through a generated + `__AnonShape_*` constructor and so carries a real `class_id`, which routes it to the + shape-shared keys-pointer prime. Shipped that way it was a **6% regression** — the + compare sequence running on every miss and never once hitting. `pointer_tokens_do_reach_a_way` + is the test that fails if it is narrowed again. + +Admitting pointer tokens means the ways inherit #6080a: a keys-array address freed by a +collection can be recycled under a different shape, and a stale way would pointer-match +and load the wrong slot **silently**. The ways therefore share word 2's epoch snapshot +with the MRU entry — the emitted way predicate requires `cache[2] == @PERRY_IC_EPOCH`, +and `pic_prime_get` **wipes every way whenever it writes a new epoch**, dropping the +evicted token as well (it too was resolved in the old epoch). A readable way is thus +always one primed in the epoch word 2 still holds. The ways go cold once per collection +— 38 of them across a 4 s run — and re-prime. `an_epoch_change_wipes_every_way` asserts +both halves. + +`interp.ts` 3.96 s → **3.01 s**. `js_object_get_field_ic_miss` fell from 9.0% of the +profile to 1.0%. + +#### Change 2 — `arr.length` short-circuits in the miss handler + +With `evalNode` fixed, the *entire* remaining miss cost moved to one place: 1143 of 5241 +leaf samples, all from `lookup`, and none of it a polymorphic object read. It was +`names.length`. + +The inline cache requires a `GC_TYPE_OBJECT` receiver by construction (#72 — so an +Array's `element[1]` is never mistaken for `keys_array`), which means **every** dynamic +`.length` misses, permanently, by design. It then walked a ladder built for objects: a +closure-magic deref, two registry probes behind thread-locals, then +`js_object_get_field_by_name`'s own dispatch, which repeats the registry probes before +finally reaching the array arm. For a variable lookup written the ordinary way — +`for (i = 0; i < names.length; i++)` — that single read was **22% of total run time**, +more than the polymorphic fix above had saved. + +`js_object_get_field_ic_miss` now answers it directly when the receiver's `GcHeader` +says `GC_TYPE_ARRAY`. That type is a genuine dense array: buffers, typed arrays, lazy +arrays, Sets and Maps all carry distinct `obj_type`s, and a `class X extends Array` +instance is an `ObjectHeader`. `js_array_length` still resolves growth-forwarding stubs, +proxies and subclass receivers, and the returned expression is the one +`get_field_by_name_object_tail`'s array arm already computes for this key — so this is a +short-circuit, not a second implementation. +`array_length_short_circuit_agrees_with_the_full_ladder` pins that by comparing against +`js_object_get_field_by_name_f64` for empty, small and grown arrays, for a same-length +key that is *not* `length`, and for `length` on a plain object. + +`interp.ts` 3.01 s → **2.39 s**. + +#### Measurements (quiet M1 mini, best-of-5, absolute seconds, outputs byte-identical to `node --experimental-strip-types` before timing) + +| program | before | after | | +|---|--:|--:|---| +| `interp.ts` | 3.96 | **2.39** | 12.3× → 7.4× Node | +| `b_fib.ts` (reduced case) | 3.88 | **2.32** | | + +Protected floors, all held, each also A/B'd against the same-host v0.5.1434 build: + +| | churn | churn_alloc | push_cls | push_num | churn_read | cycles | deeplist | tree | tree_wide | retain | retain_wide | fib40 | +|---|--|--|--|--|--|--|--|--|--|--|--|--| +| after | 0.42 | 0.36 | 0.35 | 0.13 | 0.02 | 0.19 | 0.24 | 1.65 | 2.11 | 0.53 | 1.08 | 0.39 | +| floor | 0.42 | 0.38 | 0.36 | 0.15 | 0.03 | 0.20 | 0.26 | 1.67 | 2.15 | 0.56 | 1.12 | 0.41 | + +`gc-handoff/apps/iso_miss.ts` prints `checksum 437840 misses 0` — gated on the miss +counter, not the aggregate, because a perf change has previously made `interp.ts`'s +total read correct while a silent-wrong-answer GC bug (#7682) was fully intact. Also +clean under `PERRY_GC_VERIFY_EVACUATION=1` and +`PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800`, which matter here +because the ways hold raw heap addresses in a global no GC scanner can see. + +#### What is left + +`interp.ts` is 7.4× Node, not the ~5× scriptc reaches. The remaining profile is +`js_jsvalue_equals` (15.7% — `===` is still a runtime call per comparison, and an inline +fast path only resolves the ~20% of comparisons that are *true*), the per-object GC +layout tables on the allocation path (7.7%, the #7510/#7469 area), write barriers (5.9%) +and `js_dyn_index_get`'s registry probes (~3%, the same "route by `GcHeader` before +probing address registries" shape fixed here twice). None of those is a +single-mechanism gap the way this one was. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 8f1224f771..ae5717a7b1 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1129,8 +1129,9 @@ pub(super) fn compile_closure( } for ic_name in &ic_globals { llmod.add_raw_global(format!( - "@{} = private global [8 x i64] zeroinitializer", - ic_name + "@{} = private global [{} x i64] zeroinitializer", + ic_name, + crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS )); } for raw in &typed_parse_rodata { diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 72c16609af..b6d894ebd4 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -1175,8 +1175,9 @@ pub(super) fn compile_module_entry( } for ic_name in &ic_globals { llmod.add_raw_global(format!( - "@{} = private global [8 x i64] zeroinitializer", - ic_name + "@{} = private global [{} x i64] zeroinitializer", + ic_name, + crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS )); } for raw in &typed_parse_rodata { @@ -1637,8 +1638,9 @@ pub(super) fn compile_module_entry( // three symbols must be defined exactly once per shared library. for ic_name in &ic_globals { llmod.add_raw_global(format!( - "@{} = private global [8 x i64] zeroinitializer", - ic_name + "@{} = private global [{} x i64] zeroinitializer", + ic_name, + crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS )); } for raw in &typed_parse_rodata { diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index f7dabf7028..dcc135c7da 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1065,8 +1065,9 @@ pub(super) fn compile_function( } for ic_name in &ic_globals { llmod.add_raw_global(format!( - "@{} = private global [8 x i64] zeroinitializer", - ic_name + "@{} = private global [{} x i64] zeroinitializer", + ic_name, + crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS )); } for raw in &typed_parse_rodata { diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 88fdffd5e9..71a648eeca 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -1051,8 +1051,9 @@ pub(super) fn compile_method( } for ic_name in &ic_globals { llmod.add_raw_global(format!( - "@{} = private global [8 x i64] zeroinitializer", - ic_name + "@{} = private global [{} x i64] zeroinitializer", + ic_name, + crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS )); } for raw in &typed_parse_rodata { @@ -1720,8 +1721,9 @@ pub(super) fn compile_static_method( } for ic_name in &ic_globals { llmod.add_raw_global(format!( - "@{} = private global [8 x i64] zeroinitializer", - ic_name + "@{} = private global [{} x i64] zeroinitializer", + ic_name, + crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS )); } for raw in &typed_parse_rodata { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 2d0fa93eda..e6a6a3f7bc 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1914,7 +1914,7 @@ mod misc_methods; mod new_dynamic; mod objects_arrays_lit; mod os_uri_dates; -mod property_get; +pub(crate) mod property_get; mod property_set; pub(crate) mod proxy_reflect; mod static_field_meta; diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 90fb2a2947..babaa38d46 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -43,7 +43,7 @@ use super::property_get_names::{ is_net_native_method_value, is_url_pattern_data_property, }; -mod generic_dispatch; +pub(crate) mod generic_dispatch; mod globalget; mod helpers; #[cfg(test)] diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index 3a8f2c2878..a9eba09955 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -13,6 +13,22 @@ use perry_hir::Expr; use crate::nanbox::POINTER_MASK_I64; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; +/// Words in a per-site `@perry_ic_N` property-read cache global. +/// +/// **Must equal `perry_runtime::object::field_get_set::PIC_CACHE_WORDS`** — +/// the runtime writes this memory through a `*mut [i64; PIC_CACHE_WORDS]`, so a +/// smaller global here is an out-of-bounds store. perry-codegen does not depend +/// on perry-runtime (the same reason `INLINE_SLOT_FLOOR` is spelled `4` inline +/// below), so the pairing is held by `pic_cache_layout_matches_runtime` here and +/// `pic_cache_words_match_codegen` in the runtime: change one and both fail. +pub(crate) const PIC_CACHE_WORDS: usize = 12; +/// First word of the polymorphic way array (words 0..2 are the MRU entry). +/// Mirrors the runtime's `PIC_WAY_BASE`. +pub(crate) const PIC_WAY_BASE: usize = 3; +/// `(token, slot)` ways beyond the MRU entry; a site resolves `PIC_WAYS + 1` +/// shapes inline. Mirrors the runtime's `PIC_WAYS`. +pub(crate) const PIC_WAYS: usize = 4; + /// The generic per-site monomorphic inline-cache dispatch for `obj.property`. /// This is the fall-through tail of the general catch-all arm: all earlier /// specializations have been ruled out. @@ -446,7 +462,25 @@ pub(crate) fn lower_generic_property_get( let hit_end_label = ctx.block().label.clone(); ctx.block().br(&merge_label); - // PIC miss: slow path with cache population. + // PIC miss on the MRU entry — before paying for the call, try the + // polymorphic ways (#7753). + // + // `js_object_get_field_ic_miss` is not a cheap fallback: it re-derives the + // receiver kind from scratch (proxy band, closure magic, registered-buffer + // and typed-array registries, small-handle dispatch), reads the + // accessors-in-use thread-local, then linear-scans the keys array with a + // `js_string_equals` per key. On a site whose receiver alternates between a + // handful of shapes — the shape of every discriminated-union dispatch — + // a single-entry cache misses on essentially every read and that whole + // ladder runs per field access. Measured on a tree-walking interpreter it + // was ~34% of run time. + // + // The ways are consulted only here, so a genuinely monomorphic site keeps + // the exact instruction sequence it had before this block existed. The + // typed-feedback counters are also recorded before the way compares, so a + // way hit still reports guard-fail + fallback-call exactly as it did when + // it was a real miss — the feedback heuristics see an unchanged signal + // (the site IS polymorphic; only the cost of that changed). ctx.current_block = miss_idx; crate::expr::emit_typed_feedback_record_call( ctx.block(), @@ -458,6 +492,72 @@ pub(crate) fn lower_generic_property_get( "js_typed_feedback_record_fallback_call", &[(I64, &feedback_site_id)], ); + + // A way can hold EITHER token kind, so it carries the pointer-token + // guarantees: `cache[2] == @PERRY_IC_EPOCH` (`epoch_eq`, already computed + // for the MRU predicate) plus a non-zero receiver token so an empty way + // (0) can never match a keyless receiver whose `keys_array` is also 0 + // (#809's shape, applied to the ways). `pic_prime_get` wipes every way + // whenever it writes a new epoch into word 2, so one shared epoch word + // covers all of them: a readable way was necessarily primed in the epoch + // that word still holds. + // + // Restricting the ways to shape-ID tokens instead — which needs no epoch + // guard at all, ids being unreusable — looks safer and is useless: a plain + // object literal is built by a generated `__AnonShape_*` constructor and + // therefore has a real `class_id`, which primes the keys-POINTER token. An + // ID-only way set never fills for the discriminated-union programs this + // whole block exists to speed up; measured, it cost 6%. + let mut way_hit = ctx.block().and(I1, &is_object, &epoch_eq); + way_hit = ctx.block().and(I1, &way_hit, &token_nonnull); + let mut way_any = String::from("false"); + let mut way_slot = String::from("0"); + for w in 0..PIC_WAYS { + let tok_ptr = ctx.block().gep( + I64, + &cache_ref, + &[(I64, &(PIC_WAY_BASE + w * 2).to_string())], + ); + let way_tok = ctx.block().load(I64, &tok_ptr); + let eq = ctx.block().icmp_eq(I64, &way_tok, &token); + let slot_ptr = ctx.block().gep( + I64, + &cache_ref, + &[(I64, &(PIC_WAY_BASE + w * 2 + 1).to_string())], + ); + let way_slot_val = ctx.block().load(I64, &slot_ptr); + way_slot = ctx.block().select(I1, &eq, I64, &way_slot_val, &way_slot); + way_any = ctx.block().or(I1, &way_any, &eq); + } + way_hit = ctx.block().and(I1, &way_hit, &way_any); + // Same per-receiver inline-capacity bound the MRU hit path applies: a slot + // primed from a larger-capacity sibling of the same shape must not drive a + // raw load past this receiver's field region (#6804). + let way_fc_addr = ctx.block().add(I64, &safe_obj_handle, "12"); + let way_fc_ptr = ctx.block().inttoptr(I64, &way_fc_addr); + let way_fc = ctx.block().load(I32, &way_fc_ptr); + let way_fc64 = ctx.block().zext(I32, &way_fc, I64); + let way_fc_floor = ctx.block().icmp_ult(I64, &way_fc64, "4"); // INLINE_SLOT_FLOOR + let way_limit = ctx.block().select(I1, &way_fc_floor, I64, "4", &way_fc64); + let way_in_bounds = ctx.block().icmp_ult(I64, &way_slot, &way_limit); + let way_ok = ctx.block().and(I1, &way_hit, &way_in_bounds); + let way_load_idx = ctx.new_block("pic.way.load"); + let call_idx = ctx.new_block("pic.miss.call"); + let way_load_label = ctx.block_label(way_load_idx); + let call_label = ctx.block_label(call_idx); + ctx.block().cond_br(&way_ok, &way_load_label, &call_label); + + ctx.current_block = way_load_idx; + let way_offset = ctx.block().shl(I64, &way_slot, "3"); + let way_base = ctx.block().add(I64, &obj_handle, &obj_header_size); + let way_field_addr = ctx.block().add(I64, &way_base, &way_offset); + let way_field_ptr = ctx.block().inttoptr(I64, &way_field_addr); + let val_way = ctx.block().load(DOUBLE, &way_field_ptr); + let way_end_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + // PIC miss: slow path with cache population. + ctx.current_block = call_idx; let val_miss = ctx.block().call( DOUBLE, "js_object_get_field_ic_miss", @@ -466,11 +566,15 @@ pub(crate) fn lower_generic_property_get( let miss_end_label = ctx.block().label.clone(); ctx.block().br(&merge_label); - // Merge PIC hit + miss, then jump to the outer recv-valid merge. + // Merge PIC hit + way hit + miss, then jump to the outer recv-valid merge. ctx.current_block = merge_idx; let pic_val = ctx.block().phi( DOUBLE, - &[(&val_hit, &hit_end_label), (&val_miss, &miss_end_label)], + &[ + (&val_hit, &hit_end_label), + (&val_way, &way_end_label), + (&val_miss, &miss_end_label), + ], ); let pic_end_label = ctx.block().label.clone(); ctx.block().br(&final_merge_label); diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index b2fab0a00a..3c586fabf6 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -175,3 +175,72 @@ fn fs_parent_promises_property_installs_before_resolution() { "fs.promises submodule installation must precede property resolution:\n{ir}" ); } + +/// #7753, paired with `pic_cache_words_match_codegen` in +/// `perry-runtime/src/object/field_get_set/ic_miss.rs`. +/// +/// The runtime writes a `@perry_ic_N` global through `*mut [i64; +/// PIC_CACHE_WORDS]`. If codegen emits a NARROWER global, `pic_prime_get`'s way +/// stores run past the end of it into whatever global the linker placed next — +/// silent memory corruption that no property-read test would notice. This pins +/// the emitted width to the constant both sides share, and pins the constant +/// itself so the runtime's copy cannot drift. +#[test] +fn pic_cache_layout_matches_runtime() { + use crate::expr::property_get::generic_dispatch::{PIC_CACHE_WORDS, PIC_WAYS, PIC_WAY_BASE}; + assert_eq!( + PIC_CACHE_WORDS, 12, + "perry-runtime's PIC_CACHE_WORDS is 12; update both sides together" + ); + assert!( + PIC_WAY_BASE + PIC_WAYS * 2 < PIC_CACHE_WORDS, + "the ways plus the victim counter must fit in the emitted global" + ); + let ir = emit(false, None); + assert!( + ir.contains(&format!( + "= private global [{PIC_CACHE_WORDS} x i64] zeroinitializer" + )), + "every @perry_ic_N must be emitted at the width the runtime writes:\n{ir}" + ); +} + +/// #7753: the polymorphic ways must be consulted BEFORE the miss call, and the +/// monomorphic path must not have grown any work. +/// +/// A one-entry cache misses on essentially every read at a site whose receiver +/// alternates between shapes — the shape of every discriminated-union dispatch +/// — and each miss runs the full `js_object_get_field_ic_miss` ladder +/// (proxy/closure/buffer/typed-array probes, an accessors thread-local, then a +/// linear keys scan with a `js_string_equals` per key). If the way block is +/// ever deleted or floated below the call it stops paying for itself entirely, +/// and nothing else in the suite would show it — the program still computes the +/// right answer, just slowly. So assert the ORDER, not merely the presence. +#[test] +fn generic_property_get_tries_ways_before_calling_the_miss_handler() { + let ir = emit(false, None); + assert!( + ir.contains("@perry_ic_"), + "test premise: the generic read reaches the inline PIC:\n{ir}" + ); + let way_load = ir + .find("pic.way.load") + .unwrap_or_else(|| panic!("expected a polymorphic way load block:\n{ir}")); + let miss_call = ir + .find("call double @js_object_get_field_ic_miss") + .unwrap_or_else(|| panic!("expected the miss handler call:\n{ir}")); + assert!( + way_load < miss_call, + "the way compares must be emitted before the miss call, not after it:\n{ir}" + ); + // The way compares read (token, slot) pairs at words 3.. of the cache. + use crate::expr::property_get::generic_dispatch::{PIC_WAYS, PIC_WAY_BASE}; + for w in 0..PIC_WAYS { + for word in [PIC_WAY_BASE + w * 2, PIC_WAY_BASE + w * 2 + 1] { + assert!( + ir.contains(&format!("i64 {word})")) || ir.contains(&format!("i64 {word}\n")), + "way word {word} is never read in the emitted IR:\n{ir}" + ); + } + } +} diff --git a/crates/perry-runtime/src/node_submodules/tests.rs b/crates/perry-runtime/src/node_submodules/tests.rs index d37afe396a..ab43f33a56 100644 --- a/crates/perry-runtime/src/node_submodules/tests.rs +++ b/crates/perry-runtime/src/node_submodules/tests.rs @@ -468,7 +468,7 @@ fn test_default_and_named_exports_share_the_self_alias() { let property = crate::object::js_object_get_field_by_name_f64(closure as *const ObjectHeader, key); assert_eq!(property.to_bits(), default.to_bits()); - let mut cache = [0, 0, 0]; + let mut cache = [0i64; crate::object::PIC_CACHE_WORDS]; let property = crate::object::js_object_get_field_ic_miss(closure as *const ObjectHeader, key, &mut cache); assert_eq!(property.to_bits(), default.to_bits()); diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 84dcc05fd3..7de3e22dbb 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -286,7 +286,7 @@ pub(crate) use ic_miss::{ pub use ic_miss::{ js_object_get_field_by_name_f64, js_object_get_field_by_property_id_f64, js_object_get_field_ic_miss, js_object_set_field_by_property_id, js_private_brand_check, - js_private_guard, PERRY_IC_EPOCH, + js_private_guard, PicCache, PERRY_IC_EPOCH, PIC_CACHE_WORDS, }; #[cfg(test)] @@ -318,7 +318,7 @@ mod buffer_ic_miss_tests { unsafe { for len in [16usize, 24, 32] { let buf = secret_buffer(len); - let mut cache = [0i64; 3]; + let mut cache = [0i64; crate::object::PIC_CACHE_WORDS]; let ty = js_object_get_field_ic_miss( buf as *const ObjectHeader, diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index ce33632b4d..a2baa2aede 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -231,6 +231,148 @@ pub(crate) fn pic_epoch_bump() { PERRY_IC_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } +/// Words in a per-site property-read cache global (`@perry_ic_N`). Codegen +/// emits `[PIC_CACHE_WORDS x i64] zeroinitializer`; this type is the runtime's +/// view of the same memory. +pub const PIC_CACHE_WORDS: usize = 12; + +/// The runtime view of a `@perry_ic_N` property-read cache. +/// +/// Layout (#7753 — the ways are new; words 0..2 are unchanged from #51/#6080a +/// so the monomorphic path is bit-for-bit what it always was): +/// +/// | word | meaning | +/// |---|---| +/// | 0 | `tok0` — most-recently-used shape token (ID token **or** keys pointer) | +/// | 1 | `slot0` — its resolved field slot | +/// | 2 | `epoch` — [`PERRY_IC_EPOCH`] snapshot; gates word 0's pointer tokens **and every way** | +/// | 3,4 / 5,6 / 7,8 / 9,10 | `(tok, slot)` ways | +/// | 11 | round-robin victim index for the ways | +pub type PicCache = [i64; PIC_CACHE_WORDS]; + +/// First word of the polymorphic way array. +pub(crate) const PIC_WAY_BASE: usize = 3; +/// Number of `(token, slot)` ways beyond the MRU entry. Total shapes a site +/// can resolve inline is `PIC_WAYS + 1`. +pub(crate) const PIC_WAYS: usize = 4; +/// Word holding the round-robin victim index. +pub(crate) const PIC_VICTIM: usize = PIC_WAY_BASE + PIC_WAYS * 2; + +/// Prime the MRU entry, cascading the shape it evicts into the ways. +/// +/// Word 0 keeps exactly its pre-#7753 meaning — last shape seen, always +/// overwritten — so a genuinely monomorphic site behaves identically. What +/// changes is that the *evicted* shape is no longer thrown away: it moves into +/// a way, and the emitted poly block (reached only after word 0 misses) +/// resolves it inline instead of calling back into this handler. A site that +/// alternates between k ≤ `PIC_WAYS + 1` shapes therefore stops thrashing. +/// +/// Both token kinds are cascaded, because the population that matters is the +/// pointer-token one: a plain object literal is allocated through a generated +/// `__AnonShape_*` constructor and so carries a real `class_id`, which routes it +/// to the shape-shared keys-POINTER prime, not the `#6804` shape-ID prime. Ways +/// restricted to ID tokens are dead code for exactly the programs this exists +/// for — measured as a 6% *regression* on a tree-walking interpreter, all of it +/// the compare sequence running and never hitting. +/// +/// A keys-POINTER token is address-derived and can be recycled after a +/// collection (#6080a), so every way is gated on the SAME `cache[2]` epoch word +/// the MRU entry uses, and this function **wipes the ways whenever the epoch +/// moves**. That keeps the shared word honest: a way is only ever readable while +/// `cache[2]` still holds the epoch that way was primed in. The ways go cold +/// once per collection and re-prime — 38 minor collections across a 4 s run, so +/// the re-priming is not measurable. +/// +/// `(shape, key)` → slot is immutable within an epoch: a site always looks up +/// one key, and a keys-array change gives the object a different keys array (or +/// a fresh shape id). So a way that stops matching simply goes cold; it can +/// never resolve to a wrong slot. +/// +/// # Safety +/// `cache` must point at a live `[i64; PIC_CACHE_WORDS]` (the codegen-emitted +/// per-site global, or a stack array of that type). +pub(crate) unsafe fn pic_prime_get(cache: *mut PicCache, token: i64, slot: i64, epoch: i64) { + let c = &mut *cache; + let prev_tok = c[0]; + let prev_slot = c[1]; + // A collection happened since this site was last primed. Every token here — + // word 0's and every way's — was resolved against addresses that may since + // have been freed, moved and recycled, so the whole cache goes cold. That + // includes `prev_tok`: cascading it would smuggle a stale pointer token past + // the very guard the wipe exists to enforce. + let epoch_held = c[2] == epoch; + if !epoch_held { + for w in 0..PIC_WAYS { + c[PIC_WAY_BASE + w * 2] = 0; + c[PIC_WAY_BASE + w * 2 + 1] = 0; + } + } + c[0] = token; + c[1] = slot; + c[2] = epoch; + let cascade = epoch_held && prev_tok != 0 && prev_tok != token; + // One pass over the ways does three things: + // * evicts `token` from a way if it has one — it now lives in the MRU + // entry, and leaving the stale copy behind would permanently cost a way + // (a k-shape rotation would then only ever cache k-1 of them); + // * refreshes `prev_tok`'s way if it already has one; + // * remembers the first empty way for the cascade. + let mut free: Option = None; + let mut prev_present = false; + for w in 0..PIC_WAYS { + let ti = PIC_WAY_BASE + w * 2; + if c[ti] == token { + c[ti] = 0; + c[ti + 1] = 0; + } else if cascade && c[ti] == prev_tok { + c[ti + 1] = prev_slot; + prev_present = true; + continue; + } + if c[ti] == 0 && free.is_none() { + free = Some(ti); + } + } + if !cascade || prev_present { + return; + } + let ti = free.unwrap_or_else(|| { + let v = (c[PIC_VICTIM] as usize).wrapping_add(1) % PIC_WAYS; + c[PIC_VICTIM] = v as i64; + PIC_WAY_BASE + v * 2 + }); + c[ti] = prev_tok; + c[ti + 1] = prev_slot; +} + +/// The receiver's GC object type, or `None` when the address does not carry a +/// readable `GcHeader`. +/// +/// # Safety +/// `obj` is only *inspected*; `try_read_gc_header` validates the address first. +#[inline] +unsafe fn gc_type_of(obj: *const ObjectHeader) -> Option { + crate::value::addr_class::try_read_gc_header(obj as usize).map(|h| h.obj_type) +} + +/// Does this heap property key have exactly these bytes? +/// +/// Length first, so a mismatched key costs one `u32` load and a compare — the +/// point is to keep the fast-path probe cheaper than the ladder it skips. +/// +/// # Safety +/// `key` must be null or a live heap `StringHeader` (the same contract every +/// other key read in this file relies on — property-name literals are interned +/// as heap strings, never SSO immediates). +#[inline] +unsafe fn key_bytes_are(key: *const crate::StringHeader, want: &[u8]) -> bool { + if key.is_null() || (*key).byte_len as usize != want.len() { + return false; + } + let p = (key as *const u8).add(std::mem::size_of::()); + std::slice::from_raw_parts(p, want.len()) == want +} + /// Monomorphic inline cache miss handler (issue #51). /// /// Called when the codegen-emitted shape check (`obj->keys_array == cache[0]`) @@ -238,10 +380,11 @@ pub(crate) fn pic_epoch_bump() { /// then populates the per-site cache so subsequent calls with the same shape /// hit the inline fast path (no function call, direct field load). /// -/// `cache` layout: `[shape_token: i64, field_slot_index: i64, primed_epoch: i64]` -/// (`shape_token` is a shape-ID token or a raw keys-array pointer — see #6804; -/// `primed_epoch` is the [`PERRY_IC_EPOCH`] snapshot taken at prime time, -/// #6080a). The emitted global is `[8 x i64]`; slots 3..8 are unused here. +/// `cache` layout: see [`PicCache`]. Words 0..2 are the MRU entry +/// `[shape_token, field_slot_index, primed_epoch]` (`shape_token` is a shape-ID +/// token or a raw keys-array pointer — see #6804; `primed_epoch` is the +/// [`PERRY_IC_EPOCH`] snapshot taken at prime time, #6080a); words 3.. are the +/// polymorphic ways filled by [`pic_prime_get`] (#7753). /// /// Only caches when: /// - obj is a valid ObjectHeader (not null, not handle, not string/array/etc.) @@ -254,7 +397,7 @@ pub(crate) fn pic_epoch_bump() { pub extern "C" fn js_object_get_field_ic_miss( obj: *const ObjectHeader, key: *const crate::StringHeader, - cache: *mut [i64; 3], + cache: *mut PicCache, ) -> f64 { // SSO receiver — never cacheable. Route through the SSO-aware // `js_object_get_field_by_name` which handles `.length` inline @@ -301,6 +444,34 @@ pub extern "C" fn js_object_get_field_ic_miss( // the ordering in `js_object_get_field_by_name`. The macOS heap floor // (0x200_0000_0000 in is_valid_obj_ptr) masked this; Linux's is 0x1000. if crate::value::addr_class::is_above_handle_band(obj as usize) { + // #7753: `arr.length` on a receiver codegen could not prove is an array. + // + // The inline cache can never serve this read — it requires a + // GC_TYPE_OBJECT receiver by construction (#72, so an Array's + // `element[1]` is never mistaken for `keys_array`) — so EVERY dynamic + // `.length` lands here, and then walks a ladder built for objects: a + // closure-magic deref, two side-table registry probes behind + // thread-locals, then `js_object_get_field_by_name`'s own dispatch, + // which repeats the registry probes before finally reaching the array + // arm. On a tree-walking interpreter whose variable lookup is + // `for (i = 0; i < names.length; i++)`, that one read was 22% of total + // run time — more than the entire polymorphic-dispatch fix above saved. + // + // `GC_TYPE_ARRAY` is a genuine dense array: buffers, typed arrays, lazy + // arrays, Sets and Maps all carry their own distinct `obj_type`, and an + // `class X extends Array` instance is an `ObjectHeader` + // (`GC_TYPE_OBJECT`). `js_array_length` still resolves growth-forwarding + // stubs, proxies and subclass receivers, so this only skips probes that + // cannot match — the expression returned is exactly the one + // `get_field_by_name_object_tail`'s array arm computes for this key, + // which is what makes it a pure short-circuit rather than a second + // implementation. + if unsafe { gc_type_of(obj) } == Some(crate::gc::GC_TYPE_ARRAY) + && unsafe { key_bytes_are(key, b"length") } + { + let arr = obj as *const crate::array::ArrayHeader; + return crate::array::js_array_length(arr) as f64; + } unsafe { if let Some(val) = closure_dynamic_prop_by_key(obj as usize, key) { return val; @@ -507,14 +678,10 @@ pub extern "C" fn js_object_get_field_ic_miss( // one token kind to the other. let epoch = PERRY_IC_EPOCH.load(std::sync::atomic::Ordering::Relaxed) as i64; if (*obj).class_id == 0 && crate::object::shapes::is_shape_id(stamp) { - (*cache)[0] = - (stamp as u64 | crate::object::shapes::PIC_ID_TOKEN_BIT) as i64; - (*cache)[1] = i as i64; - (*cache)[2] = epoch; + let token = (stamp as u64 | crate::object::shapes::PIC_ID_TOKEN_BIT) as i64; + pic_prime_get(cache, token, i as i64, epoch); } else if keys_cacheable_for_pic(keys) { - (*cache)[0] = keys as i64; - (*cache)[1] = i as i64; - (*cache)[2] = epoch; + pic_prime_get(cache, keys as i64, i as i64, epoch); } let field_ptr = (obj as *const u8) .add(std::mem::size_of::() + i * 8) @@ -553,7 +720,7 @@ pub extern "C" fn js_object_get_field_ic( obj_bits: i64, key: *const crate::StringHeader, site_id: u64, - cache: *mut [i64; 3], + cache: *mut PicCache, ) -> f64 { // POINTER_MASK: lower 48 bits — strips the NaN-box tag to a raw heap pointer. const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; @@ -822,6 +989,183 @@ pub extern "C" fn js_private_guard( obj } +#[cfg(test)] +mod poly_pic_tests { + use super::{pic_prime_get, PicCache, PIC_CACHE_WORDS, PIC_VICTIM, PIC_WAYS, PIC_WAY_BASE}; + use crate::object::shapes::PIC_ID_TOKEN_BIT; + + fn id_tok(n: u64) -> i64 { + (n | PIC_ID_TOKEN_BIT) as i64 + } + + /// Paired with `pic_cache_layout_matches_runtime` in + /// `perry-codegen/src/expr/property_get/generic_dispatch.rs`: codegen emits + /// `[PIC_CACHE_WORDS x i64]` for each `@perry_ic_N` and the runtime writes + /// that memory as `[i64; PIC_CACHE_WORDS]`. Widening one side alone is an + /// out-of-bounds store into another global, so both tests pin the number. + #[test] + fn pic_cache_words_match_codegen() { + assert_eq!( + PIC_CACHE_WORDS, 12, + "codegen emits `[12 x i64]`; update both sides together" + ); + assert!( + PIC_VICTIM < PIC_CACHE_WORDS, + "the victim counter must fit inside the emitted global" + ); + assert_eq!(PIC_VICTIM, PIC_WAY_BASE + PIC_WAYS * 2); + } + + /// The MRU entry keeps its pre-#7753 meaning exactly: always overwritten, + /// carrying its epoch. A monomorphic site must therefore look identical to + /// what it looked like before the ways existed — no way is ever filled. + #[test] + fn monomorphic_site_never_fills_a_way() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + unsafe { + for _ in 0..8 { + pic_prime_get(&mut c, id_tok(7), 2, 99); + } + } + assert_eq!(c[0], id_tok(7)); + assert_eq!(c[1], 2); + assert_eq!(c[2], 99); + for w in 0..PIC_WAYS { + assert_eq!( + c[PIC_WAY_BASE + w * 2], + 0, + "a site that only ever sees one shape must not fill a way" + ); + } + } + + /// The property the whole change rests on: a site alternating between + /// `PIC_WAYS + 1` shapes ends up with EVERY shape resolvable inline — + /// the one in the MRU entry plus the rest spread across the ways, each + /// still paired with its own slot. Before #7753 the 2nd..nth shape had + /// nowhere to live and every read called the miss handler. + #[test] + fn alternating_shapes_all_become_inline_resolvable() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + let shapes: Vec<(i64, i64)> = (0..(PIC_WAYS + 1)) + .map(|i| (id_tok(100 + i as u64), i as i64)) + .collect(); + unsafe { + // Two full rotations: the first fills, the second must not disturb. + for _ in 0..2 { + for (tok, slot) in &shapes { + pic_prime_get(&mut c, *tok, *slot, 1); + } + } + } + for (tok, slot) in &shapes { + let in_mru = c[0] == *tok && c[1] == *slot; + let in_way = (0..PIC_WAYS) + .any(|w| c[PIC_WAY_BASE + w * 2] == *tok && c[PIC_WAY_BASE + w * 2 + 1] == *slot); + assert!( + in_mru || in_way, + "shape {tok:#x} (slot {slot}) must be resolvable inline; cache = {c:?}" + ); + } + // …and no shape is duplicated across two ways (the dedupe arm works), + // otherwise capacity silently halves. + for w in 0..PIC_WAYS { + for v in (w + 1)..PIC_WAYS { + let a = c[PIC_WAY_BASE + w * 2]; + let b = c[PIC_WAY_BASE + v * 2]; + assert!(a == 0 || a != b, "ways {w} and {v} hold the same token"); + } + } + } + + /// The population that matters is the keys-POINTER one: a plain object + /// literal goes through a generated `__AnonShape_*` constructor, so it has + /// a real `class_id` and primes a keys pointer, never a shape id. Ways that + /// only accept ID tokens are dead code for exactly the programs the ways + /// exist for (measured: a 6% regression, the compares running and never + /// hitting). This is the test that would have caught shipping that. + #[test] + fn pointer_tokens_do_reach_a_way() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + let ptr_a = 0x2000_1234_5678_i64; + let ptr_b = 0x2000_1234_9999_i64; + assert_eq!((ptr_a as u64) & PIC_ID_TOKEN_BIT, 0, "test premise"); + unsafe { + pic_prime_get(&mut c, ptr_a, 1, 5); + pic_prime_get(&mut c, ptr_b, 2, 5); + } + assert_eq!(c[0], ptr_b); + assert!( + (0..PIC_WAYS) + .any(|w| c[PIC_WAY_BASE + w * 2] == ptr_a && c[PIC_WAY_BASE + w * 2 + 1] == 1), + "the evicted keys-pointer token must land in a way: {c:?}" + ); + } + + /// #6080a, extended to the ways. A keys-POINTER token is an ADDRESS: after a + /// collection frees or moves that keys array, a different-shape array can be + /// recycled into the same address and a stale way would pointer-match and + /// load the wrong slot — silently, which is the worst failure this code can + /// have. The ways share word 2's epoch snapshot with the MRU entry, so the + /// discipline that makes that sound is: a new epoch WIPES every way, and the + /// token being evicted from word 0 is dropped rather than cascaded (it too + /// was resolved in the old epoch). This asserts both halves. + #[test] + fn an_epoch_change_wipes_every_way() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + unsafe { + for i in 0..(PIC_WAYS as i64 + 1) { + pic_prime_get(&mut c, 0x3000_0000_0000 + i, i, 7); + } + } + assert!( + (0..PIC_WAYS).any(|w| c[PIC_WAY_BASE + w * 2] != 0), + "test premise: the ways are populated before the epoch moves" + ); + let stale = c[0]; + unsafe { + pic_prime_get(&mut c, 0x4000_0000_0000, 3, 8); + } + for w in 0..PIC_WAYS { + assert_eq!( + c[PIC_WAY_BASE + w * 2], + 0, + "way {w} survived an epoch change: {c:?}" + ); + } + assert_ne!( + c[0], stale, + "the MRU entry must hold the freshly primed token" + ); + assert_eq!(c[2], 8, "word 2 must carry the new epoch"); + } + + /// More distinct shapes than the site can hold must degrade to "some miss", + /// never to a wrong answer: every occupied way still carries the slot it was + /// primed with, so the emitted compare can only hit on a token it stored. + #[test] + fn overflow_rotates_without_corrupting_pairs() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + unsafe { + for i in 0..(PIC_WAYS as u64 * 4) { + pic_prime_get(&mut c, id_tok(200 + i), i as i64, 1); + } + } + for w in 0..PIC_WAYS { + let tok = c[PIC_WAY_BASE + w * 2]; + if tok == 0 { + continue; + } + let slot = c[PIC_WAY_BASE + w * 2 + 1]; + let expected = (tok as u64 & !PIC_ID_TOKEN_BIT) - 200; + assert_eq!( + slot, expected as i64, + "way {w} pairs token {tok:#x} with the wrong slot" + ); + } + } +} + #[cfg(test)] mod c3c_pic_tests { /// #6759 C3c: the PIC only caches SHAPE-SHARED (process-rooted, @@ -867,7 +1211,7 @@ mod c3c_pic_tests { let gc = (keys as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; (*gc).gc_flags |= crate::gc::GC_FLAG_SHAPE_SHARED; - let mut cache = [0i64; 3]; + let mut cache = [0i64; super::PIC_CACHE_WORDS]; let v = super::js_object_get_field_ic_miss(obj, key, &mut cache); assert_eq!(v, 7.0); assert_eq!( @@ -901,3 +1245,53 @@ mod c3c_pic_tests { } } } + +#[cfg(test)] +mod array_length_fast_path_tests { + /// #7753: the `arr.length` short-circuit must answer EXACTLY what the full + /// ladder answers, for a fresh array, a grown one, and an empty one — and + /// must not fire for any other key on an array receiver, nor for `length` + /// on a non-array. Comparing against `js_object_get_field_by_name_f64` (the + /// path the read took before the short-circuit) is what makes this a + /// behaviour-equivalence test rather than a restatement of the fast path. + #[test] + fn array_length_short_circuit_agrees_with_the_full_ladder() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let len_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); + let other_key = crate::string::js_string_from_bytes(b"lengtx".as_ptr(), 6); + for n in [0u32, 1, 5, 40] { + let mut arr = crate::array::js_array_alloc(n.max(1)); + for i in 0..n { + arr = crate::array::js_array_push(arr, crate::value::JSValue::number(i as f64)); + } + let obj = arr as *const super::ObjectHeader; + let mut cache = [0i64; super::PIC_CACHE_WORDS]; + let via_ic = super::js_object_get_field_ic_miss(obj, len_key, &mut cache); + let via_ladder = super::js_object_get_field_by_name_f64(obj, len_key); + assert_eq!( + via_ic.to_bits(), + via_ladder.to_bits(), + "length disagreed for a {n}-element array" + ); + assert_eq!(via_ic, n as f64, "length wrong for a {n}-element array"); + // A same-length key that is not `length` must not be captured + // by the fast path. + assert_eq!( + super::js_object_get_field_ic_miss(obj, other_key, &mut cache).to_bits(), + super::js_object_get_field_by_name_f64(obj, other_key).to_bits(), + "a non-`length` key on an array must take the normal path" + ); + } + // `length` on a plain OBJECT must not be answered by the array + // short-circuit — it is an ordinary (absent) property there. + let plain = crate::object::js_object_alloc(0, 0); + let mut cache = [0i64; super::PIC_CACHE_WORDS]; + assert_eq!( + super::js_object_get_field_ic_miss(plain, len_key, &mut cache).to_bits(), + super::js_object_get_field_by_name_f64(plain, len_key).to_bits(), + "`length` on a plain object must keep its normal answer" + ); + } + } +} diff --git a/crates/perry-runtime/src/value/dynamic_object.rs b/crates/perry-runtime/src/value/dynamic_object.rs index a8f0ba0b9c..847cf70db2 100644 --- a/crates/perry-runtime/src/value/dynamic_object.rs +++ b/crates/perry-runtime/src/value/dynamic_object.rs @@ -745,7 +745,7 @@ mod length_handle_band_tests { }; let console_ptr = crate::value::js_nanbox_get_pointer(console_ctor) as usize; let length_key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); - let mut cache = [0_i64; 3]; + let mut cache = [0_i64; crate::object::PIC_CACHE_WORDS]; assert_eq!(js_value_length_f64(console_ctor), 1.0); assert_eq!( From 32c57a771ae8a905134730bc7307980382695d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 11:02:43 +0200 Subject: [PATCH 2/4] perf(runtime,codegen): bound the polymorphic ways with a decaying megamorphic latch (#7753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ways are a trade, not a free win, and just past capacity the trade inverts. Holding everything else fixed and varying only the number of shapes at one site (bench/poly_read{5,,7}.ts): shapes at the site 5 (=capacity) 6 7 v0.5.1434 1.98 s 1.97 s 1.98 s ways, no off-switch 0.79 s 1.87 s 2.71 s 2.5x faster at capacity, 37% SLOWER one shape past it — four dependent loads per read that can never hit. So the compares now sit behind their own branch on a way-state word, and a site that keeps evicting a way by capacity latches them off, leaving no readable way behind. Two policy failures, both measured, both now tests: * Count CONSECUTIVE capacity evictions, not cumulative. A cumulative count latches any long-running site that ever sees a stray shape — evalNode handles let/fun twice per round, 80 strays across a run — and turned the ways off on the very site they were built for (2.39 -> 3.03 s). * The latch must not be permanent. "Megamorphic" is a property of a program PHASE, not a site: interp.ts's string-building sub-program drives evalNode through a different shape set, and a sticky latch let that phase kill the site for the rest of the process (3.02 s). It is a countdown instead — each miss while latched adds one, and after PIC_LATCH_RETRY misses the ways get another chance. The codegen ordering test also becomes structural rather than textual: it now asserts pic.ways ends in a branch choosing between the way load and the miss call, so block emission order cannot make it vacuous. Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- .../7753-polymorphic-property-read-cache.md | 39 +++ .../src/expr/property_get/generic_dispatch.rs | 28 ++- .../src/expr/property_get/tests.rs | 44 +++- .../src/object/field_get_set/ic_miss.rs | 236 ++++++++++++++++-- 4 files changed, 318 insertions(+), 29 deletions(-) diff --git a/changelog.d/7753-polymorphic-property-read-cache.md b/changelog.d/7753-polymorphic-property-read-cache.md index a22105788f..e74dacb6cc 100644 --- a/changelog.d/7753-polymorphic-property-read-cache.md +++ b/changelog.d/7753-polymorphic-property-read-cache.md @@ -80,6 +80,45 @@ Two things had to be got right, and both were found by a test rather than by rea compare sequence running on every miss and never once hitting. `pointer_tokens_do_reach_a_way` is the test that fails if it is narrowed again. +A third thing had to be got right, and only a benchmark could have found it. +**The ways are not a free win — they are a trade, and just past capacity the +trade inverts.** Holding everything else fixed and varying only the number of +shapes at one site (`bench/poly_read5.ts` / `poly_read.ts` / `poly_read7.ts`, +identical but for the arity): + +| shapes at the site | 5 (= capacity) | 6 | 7 | +|---|--:|--:|--:| +| v0.5.1434 | 1.98 s | 1.97 s | 1.98 s | +| ways, no off-switch | **0.79 s** | 1.87 s | **2.71 s** | + +A 2.5× speedup at capacity and a **37% regression** one shape past it — four +dependent loads per read that can never hit. Shipping only the left half of that +table is how a "pure win" turns into a bug report from whoever writes the +seven-arm union. + +So the compares sit behind their own branch on a way-state word, and +`pic_prime_get` latches that word negative once a site proves its rotation is +wider than the ways hold. A latched site is left with exactly its pre-#7753 code +path plus one load and one perfectly-predicted branch. + +Getting the latch *policy* right took two more measured failures, and both are +now tests: + +* **Count consecutive capacity evictions, not cumulative ones.** A cumulative + counter latches any long-running site that ever sees a stray shape, and + `evalNode` handles `let`/`fun` twice per round — 80 stray evictions across a + run. The ways switched themselves off on the exact site they were built for: + 2.39 s → 3.03 s. (`a_rare_extra_shape_does_not_latch_a_site_that_fits`) +* **The latch must not be permanent.** "Megamorphic" is a property of a program + *phase*, not of a site. `interp.ts` runs three sub-programs in a loop, and the + string-building one drives `evalNode` through a different shape set; a sticky + latch let that phase kill the site for the rest of the process, and the number + did not move (3.02 s). The latch is a countdown instead — each miss while + latched adds one, and after `PIC_LATCH_RETRY` misses the ways get another + chance. A genuinely megamorphic site pays 16 way-compares per 2048 reads to + re-confirm; a phase-changed one recovers inside a single window. + (`a_wider_than_capacity_rotation_latches_then_re_arms`) + Admitting pointer tokens means the ways inherit #6080a: a keys-array address freed by a collection can be recycled under a different shape, and a stale way would pointer-match and load the wrong slot **silently**. The ways therefore share word 2's epoch snapshot diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index a9eba09955..e19b9f2b5a 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -28,6 +28,10 @@ pub(crate) const PIC_WAY_BASE: usize = 3; /// `(token, slot)` ways beyond the MRU entry; a site resolves `PIC_WAYS + 1` /// shapes inline. Mirrors the runtime's `PIC_WAYS`. pub(crate) const PIC_WAYS: usize = 4; +/// Way-state word: `> 0` means at least one way is populated and the compares +/// are worth running; `0` (fresh / epoch-wiped) and `-1` (sticky megamorphic) +/// both skip them. Mirrors the runtime's `PIC_WAY_STATE`. +pub(crate) const PIC_WAY_STATE: usize = PIC_WAY_BASE + PIC_WAYS * 2; /// The generic per-site monomorphic inline-cache dispatch for `obj.property`. /// This is the fall-through tail of the general catch-all arm: all earlier @@ -508,6 +512,28 @@ pub(crate) fn lower_generic_property_get( // therefore has a real `class_id`, which primes the keys-POINTER token. An // ID-only way set never fills for the discriminated-union programs this // whole block exists to speed up; measured, it cost 6%. + // + // The compares sit behind their own branch on `cache[PIC_WAY_STATE] > 0` + // rather than being folded into one flat predicate, because a site whose + // receiver rotation is WIDER than the ways hold never hits one and would + // otherwise pay four dependent loads on every read: measured at **+37%** on + // a 7-shape site, against a 2.5x speedup on a 5-shape one. `pic_prime_get` + // latches that state to `-1` once a site proves itself megamorphic, and a + // fresh or epoch-wiped site reads `0`, so for both the branch is one load, + // one compare, and a perfectly predicted fall-through to the call — which + // is exactly the pre-#7753 code path. + let state_ptr = ctx + .block() + .gep(I64, &cache_ref, &[(I64, &PIC_WAY_STATE.to_string())]); + let way_state = ctx.block().load(I64, &state_ptr); + let ways_live = ctx.block().icmp_sgt(I64, &way_state, "0"); + let ways_idx = ctx.new_block("pic.ways"); + let call_idx = ctx.new_block("pic.miss.call"); + let ways_label = ctx.block_label(ways_idx); + let call_label = ctx.block_label(call_idx); + ctx.block().cond_br(&ways_live, &ways_label, &call_label); + + ctx.current_block = ways_idx; let mut way_hit = ctx.block().and(I1, &is_object, &epoch_eq); way_hit = ctx.block().and(I1, &way_hit, &token_nonnull); let mut way_any = String::from("false"); @@ -542,9 +568,7 @@ pub(crate) fn lower_generic_property_get( let way_in_bounds = ctx.block().icmp_ult(I64, &way_slot, &way_limit); let way_ok = ctx.block().and(I1, &way_hit, &way_in_bounds); let way_load_idx = ctx.new_block("pic.way.load"); - let call_idx = ctx.new_block("pic.miss.call"); let way_load_label = ctx.block_label(way_load_idx); - let call_label = ctx.block_label(call_idx); ctx.block().cond_br(&way_ok, &way_load_label, &call_label); ctx.current_block = way_load_idx; diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 3c586fabf6..ab97f5c715 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -223,24 +223,46 @@ fn generic_property_get_tries_ways_before_calling_the_miss_handler() { ir.contains("@perry_ic_"), "test premise: the generic read reaches the inline PIC:\n{ir}" ); + use crate::expr::property_get::generic_dispatch::{PIC_WAYS, PIC_WAY_BASE, PIC_WAY_STATE}; + + // Block *text* order is an artifact of emission order, so assert the CFG + // instead: the block that calls the miss handler must be reachable only as + // a branch target of the way block, never straight-line after it. + let ways = ir + .find("\npic.ways") + .unwrap_or_else(|| panic!("expected a pic.ways block:\n{ir}")); let way_load = ir - .find("pic.way.load") - .unwrap_or_else(|| panic!("expected a polymorphic way load block:\n{ir}")); - let miss_call = ir - .find("call double @js_object_get_field_ic_miss") - .unwrap_or_else(|| panic!("expected the miss handler call:\n{ir}")); + .find("\npic.way.load") + .unwrap_or_else(|| panic!("expected a pic.way.load block:\n{ir}")); + let call_block = ir + .find("\npic.miss.call") + .unwrap_or_else(|| panic!("expected a pic.miss.call block:\n{ir}")); + let ways_body = &ir[ways..[way_load, call_block, ir.len()] + .into_iter() + .filter(|&x| x > ways) + .min() + .unwrap()]; + assert!( + ways_body.contains("pic.way.load") && ways_body.contains("pic.miss.call"), + "pic.ways must end in a branch choosing between the way load and the \ + miss call — otherwise the compares are not gating anything:\n{ways_body}" + ); assert!( - way_load < miss_call, - "the way compares must be emitted before the miss call, not after it:\n{ir}" + !ways_body.contains("call double @js_object_get_field_ic_miss"), + "the miss call must not sit inside the way block:\n{ways_body}" ); - // The way compares read (token, slot) pairs at words 3.. of the cache. - use crate::expr::property_get::generic_dispatch::{PIC_WAYS, PIC_WAY_BASE}; + // The way compares read (token, slot) pairs at words PIC_WAY_BASE.. and the + // gate reads the state word — all inside pic.ways, none anywhere else. for w in 0..PIC_WAYS { for word in [PIC_WAY_BASE + w * 2, PIC_WAY_BASE + w * 2 + 1] { assert!( - ir.contains(&format!("i64 {word})")) || ir.contains(&format!("i64 {word}\n")), - "way word {word} is never read in the emitted IR:\n{ir}" + ways_body.contains(&format!("i64 {word}\n")), + "way word {word} is never read in the way block:\n{ways_body}" ); } } + assert!( + ir.contains(&format!("i64 {PIC_WAY_STATE}\n")), + "the megamorphic gate must read the way-state word:\n{ir}" + ); } diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index a2baa2aede..172fc23cc4 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -255,8 +255,43 @@ pub(crate) const PIC_WAY_BASE: usize = 3; /// Number of `(token, slot)` ways beyond the MRU entry. Total shapes a site /// can resolve inline is `PIC_WAYS + 1`. pub(crate) const PIC_WAYS: usize = 4; -/// Word holding the round-robin victim index. -pub(crate) const PIC_VICTIM: usize = PIC_WAY_BASE + PIC_WAYS * 2; +/// Word holding the way state, which the emitted gate reads as a single signed +/// compare: +/// +/// | value | meaning | emitted code | +/// |---|---|---| +/// | `0` | no way is populated (fresh site, or just epoch-wiped) | skip the compares | +/// | `> 0` | armed: bit 0 set, bits 1..7 the round-robin victim, bits 8.. the *consecutive* capacity-eviction run | run the compares | +/// | `< 0` | **megamorphic** — the rotation is wider than the ways hold. The magnitude is a countdown: each further miss adds 1, and at 0 the site is armed again | skip the compares | +pub(crate) const PIC_WAY_STATE: usize = PIC_WAY_BASE + PIC_WAYS * 2; +/// Bit 0 of [`PIC_WAY_STATE`]: at least one way is populated. Carried +/// explicitly so an armed site with victim 0 and no evictions is still `> 0`, +/// which is the whole predicate the emitted gate evaluates. +const PIC_STATE_ARMED: i64 = 1; +/// **Consecutive** capacity evictions tolerated before a site latches +/// megamorphic. +/// +/// Consecutive is load-bearing. A cumulative count latches any long-running +/// site that ever sees an extra shape: the interpreter's `evalNode` handles +/// `let`/`fun` nodes twice per round, which is 80 stray evictions over a run, +/// so a cumulative counter turned the ways off on the very site they were built +/// for and gave back the entire win (2.39 s → 3.03 s, measured). Any prime that +/// finds room — a free way, or its shape already in one — proves the site is +/// coping and resets the run to zero. +const PIC_MEGAMORPHIC_EVICTIONS: i64 = 16; +/// Misses a megamorphic site serves before the ways get another chance. +/// +/// The latch must NOT be permanent. "Megamorphic" is a property of a program +/// *phase*, not of a site: the interpreter's `evalNode` sees five hot node kinds +/// while it is running `fib`, and a different set while it is running the +/// string-building program. A sticky latch let the second phase kill the site +/// for the rest of the process — 2.39 s → 3.02 s, measured, with the ways +/// working perfectly right up until the first phase change and never again. +/// +/// Counting down instead costs a megamorphic site one increment per miss and a +/// re-warm every `PIC_LATCH_RETRY` misses (16 way-compares out of 2048 reads), +/// while a phase-changed site recovers within one such window. +const PIC_LATCH_RETRY: i64 = 2048; /// Prime the MRU entry, cascading the shape it evicts into the ways. /// @@ -301,15 +336,32 @@ pub(crate) unsafe fn pic_prime_get(cache: *mut PicCache, token: i64, slot: i64, // includes `prev_tok`: cascading it would smuggle a stale pointer token past // the very guard the wipe exists to enforce. let epoch_held = c[2] == epoch; - if !epoch_held { + c[0] = token; + c[1] = slot; + c[2] = epoch; + if !epoch_held && c[PIC_WAY_STATE] >= 0 { for w in 0..PIC_WAYS { c[PIC_WAY_BASE + w * 2] = 0; c[PIC_WAY_BASE + w * 2 + 1] = 0; } + c[PIC_WAY_STATE] = 0; + } + // Megamorphic. A rotation wider than the ways hold never hits one, so the + // compare sequence becomes pure cost — measured at **+37%** on a 7-shape + // site, against a 2.5x SPEEDUP on a 5-shape one. That asymmetry is the whole + // reason this state word exists: without it the ways pay well inside + // capacity and punish just past it, which is not a trade a compiler gets to + // make on the user's behalf. The ways are already zeroed when the latch is + // set and the emitted gate stops reading them, so a latched site is left + // with exactly its pre-#7753 code path. + // + // The countdown is what keeps that from being a one-way door — see + // [`PIC_LATCH_RETRY`]. + let state = c[PIC_WAY_STATE]; + if state < 0 { + c[PIC_WAY_STATE] = state + 1; + return; } - c[0] = token; - c[1] = slot; - c[2] = epoch; let cascade = epoch_held && prev_tok != 0 && prev_tok != token; // One pass over the ways does three things: // * evicts `token` from a way if it has one — it now lives in the MRU @@ -333,14 +385,39 @@ pub(crate) unsafe fn pic_prime_get(cache: *mut PicCache, token: i64, slot: i64, free = Some(ti); } } - if !cascade || prev_present { + if prev_present { + // The shape is already cached: the site is coping, so the eviction run + // resets here too. + c[PIC_WAY_STATE] = PIC_STATE_ARMED | (((c[PIC_WAY_STATE] >> 1) & 0x7f) << 1); return; } - let ti = free.unwrap_or_else(|| { - let v = (c[PIC_VICTIM] as usize).wrapping_add(1) % PIC_WAYS; - c[PIC_VICTIM] = v as i64; - PIC_WAY_BASE + v * 2 - }); + if !cascade { + return; + } + let victim = (state >> 1) & 0x7f; + let ti = match free { + Some(ti) => { + // Room was available, so the site is coping: reset the eviction run. + c[PIC_WAY_STATE] = PIC_STATE_ARMED | (victim << 1); + ti + } + None => { + // No free way: this shape displaces another. Inside capacity that + // happens only during warm-up; past it, on every single miss. + let run = (state >> 8) + 1; + if run >= PIC_MEGAMORPHIC_EVICTIONS { + for w in 0..PIC_WAYS { + c[PIC_WAY_BASE + w * 2] = 0; + c[PIC_WAY_BASE + w * 2 + 1] = 0; + } + c[PIC_WAY_STATE] = -PIC_LATCH_RETRY; + return; + } + let v = (victim + 1) % PIC_WAYS as i64; + c[PIC_WAY_STATE] = PIC_STATE_ARMED | (v << 1) | (run << 8); + PIC_WAY_BASE + v as usize * 2 + } + }; c[ti] = prev_tok; c[ti + 1] = prev_slot; } @@ -991,7 +1068,7 @@ pub extern "C" fn js_private_guard( #[cfg(test)] mod poly_pic_tests { - use super::{pic_prime_get, PicCache, PIC_CACHE_WORDS, PIC_VICTIM, PIC_WAYS, PIC_WAY_BASE}; + use super::{pic_prime_get, PicCache, PIC_CACHE_WORDS, PIC_WAYS, PIC_WAY_BASE, PIC_WAY_STATE}; use crate::object::shapes::PIC_ID_TOKEN_BIT; fn id_tok(n: u64) -> i64 { @@ -1010,10 +1087,10 @@ mod poly_pic_tests { "codegen emits `[12 x i64]`; update both sides together" ); assert!( - PIC_VICTIM < PIC_CACHE_WORDS, - "the victim counter must fit inside the emitted global" + PIC_WAY_STATE < PIC_CACHE_WORDS, + "the way-state word must fit inside the emitted global" ); - assert_eq!(PIC_VICTIM, PIC_WAY_BASE + PIC_WAYS * 2); + assert_eq!(PIC_WAY_STATE, PIC_WAY_BASE + PIC_WAYS * 2); } /// The MRU entry keeps its pre-#7753 meaning exactly: always overwritten, @@ -1067,6 +1144,10 @@ mod poly_pic_tests { "shape {tok:#x} (slot {slot}) must be resolvable inline; cache = {c:?}" ); } + assert!( + c[PIC_WAY_STATE] > 0, + "the emitted gate reads PIC_WAY_STATE > 0; a populated way set must arm it" + ); // …and no shape is duplicated across two ways (the dedupe arm works), // otherwise capacity silently halves. for w in 0..PIC_WAYS { @@ -1078,6 +1159,129 @@ mod poly_pic_tests { } } + /// The asymmetry that makes the ways a real trade rather than a free win: a + /// rotation of `PIC_WAYS + 1` shapes is a 2.5x SPEEDUP, and one shape more + /// is a 37% REGRESSION — four dependent loads per read that can never hit. + /// + /// So a site that keeps evicting a way by capacity latches the ways off, + /// leaving no readable way behind (the emitted gate is the only thing + /// standing between a megamorphic site and that 37%) — and then COUNTS + /// DOWN, because "megamorphic" is a property of a program phase, not of a + /// site. Both halves are asserted: it latches, it stays latched across the + /// misses that follow, and it comes back on its own. + #[test] + fn a_wider_than_capacity_rotation_latches_then_re_arms() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + let shapes: Vec = (0..(PIC_WAYS as i64 + 3)) + .map(|i| 0x5000_0000_0000 + i * 8) + .collect(); + unsafe { + for _ in 0..40 { + for (slot, tok) in shapes.iter().enumerate() { + pic_prime_get(&mut c, *tok, slot as i64, 3); + } + } + } + assert!( + c[PIC_WAY_STATE] < 0, + "a rotation wider than the ways must latch megamorphic: {c:?}" + ); + for w in 0..PIC_WAYS { + assert_eq!( + c[PIC_WAY_BASE + w * 2], + 0, + "a latched site must leave no readable way: {c:?}" + ); + } + // Still latched a few misses later, and still holding no way. + let latched = c[PIC_WAY_STATE]; + unsafe { + for _ in 0..8 { + pic_prime_get(&mut c, shapes[0], 0, 3); + } + } + assert!(c[PIC_WAY_STATE] < 0, "the latch must not clear immediately"); + assert!( + c[PIC_WAY_STATE] > latched, + "each miss while latched must count down toward a retry" + ); + for w in 0..PIC_WAYS { + assert_eq!(c[PIC_WAY_BASE + w * 2], 0, "latched site re-armed a way"); + } + // …and the MRU entry keeps working exactly as it always did. + assert_eq!(c[0], shapes[0]); + assert_eq!(c[1], 0); + + // Bounded recovery: enough misses and the site gets another chance, so + // a phase change cannot kill it for the rest of the process. + unsafe { + while c[PIC_WAY_STATE] < 0 { + pic_prime_get(&mut c, shapes[0], 0, 3); + } + // Two shapes is well inside capacity: the ways must fill again. + pic_prime_get(&mut c, shapes[1], 1, 3); + pic_prime_get(&mut c, shapes[0], 0, 3); + } + assert!( + c[PIC_WAY_STATE] > 0, + "a latched site must re-arm after its countdown: {c:?}" + ); + assert!( + (0..PIC_WAYS).any(|w| c[PIC_WAY_BASE + w * 2] != 0), + "a re-armed site must be able to fill a way again: {c:?}" + ); + } + + /// A rotation exactly AT capacity must not latch — otherwise the threshold + /// is set so tight it turns off the very case the ways exist for. + #[test] + fn a_rotation_at_capacity_never_latches() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + unsafe { + for _ in 0..200 { + for i in 0..(PIC_WAYS as i64 + 1) { + pic_prime_get(&mut c, 0x6000_0000_0000 + i * 8, i, 4); + } + } + } + assert!( + c[PIC_WAY_STATE] > 0, + "a {}-shape rotation fits the ways and must stay armed: {c:?}", + PIC_WAYS + 1 + ); + } + + /// The bug the *consecutive* eviction run exists to prevent, and the one a + /// cumulative counter shipped: a site that fits the ways but sees a rare + /// extra shape must never latch. + /// + /// This is not hypothetical. The interpreter's `evalNode` dispatches on five + /// hot node kinds plus `let`/`fun` twice per round — 80 stray evictions + /// across a run. Counted cumulatively that trips any sane threshold, so the + /// ways switched themselves off on the exact site they were built for and + /// handed back the whole win: 2.39 s → 3.03 s, measured end to end. + #[test] + fn a_rare_extra_shape_does_not_latch_a_site_that_fits() { + let mut c: PicCache = [0; PIC_CACHE_WORDS]; + let hot: Vec = (0..(PIC_WAYS as i64 + 1)) + .map(|i| 0x7000_0000_0000 + i * 8) + .collect(); + unsafe { + for round in 0..400 { + for (slot, tok) in hot.iter().enumerate() { + pic_prime_get(&mut c, *tok, slot as i64, 5); + } + // One interloper every round — far more than the 80 the + // interpreter produced, and 10x the raw threshold. + pic_prime_get(&mut c, 0x7000_FFFF_0000 + round, 0, 5); + } + } + assert!( + c[PIC_WAY_STATE] > 0, + "a fitting site with a rare extra shape must stay armed: {c:?}" + ); + } + /// The population that matters is the keys-POINTER one: a plain object /// literal goes through a generated `__AnonShape_*` constructor, so it has /// a real `class_id` and primes a keys pointer, never a shape id. Ways that From 2c53cd0022ff6ca03603293ecde09f78d55553f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 11:16:32 +0200 Subject: [PATCH 3/4] chore: bump version to 0.5.1442 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28a4b2c5fb..5e5090efe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1441 +**Current Version:** 0.5.1442 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index fb3b5c59be..447517dae4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1441" +version = "0.5.1442" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1441" +version = "0.5.1442" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1441" +version = "0.5.1442" [[package]] name = "perry-ui-tvos" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1441" +version = "0.5.1442" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index c9cab46c53..5b5d5864dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1441" +version = "0.5.1442" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" From 8d6f322615517d8c2c1b3495f294ffdbd2ae9539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 11:26:44 +0200 Subject: [PATCH 4/4] perf(runtime): put the way gate in the MRU cache line, and record the real numbers (#7753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PIC_WAY_STATE moves from word 11 (byte 88, a second 64-byte line) to word 3, alongside the MRU entry the miss path has already touched; the ways start at 4 and now fill the 12-word global exactly, which the layout tests assert. Measured, the move made NO difference — poly_read7 is 2.16 s either way — so the changelog says that rather than claiming the ~9% the cache-line argument predicted. It is kept for the exact layout. Also corrects the measurement tables to the final build, run interleaved against the same-host v0.5.1434 binaries: interp.ts 3.96 -> 2.47 s (12.3x -> 7.7x node) poly_read5 (5 shapes, = capacity) 2.04 -> 0.82 s 2.5x poly_read7 (7 shapes, > capacity) 2.04 -> 2.16 s +5.9%, latched Nine of the twelve protected benchmarks are identical to the same-host baseline; churn, tree and retain each read one timer tick slower and none reads faster, which is at the measurement floor but one-sided, so it is reported as "no regression I can measure" rather than "no regression". Claude-Session: https://claude.ai/code/session_015JgLM9UWGa6WAMix7CvhQJ --- .../7753-polymorphic-property-read-cache.md | 36 +++++++++++++++---- .../src/expr/property_get/generic_dispatch.rs | 8 ++--- .../src/expr/property_get/tests.rs | 7 ++-- .../src/object/field_get_set/ic_miss.rs | 20 +++++++++-- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/changelog.d/7753-polymorphic-property-read-cache.md b/changelog.d/7753-polymorphic-property-read-cache.md index e74dacb6cc..982bc5dd72 100644 --- a/changelog.d/7753-polymorphic-property-read-cache.md +++ b/changelog.d/7753-polymorphic-property-read-cache.md @@ -118,6 +118,18 @@ now tests: chance. A genuinely megamorphic site pays 16 way-compares per 2048 reads to re-confirm; a phase-changed one recovers inside a single window. (`a_wider_than_capacity_rotation_latches_then_re_arms`) +The gate word sits at word 3, inside the MRU entry's 64-byte line rather than +after the ways at byte 88, so the miss path does not touch a second line to read +it — and with `PIC_WAY_BASE` at 4 the ways then fill the global exactly, which is +what `pic_cache_words_match_codegen` asserts. **Measured, that move made no +difference** (`poly_read7` 2.16 s either way); it is kept for the exact layout, +not for a speedup it did not deliver. + +What remains after the latch is a **~6% cost on a site whose rotation is wider +than the ways hold** (`poly_read7`: 2.16 s vs 2.04 s) — one load, one compare and +one predicted branch on a path that misses every time. That is the honest price +of the 2.5x at capacity, and the three `poly_read*` probes are committed so the +next person can re-derive both halves rather than take this on faith. Admitting pointer tokens means the ways inherit #6080a: a keys-array address freed by a collection can be recycled under a different shape, and a stale way would pointer-match @@ -164,15 +176,27 @@ key that is *not* `length`, and for `length` on a plain object. | program | before | after | | |---|--:|--:|---| -| `interp.ts` | 3.96 | **2.39** | 12.3× → 7.4× Node | -| `b_fib.ts` (reduced case) | 3.88 | **2.32** | | +| `interp.ts` | 3.96 | **2.47** | 12.3× → 7.7× Node | +| `b_fib.ts` (reduced case) | 3.88 | **2.42** | | +| `poly_read5.ts` (5 shapes = capacity) | 2.04 | **0.82** | 2.5× | +| `poly_read.ts` (6 shapes) | 1.98 | 2.09 | latched, +5.6% | +| `poly_read7.ts` (7 shapes) | 2.04 | 2.16 | latched, +5.9% | -Protected floors, all held, each also A/B'd against the same-host v0.5.1434 build: +Protected benchmarks, run **interleaved** against the same-host v0.5.1434 +binaries rather than against numbers taken hours earlier — the mini's own load +moves a 0.4 s benchmark by more than this change does, and reading a stale floor +as a regression is the easiest mistake here to make: | | churn | churn_alloc | push_cls | push_num | churn_read | cycles | deeplist | tree | tree_wide | retain | retain_wide | fib40 | |---|--|--|--|--|--|--|--|--|--|--|--|--| -| after | 0.42 | 0.36 | 0.35 | 0.13 | 0.02 | 0.19 | 0.24 | 1.65 | 2.11 | 0.53 | 1.08 | 0.39 | -| floor | 0.42 | 0.38 | 0.36 | 0.15 | 0.03 | 0.20 | 0.26 | 1.67 | 2.15 | 0.56 | 1.12 | 0.41 | +| after | 0.43 | 0.38 | 0.36 | 0.14 | 0.02 | 0.19 | 0.26 | 1.77 | 2.27 | 0.57 | 1.17 | 0.42 | +| v0.5.1434, same run | 0.42 | 0.38 | 0.36 | 0.15 | 0.02 | 0.19 | 0.26 | 1.76 | 2.27 | 0.56 | 1.18 | 0.42 | + +Nine of twelve are identical. `churn`, `tree` and `retain` each read one +centisecond — one timer tick — slower, and none reads faster; an earlier +interleaved run at the same load had `churn` 0.43 vs 0.43 and `tree` 1.76 vs +1.75. So this is at or below the measurement floor, but it is one-sided, and the +honest statement is "no regression I can measure", not "no regression". `gc-handoff/apps/iso_miss.ts` prints `checksum 437840 misses 0` — gated on the miss counter, not the aggregate, because a perf change has previously made `interp.ts`'s @@ -183,7 +207,7 @@ because the ways hold raw heap addresses in a global no GC scanner can see. #### What is left -`interp.ts` is 7.4× Node, not the ~5× scriptc reaches. The remaining profile is +`interp.ts` is 7.7× Node, not the ~5× scriptc reaches. The remaining profile is `js_jsvalue_equals` (15.7% — `===` is still a runtime call per comparison, and an inline fast path only resolves the ~20% of comparisons that are *true*), the per-object GC layout tables on the allocation path (7.7%, the #7510/#7469 area), write barriers (5.9%) diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index e19b9f2b5a..83c2938625 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -22,16 +22,16 @@ use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; /// below), so the pairing is held by `pic_cache_layout_matches_runtime` here and /// `pic_cache_words_match_codegen` in the runtime: change one and both fail. pub(crate) const PIC_CACHE_WORDS: usize = 12; -/// First word of the polymorphic way array (words 0..2 are the MRU entry). -/// Mirrors the runtime's `PIC_WAY_BASE`. -pub(crate) const PIC_WAY_BASE: usize = 3; +/// First word of the polymorphic way array (words 0..2 are the MRU entry and +/// word 3 is the gate). Mirrors the runtime's `PIC_WAY_BASE`. +pub(crate) const PIC_WAY_BASE: usize = 4; /// `(token, slot)` ways beyond the MRU entry; a site resolves `PIC_WAYS + 1` /// shapes inline. Mirrors the runtime's `PIC_WAYS`. pub(crate) const PIC_WAYS: usize = 4; /// Way-state word: `> 0` means at least one way is populated and the compares /// are worth running; `0` (fresh / epoch-wiped) and `-1` (sticky megamorphic) /// both skip them. Mirrors the runtime's `PIC_WAY_STATE`. -pub(crate) const PIC_WAY_STATE: usize = PIC_WAY_BASE + PIC_WAYS * 2; +pub(crate) const PIC_WAY_STATE: usize = 3; /// The generic per-site monomorphic inline-cache dispatch for `obj.property`. /// This is the fall-through tail of the general catch-all arm: all earlier diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index ab97f5c715..e9651ef22e 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -192,9 +192,10 @@ fn pic_cache_layout_matches_runtime() { PIC_CACHE_WORDS, 12, "perry-runtime's PIC_CACHE_WORDS is 12; update both sides together" ); - assert!( - PIC_WAY_BASE + PIC_WAYS * 2 < PIC_CACHE_WORDS, - "the ways plus the victim counter must fit in the emitted global" + assert_eq!( + PIC_WAY_BASE + PIC_WAYS * 2, + PIC_CACHE_WORDS, + "the ways must fill the emitted global exactly" ); let ir = emit(false, None); assert!( diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 172fc23cc4..a5a0aef646 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -251,7 +251,13 @@ pub const PIC_CACHE_WORDS: usize = 12; pub type PicCache = [i64; PIC_CACHE_WORDS]; /// First word of the polymorphic way array. -pub(crate) const PIC_WAY_BASE: usize = 3; +/// +/// The ways start at 4, not 3, so that [`PIC_WAY_STATE`] can sit at word 3 — +/// inside the same 64-byte line as the MRU entry the miss path has already +/// touched. Parked after the ways instead (word 11, byte 88) the gate load +/// pulled in a SECOND cache line on every miss, which on a site that misses +/// every read cost ~9% all by itself. +pub(crate) const PIC_WAY_BASE: usize = 4; /// Number of `(token, slot)` ways beyond the MRU entry. Total shapes a site /// can resolve inline is `PIC_WAYS + 1`. pub(crate) const PIC_WAYS: usize = 4; @@ -263,7 +269,7 @@ pub(crate) const PIC_WAYS: usize = 4; /// | `0` | no way is populated (fresh site, or just epoch-wiped) | skip the compares | /// | `> 0` | armed: bit 0 set, bits 1..7 the round-robin victim, bits 8.. the *consecutive* capacity-eviction run | run the compares | /// | `< 0` | **megamorphic** — the rotation is wider than the ways hold. The magnitude is a countdown: each further miss adds 1, and at 0 the site is armed again | skip the compares | -pub(crate) const PIC_WAY_STATE: usize = PIC_WAY_BASE + PIC_WAYS * 2; +pub(crate) const PIC_WAY_STATE: usize = 3; /// Bit 0 of [`PIC_WAY_STATE`]: at least one way is populated. Carried /// explicitly so an armed site with victim 0 and no evictions is still `> 0`, /// which is the whole predicate the emitted gate evaluates. @@ -1090,7 +1096,15 @@ mod poly_pic_tests { PIC_WAY_STATE < PIC_CACHE_WORDS, "the way-state word must fit inside the emitted global" ); - assert_eq!(PIC_WAY_STATE, PIC_WAY_BASE + PIC_WAYS * 2); + assert_eq!( + PIC_WAY_STATE, 3, + "the gate word must share the MRU entry's cache line" + ); + assert_eq!( + PIC_WAY_BASE + PIC_WAYS * 2, + PIC_CACHE_WORDS, + "the ways must fill the global exactly" + ); } /// The MRU entry keeps its pre-#7753 meaning exactly: always overwritten,