diff --git a/CLAUDE.md b/CLAUDE.md index 3b5a1dac6c..f9d1f72f64 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.1305 +**Current Version:** 0.5.1306 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 9e3cd8548c..cc710ced3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1305" +version = "0.5.1306" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1305" +version = "0.5.1306" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1305" +version = "0.5.1306" [[package]] name = "perry-ui-tvos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1305" +version = "0.5.1306" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index e0b6a27f02..45ea81b897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1305" +version = "0.5.1306" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7536-barrier-elision.md b/changelog.d/7536-barrier-elision.md new file mode 100644 index 0000000000..3cb3bfae5a --- /dev/null +++ b/changelog.d/7536-barrier-elision.md @@ -0,0 +1,73 @@ +### Codegen: class-field store GC bookkeeping decided by one inline test instead of three calls (#7511) + +Write barriers were 16.1% of `churn_alloc`'s symbolicated profile on a program +whose stores are all doubles, and #5334 lever D — which elides the barrier for a +value that is a non-pointer *by construction* — never fired there. + +**Why lever D cannot fire, which is not what the ticket assumed.** The ticket's +premise ("the types say `v: number`") is wrong in that form: Perry does not +validate declared types at runtime, so a field declared `number` legitimately +receives a string through an `any`, and the annotation is never a layout fact. +The real reason is structural. Since the `[#bloat]` `force_ctor_call` default +(`lower_call/new.rs`), a class with its own constructor is **not inlined** at the +`new` site — its body is compiled once as the shared +`_constructor(this, p0, …)` symbol and called. HIR rewrites every +closed-shape object literal into a `New` of a synthesized anon-shape class with +exactly that constructor shape (`lower/context.rs::mint_anon_shape_class`), so +`{v: base + j, w: j}` lands there too. The expression reaching the field store is +therefore `Expr::LocalGet()` — an LLVM function argument of a +function shared by every `new` site in the module, including ones that pass +pointers. No by-construction proof about that value can exist at that site. + +What can be decided there is the same question the three bookkeeping callees each +ask first, one at a time, across three cross-crate calls: `js_write_barrier_slot` +(`barrier_child_prologue` returns immediately when `decode_heap_addr(child) == 0`), +`js_string_addref_if_heap_string` (tag-checked no-op off `STRING_TAG`), and +`js_gc_note_slot_layout` (for a non-pointer value the note can only ever *clear* +mask state, never set it — the identical argument +`class_field_store_needs_layout_note` already ships, including its +`requires_raw_f64 == false` precondition). Ask it once, inline, and branch over +all three: + +```text +may_carry_heap_pointer(bits) := + (bits >> 48) ∈ { 0x7FFA, 0x7FFD, 0x7FFF } // BIGINT / POINTER / STRING + || ((bits >> 48) == 0 && bits >= 0x1000) // bare heap address +``` + +The slot store stays unconditional and outside the branch. Callers that already +proved the value statically pass all three flags `false`, so lever D's existing +elision emits no test and no blocks at all — this composes with it rather than +replacing it. The bare-address floor is the *lower* of the two runtime floors +(`layout_pointer_bearing_bits`' `0x1000` rather than `decode_heap_addr`'s +`0x10000`), which is also what keeps `0.0` — whose bit pattern is all zeros — +off the slow path. + +Soundness is the superset property, pinned from both sides because codegen cannot +call the runtime predicates. `perry-runtime`'s new +`gc::tests::inline_pointer_bearing_contract` enumerates all 65,536 tags against +`decode_heap_addr` *and* `layout_pointer_bearing_bits`, and carries a stranding +witness: an old parent, a young child, and the exact guarded store sequence, where +a sabotaged always-false guard leaves the old→young edge unrecorded and +`verify_old_to_young_edges_covered` rejects it, while the shipped guard keeps it +and the remembered-set scan marks the child. Deleting `0x7FFD` from the comparand +set turns three of the five tests red. Codegen-side structure (store outside the +guard, all three calls inside it, every call still emitted, no guard on the +raw-f64 path) is pinned in `tests/class_field_store_pointer_test.rs`. + +Measured on `gc-handoff/bench/churn_alloc.ts` via `PERRY_GC_TRACE` counters: +barrier calls 79,780,888 → 39,890,444 with `non_pointer_child_skips` +39,890,444 → 0 — exactly the wasted calls, with every remaining call doing real +work and the GC cycle count unchanged at 105. On the pinned quiet host (M1, load +1.46, best-of-7 `user+sys`, both arms linked against the same runtime archive): +`churn_alloc` 1.930 s → 1.630 s (1.18×) and `churn` 2.210 s → 1.960 s (1.13×), +with `push_cls`, `tree`, `churn_read`, `deeplist` and `push_num` unchanged — the +prediction, since none of those uses the `class_field_set` store path. The whole +bench set produces byte-identical stdout with `rc=0` and re-runs clean under +`PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_VERIFY_MARK=1` with copying minors +confirmed live (105 copying-minor verifications on `churn_alloc`). + +The `put.pic.hit` store path (`expr/proxy_reflect.rs`), which is what a +user-written `this.f = v` on a union-typed field lowers to, emits the same triple +and is untouched — its layout note is the scalar-aware variant and its existing +static skip rests on a separate precondition, so it is left for a follow-up. diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index 0d2dc10785..7ef183118f 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -524,6 +524,18 @@ impl LlBlock { r } + pub fn icmp_uge(&mut self, ty: LlvmType, a: &str, b: &str) -> String { + let r = self.reg(); + self.push_inst(crate::inst::LlInst::ICmp { + dst: r.clone(), + pred: "uge", + ty, + a: a.to_string(), + b: b.to_string(), + }); + r + } + pub fn icmp_sge(&mut self, ty: LlvmType, a: &str, b: &str) -> String { let r = self.reg(); self.push_inst(crate::inst::LlInst::ICmp { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index af53065703..42bd4e681a 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -119,11 +119,11 @@ pub(crate) use v8_interop::{ }; pub(crate) use write_barrier::{ emit_array_numeric_write_note_on_block, emit_jsvalue_slot_store_on_block, - emit_jsvalue_slot_store_scalar_aware_on_block, emit_jsvalue_slot_store_with_flags_on_block, - emit_jsvalue_slot_store_with_value_bits_on_block, emit_root_heap_word_store_on_block, - emit_root_nanbox_store_on_block, emit_write_barrier, emit_write_barrier_slot_on_block, - lower_array_super_init, lower_event_emitter_subclass_init, lower_node_stream_super_init, - lower_stream_super_init, + emit_jsvalue_slot_store_pointer_tested, emit_jsvalue_slot_store_scalar_aware_on_block, + emit_jsvalue_slot_store_with_flags_on_block, emit_jsvalue_slot_store_with_value_bits_on_block, + emit_root_heap_word_store_on_block, emit_root_nanbox_store_on_block, emit_write_barrier, + emit_write_barrier_slot_on_block, lower_array_super_init, lower_event_emitter_subclass_init, + lower_node_stream_super_init, lower_stream_super_init, }; // Issue #1098 phase 3: the `FnCtx` definition stays in this trunk, but its diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 3b729ee15f..4b8d4d37b8 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -20,7 +20,7 @@ use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; use super::{ class_field_store_needs_layout_note, class_field_store_needs_string_addref, - emit_jsvalue_slot_store_with_flags_on_block, emit_typed_feedback_register_site, + emit_jsvalue_slot_store_pointer_tested, emit_typed_feedback_register_site, expr_produces_non_pointer_bits_by_construction, lower_expr, lower_expr_native, raw_f64_layout_fact, try_lower_pod_field_set, unbox_to_i64, FnCtx, TypedFeedbackContract, TypedFeedbackKind, @@ -868,10 +868,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { class_field_store_needs_layout_note(ctx, value); let string_addref_needed = class_field_store_needs_string_addref(ctx, value); - let blk = ctx.block(); - let field_addr = blk.ptrtoint(&field_ptr, I64); - emit_jsvalue_slot_store_with_flags_on_block( - blk, + let field_addr = ctx.block().ptrtoint(&field_ptr, I64); + // #7511: whatever these three flags could not + // be proved away statically is decided by ONE + // live test of the stored bits — see + // `emit_jsvalue_slot_store_pointer_tested`. + emit_jsvalue_slot_store_pointer_tested( + ctx, &field_ptr, &val_double, &obj_handle, @@ -1056,16 +1059,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple) .to_string(); - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let field_ptr = { + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]) + }; let raw_stored_value = if requires_raw_f64 { // Guarded raw-f64 slots are pointer-free by typed // shape descriptor; non-number writes miss the // guard and use the boxed setter fallback. // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class // slots contain numbers only. + let blk = ctx.block(); let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, &val_double); blk.store(DOUBLE, &numeric_value, &field_ptr); @@ -1083,9 +1089,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `requires_raw_f64` is false here, which is the // precondition `class_field_store_needs_layout_note` // documents. - let field_addr = blk.ptrtoint(&field_ptr, I64); - emit_jsvalue_slot_store_with_flags_on_block( - blk, + // + // #7511: this is the arm the shared + // `_constructor` symbol lands on, where the + // value is an opaque function parameter and lever D + // can never fire. Whatever survives it is decided by + // ONE live test of the stored bits instead of three + // cross-crate calls that each re-ask the same + // question — see + // `emit_jsvalue_slot_store_pointer_tested`. + let field_addr = ctx.block().ptrtoint(&field_ptr, I64); + emit_jsvalue_slot_store_pointer_tested( + ctx, &field_ptr, &val_double, &obj_handle, @@ -1098,7 +1113,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); None }; - blk.br(&merge_label); + ctx.block().br(&merge_label); raw_stored_value }; if let Some(numeric_value) = raw_stored_value { diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index e4d0fcdcb1..6be29f54c8 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -9,7 +9,7 @@ use super::{lower_expr, FnCtx}; use crate::block::LlBlock; use crate::nanbox::double_literal; use crate::native_value::LoweredValue; -use crate::types::{DOUBLE, I32, I64}; +use crate::types::{DOUBLE, I1, I32, I64}; /// Gen-GC Phase C2 helper: emit a write barrier after heap-store sites /// by default. Only explicit `PERRY_WRITE_BARRIERS=0`/`off`/`false` @@ -260,6 +260,164 @@ pub(crate) fn emit_jsvalue_slot_store_scalar_aware_on_block( ) } +/// #7511 — emit the `i1` predicate "these NaN-boxed bits MAY carry a heap +/// pointer", as a superset of every heap address the runtime can decode. +/// +/// This is the codegen mirror of `perry-runtime::gc::barrier::decode_heap_addr` +/// and `gc::layout::layout_pointer_bearing_bits`, narrowed to the part that can +/// be decided from the top 16 bits alone. Both runtime functions resolve a heap +/// address in exactly two situations: +/// +/// - the NaN-box tag is `POINTER_TAG` / `STRING_TAG` / `BIGINT_TAG` +/// (`0x7FFD` / `0x7FFF` / `0x7FFA`), or +/// - the value is a **bare** heap address: all-zero high 16 bits +/// (`decode_heap_addr`'s `(bits >> 48) != 0` reject, +/// `layout_pointer_bearing_bits`' `bits <= POINTER_MASK` range) and at or +/// above a low floor — `0x10000` in `decode_heap_addr`, `0x1000` in +/// `layout_pointer_bearing_bits`. The **lower** of the two floors is used +/// here, so neither runtime predicate can accept a value this one rejects. +/// +/// Everything else — every plain IEEE double, `SHORT_STRING_TAG` (inline data, +/// not a pointer), `JS_HANDLE`, the `0x7FFC` primitives, `INT32_TAG`, +/// `STATIC_DISPATCH_TAG` — is rejected by both, so this predicate is `false` +/// for them. The floor is what keeps `0.0` — whose bit pattern is all zeros, +/// and which is one of the most-stored values in any program — on the fast +/// path instead of colliding with the bare-address arm. +/// +/// **The direction of the approximation is load-bearing.** The refinements the +/// runtime applies *after* these tests (a null pointer payload, a misaligned +/// address, an arena page-map miss) are all further REJECTIONS. Dropping them +/// can only make this predicate say "maybe" where the runtime would say "no" — +/// an extra call that does nothing. It can never say "no" where the runtime +/// would say "yes", which is the direction that strands a child. +/// `perry-runtime`'s `gc::tests::inline_pointer_bearing_contract` enumerates +/// the entire 16-bit tag space against both runtime predicates to pin exactly +/// that, and `nanbox::inline_pointer_bearing_top16_set_covers_every_heap_tag` +/// pins the comparand set against the tag constants. +pub(crate) fn emit_may_carry_heap_pointer_check(blk: &mut LlBlock, value_bits: &str) -> String { + use crate::nanbox::{BIGINT_TAG_TOP16_I64, POINTER_TAG_TOP16_I64, STRING_TAG_TOP16_I64}; + // `layout_pointer_bearing_bits`' floor (`0x1000`), the lower of the two. + const BARE_HEAP_ADDR_FLOOR_I64: &str = "4096"; + let top16 = blk.lshr(I64, value_bits, "48"); + let top16_zero = blk.icmp_eq(I64, &top16, "0"); + let above_floor = blk.icmp_uge(I64, value_bits, BARE_HEAP_ADDR_FLOOR_I64); + let is_raw_addr = blk.and(I1, &top16_zero, &above_floor); + let is_pointer_tag = blk.icmp_eq(I64, &top16, POINTER_TAG_TOP16_I64); + let is_string_tag = blk.icmp_eq(I64, &top16, STRING_TAG_TOP16_I64); + let is_bigint_tag = blk.icmp_eq(I64, &top16, BIGINT_TAG_TOP16_I64); + let tagged = blk.or(I1, &is_pointer_tag, &is_string_tag); + let tagged = blk.or(I1, &tagged, &is_bigint_tag); + blk.or(I1, &tagged, &is_raw_addr) +} + +/// #7511 — a class-field JSValue slot store whose three GC-bookkeeping calls +/// are placed behind ONE inline, live test of the stored value. +/// +/// ## Why a live test rather than a wider static proof +/// +/// The bookkeeping this guards costs 16.1% of `churn_alloc`'s profile on a +/// program whose stores are all doubles, and #5334 lever D — which elides it +/// for a value that is a non-pointer BY CONSTRUCTION — never fires there. The +/// reason is structural, not a missing arm: since the `[#bloat]` +/// `force_ctor_call` default (`lower_call/new.rs`), a class with its own +/// constructor is NOT inlined at the `new` site. Its body is compiled once as +/// the shared `_constructor(this, p0, …)` symbol, and HIR rewrites every +/// closed-shape object literal into a `New` of a synthesized anon-shape class +/// with exactly that shape (`lower/context.rs::mint_anon_shape_class`). So the +/// expression reaching the field store is `Expr::LocalGet()` — an +/// LLVM *function argument* of a function shared by every `new` site in the +/// module, including ones passing pointers. No by-construction proof about that +/// value can exist, and a declared `v: number` is not a layout fact (CLAUDE.md, +/// "No runtime type *validation*"): the field legitimately receives a string +/// through an `any`. +/// +/// What CAN be decided there is the same question the three callees each ask +/// first, at runtime, one at a time, across three cross-crate calls. Asking it +/// ONCE inline and branching over all three is exactly #7501's shape (a live +/// test at the store standing in for a static claim that cannot be made). +/// +/// ## Why each call is dead when the test says "no pointer" +/// +/// - `js_write_barrier_slot` → `write_barrier_slot_inner` opens with +/// `barrier_child_prologue`, which returns immediately when +/// `decode_heap_addr(child) == 0`. Nothing else in the barrier runs — not the +/// incremental-mark shading (there is no heap object to shade), not the +/// remembered set (a non-pointer publishes no old→young edge). +/// - `js_string_addref_if_heap_string` is tag-checked and a no-op for every +/// non-`STRING_TAG` value, SSO short strings included. +/// - `js_gc_note_slot_layout` for a non-pointer value can only ever CLEAR mask +/// state, never set it, so skipping it is never the difference between a slot +/// being scanned and a live child being stranded. That is the identical +/// argument `class_field_store_needs_layout_note` already ships for the +/// static case, including its precondition — the caller only reaches here +/// with `requires_raw_f64 == false`, so the note's raw-f64-mask arm (the one +/// that MUST downgrade) is unreachable. Turning a static claim into a live +/// test does not weaken it. +/// +/// The store itself stays unconditional and outside the branch — only the +/// bookkeeping moves. +/// +/// Callers that already proved the value statically pass all three flags +/// `false`; then no test and no blocks are emitted at all, and lever D's +/// existing elision is unchanged. +#[allow(clippy::too_many_arguments)] +pub(crate) fn emit_jsvalue_slot_store_pointer_tested( + ctx: &mut FnCtx<'_>, + slot_ptr: &str, + value_double: &str, + layout_parent_bits: &str, + slot_index: &str, + string_addref_needed: bool, + layout_note_needed: bool, + barrier_parent_bits: &str, + slot_addr: &str, + write_barrier_needed: bool, +) -> Option { + { + let blk = ctx.block(); + // GC_STORE_AUDIT(BARRIERED): the slot write itself is unconditional; + // the barrier below is guarded only by a live test that the stored + // bits carry no heap pointer, which is the barrier's own first test. + blk.store(DOUBLE, value_double, slot_ptr); + } + // `emit_write_barrier_slot_on_block` emits nothing when barrier emission is + // compile-time disabled, so counting `write_barrier_needed` alone would put + // a predicate, a `cond_br` and two blocks around an arm holding only a + // `br` under `PERRY_WRITE_BARRIERS=0`. That knob exists to A/B the barrier's + // cost; leaving dead IR in one arm of the A/B is exactly the kind of thing + // that makes such a comparison lie. + let write_barrier_emitted = write_barrier_needed && crate::codegen::write_barriers_enabled(); + if !string_addref_needed && !layout_note_needed && !write_barrier_emitted { + return None; + } + let value_bits = ctx.block().bitcast_double_to_i64(value_double); + let bookkeeping_idx = ctx.new_block("class_field_set.gc_bookkeeping"); + let done_idx = ctx.new_block("class_field_set.gc_bookkeeping.done"); + let bookkeeping_label = ctx.block_label(bookkeeping_idx); + let done_label = ctx.block_label(done_idx); + { + let blk = ctx.block(); + let may_carry_pointer = emit_may_carry_heap_pointer_check(blk, &value_bits); + blk.cond_br(&may_carry_pointer, &bookkeeping_label, &done_label); + } + ctx.current_block = bookkeeping_idx; + { + let blk = ctx.block(); + if string_addref_needed { + blk.call_void("js_string_addref_if_heap_string", &[(DOUBLE, value_double)]); + } + if layout_note_needed { + emit_layout_note_slot_on_block(blk, layout_parent_bits, slot_index, &value_bits); + } + if write_barrier_emitted { + emit_write_barrier_slot_on_block(blk, barrier_parent_bits, slot_addr, &value_bits); + } + blk.br(&done_label); + } + ctx.current_block = done_idx; + Some(value_bits) +} + #[allow(clippy::too_many_arguments)] fn emit_jsvalue_slot_store_on_block_inner( blk: &mut LlBlock, diff --git a/crates/perry-codegen/src/nanbox.rs b/crates/perry-codegen/src/nanbox.rs index e97f04d14f..e6b6d7bf4a 100644 --- a/crates/perry-codegen/src/nanbox.rs +++ b/crates/perry-codegen/src/nanbox.rs @@ -57,6 +57,13 @@ pub const BIGINT_TAG_I64: &str = "9221683186994511872"; /// Asserted against the u64 tags in `tag_strings_match_u64_values`. pub const STRING_TAG_TOP16_I64: &str = "32767"; pub const SHORT_STRING_TAG_TOP16_I64: &str = "32761"; +/// The other two `lshr 48` comparands that name a HEAP-POINTER-bearing tag. +/// Together with [`STRING_TAG_TOP16_I64`] these are exactly the three tags +/// `perry-runtime::gc::barrier::decode_heap_addr` resolves to a heap address — +/// the set the #7511 inline pointer-bearing test is built from. Asserted +/// against the u64 tags in `tag_strings_match_u64_values`. +pub const POINTER_TAG_TOP16_I64: &str = "32765"; +pub const BIGINT_TAG_TOP16_I64: &str = "32762"; /// Format a `u64` as a signed LLVM i64 literal (LLVM IR integer literals are signed). pub fn i64_literal(v: u64) -> String { @@ -117,6 +124,47 @@ mod tests { i64_literal(SHORT_STRING_TAG >> 48), SHORT_STRING_TAG_TOP16_I64 ); + assert_eq!(i64_literal(POINTER_TAG >> 48), POINTER_TAG_TOP16_I64); + assert_eq!(i64_literal(BIGINT_TAG >> 48), BIGINT_TAG_TOP16_I64); + } + + /// #7511 — the inline pointer-bearing test emitted at class-field stores + /// must be a **superset** of every tag the runtime resolves to a heap + /// address. This enumerates the whole 16-bit tag space and asserts the + /// codegen predicate never says "no pointer" where the runtime's + /// `decode_heap_addr` / `layout_pointer_bearing_bits` would say "pointer". + /// + /// Written against the tag values rather than against the emitted IR on + /// purpose: the IR is a rendering of this set, and it is the SET that has + /// to be right. If a future tag joins the heap-pointer family + /// (`perry-runtime/src/value.rs`), this test still passes while the + /// generated code silently drops its barrier — so the mirror assertion + /// lives in the runtime too (`gc::barrier::tests`). + #[test] + fn inline_pointer_bearing_top16_set_covers_every_heap_tag() { + let comparands: Vec = vec![ + POINTER_TAG_TOP16_I64.parse().unwrap(), + STRING_TAG_TOP16_I64.parse().unwrap(), + BIGINT_TAG_TOP16_I64.parse().unwrap(), + ]; + for tag in [POINTER_TAG, STRING_TAG, BIGINT_TAG] { + assert!( + comparands.contains(&(tag >> 48)), + "heap tag {tag:#x} is missing from the inline pointer-bearing comparand set" + ); + } + // Non-heap tags must NOT be in the set, or the test would be vacuous. + for tag in [ + TAG_UNDEFINED & TAG_MASK, + INT32_TAG, + SHORT_STRING_TAG, + STATIC_DISPATCH_TAG, + ] { + assert!( + !comparands.contains(&(tag >> 48)), + "non-heap tag {tag:#x} must not force a barrier call" + ); + } } #[test] diff --git a/crates/perry-codegen/tests/class_field_store_pointer_test.rs b/crates/perry-codegen/tests/class_field_store_pointer_test.rs new file mode 100644 index 0000000000..4681f0a148 --- /dev/null +++ b/crates/perry-codegen/tests/class_field_store_pointer_test.rs @@ -0,0 +1,449 @@ +//! #7511 — the class-field store's GC bookkeeping sits behind ONE inline, +//! live test of the stored value's bits. +//! +//! The subject is `expr::write_barrier::emit_jsvalue_slot_store_pointer_tested`. +//! What these tests have to pin is not "the calls got faster" but the two +//! structural properties that make the guard sound: +//! +//! 1. the SLOT STORE is unconditional and stays outside the guarded block, and +//! 2. all three bookkeeping calls — write barrier, layout note, string addref — +//! are inside it, so none of them can be skipped by a *different* condition +//! than the one that proves them dead. +//! +//! Both are asserted on emitted IR rather than by calling the emitter directly, +//! because the failure mode this ticket is one wrong branch away from is a +//! stranded child in generated code. + +use perry_codegen::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +fn empty_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + nextjs_path_init_modules: Vec::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn assert_default_barrier_env_not_disabled() { + assert!( + !matches!( + std::env::var("PERRY_WRITE_BARRIERS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ), + "these tests describe DEFAULT barrier emission; PERRY_WRITE_BARRIERS must be unset or on" + ); +} + +fn field(name: &str, ty: Type) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +/// `constructor(v) { this.v = v }` — the shape HIR synthesizes for every +/// closed-shape object literal (`lower/context.rs::mint_anon_shape_class`) and +/// the shape a hand-written data class already has. +fn param_prologue_ctor(field_name: &str, param_id: u32, param_ty: Type) -> Function { + Function { + id: 90, + name: "constructor".to_string(), + type_params: Vec::new(), + params: vec![param(param_id, field_name, param_ty)], + return_type: Type::Void, + body: vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: field_name.to_string(), + value: Box::new(Expr::LocalGet(param_id)), + })], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn class(id: u32, name: &str, fields: Vec, constructor: Option) -> Class { + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields, + constructor, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + } +} + +fn module_with_new(class: Class, args: Vec) -> Module { + let class_name = class.name.clone(); + Module { + name: "class_field_store_pointer_test.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: vec![class], + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Named(class_name.clone()), + body: vec![Stmt::Return(Some(Expr::New { + class_name, + args, + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn compile_ir(module: &Module) -> String { + String::from_utf8(compile_module(module, empty_opts()).unwrap()).expect("IR should be UTF-8") +} + +/// The body of the guarded block: every line after the +/// `class_field_set.gc_bookkeeping.:` LABEL up to (and excluding) its +/// terminator. `None` when no guard was emitted. +/// +/// Matched on the label DEFINITION, never on the first textual occurrence of +/// the name — that is the `br i1 …, label %class_field_set.gc_bookkeeping.N, +/// label %class_field_set.gc_bookkeeping.done.M` a line above, and starting +/// there yields an empty slice that passes nothing and fails everything. +fn gc_bookkeeping_block(ir: &str) -> Option { + let mut lines = ir.lines().skip_while(|line| { + let label = line.trim_end_matches(':'); + !(line.ends_with(':') + && label.starts_with("class_field_set.gc_bookkeeping.") + && !label.starts_with("class_field_set.gc_bookkeeping.done")) + }); + lines.next()?; + Some( + lines + .take_while(|line| !line.trim_start().starts_with("br ")) + .collect::>() + .join("\n"), + ) +} + +/// An `any`-typed field is the case the whole ticket is about: it takes the +/// boxed store path, so the three bookkeeping calls exist, and the value is a +/// constructor PARAMETER, so no by-construction proof can retire them. +#[test] +fn opaque_param_store_guards_all_three_bookkeeping_calls() { + assert_default_barrier_env_not_disabled(); + let module = module_with_new( + class( + 1, + "Boxed", + vec![field("v", Type::Any)], + Some(param_prologue_ctor("v", 7, Type::Any)), + ), + vec![Expr::Number(1.0)], + ); + let ir = compile_ir(&module); + + // The guard exists, and it is the top-16-bit test — not something that + // happens to look like one. `32765`/`32767`/`32762` are POINTER/STRING/ + // BIGINT `>> 48`; `4096` is the bare-address floor. + assert!( + ir.contains("lshr i64") && ir.contains("class_field_set.gc_bookkeeping"), + "expected an inline pointer-bearing guard around the field-store bookkeeping:\n{ir}" + ); + for comparand in ["32765", "32767", "32762", "4096"] { + assert!( + ir.contains(comparand), + "the inline guard is missing the {comparand} comparand:\n{ir}" + ); + } + + let guarded = gc_bookkeeping_block(&ir).expect("a gc_bookkeeping block"); + for call in [ + "@js_write_barrier_slot", + "@js_gc_note_slot_layout", + "@js_string_addref_if_heap_string", + ] { + assert!( + guarded.contains(call), + "{call} must live inside the guarded block, not beside it:\n{guarded}" + ); + } + // …and the slot store must NOT, or a value would be dropped whenever the + // guard says "no pointer". + assert!( + !guarded.contains("store double"), + "the slot store must stay unconditional, outside the guard:\n{guarded}" + ); +} + +/// **The boundary.** The guard replaces a *runtime* early-out, so it must never +/// remove a call outright: a module that can store a pointer still has all +/// three calls present in the emitted IR, reachable on the guard's taken edge. +/// This is what distinguishes the change from an elision. +#[test] +fn every_bookkeeping_call_is_still_emitted_not_removed() { + assert_default_barrier_env_not_disabled(); + let module = module_with_new( + class( + 2, + "Boxed2", + vec![field("v", Type::Any)], + Some(param_prologue_ctor("v", 7, Type::Any)), + ), + vec![Expr::Number(1.0)], + ); + let ir = compile_ir(&module); + for call in [ + "call void @js_write_barrier_slot", + "call void @js_gc_note_slot_layout", + "call void @js_string_addref_if_heap_string", + ] { + assert!(ir.contains(call), "{call} must still be emitted:\n{ir}"); + } + assert!( + ir.contains("call void @js_gc_write_barriers_emitted(i32 1)"), + "the module must still declare to the runtime that generated barriers exist" + ); +} + +/// A module whose `probe` binds `let o = new C(v)` and then writes a field on +/// the LOCAL receiver rather than on `this`, so the store is lowered from a +/// different `property_set.rs` entry than the constructor prologue's. +fn module_with_local_receiver_store(class: Class, stored: Expr) -> Module { + let class_name = class.name.clone(); + let mut module = module_with_new(class, vec![Expr::Number(1.0)]); + module.functions[0].body = vec![ + Stmt::Let { + id: 5, + name: "o".to_string(), + ty: Type::Named(class_name.clone()), + mutable: false, + init: Some(Expr::New { + class_name, + args: vec![Expr::Number(1.0)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(5)), + property: "v".to_string(), + value: Box::new(stored), + }), + Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(5)), + property: "v".to_string(), + byte_offset: 0, + })), + ]; + module.functions[0].return_type = Type::Any; + module +} + +/// **Every emitted block is still terminated.** +/// +/// `emit_jsvalue_slot_store_pointer_tested` leaves `ctx.current_block` on a +/// freshly created merge block, and `property_set.rs` has arms that terminate +/// explicitly (the typed-feedback guarded arm's `br` to `class_field_set.merge`) +/// and arms that fall through to whatever the caller emits next (the +/// `ptr_shape_receiver_fact` arm returns without a terminator, exactly as it did +/// before this change). A dangling merge block is invisible to a call-count +/// assertion and is how the second shape would break, so scan the whole module: +/// no label may follow another label with no terminator in between. +#[test] +fn guarded_store_leaves_every_block_terminated() { + assert_default_barrier_env_not_disabled(); + let module = module_with_local_receiver_store( + class( + 4, + "LocalBoxed", + vec![field("v", Type::Any)], + Some(param_prologue_ctor("v", 7, Type::Any)), + ), + // An opaque local read: no by-construction proof, so the flags survive + // to the guard instead of being retired by lever D. + Expr::LocalGet(5), + ); + let ir = compile_ir(&module); + let guarded = gc_bookkeeping_block(&ir) + .unwrap_or_else(|| panic!("expected a guarded bookkeeping block in:\n{ir}")); + for call in [ + "@js_write_barrier_slot", + "@js_gc_note_slot_layout", + "@js_string_addref_if_heap_string", + ] { + assert!( + guarded.contains(call), + "{call} must live inside the guarded block:\n{guarded}" + ); + } + + let mut open_block: Option<&str> = None; + for line in ir.lines() { + let trimmed = line.trim(); + if line.ends_with(':') && !line.starts_with(' ') && !line.starts_with('%') { + assert!( + open_block.is_none(), + "block {:?} was left unterminated before {line:?}", + open_block.unwrap() + ); + open_block = Some(line); + } else if trimmed.starts_with("br ") + || trimmed.starts_with("ret ") + || trimmed.starts_with("unreachable") + || trimmed.starts_with("switch ") + { + open_block = None; + } + } + // A closing `}` is deliberately NOT treated as a terminator: the last block + // of a function is exactly where a fall-through arm leaves a dangling + // merge block, and accepting `}` would let that pass. + assert!( + open_block.is_none(), + "block {:?} was left unterminated at the end of the module", + open_block.unwrap() + ); +} + +/// A `number`-declared field takes the raw-f64 store path, which is proven +/// pointer-free by the typed shape descriptor and never had bookkeeping to +/// guard. Asserting the guard is ABSENT there keeps the test above from being +/// satisfied by "codegen emits this block everywhere". +#[test] +fn raw_f64_field_store_emits_no_guard() { + assert_default_barrier_env_not_disabled(); + let module = module_with_new( + class( + 3, + "Numeric", + vec![field("v", Type::Number)], + Some(param_prologue_ctor("v", 7, Type::Number)), + ), + vec![Expr::Number(1.0)], + ); + let ir = compile_ir(&module); + assert!( + !ir.contains("class_field_set.gc_bookkeeping"), + "a raw-f64 class-field store has no bookkeeping to guard:\n{ir}" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs new file mode 100644 index 0000000000..2b2042ef0c --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/inline_pointer_bearing_contract.rs @@ -0,0 +1,287 @@ +//! #7511 — the contract the codegen-side inline pointer-bearing test rests on. +//! +//! `perry-codegen`'s `expr::write_barrier::emit_may_carry_heap_pointer_check` +//! puts the three per-class-field-store GC bookkeeping calls +//! (`js_write_barrier_slot`, `js_gc_note_slot_layout`, +//! `js_string_addref_if_heap_string`) behind ONE inline test of the stored +//! bits' TOP 16 BITS: +//! +//! ```text +//! may_carry_heap_pointer(bits) := +//! (bits >> 48) ∈ { 0x7FFA, 0x7FFD, 0x7FFF } // NaN-boxed heap tags +//! || ((bits >> 48) == 0 && bits >= 0x1000) // bare heap address +//! ``` +//! +//! That is sound only if it is a **superset** of what the runtime itself would +//! resolve to a heap pointer. The codegen crate cannot call +//! `decode_heap_addr` / `layout_pointer_bearing_bits` (they are private to this +//! crate, and codegen runs in a different process from the program it emits), +//! so the two halves are pinned from opposite sides: +//! +//! - `perry-codegen::nanbox::inline_pointer_bearing_top16_set_covers_every_heap_tag` +//! pins the comparand set against the NaN-box tag constants. +//! - **this file** pins the runtime predicates against the same set, by +//! enumerating the entire 16-bit tag space. +//! +//! A future tag that starts resolving to a heap address — the only way this +//! elision can turn into a stranded child — makes THIS test fail, on the side +//! that knows about it. + +use super::super::barrier::decode_heap_addr; +use super::super::layout::layout_pointer_bearing_bits; +use super::super::*; +use super::support::*; + +/// The exact tag comparands emitted by `emit_may_carry_heap_pointer_check`. +/// Kept as literals so a drift in codegen has to be mirrored here by hand +/// rather than silently inherited. +const CODEGEN_HEAP_TAG_TOP16: [u64; 3] = [0x7FFA, 0x7FFD, 0x7FFF]; +/// The bare-address floor codegen emits — `layout_pointer_bearing_bits`' own +/// `0x1000`, which is the LOWER of the two runtime floors (`decode_heap_addr` +/// uses `0x10000`). Using the lower one is what makes the codegen test a +/// superset of both. +const CODEGEN_BARE_ADDR_FLOOR: u64 = 0x1000; + +fn codegen_may_carry_heap_pointer(bits: u64) -> bool { + let top16 = bits >> 48; + CODEGEN_HEAP_TAG_TOP16.contains(&top16) || (top16 == 0 && bits >= CODEGEN_BARE_ADDR_FLOOR) +} + +/// Payload patterns exercised under every tag: zero, an 8-aligned address below +/// every floor, one **between the two runtime floors** (`0x1000 ≤ p < 0x10000`), +/// an 8-aligned address above both, a misaligned one, a plausible heap address, +/// and an all-ones payload. +/// +/// The `0x2000` entry is the one that earns `CODEGEN_BARE_ADDR_FLOOR`'s value: +/// it is the band where `layout_pointer_bearing_bits` (floor `0x1000`) and +/// `decode_heap_addr` (floor `0x10000`) disagree, so without it the enumeration +/// would pass for either choice of floor and prove nothing about picking the +/// lower one. +const PAYLOADS: [u64; 7] = [ + 0x0000_0000_0000_0000, + 0x0000_0000_0000_0008, + 0x0000_0000_0000_2000, + 0x0000_0001_0000_0000, + 0x0000_0001_0000_0007, + 0x0000_1234_5678_9AB0, + 0x0000_FFFF_FFFF_FFFF, +]; + +/// **The load-bearing direction.** For every one of the 65,536 possible tags +/// and a spread of payloads, a value the runtime would resolve to a heap +/// pointer must also pass the codegen test. A violation here is a barrier that +/// codegen skips and the collector needed — a stranded child. +#[test] +fn codegen_top16_test_is_a_superset_of_decode_heap_addr() { + for tag in 0u64..=0xFFFF { + for payload in PAYLOADS { + let bits = (tag << 48) | payload; + if decode_heap_addr(bits) != 0 { + assert!( + codegen_may_carry_heap_pointer(bits), + "decode_heap_addr resolved {bits:#018x} (tag {tag:#06x}) to a heap address, \ + but the codegen inline test would have skipped the write barrier" + ); + } + } + } +} + +/// Same obligation for the layout note: `js_gc_note_slot_layout` is only +/// skippable when the value is not pointer-bearing in the layout machinery's +/// own sense. +#[test] +fn codegen_top16_test_is_a_superset_of_layout_pointer_bearing_bits() { + for tag in 0u64..=0xFFFF { + for payload in PAYLOADS { + let bits = (tag << 48) | payload; + if layout_pointer_bearing_bits(bits) { + assert!( + codegen_may_carry_heap_pointer(bits), + "layout_pointer_bearing_bits accepted {bits:#018x} (tag {tag:#06x}), but the \ + codegen inline test would have skipped the layout note" + ); + } + } + } +} + +/// **Non-vacuity check for the two enumerations above.** +/// +/// A green enumeration could mean "nothing violated the superset property" or +/// "the enumeration never looked at a real pointer". This pins the second +/// reading shut: an ordinary NaN-boxed object pointer is a value the runtime +/// really does resolve to a heap address, and the shipped comparand set really +/// does keep its bookkeeping. +/// +/// The MUTATIONAL half — what actually happens when the set is wrong — is +/// `sabotaged_guard_strands_a_young_child_the_shipped_guard_keeps` below, which +/// runs a sabotaged guard through the real barrier and remembered-set +/// machinery rather than comparing literals. +#[test] +fn an_object_pointer_keeps_its_bookkeeping() { + let object_bits = crate::value::POINTER_TAG | 0x0000_1234_5678_9AB0; + assert_ne!( + decode_heap_addr(object_bits), + 0, + "the witness must be a value the runtime really does resolve to a heap address" + ); + assert!( + layout_pointer_bearing_bits(object_bits), + "the witness must also be pointer-bearing to the layout machinery" + ); + assert!( + codegen_may_carry_heap_pointer(object_bits), + "the shipped comparand set must keep the barrier for an object pointer" + ); +} + +/// The complement, so the elision is not vacuous: the value classes that +/// dominate a numeric store loop must be classified barrier-free, and the +/// runtime must agree that skipping their bookkeeping changes nothing. +#[test] +fn plain_numbers_and_primitives_need_no_bookkeeping() { + let barrier_free = [ + 1234.5f64.to_bits(), + (-1234.5f64).to_bits(), + 0f64.to_bits(), + f64::NAN.to_bits(), + f64::INFINITY.to_bits(), + crate::value::TAG_UNDEFINED, + crate::value::TAG_NULL, + crate::value::TAG_TRUE, + crate::value::TAG_FALSE, + crate::value::INT32_TAG | 42, + ]; + for bits in barrier_free { + assert!( + !codegen_may_carry_heap_pointer(bits), + "{bits:#018x} should not force the bookkeeping call" + ); + assert_eq!( + decode_heap_addr(bits), + 0, + "{bits:#018x} must carry no heap address" + ); + assert!( + !layout_pointer_bearing_bits(bits), + "{bits:#018x} must not be layout-pointer-bearing" + ); + } +} + +/// Perform a field store the way the guarded codegen sequence does: the slot +/// write is unconditional, and the barrier runs only when `guard` accepts the +/// stored bits. Returns whether the barrier was called. +unsafe fn guarded_field_store( + old_obj: *mut crate::object::ObjectHeader, + fields: *mut u64, + child_bits: u64, + guard: fn(u64) -> bool, +) -> bool { + *fields = child_bits; + if guard(child_bits) { + js_write_barrier_slot(ptr_bits(old_obj as usize), fields as u64, child_bits); + return true; + } + false +} + +/// **The stranding witness.** An OLD parent, a YOUNG child, and the exact store +/// sequence codegen now emits — assert three things in one place: +/// +/// 1. with the SHIPPED guard, a heap-pointer child takes the bookkeeping branch, +/// the old→young edge verifier is satisfied, and a remembered-set scan marks +/// the child (it survives the minor); +/// 2. with a SABOTAGED guard (one that always answers "no pointer" — the shape +/// a wrong comparand set or an inverted branch produces), the same store +/// leaves the edge unrecorded and the verifier REJECTS it: that child is +/// stranded and the next minor frees it under a live reference; +/// 3. a NUMERIC child through the shipped guard skips the barrier and the +/// verifier is still satisfied — which is exactly the case the elision +/// exists for, and proves (2)'s failure is about the pointer, not about +/// skipping a barrier per se. +#[test] +fn sabotaged_guard_strands_a_young_child_the_shipped_guard_keeps() { + let _guard = GcTestIsolationGuard::new(); + + // (1) shipped guard, pointer child — barrier runs, edge covered, marked. + // + // The OLD parent is allocated FIRST in every phase below. `young` is a bare + // Rust `usize`, which production mode neither roots nor pins (the + // conservative stack scan resolves to `SkipDisabled`), so an allocation + // after it could collect and leave it naming freed or relocated memory. + // Old-gen first means no allocation ever follows the young child. + reset_remembered_set(); + clear_marks(); + let (old_obj, fields) = unsafe { alloc_old_test_object(1) }; + let young = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; + let old_header = unsafe { header_from_user_ptr(old_obj as *const u8) }; + unsafe { (*old_header).gc_flags |= GC_FLAG_MARKED }; + let child_bits = ptr_bits(young); + assert!( + unsafe { guarded_field_store(old_obj, fields, child_bits, codegen_may_carry_heap_pointer) }, + "the shipped guard must take the bookkeeping branch for a heap-pointer child" + ); + let stats = verify_old_to_young_edges_covered(); + assert_eq!(stats.checked_old_to_young_edges, 1); + assert_eq!(stats.missing_edges, 0); + let valid_ptrs = build_valid_pointer_set(); + let scan = mark_remembered_set_roots(&valid_ptrs); + assert_eq!(scan.newly_marked, 1, "the child must survive the minor"); + unsafe { (*old_header).gc_flags &= !GC_FLAG_MARKED }; + clear_marks(); + remembered_set_clear(); + + // (2) SABOTAGE: a guard that never accepts. Same store, same child. + fn never_needs_bookkeeping(_bits: u64) -> bool { + false + } + reset_remembered_set(); + clear_marks(); + let (old_obj2, fields2) = unsafe { alloc_old_test_object(1) }; + let young2 = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; + let old_header2 = unsafe { header_from_user_ptr(old_obj2 as *const u8) }; + unsafe { (*old_header2).gc_flags |= GC_FLAG_MARKED }; + assert!( + !unsafe { + guarded_field_store(old_obj2, fields2, ptr_bits(young2), never_needs_bookkeeping) + }, + "the sabotaged guard must skip the barrier — otherwise this arm proves nothing" + ); + let rejected = std::panic::catch_unwind(verify_old_to_young_edges_covered); + assert!( + rejected.is_err(), + "a skipped barrier on a heap-pointer child must leave the old->young edge \ + unrecorded — this is the stranded child the shipped guard must never produce" + ); + unsafe { (*old_header2).gc_flags &= !GC_FLAG_MARKED }; + clear_marks(); + remembered_set_clear(); + + // (3) shipped guard, NUMERIC child — barrier skipped, nothing stranded. + reset_remembered_set(); + clear_marks(); + let (old_obj3, fields3) = unsafe { alloc_old_test_object(1) }; + let old_header3 = unsafe { header_from_user_ptr(old_obj3 as *const u8) }; + unsafe { (*old_header3).gc_flags |= GC_FLAG_MARKED }; + assert!( + !unsafe { + guarded_field_store( + old_obj3, + fields3, + 1234.5f64.to_bits(), + codegen_may_carry_heap_pointer, + ) + }, + "a plain double must skip the bookkeeping branch" + ); + let numeric_stats = verify_old_to_young_edges_covered(); + assert_eq!( + numeric_stats.missing_edges, 0, + "a numeric store publishes no old->young edge, so skipping its barrier strands nothing" + ); + unsafe { (*old_header3).gc_flags &= !GC_FLAG_MARKED }; + clear_marks(); + remembered_set_clear(); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index dbc1502221..925260be2f 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -19,6 +19,7 @@ mod global_bootstrap; mod helper_stores; mod host_safepoints; mod incremental_sweep_reclaim; +mod inline_pointer_bearing_contract; mod layout_trace; mod oldgen; mod os_tag;