diff --git a/CLAUDE.md b/CLAUDE.md index e31f1c8e67..9e56f54c9b 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.1339 +**Current Version:** 0.5.1340 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index f4b1a459fa..68d351c2df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1339" +version = "0.5.1340" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1339" +version = "0.5.1340" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1339" +version = "0.5.1340" [[package]] name = "perry-ui-tvos" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1339" +version = "0.5.1340" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index b92fe0bc64..800fb5c329 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1339" +version = "0.5.1340" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/benchmarks/honest_bench/workloads/1_json_pipeline/perry/json_pipeline.ts b/benchmarks/honest_bench/workloads/1_json_pipeline/perry/json_pipeline.ts index 574fc5799d..409ab09c05 100644 --- a/benchmarks/honest_bench/workloads/1_json_pipeline/perry/json_pipeline.ts +++ b/benchmarks/honest_bench/workloads/1_json_pipeline/perry/json_pipeline.ts @@ -1,30 +1,19 @@ // JSON pipeline: read input JSON, filter active records, add 2 derived fields, // serialize, write output. Perry stdlib JSON + fs (utf-8 text). // -// PERRY GAP NOTES (honest-benchmark findings, all in v0.5.29): +// This file used to carry a block of "PERRY GAP NOTES (all in v0.5.29)" +// claiming that `process.argv.slice(2)` returned garbage, that iterating a +// large `JSON.parse` result corrupted records above ~200 of them, and that the +// driver therefore ran Perry on the 100-record fixture only. **Every one of +// those claims is false as of v0.5.1338** and they were deleted rather than +// left to mislead: `run.sh` runs Perry on the full 500k-record / 107.5 MB +// fixture like every other language, the output matched the Bun reference on +// 20 of 20 runs during the v0.5.1335 baseline regeneration, and an argv probe +// returns two proper strings. The correctness half was fixed at some point +// without the comment being updated (#7592). // -// 1. process.argv.slice(2) returns an array whose elements come back as -// garbage numbers. Indexing argv directly works. -// -// 2. Iterating over a large `JSON.parse` result and accessing .fields on each -// record triggers a GC-scan issue at scale — records allocated by the JSON -// parser get swept mid-iteration, yielding fewer .active===true matches -// than exist, or corrupting fields in the serialized output. Observed -// break-point is ~200 records; above that, output is non-deterministic. -// -// 3. Mutating a parsed-record object (`r.display_name = …`) then -// `JSON.stringify(r)` also trips the same issue — stringify sometimes -// panics inside `perry-runtime/src/json.rs:427` with "byte index … is not -// a char boundary" reading corrupted strings. Constructing a fresh object -// literal and stringifying *that* is reliable, so we do that here. -// -// 4. Reading a ~108 MB file into a string via fs.readFileSync works, but -// JSON.parse on 500k objects tips the same GC issues even before we get -// to iteration. -// -// The driver runs this binary on the 100-record fixture only. Rust and Zig -// also run on the full 108 MB / 500k-record fixture; the report calls out -// the scale gap explicitly. +// What IS true at 500k is a performance gap, tracked in #7592 — see the issue +// for the per-phase split. Nothing about the code below is a workaround. import * as fs from 'fs'; diff --git a/changelog.d/7601-inline-char-code-at.md b/changelog.d/7601-inline-char-code-at.md new file mode 100644 index 0000000000..c25de31707 --- /dev/null +++ b/changelog.d/7601-inline-char-code-at.md @@ -0,0 +1,66 @@ +**perf(codegen): inline `charCodeAt` and stop routing it through the dynamic bitwise helper (#7592)** + +`honest_bench`'s `json_pipeline` finishes by hashing its own 68 MB serialized +output with a hand-rolled FNV-1a loop — `h = (h ^ s.charCodeAt(i)) | 0` over +every character. At 500k records that phase cost **1,207 ms at 17.7 ns/char**, +roughly twice bun's entire run. A leaf profile showed 85% of it was FFI rather +than work: `js_string_char_code_at` 31.5%, `js_dynamic_bitxor` 31.0%, +`js_string_index_to_i32` 13.1%, `js_get_string_pointer_unified` 9.0% — the JS +loop itself was 15.3%. Four opaque runtime calls per character. + +Two independent defects, each fixed: + +1. **`charCodeAt` was not statically a Number.** `is_numeric_expr` had no arm + for a String-method call, so `h ^ s.charCodeAt(i)` failed `expr/binary.rs`'s + "both operands are statically primitive" test and computed an integer xor + through the BigInt-aware `js_dynamic_bitxor`. The admitted set is exactly + `charCodeAt`/`indexOf`/`lastIndexOf`/`search`/`localeCompare`, each verified + against its lowering. `codePointAt` is deliberately excluded — it returns + `undefined` out of range, a NaN-box *tag*, not a number. The claim is gated + on the receiver taking codegen's proven-string routing, mirroring + `lower_call/property_get.rs`'s condition, so an `any`-typed receiver (which + may be a user object with its own `charCodeAt`) is never claimed. + +2. **`charCodeAt` had no inline path.** It has one now: a guard chain that + reproduces exactly what `js_string_char_code_at` + `js_string_index_to_i32` + compute — `STRING_TAG` receiver, handle ≥ 4096, `0 <= index < 2^31-1` as + ORDERED comparisons (so a NaN-boxed index falls back to the full + `ToIntegerOrInfinity`, and the following `fptosi` can never be poison), + `utf16_len == byte_len` (the runtime's own `is_ascii_string`, which implies + every byte < 0x80 so no WTF-8 / lone-surrogate / astral payload reaches the + byte load), `index < utf16_len` — and falls back to those same two calls for + anything it cannot prove. Removing the calls is also what lets LICM hoist + the loop-invariant receiver unbox and header loads, which an opaque call on + the critical path had been blocking. + +Measured on the pinned quiet host, both arms built from one target dir with an +identical package set and run interleaved, output hash byte-identical on every +row: + +| | fnv1a phase | ns/char | peak RSS | +|---|--:|--:|--:| +| 200k records, before | 483 ms | 17.7 | 598.7 MB | +| 200k records, after | **43 ms** | 1.6 | 598.7 MB | +| 500k records, before | 1,247 ms | 17.7 | 1,389.2 MB | +| 500k records, after | **111 ms** | 1.6 | 1,389.2 MB | + +**11.2x**, RSS unchanged. The post-fix leaf profile is 100% the JS loop — +every runtime call is gone from the hot path. + +No new env knob: the inline path rides `PERRY_STATIC_STRING_LOWERING`, the +gate the sibling inline `.length` fast path already uses. The fast path reads +`StringHeader` at offsets 0/4/20; because `perry-codegen` cannot depend on +`perry-runtime`, the struct definition gained a `const` assertion +(`STRING_HEADER_ABI_MATCHES_CODEGEN`) so a layout change fails the runtime +build instead of silently miscompiling every `.length` and `charCodeAt` in +every user program. + +Two things deliberately NOT changed. `JSON.stringify` was listed in #7592 at +1,451 ms; re-measured it is **267 ms** — the old figure was GC pause charged to +the phase it landed in, and #7594/#7596 already removed it. And #7596's +`scavenge_nursery_cap_effective_bytes` gained a `max(influx_driven, +old_gen_reclaimable/2)` term with no test; the policy is now a pure function of +its two inputs and is covered directly, because the sibling cap-scale test only +*looks* like coverage — it asserts against the effective cap in a unit-test +thread whose old-gen is ~empty, so it stays green with the proportional term +deleted. diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index a8bb8793df..74a06f57b7 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -56,7 +56,11 @@ mod new_ctor_args; mod new_helpers; mod omitted_native_params; mod options; -mod property_get; +/// `pub(crate)` so `type_analysis` can reuse the exact receiver/method +/// predicates that decide whether a `PropertyGet` call takes the static +/// String lowering — a static type claim about such a call must be gated on +/// the same condition that routes it (#7592). +pub(crate) mod property_get; mod scalar_method; /// #7510: which of the two typed-shape layout entry points a `new` site emits, /// and where. Split out of `new.rs` to keep it under the 2000-line cap. diff --git a/crates/perry-codegen/src/lower_string_method.rs b/crates/perry-codegen/src/lower_string_method.rs index b3272193bb..f34db80620 100644 --- a/crates/perry-codegen/src/lower_string_method.rs +++ b/crates/perry-codegen/src/lower_string_method.rs @@ -17,7 +17,10 @@ use crate::expr::{ FnCtx, }; use crate::type_analysis::is_string_expr; + +mod char_code_at; use crate::types::{DOUBLE, I1, I32, I64, PTR}; +use char_code_at::lower_char_code_at_inline; fn regexp_search_method_id(property: &str) -> String { match property { @@ -762,6 +765,9 @@ fn lower_string_method_dispatch( let _ = lower_expr(ctx, extra)?; } let recv_box = reread_recv(ctx, recv_root, &recv_box); + if let Some(value) = lower_char_code_at_inline(ctx, object, &recv_box, &idx_d) { + return Ok(value); + } let recv_handle = str_operand_handle_tag_dispatched(ctx, object, &recv_box); let blk = ctx.block(); let idx_i32 = blk.call(I32, "js_string_index_to_i32", &[(DOUBLE, &idx_d)]); diff --git a/crates/perry-codegen/src/lower_string_method/char_code_at.rs b/crates/perry-codegen/src/lower_string_method/char_code_at.rs new file mode 100644 index 0000000000..26a9809adc --- /dev/null +++ b/crates/perry-codegen/src/lower_string_method/char_code_at.rs @@ -0,0 +1,157 @@ +//! Inline `charCodeAt` on an ASCII heap string (#7592). +//! +//! Split out of `lower_string_method.rs` to stay under the repo's +//! 2000-line-per-file lint cap. Pure code move plus the `pub(super)` +//! visibility the split requires. + +use perry_hir::Expr; + +use crate::expr::FnCtx; +use crate::type_analysis::is_string_expr; +use crate::types::{DOUBLE, I1, I32, I64}; + +use super::str_operand_handle_tag_dispatched; + +// `StringHeader` layout the fast path reads, from +// `crates/perry-runtime/src/string/mod.rs`. That struct is `#[repr(C)]` with +// five `u32` fields; the runtime carries a `const` assertion +// (`STRING_HEADER_ABI_MATCHES_CODEGEN`) pinning exactly these three numbers, +// so a layout change fails the runtime build rather than miscompiling here. +// +// Offset 0 is already an established codegen/runtime contract: the inline +// `.length` fast path in `expr/property_get.rs` loads it, and the struct's +// own doc comment says "`utf16_len` is at offset 0 so codegen can inline +// `.length` as a single i32 load." +const STRING_HEADER_UTF16_LEN_OFFSET: &str = "0"; +const STRING_HEADER_BYTE_LEN_OFFSET: &str = "4"; +const STRING_HEADER_SIZE: &str = "20"; + +/// Inline `s.charCodeAt(i)` for a heap-tagged, all-ASCII receiver. +/// +/// #7592 — the FNV-1a phase of `json_pipeline` spent 85% of its leaf profile +/// in four opaque runtime calls per character over a 68 MB string: +/// `js_string_char_code_at` (31.5%), `js_dynamic_bitxor` (31.0%), +/// `js_string_index_to_i32` (13.1%) and `js_get_string_pointer_unified` +/// (9.0%). Only 15% was the JS loop. The individual helpers are each a +/// handful of instructions; the cost is the FFI boundary itself, and — worse +/// — an opaque call on the loop's critical path blocks LICM, so the +/// loop-invariant receiver unbox and header loads could never be hoisted. +/// +/// The guard chain reproduces exactly what `js_string_char_code_at` + +/// `js_string_index_to_i32` would compute, and anything it cannot prove +/// branches to those same two calls: +/// * `STRING_TAG` receiver — an SSO short string, a lying `string` +/// annotation, or `undefined` all take the slow arm, which still routes +/// through `str_operand_handle_tag_dispatched` (SSO materialization +/// included); +/// * handle ≥ 4096 — the runtime's `is_valid_string_ptr` magnitude check; +/// * `0.0 <= index < 2^31-1` — ORDERED comparisons, so a NaN-boxed index +/// (a string, a bool, `undefined`, or a genuine NaN) fails both and takes +/// the slow arm, where `js_string_index_to_i32` performs the full +/// `ToIntegerOrInfinity` including user `valueOf`. Also makes the +/// subsequent `fptosi` in-range, so it can never be poison; +/// * `utf16_len == byte_len` — the runtime's own `is_ascii_string` +/// predicate. Equality implies every byte is one UTF-16 code unit, i.e. +/// every byte is < 0x80, so no WTF-8 / lone-surrogate / astral input can +/// reach the byte load (#6085's bounded walk still owns those); +/// * `index < utf16_len` — out of range returns `NaN` from the slow arm. +/// +/// No allocation and no call occur between the receiver re-read and the byte +/// load, so no collection can move the header underneath the fast path. +/// +/// Gated on `static_string_lowering_enabled()` (`PERRY_STATIC_STRING_LOWERING`) +/// — the same knob the sibling inline `.length` fast path uses, so this adds +/// no new mode. +pub(super) fn lower_char_code_at_inline( + ctx: &mut FnCtx<'_>, + object: &Expr, + recv_box: &str, + idx_d: &str, +) -> Option { + use crate::nanbox::POINTER_MASK_I64; + if !crate::expr::static_string_lowering_enabled() || !is_string_expr(ctx, object) { + return None; + } + + let bits = ctx.block().bitcast_double_to_i64(recv_box); + let tag = ctx.block().lshr(I64, &bits, "48"); + let is_heap = ctx + .block() + .icmp_eq(I64, &tag, crate::nanbox::STRING_TAG_TOP16_I64); + let handle = ctx.block().and(I64, &bits, POINTER_MASK_I64); + let handle_ok = ctx.block().icmp_uge(I64, &handle, "4096"); + let idx_ge0 = ctx.block().fcmp("oge", idx_d, "0.0"); + let idx_lt_max = ctx + .block() + .fcmp("olt", idx_d, &crate::nanbox::double_literal(2147483647.0)); + let recv_ok = ctx.block().and(I1, &is_heap, &handle_ok); + let idx_ok = ctx.block().and(I1, &idx_ge0, &idx_lt_max); + let entry_ok = ctx.block().and(I1, &recv_ok, &idx_ok); + + let hdr_idx = ctx.new_block("cca.hdr"); + let fast_idx = ctx.new_block("cca.fast"); + let slow_idx = ctx.new_block("cca.slow"); + let merge_idx = ctx.new_block("cca.merge"); + let hdr_label = ctx.block_label(hdr_idx); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&entry_ok, &hdr_label, &slow_label); + + // Header block: ASCII + in-range test. Dominated by `handle >= 4096`, so + // the two loads are safe; dominated by the index range test, so `fptosi` + // is in range. + ctx.current_block = hdr_idx; + let hdr_ptr = ctx.block().inttoptr(I64, &handle); + let u16_ptr = ctx.block().gep_inbounds( + crate::types::I8, + &hdr_ptr, + &[(I64, STRING_HEADER_UTF16_LEN_OFFSET)], + ); + let utf16_len = ctx.block().load(I32, &u16_ptr); + let blen_ptr = ctx.block().gep_inbounds( + crate::types::I8, + &hdr_ptr, + &[(I64, STRING_HEADER_BYTE_LEN_OFFSET)], + ); + let byte_len = ctx.block().load(I32, &blen_ptr); + let is_ascii = ctx.block().icmp_eq(I32, &utf16_len, &byte_len); + let idx_i32 = ctx.block().fptosi(DOUBLE, idx_d, I32); + let in_bounds = ctx.block().icmp_ult(I32, &idx_i32, &utf16_len); + let fast_ok = ctx.block().and(I1, &is_ascii, &in_bounds); + ctx.block().cond_br(&fast_ok, &fast_label, &slow_label); + + // Fast block: one byte load, zero calls. + ctx.current_block = fast_idx; + let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); + let data_ptr = + ctx.block() + .gep_inbounds(crate::types::I8, &hdr_ptr, &[(I64, STRING_HEADER_SIZE)]); + let char_ptr = ctx + .block() + .gep_inbounds(crate::types::I8, &data_ptr, &[(I64, &idx_i64)]); + let byte = ctx.block().load(crate::types::I8, &char_ptr); + let fast_val = ctx.block().uitofp(crate::types::I8, &byte, DOUBLE); + let fast_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + // Slow block: bit-for-bit the pre-#7592 lowering. + ctx.current_block = slow_idx; + let recv_handle = str_operand_handle_tag_dispatched(ctx, object, recv_box); + let slow_idx_i32 = ctx + .block() + .call(I32, "js_string_index_to_i32", &[(DOUBLE, idx_d)]); + let slow_val = ctx.block().call( + DOUBLE, + "js_string_char_code_at", + &[(I64, &recv_handle), (I32, &slow_idx_i32)], + ); + let slow_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + Some( + ctx.block() + .phi(DOUBLE, &[(&fast_val, &fast_pred), (&slow_val, &slow_pred)]), + ) +} diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index ace03d7cab..f0cb45a65f 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -80,6 +80,50 @@ pub(crate) fn is_bigint_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } +/// `String.prototype` methods whose lowering in `lower_string_method.rs` +/// produces a **real f64** on every input — either `sitofp` of an `i32` +/// helper result, or a helper documented to return a plain double. +/// +/// Deliberately excluded, and why each one would be wrong here: +/// * `codePointAt` — returns `undefined` (a NaN-BOX tag, not a number) for an +/// out-of-range index; `js_string_code_point_at` is documented as +/// "NaN-boxed number **or undefined**". +/// * `at` / `charAt` — string results. +/// * `startsWith` / `endsWith` / `includes` — booleans (NaN-boxed tags). +/// +/// `charCodeAt` IS admitted: its out-of-range result is `f64::NAN` +/// (`0x7FF8_0000_0000_0000`), a hardware quiet NaN outside the NaN-box tag +/// band `0x7FF9..=0x7FFF`, so it is a genuine Number in exactly the sense +/// `is_numeric_expr` promises. +fn string_method_returns_number(name: &str) -> bool { + matches!( + name, + "charCodeAt" | "indexOf" | "lastIndexOf" | "search" | "localeCompare" + ) +} + +/// True when `.(…)` is one of the Number-returning +/// `String.prototype` methods AND the receiver takes codegen's proven-string +/// lowering path. +/// +/// The receiver test mirrors `lower_call/property_get.rs`'s static-String +/// dispatch condition exactly (`is_string_expr && !is_array_only_method_name +/// && is_known_string_method_name`), so the predicate can only answer `true` +/// for a call that really is lowered by `lower_string_method` — the +/// Any-typed-receiver fallback, which routes to `js_native_call_method` and +/// can return `undefined` for a non-string runtime value, is not claimed. +/// +/// #7592: without this, `h ^ s.charCodeAt(i)` fails the "both operands are +/// statically primitive" test in `expr/binary.rs` and every iteration of an +/// FNV-style hash loop pays a `js_dynamic_bitxor` FFI call — 31% of the leaf +/// profile of `json_pipeline`'s hash phase, for an integer xor. +fn string_method_call_returns_number(ctx: &FnCtx<'_>, object: &Expr, property: &str) -> bool { + string_method_returns_number(property) + && crate::type_analysis::is_string_expr(ctx, object) + && !crate::lower_call::property_get::is_array_only_method_name(property) + && crate::lower_string_method::is_known_string_method_name(property) +} + pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::Integer(_) @@ -335,6 +379,9 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { object, property, .. } = callee.as_ref() { + if string_method_call_returns_number(ctx, object, property) { + return true; + } if is_fixed_width_buffer_numeric_read(property) && receiver_class_name(ctx, object) .as_deref() diff --git a/crates/perry-codegen/src/type_analysis/numeric/tests.rs b/crates/perry-codegen/src/type_analysis/numeric/tests.rs index 49131bee4f..77050dbc97 100644 --- a/crates/perry-codegen/src/type_analysis/numeric/tests.rs +++ b/crates/perry-codegen/src/type_analysis/numeric/tests.rs @@ -351,3 +351,196 @@ fn i64_range_local_is_refused_as_a_modulo_divisor() { (srem by 0 is UB; JS requires NaN):\n{ir}" ); } + +// ── #7592: `s.charCodeAt(i)` in a hash loop ───────────────────────────── +// +// `honest_bench`'s `json_pipeline` FNV-1a phase hashes a 68 MB string one +// `charCodeAt` at a time. Its leaf profile was 85% opaque runtime calls: +// `js_string_char_code_at` 31.5%, `js_dynamic_bitxor` 31.0%, +// `js_string_index_to_i32` 13.1%, `js_get_string_pointer_unified` 9.0% — +// only 15% was the JS loop. Two independent defects produced that, and each +// assertion below pins exactly one of them. + +fn typed_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, + } +} + +/// `recv.charCodeAt(index)` +fn char_code_at(recv: Expr, index: Expr) -> Expr { + Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(recv), + property: "charCodeAt".to_string(), + byte_offset: 0, + }), + args: vec![index], + type_args: Vec::new(), + byte_offset: 0, + } +} + +/// `for (let i = 0; i < 64; i++) h = (h ^ recv.charCodeAt(i)) | 0;` +fn hash_loop_ir(param_ty: Type) -> String { + let recv = Expr::LocalGet(1); + emitted_ir(probe_module( + "char_code_at_unit.ts", + vec![typed_param(1, "s", param_ty)], + vec![ + number_let(10, "h", true, Expr::Integer(0)), + Stmt::For { + init: Some(Box::new(number_let(11, "i", true, Expr::Integer(0)))), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(11)), + right: Box::new(Expr::Integer(64)), + }), + update: Some(Expr::Update { + id: 11, + op: UpdateOp::Increment, + prefix: false, + }), + body: vec![Stmt::Expr(Expr::LocalSet( + 10, + Box::new(Expr::Binary { + op: BinaryOp::BitOr, + left: Box::new(Expr::Binary { + op: BinaryOp::BitXor, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(char_code_at(recv, Expr::LocalGet(11))), + }), + right: Box::new(Expr::Integer(0)), + }), + ))], + }, + Stmt::Return(Some(Expr::LocalGet(10))), + ], + )) +} + +#[test] +fn char_code_at_on_a_string_receiver_is_statically_numeric() { + // Defect 1: `is_numeric_expr` had no arm for a String-method call, so + // `h ^ s.charCodeAt(i)` failed `expr/binary.rs`'s "both operands are + // statically primitive" test and every iteration paid a + // `js_dynamic_bitxor` FFI call to compute an integer xor. + let ir = hash_loop_ir(Type::String); + assert!( + !ir.contains("call double @js_dynamic_bitxor"), + "a xor against String.prototype.charCodeAt must not route through \ + the BigInt-aware dynamic helper — charCodeAt is a Number:\n{ir}" + ); + assert!( + ir.contains("xor i32"), + "the xor must lower inline once both operands are proven \ + non-BigInt primitives:\n{ir}" + ); +} + +#[test] +fn char_code_at_on_a_string_receiver_emits_the_inline_ascii_read() { + // Defect 2: even with the receiver handle resolved, each character cost + // two more opaque calls (`js_string_index_to_i32` + + // `js_string_char_code_at`), which also pinned the loop-invariant header + // loads inside the loop because LICM cannot hoist across an opaque call. + let ir = hash_loop_ir(Type::String); + assert!( + ir.contains("cca.fast"), + "a string-typed receiver must get the inline ASCII charCodeAt fast \ + path:\n{ir}" + ); + assert!( + ir.contains("load i8"), + "the ASCII fast path must read the character as a single byte \ + load:\n{ir}" + ); + // The slow arm is still emitted — it is what services SSO receivers, + // non-ASCII payloads, out-of-range and non-numeric indices. Its presence + // is the proof that the fast path did NOT replace the correct lowering, + // only shortcut it. + assert!( + ir.contains("call double @js_string_char_code_at") + && ir.contains("call i32 @js_string_index_to_i32"), + "the inline path must keep the runtime helpers as its fallback \ + arm:\n{ir}" + ); +} + +#[test] +fn char_code_at_on_an_unproven_receiver_keeps_the_runtime_lowering() { + // The negative control. An `any`-typed receiver may be a user object with + // its own `charCodeAt`, so neither the static Number claim nor the inline + // header read is admissible — and this assertion is what makes the two + // tests above meaningful rather than tautological (both would pass on a + // build that fired the fast path unconditionally). + let ir = hash_loop_ir(Type::Any); + assert!( + !ir.contains("cca.fast"), + "an unproven receiver must not read a StringHeader inline:\n{ir}" + ); + assert!( + ir.contains("call double @js_dynamic_bitxor"), + "an unproven receiver's method result may still be a BigInt, so the \ + xor must keep the dynamic helper:\n{ir}" + ); +} + +#[test] +fn only_number_returning_string_methods_are_claimed_numeric() { + // `codePointAt` returns `undefined` (a NaN-BOX tag) for an out-of-range + // index and `at`/`charAt` return strings, so claiming them numeric would + // hand a tagged value to an `fadd`/inline-xor. Pinning the exact set here + // means widening it is a deliberate edit, not a copy-paste from + // `is_known_string_method_name`. + const CLAIMED: [&str; 5] = [ + "charCodeAt", + "indexOf", + "lastIndexOf", + "search", + "localeCompare", + ]; + for name in CLAIMED { + assert!( + super::string_method_returns_number(name), + "{name} lowers to a raw double and must be claimed numeric" + ); + } + for name in [ + "codePointAt", + "at", + "charAt", + "startsWith", + "endsWith", + "includes", + "slice", + "split", + "match", + ] { + assert!( + !super::string_method_returns_number(name), + "{name} does not always evaluate to a Number and must not be claimed" + ); + } + // The claim mirrors `lower_call/property_get.rs`'s static-String routing + // condition; an admitted name that is ALSO array-only would never reach + // `lower_string_method`, and the claim would be about a call that lowers + // somewhere else entirely. + for name in CLAIMED { + assert!( + !crate::lower_call::property_get::is_array_only_method_name(name), + "{name} must not be array-only, or the routing mirror is wrong" + ); + assert!( + crate::lower_string_method::is_known_string_method_name(name), + "{name} must be a known String method, or it never routes to \ + lower_string_method" + ); + } +} diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index a41b73bedf..4082e7e653 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -129,10 +129,30 @@ pub(super) fn tenuring_survivals() -> u8 { /// minus free-list holes, #7437) rather than raw in-use, so dead-but-swept /// old bytes do not inflate Eden. pub(super) fn scavenge_nursery_cap_effective_bytes() -> usize { - let influx_driven = - gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize); - let tenured_proportional = old_gen_reclaimable_pressure_bytes() / TENURED_EDEN_DIVISOR; - influx_driven.max(tenured_proportional) + scavenge_nursery_cap_from( + influx_driven_nursery_cap_bytes(), + old_gen_reclaimable_pressure_bytes(), + ) +} + +/// The influx-driven half of the cap on its own: the configured base times +/// the debounced `NURSERY_CAP_SCALE`. Named so the composition below reads as +/// the two-term policy it is. +fn influx_driven_nursery_cap_bytes() -> usize { + gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize) +} + +/// The cap policy as a **pure function of its two inputs**, so it is testable +/// without arranging a heap state. +/// +/// Splitting this out is not cosmetic. `old_gen_reclaimable_pressure_bytes()` +/// reads live arena state, and in a unit-test thread old-gen is ~empty — so +/// every assertion made against `scavenge_nursery_cap_effective_bytes()` +/// directly would pass with the proportional term deleted. That is the #7024 +/// shape: a green test whose subject never ran. The two terms are exercised +/// here explicitly instead. +pub(super) fn scavenge_nursery_cap_from(influx_driven: usize, tenured_reclaimable: usize) -> usize { + influx_driven.max(tenured_reclaimable / TENURED_EDEN_DIVISOR) } /// #7592: divisor for the tenured-proportional nursery cap — Eden may grow to @@ -459,4 +479,90 @@ mod tests { } reset_for_test(); } + + // ── #7596's tenured-proportional cap term ─────────────────────────── + // + // #7596 added `max(influx_driven, old_gen_reclaimable / 2)` to + // `scavenge_nursery_cap_effective_bytes` with no test of its own. The + // sibling `cap_scale_grows_on_heavy_influx_and_shrinks_when_quiet` looks + // like coverage but is not: it asserts against the effective cap in a + // unit-test thread whose old-gen is ~empty, so the proportional term + // contributes 0 and the test stays green with that term deleted. These + // two exercise the policy directly. + + #[test] + fn nursery_cap_keeps_the_influx_floor_below_the_crossover() { + // #7377's guarantee: a program whose tenured live set is small keeps + // the configured 16 MB base. If the `.max()` were dropped — leaving + // only `tenured / 2` — a small-live-set program would collect on a + // near-zero cap, which is the regression #7377 fixed. + let base = gc_scavenge_nursery_cap_bytes(); + assert_eq!(scavenge_nursery_cap_from(base, 0), base); + assert_eq!(scavenge_nursery_cap_from(base, 1), base); + // Just below the crossover the floor still decides. + assert_eq!( + scavenge_nursery_cap_from(base, base * TENURED_EDEN_DIVISOR - 1), + base + ); + // Exactly at it the two terms coincide. + assert_eq!( + scavenge_nursery_cap_from(base, base * TENURED_EDEN_DIVISOR), + base + ); + // The floor tracks the influx-driven scale, not the raw base: at the + // ×4 ceiling a 3×base tenured set is still below the crossover. + assert_eq!(scavenge_nursery_cap_from(base * 4, base * 3), base * 4); + } + + #[test] + fn nursery_cap_becomes_tenured_proportional_above_the_crossover() { + // The #7592 shape: `json_pipeline` at 500k promotes ~400 MB. With a + // constant cap the young collection count is linear in bytes + // allocated and each collection is O(live) — quadratic total. Above + // the crossover the cap must BE `tenured / 2`, so `old_{n+1} ≈ + // old_n × 1.5` and the count is logarithmic instead. + // + // If the proportional term were deleted, every assertion here would + // read `base` and fail. + let base = gc_scavenge_nursery_cap_bytes(); + let tenured = base * 50; + assert_eq!( + scavenge_nursery_cap_from(base, tenured), + tenured / TENURED_EDEN_DIVISOR + ); + // Strictly above the floor, and exactly the divisor — not merely + // "bigger than base", which a wrong divisor would also satisfy. + assert!(scavenge_nursery_cap_from(base, tenured) > base); + assert_eq!( + scavenge_nursery_cap_from(base, 400 * 1024 * 1024), + 200 * 1024 * 1024 + ); + // A larger influx-driven term still wins when it is the bigger of + // the two: the policy is a max, not a takeover. + assert_eq!( + scavenge_nursery_cap_from(tenured, tenured), + tenured, + "the proportional term must never LOWER the cap" + ); + } + + #[test] + fn effective_nursery_cap_is_the_two_term_policy() { + // Wiring pin: the effective accessor must be the composition, so a + // future edit that inlines one term and drops the other cannot pass + // the two policy tests above while shipping a different cap. + // + // Honest about its limits: on a quiescent test thread old-gen is + // ~empty, so this cannot distinguish the two terms by value — it only + // proves the accessor and the policy agree on the live inputs, and + // that the #7377 floor survives whatever old-gen happens to be. + reset_for_test(); + let expected = scavenge_nursery_cap_from( + influx_driven_nursery_cap_bytes(), + old_gen_reclaimable_pressure_bytes(), + ); + assert_eq!(scavenge_nursery_cap_effective_bytes(), expected); + assert!(scavenge_nursery_cap_effective_bytes() >= gc_scavenge_nursery_cap_bytes()); + reset_for_test(); + } } diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 21225bf7c6..308c5f62b9 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -318,6 +318,29 @@ pub struct StringHeader { pub flags: u32, } +/// ABI pin for the codegen-side inline string fast paths. +/// +/// `perry-codegen` emits raw loads at these offsets instead of calling into +/// the runtime — `expr/property_get.rs` reads `utf16_len` for the inline +/// `.length`, and `lower_string_method.rs`'s inline `charCodeAt` (#7592) +/// additionally reads `byte_len` (for the runtime's own +/// `is_ascii_string` predicate, `utf16_len == byte_len`) and the payload at +/// `size_of::()`. +/// +/// `perry-codegen` does not depend on `perry-runtime`, so the two sides +/// cannot share a constant. This assertion is the link: reordering, resizing, +/// or padding this struct fails the runtime BUILD here, at the definition, +/// rather than silently miscompiling every `.length` and `charCodeAt` in +/// every user program. The literals are duplicated in +/// `lower_string_method.rs`'s `STRING_HEADER_*` constants, which name this +/// item. +const STRING_HEADER_ABI_MATCHES_CODEGEN: () = { + assert!(std::mem::size_of::() == 20); + assert!(std::mem::offset_of!(StringHeader, utf16_len) == 0); + assert!(std::mem::offset_of!(StringHeader, byte_len) == 4); +}; +const _: () = STRING_HEADER_ABI_MATCHES_CODEGEN; + // ── UTF-8 ↔ UTF-16 conversion helpers ────────────────────────────────── /// Count UTF-16 code units for a UTF-8 byte slice. Returns 0 for empty/null. diff --git a/test-files/test_gap_7592_char_code_at_inline.ts b/test-files/test_gap_7592_char_code_at_inline.ts new file mode 100644 index 0000000000..c7473f003b --- /dev/null +++ b/test-files/test_gap_7592_char_code_at_inline.ts @@ -0,0 +1,126 @@ +// #7592: `String.prototype.charCodeAt` on a statically-string receiver now +// lowers to an inline guarded byte load instead of two opaque runtime calls +// (`js_string_index_to_i32` + `js_string_char_code_at`). Everything the guard +// chain cannot prove — a short (SSO) receiver, a non-ASCII payload, a lone +// surrogate, an out-of-range or non-numeric index — must still take the +// runtime arm and produce exactly the spec result. +// +// The receiver is passed as a `string` parameter on purpose: that is the shape +// that takes the inline path (`is_string_expr` on a parameter), and it is the +// shape `honest_bench`'s FNV-1a hash loop uses. + +function at(s: string, i: number): number { + return s.charCodeAt(i); +} + +function atAny(s: string, i: any): number { + return s.charCodeAt(i); +} + +// ── ASCII heap string: the fast arm ─────────────────────────────────────── +let ascii = ''; +for (let i = 0; i < 40; i++) ascii += String.fromCharCode(65 + (i % 26)); +console.log('ascii len', ascii.length); +console.log('ascii 0', at(ascii, 0)); +console.log('ascii 25', at(ascii, 25)); +console.log('ascii last', at(ascii, ascii.length - 1)); + +// Out of range on both ends -> NaN (never a byte read past the payload). +console.log('ascii len-index', at(ascii, ascii.length)); +console.log('ascii huge', at(ascii, 1e9)); +console.log('ascii -1', at(ascii, -1)); +console.log('ascii -1e12', at(ascii, -1e12)); + +// ToIntegerOrInfinity on the index: truncation toward zero, NaN -> 0. +console.log('ascii 3.9', at(ascii, 3.9)); +console.log('ascii -0.5', at(ascii, -0.5)); +console.log('ascii NaN', at(ascii, NaN)); +console.log('ascii Infinity', at(ascii, Infinity)); +console.log('ascii -Infinity', at(ascii, -Infinity)); + +// Non-numeric indices must still run the full coercion. +console.log('ascii "2"', atAny(ascii, '2')); +console.log('ascii true', atAny(ascii, true)); +console.log('ascii null', atAny(ascii, null)); +console.log('ascii undefined', atAny(ascii, undefined)); +console.log('ascii {valueOf}', atAny(ascii, { valueOf: () => 4 })); +console.log('ascii no-arg', (ascii as any).charCodeAt()); + +// ── Short (SSO) receiver: guard must route to the runtime arm ──────────── +const sso = 'ab'; +console.log('sso 0', at(sso, 0)); +console.log('sso 1', at(sso, 1)); +console.log('sso 2', at(sso, 2)); +console.log('empty 0', at('', 0)); + +// ── Non-ASCII payloads: utf16_len != byte_len, runtime arm ─────────────── +const accented = 'héllo wörld'; +console.log('accented len', accented.length); +for (let i = 0; i < accented.length; i++) { + console.log('accented', i, at(accented, i)); +} + +// Astral code point -> two UTF-16 code units. +const astral = 'a\u{1F600}b'; +console.log('astral len', astral.length); +console.log('astral 0', at(astral, 0)); +console.log('astral 1', at(astral, 1)); +console.log('astral 2', at(astral, 2)); +console.log('astral 3', at(astral, 3)); +console.log('astral 4', at(astral, 4)); + +// Lone surrogate (WTF-8 payload). +const lone = 'x\uD800y'; +console.log('lone len', lone.length); +console.log('lone 0', at(lone, 0)); +console.log('lone 1', at(lone, 1)); +console.log('lone 2', at(lone, 2)); + +// A high-byte binary string: every code unit is one byte 0..255, but bytes +// >= 128 make byte_len > utf16_len, so this must NOT take the ASCII arm. +let bytes = ''; +for (let i = 0; i < 256; i++) bytes += String.fromCharCode(i); +let roundTrip = true; +for (let i = 0; i < 256 && roundTrip; i++) roundTrip = at(bytes, i) === i; +console.log('binary 0-255 round-trip', roundTrip); + +// ── The hash-loop shape itself ─────────────────────────────────────────── +function imul32(a: number, b: number): number { + const aHi = (a >>> 16) & 0xffff; + const aLo = a & 0xffff; + const bHi = (b >>> 16) & 0xffff; + const bLo = b & 0xffff; + return (aLo * bLo + (((aHi * bLo + aLo * bHi) << 16) >>> 0)) | 0; +} +function fnv1a32(s: string): number { + let h = 0x811c9dc5 | 0; + for (let i = 0; i < s.length; i++) { + h = (h ^ s.charCodeAt(i)) | 0; + h = imul32(h, 0x01000193); + } + return h >>> 0; +} +console.log('fnv ascii', fnv1a32(ascii).toString(16)); +console.log('fnv accented', fnv1a32(accented).toString(16)); +console.log('fnv astral', fnv1a32(astral).toString(16)); +console.log('fnv binary', fnv1a32(bytes).toString(16)); +console.log('fnv empty', fnv1a32('').toString(16)); + +// A nullable-string receiver still reaches the string lowering. +function atNullable(s: string | null, i: number): number { + return s === null ? -1 : s.charCodeAt(i); +} +console.log('nullable', atNullable(ascii, 1), atNullable(null, 1)); + +// The other Number-returning String methods that #7592 also marks numeric — +// each xored so the inline bitwise path is exercised, not just the call. +console.log('indexOf xor', (0 ^ ascii.indexOf('C')) | 0); +console.log('lastIndexOf xor', (0 ^ ascii.lastIndexOf('C')) | 0); +console.log('search xor', (0 ^ ascii.search(/C/)) | 0); +console.log('localeCompare', 'a'.localeCompare('b'), 'b'.localeCompare('a'), 'a'.localeCompare('a')); +console.log('indexOf missing', (0 ^ ascii.indexOf('zzz')) | 0); + +// `codePointAt` is deliberately NOT claimed numeric: it returns `undefined` +// out of range, which is a NaN-boxed tag rather than a number. +console.log('codePointAt 0', ascii.codePointAt(0)); +console.log('codePointAt oob', ascii.codePointAt(1000));