Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OWNER
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OWNER: session 57b8a088 — train120 assembly, no build
52 changes: 52 additions & 0 deletions changelog.d/9728-dynamic-number-tostring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
**A dynamically dispatched `x["toString"]()` on a number now produces
`NumberToString`, not Rust's `f64` Display** (#9713). It printed `inf` for
`Infinity` and the full decimal expansion past the exponential thresholds, so
the same value stringified four static ways and once dynamically disagreed
inside one program:

```ts
const a = 2.2e-308;
a.toString(); // 2.2e-308 (all four static forms)
((x: any, m: string) => x[m]())(a, "toString"); // 0.000…00022 — ~308 digits
```

Three arms of the native-method tower — the plain-number and boxed-`Number`
`toString` in `dispatch_common`, and the boxed-`Number`
`toString`/`toLocaleString` in `dispatch_primitive` — formatted with

```rust
if n.fract() == 0.0 && n.abs() < INT_EXACT_FASTPATH_LIMIT { (n as i64).to_string() } else { n.to_string() }
```

`f64::to_string()` is Rust's shortest-round-trip Display, which never switches
to scientific notation and renders the infinities as `inf`. It is the exact
mistake `js_format_f64`'s doc comment already warns about — #3987 replaced the
same `format!("{}", n)` in the string-concat fast paths and these three arms
were not part of that sweep. They now call `js_number_to_string`, which carries
the spec's `|n| >= 1e21 || |n| < 1e-6` switch, the `Infinity` / `NaN` / `-0`
spellings, and its own (safer) integer fast path — `js_format_f64` cuts over to
the shortest-round-trip formatter at 1e15 rather than 2^53, so it also avoids
the `2**58` → `…744` vs `…740` divergence the local fast path could reach.

Measured against node 26.5.1, previously wrong and now correct: `1e21`,
`1e-7`, `-2.5e-9`, `2.2e-308`, `Number.MAX_VALUE`, `Number.MIN_VALUE`,
`Number.EPSILON`, `±Infinity`, and every one of those again through
`new Number(x).toString()`.

One neighbouring defect in the same arms rides along: a boxed receiver dropped
an explicit radix entirely, so `new Number(255).toString(16)` answered `"255"`
instead of `"ff"`. Both boxed arms now route an explicit radix through
`js_jsvalue_to_string_radix` the way the unboxed arm already did (which also
means an out-of-range radix throws `RangeError` there, as the spec requires).
`toLocaleString` keeps ignoring its argument — that one is a locale, not a
radix.

`test-files/test_gap_9713_dynamic_number_tostring.ts` pins 18 values across the
thresholds in all seven renderings plus the radix and `toFixed` /
`toPrecision` / `toExponential` forms. Unpatched it differs from node on 12
lines; patched it is byte-identical.

Not fixed here, and filed separately: `toString(radix)` above 2^53 for a
non-power-of-two radix still emits exact digits rather than V8's shortest
round-trip (#9725) — that one reproduces from a plain static call and is a
different formatter.
70 changes: 70 additions & 0 deletions changelog.d/9729-lazy-inline-cache-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
### Inline caches are allocated per *used* site, not per emitted site (#9708)

Every inline-cache site codegen emitted — the generic property read, the
static- and dynamic-key write ICs and their poly tail, the Symbol-keyed and
composed `o[sym].field` reads, the Array-subclass `length` / `[i]` caches,
the fused `if (a.f[i]) return a.f[i]` cache and the imported-object method
guard — owned a `[12 x i64] zeroinitializer` global: 96 B of `__bss` per
site whether or not the program ever executed it. On the Claude Code bundle
that was 262k caches, 25 MB of zero-fill, and 18.7 MB of it **dirty resident
memory at idle**, because a page is dirtied by the first cache touched on it
and the few thousand hot sites are scattered across all of them.

A site now owns an 8-byte pointer **slot**, `@perry_ic_N = private global
ptr null`. The cache words live in a runtime arena
(`perry-runtime/src/object/field_get_set/ic_slot.rs`): the miss handler
resolves the slot with `pic_slot_resolve` the first time it actually
*primes* the site, bump-allocates the words from a 64 KiB zeroed chunk and
publishes them with a compare-and-swap (two `perry/thread` agents racing on
one site agree on one cache). A miss that cannot prime — proxy, string or
small-handle receiver, a missing key, an accessor, a frozen target — never
touches the slot, so such a site costs its 8 bytes and nothing else; the
write IC's poly tail is not allocated until a fifth shape arrives. Cache
layout and every prime/evict policy are unchanged: the runtime writes the
same words through the same `PicCache` type, and `pic_slot_resolve` sizes
the allocation from that type, so the width pairing test keeps its meaning.

**Hot path.** Each inline hit path loads the slot (a load with no dependency
on the receiver, so it issues alongside the header loads) and folds `!= null`
into the receiver guard it already evaluates — one fused compare, no new
block — then reads the cache words through the loaded pointer; the runtime
entries take the slot's address. Where a site reads word 0 inside a flat
predicate (the dynamic-key write IC, the array-like index cache, the method
guard) it reads through `select(present, cache, slot)`: the slot's own 8
bytes of null are exactly the zero token an empty global used to read as, so
the branch structure and the transition-IC reachability are untouched.
Measured on x86-64 (`perf stat -e instructions:u`, perry-dev builds):

| program | base | lazy slots | delta |
|---|---:|---:|---:|
| all-generic-IC microbenchmark (95M IC ops) | 13.662 G | 14.196 G | +3.9 % (3 instr per hit: slot load, `test`, never-taken `je`) |
| `bench_object_property` | 250.9 M | 248.6 M | −0.9 % |
| `bench_json_readonly` | 2 263.2 M | 2 258.6 M | −0.2 % |
| `bench_dynamic_property_keys` | 1 129.1 M | 1 124.5 M | −0.4 % |
| `07_object_create`, `09_method_calls`, `12_binary_trees`, `14_closure` | | | ±0.00 % |

The typed-feedback IC counters (`PERRY_TYPED_FEEDBACK_TRACE`) are identical
on both arms for the microbenchmark — 81 666 674 guard passes, 18 333 339
guard failures, 18 333 339 fallback calls over 18 sites — so hit rates are
unchanged, not merely output. On the issue's target (macOS arm64) the fused
compare is a `ccmp`, so the hit-path cost there is the slot load plus one
instruction.

**Footprint.** A generated probe with 16 000 read sites of which 1 604 prime
(every 10th function runs — the scattered-hot-sites shape from the issue),
Linux x86-64, 4 KiB pages: `.bss` 2 408 752 → 997 144 B, whole-process
anonymous `Private_Dirty` 1 660 → 632 kB. The `PERRY_GC_CENSUS` side table
gains an `ic.lazy_caches` row (resolved sites, arena bytes) so a run can
assert the subject was live; the issue's macOS `vmmap` numbers are the ones
to re-measure on a bundle build.

Gap coverage: `test_gap_9708_lazy_inline_cache_slots.ts` exercises every
IC shape across the null → allocated transition — mono/poly/megamorphic
reads, a site that can never prime, a nullish first read, inherited
properties, static writes through the four ways and the poly tail, a frozen
target, rotating dynamic keys, Symbol and composed Symbol-then-field reads
with invalidation, Array-subclass `length`/index, the fused field-index
return, and hundreds of never-executed sites — and matches node byte for
byte. `array/subclass.rs` was at the 2 000-line cap, so
`js_packed_arraylike_index_get` and its cache types moved to the child module
`array/subclass_packed_index.rs`.
51 changes: 51 additions & 0 deletions changelog.d/9730-labeled-escape-nested-loops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
**A labeled `break`/`continue` that targets an outer loop from inside a nested
loop now works in generators, async generators and async functions** (#9199).
It previously threw `TypeError: Cannot read properties of undefined (reading
'done')` for `break`, and silently produced nothing for `continue`.

```ts
async function* g() {
O: for (const x of [1, 2]) { I: for (const y of [0, 1]) { yield "b" + x + y; break O; } }
}
// node: b10 before: TypeError … reading 'done'
```

Generator linearization gives each loop a single `break` sentinel and a single
`continue` sentinel, so a completion can only name the loop it sits in.
`rewrite_labeled_bc_in_stmts` therefore converted `break label` / `continue
label` to plain completions **only at the labeled loop's own body level** and
stopped at nested loops — correctly, since a plain completion inside a nested
loop would bind to that loop. What was missing is what happens to the escape
that is left: it survived verbatim into a state body, where the dispatch
lowering has no sentinel for it and dropped it. The limitation was noted in the
code ("the single-sentinel scheme can't yet distinguish targets").

Rather than teach the state machine to name a distant target, the escape is now
unwound one loop at a time through a carrier local, so every completion the
linearizer sees is plain and binds to the loop it is in:

```
__esc = 0;
inner: while (…) { … __esc = 1; break; … } // was `break label`
if (__esc == 1) break; // in the labeled loop
if (__esc == 2) continue;
```

Deeper nesting reuses the same carrier and propagates outward with a bare
`if (__esc != 0) break;` after each intermediate loop. A `switch` that carries
an escape is desugared to `if`s first, since a plain `break` inside a switch
would bind to the switch.

The hole was wider than the issue's own repro, which #9189 had already closed:
it reached sync generators and async functions as well as async generators,
and the `switch` in the report was incidental — a bare `break outer` in a
nested loop failed on its own, while the switch-wrapped form worked because
#9186's routing already handled it.

`test-files/test_gap_9199_labeled_escape_nested_loops.ts` pins 13 shapes:
`break`/`continue` of an outer label from a nested loop in all three function
kinds, three-deep nesting, a `while` outer, an `await` before the escape, a
conditional escape, `try`/`finally` around it (finalizers still run in order),
a reused label name on a sibling loop, and the switch-wrapped form that already
worked. Unpatched the fixture throws on its first row and then hangs; patched
it is byte-identical to node 26.5.1.
20 changes: 20 additions & 0 deletions changelog.d/9731-arena-right-sizing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
**Idle heaps now return arena capacity left behind by a burst.** General-arena
blocks deliberately need two full-GC observations before their mappings can be
released, but the idle reducer excluded its own collections from its activity
clock. A quiet heap therefore got one full and could stop forever with every
empty block only halfway through the page-return protocol (#9709).

Two consecutive post-collection samples at or below 50% utilization, above a
32 MiB capacity floor, now open a bounded arena right-size episode. The existing
idle-reclaim and page-return paths supply only the full observations still
needed, stop early once live data reaches 60% of capacity, and remain subject to
the reducer's quiet-time, rate, wake, and work-budget gates. A completed episode
stays disarmed until utilization reaches 70% or capacity grows materially
(at least 25% and 8 MiB), so a retained low live set cannot turn the idle timer
into a periodic full-GC loop.

On the compiled Claude Code 2.1.112 workload from the report, arena capacity
fell from 96.5 MiB before the episode to 36.7 MiB, then 35.7 MiB and 35.7 MiB
across a five-minute idle soak with about 23 MiB live. RSS fell from 401 MiB at
the first census to 132 MiB at the last, and exactly one idle full was attributed
to arena right-sizing.
5 changes: 5 additions & 0 deletions changelog.d/9732-followup-diag-counter-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**Classify #9717's `forwarded_stub_recoveries` diagnostic counter.** The
`gc_runtime_root_holders` gate requires a written verdict for every new
core `perry_thread_local!` declaration; `FORWARDED_STUB_MEMBERSHIP_RECOVERIES`
is a `Cell<u64>` tally reported on the `PERRY_GC_DIAG` `[gc-incremental]` line
and holds no address, so it records as `not_a_gc_pointer`.
7 changes: 7 additions & 0 deletions changelog.d/9732-idle-reclaim-growth-stub-membership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**Idle-time (budgeted) collections no longer sweep an array a live field reaches only through an array-growth forwarding stub (#9717).** A `#private` array pushed past its inline capacity leaves a *permanent* forwarding stub at the pre-grow address, and the reference pointing at it is never rewritten (#6228/#233), so a live slot — hono `SmartRouter`'s `#routes`, in the report — keeps naming the stub. A **synchronous** full trace is fine: its exact census (`ValidPointerSetBuilder::record_arena_header`) admits every arena object, stubs included, so `mark_field_into_worklist` marks the stub and `trace_one_worklist_header` follows it to the live array.

A **budgeted** full trace — the one the idle-time reducer (`PERRY_GC_IDLE_RECLAIM`) runs when a server goes quiet between requests — resolves membership through the page-metadata classifier instead of a census. `classifier_valid_object_start` rejected every `GC_FLAG_FORWARDED` header by design (a dead metadata key's recycled bytes can set that bit, #8040), so the field→stub edge was silently dropped: the stub was never marked, the FORWARDED-follow never ran, and the array reachable *only* through the stub was swept. The field then resolved to reused memory — an empty array — and every route `match()` returned 404 for the life of the process. It reproduced only when the first request arrived ~10–20 s after startup while background work allocated: an early request built the router before any idle collection ran.

**Fix.** The classifier is documented as a census *superset*; for growth stubs it was not. `classifier_valid_object_start` now admits a plausible forwarded arena stub (`GC_FLAG_ARENA` set, valid `obj_type`/size — the shape a real growth stub has, which separates it from off-heap bytes that coincidentally set the bit). The forwarding *target* is still validated where it always was, in `trace_one_worklist_header`'s follow, so a garbage target simply stops the walk. A `PERRY_GC_DIAG` counter (`forwarded_stub_recoveries=` on the `[gc-incremental]` line) reports how many such stubs a budgeted cycle recovered; it stays zero on a run with no such edge.

Regression coverage: `gc::tests::forwarded_stub_membership` plants the edge, asserts the pre-fix census-superset gate would have rejected the stub, and drives a budgeted full cycle to completion — the array reached only through the stub survives with its contents intact, and a synchronous full cycle keeps it without needing the recovery path.
6 changes: 1 addition & 5 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1403,11 +1403,7 @@ pub(super) fn compile_closure(
llmod.declare_function(&name, ret, &params);
}
for ic_name in &ic_globals {
llmod.add_raw_global(format!(
"@{} = private global [{} x i64] zeroinitializer",
ic_name,
crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS
));
llmod.add_raw_global(crate::expr::inline_cache_global_definition(ic_name));
}
for raw in &typed_parse_rodata {
llmod.add_raw_global(raw.clone());
Expand Down
12 changes: 2 additions & 10 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1426,11 +1426,7 @@ pub(super) fn compile_module_entry(
llmod.declare_function(&name, ret, &params);
}
for ic_name in &ic_globals {
llmod.add_raw_global(format!(
"@{} = private global [{} x i64] zeroinitializer",
ic_name,
crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS
));
llmod.add_raw_global(crate::expr::inline_cache_global_definition(ic_name));
}
for raw in &typed_parse_rodata {
llmod.add_raw_global(raw.clone());
Expand Down Expand Up @@ -1959,11 +1955,7 @@ pub(super) fn compile_module_entry(
// A dylib's top-level plugin exports live in its entry module, and the
// three symbols must be defined exactly once per shared library.
for ic_name in &ic_globals {
llmod.add_raw_global(format!(
"@{} = private global [{} x i64] zeroinitializer",
ic_name,
crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS
));
llmod.add_raw_global(crate::expr::inline_cache_global_definition(ic_name));
}
for raw in &typed_parse_rodata {
llmod.add_raw_global(raw.clone());
Expand Down
6 changes: 1 addition & 5 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,11 +1454,7 @@ pub(super) fn compile_function(
llmod.declare_function(&name, ret, &params);
}
for ic_name in &ic_globals {
llmod.add_raw_global(format!(
"@{} = private global [{} x i64] zeroinitializer",
ic_name,
crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS
));
llmod.add_raw_global(crate::expr::inline_cache_global_definition(ic_name));
}
for raw in &typed_parse_rodata {
llmod.add_raw_global(raw.clone());
Expand Down
12 changes: 2 additions & 10 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1336,11 +1336,7 @@ pub(super) fn compile_method(
llmod.declare_function(&name, ret, &params);
}
for ic_name in &ic_globals {
llmod.add_raw_global(format!(
"@{} = private global [{} x i64] zeroinitializer",
ic_name,
crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS
));
llmod.add_raw_global(crate::expr::inline_cache_global_definition(ic_name));
}
for raw in &typed_parse_rodata {
llmod.add_raw_global(raw.clone());
Expand Down Expand Up @@ -1870,11 +1866,7 @@ pub(super) fn compile_static_method(
llmod.declare_function(&name, ret, &params);
}
for ic_name in &ic_globals {
llmod.add_raw_global(format!(
"@{} = private global [{} x i64] zeroinitializer",
ic_name,
crate::expr::property_get::generic_dispatch::PIC_CACHE_WORDS
));
llmod.add_raw_global(crate::expr::inline_cache_global_definition(ic_name));
}
for raw in &typed_parse_rodata {
llmod.add_raw_global(raw.clone());
Expand Down
16 changes: 14 additions & 2 deletions crates/perry-codegen/src/expr/index_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,27 @@ pub(crate) fn lower_symbol_property_get_ic(
ctx.ic_site_counter += 1;
let cache_name = super::inline_cache_global_name(ctx, site_id);
ctx.ic_globals.push(cache_name.clone());
let cache_ref = format!("@{cache_name}");

let probe_idx = ctx.new_block("symic.probe");
let hit_idx = ctx.new_block("symic.hit");
let miss_idx = ctx.new_block("symic.miss");
let merge_idx = ctx.new_block("symic.merge");
let probe_label = ctx.block_label(probe_idx);
let hit_label = ctx.block_label(hit_idx);
let miss_label = ctx.block_label(miss_idx);
let merge_label = ctx.block_label(merge_idx);

// #9708: the cache sits behind a pointer slot that the miss handler fills
// on the first prime. The probe's three loads go through the pointer, so
// an absent cache branches straight to the miss — the edge a fresh
// (all-zero) global took anyway, since a zero epoch never matches.
let ic_slot = super::emit_inline_cache_slot(ctx, &cache_name);
let cache_ref = ic_slot.cache.clone();
let cache_slot_ref = ic_slot.slot_ref.clone();
ctx.block()
.cond_br(&ic_slot.present, &probe_label, &miss_label);

ctx.current_block = probe_idx;
let epoch = ctx
.block()
.load_atomic_acquire(I64, "@PERRY_SYMBOL_PROPERTY_IC_EPOCH", 8);
Expand Down Expand Up @@ -110,7 +122,7 @@ pub(crate) fn lower_symbol_property_get_ic(
let miss_value = ctx.block().call(
DOUBLE,
"js_object_get_symbol_property_ic_miss",
&[(DOUBLE, obj_box), (DOUBLE, sym_box), (PTR, &cache_ref)],
&[(DOUBLE, obj_box), (DOUBLE, sym_box), (PTR, &cache_slot_ref)],
);
let miss_end = ctx.block().label.clone();
ctx.block().br(&merge_label);
Expand Down
Loading
Loading