From c00d81f06038559004bfecf3c7e5cf1745a0cbbd Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sat, 19 Sep 2026 23:40:46 +0000 Subject: [PATCH 1/5] perf(transform): re-apply the literal-key member fold after const substitution (#10761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `o["a"]` written in source is already lowered to `o.a` by the AST->HIR member lowering (the #529 fold in `lower/expr_member/member_tail.rs`). But `module_const_fold` substitutes a hoisted `const K = "a"` into the key position *after* that matcher has run, and nothing re-ran it — so the enclosing node stayed an `IndexGet` and codegen's static-string-key arm resolved it by name at runtime on every read: UTF-8-validate the key, hash it for the accessor Bloom summary, classify the receiver, then scan the shape's key array. Phase 2 re-applies the same rewrite. The produced node is bit-identical to the one `o["name"]` produces in source, so there is no new fast path and no new guard; the read simply reaches the per-site monomorphic inline cache that the dotted spelling already used. O[K] + O[J] on {a:1,b:2,c:3} 1236 -> 169 instructions/iteration (7.31x) which is exactly what the same pair spelled `O.a + O.b` costs. Identical at both fit ranges. It also corrects a spec divergence: `null[K]` and `undefined[K]` silently read `undefined` before this change, where node throws a TypeError. Numeric-index strings are excluded, mirroring the source-level fold verbatim, so `arr["0"]` keeps IndexGet semantics. --- changelog.d/10761-const-key-member-fold.md | 5 + .../perry-transform/src/module_const_fold.rs | 234 ++++++++++++++++++ ...test_gap_10761_const_key_property_reads.ts | 232 +++++++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 changelog.d/10761-const-key-member-fold.md create mode 100644 test-files/test_gap_10761_const_key_property_reads.ts diff --git a/changelog.d/10761-const-key-member-fold.md b/changelog.d/10761-const-key-member-fold.md new file mode 100644 index 0000000000..5f6b02acfb --- /dev/null +++ b/changelog.d/10761-const-key-member-fold.md @@ -0,0 +1,5 @@ +**A hoisted `const K = "a"` used as a property key no longer costs 7.3× the same read spelled `o.a`.** + +`o["a"]` in source is already folded to `o.a` by the member lowering. But `module_const_fold` substitutes a hoisted const into the key position *after* that matcher has run, so the node stayed an `IndexGet` and was resolved by name at runtime on every read — UTF-8-validating the key, hashing it for the accessor Bloom summary, classifying the receiver and scanning the shape's key array. + +Re-applying the same fold takes `O[K] + O[J]` from **1236 to 169 instructions**, exactly what `O.a + O.b` costs. It also fixes a spec divergence: `null[K]` and `undefined[K]` read `undefined` before, where node throws. diff --git a/crates/perry-transform/src/module_const_fold.rs b/crates/perry-transform/src/module_const_fold.rs index 3e22229d6a..3d67f65d26 100644 --- a/crates/perry-transform/src/module_const_fold.rs +++ b/crates/perry-transform/src/module_const_fold.rs @@ -40,6 +40,12 @@ use perry_hir::{Expr, Function, Module, Stmt}; use crate::closure_local_inline::{for_each_expr_in_stmt_mut, nested_stmt_lists}; pub fn run(module: &mut Module) { + fold_module_consts(module); + // Phase 2 runs unconditionally — see `rewrite_literal_index_gets`. + rewrite_literal_index_gets(module); +} + +fn fold_module_consts(module: &mut Module) { let mut consts: HashMap = HashMap::new(); let mut decl_index: HashMap = HashMap::new(); for (index, stmt) in module.init.iter().enumerate() { @@ -254,6 +260,97 @@ fn fold_expr(expr: &mut Expr, consts: &HashMap) { walk_expr_children_mut(expr, &mut |child| fold_expr(child, consts)); } +/// Phase 2 (#10761) — rewrite `o[]` into `o.`. +/// +/// This is the SAME rewrite the AST→HIR member lowering already applies to a +/// literal key written in source (`lower/expr_member/member_tail.rs`, the +/// issue #529 fold), re-applied here because phase 1 above — and the inliner +/// before it — *create* `IndexGet { _, String(_) }` nodes AFTER that matcher +/// has run, and nothing re-ran it. +/// +/// The gap is worth 7.3x. A hoisted `const K = "a"` is folded to its literal +/// by phase 1, but the enclosing node stays an `IndexGet`, and codegen's +/// `IndexGet` arm for a static string key +/// (`expr/index_get.rs`, the `Expr::String(literal)` branch) calls +/// `js_typed_feedback_object_get_field_by_name_f64` — a full by-name runtime +/// resolution per read: UTF-8-validate the key, hash it for the accessor +/// Bloom summary, classify the receiver, then scan the shape's key array. +/// Measured on `O[K] + O[J]` over `{a:1,b:2,c:3}`: **618 instructions per +/// read**, against **24** for the identical read spelled `O.a`, which reaches +/// the per-site monomorphic inline cache in +/// `expr/property_get/generic_dispatch.rs`. `O["a"]` written in source is +/// already 24 — only the spelling that goes through a binding was stranded. +/// +/// Numeric-index strings are excluded, exactly as the source-level fold +/// excludes them: `arr["0"]` keeps `IndexGet` semantics (string-coerced +/// element access on an array), and that is the disambiguator the spec itself +/// uses between indexed and named properties. +/// +/// Everything else about the read is unchanged, because the produced node is +/// bit-identical to the one `o["name"]` produces in source: the same +/// `PropertyGet`, the same receiver expression, the same key string. There is +/// no new fast path here and no new guard — the rewrite moves a read onto a +/// lowering the whole test suite already exercises. +fn rewrite_literal_index_gets(module: &mut Module) { + for_each_function(module, &mut |f| rewrite_stmts(&mut f.body)); + rewrite_stmts(&mut module.init); +} + +/// A key that JavaScript resolves as an array index rather than a name. +/// +/// Mirrors `member_tail.rs`'s test verbatim so the two folds admit exactly the +/// same key set; if they ever diverge, `o["0"]` and a `const Z = "0"` spelling +/// of it would compile to different lowerings. +fn is_numeric_index_string(key: &str) -> bool { + !key.is_empty() + && key.chars().all(|c| c.is_ascii_digit()) + && !(key.len() > 1 && key.starts_with('0')) +} + +fn rewrite_stmts(stmts: &mut [Stmt]) { + for stmt in stmts.iter_mut() { + rewrite_stmt(stmt); + } +} + +fn rewrite_stmt(stmt: &mut Stmt) { + for inner in nested_stmt_lists(stmt) { + rewrite_stmts(inner); + } + for_each_expr_in_stmt_mut(stmt, &mut rewrite_expr); +} + +fn rewrite_expr(expr: &mut Expr) { + if let Expr::IndexGet { object, index } = expr { + let property = match index.as_ref() { + Expr::String(key) if !is_numeric_index_string(key) => Some(key.clone()), + _ => None, + }; + if let Some(property) = property { + // The index is a literal, so there is no key expression to keep + // alive and no evaluation-order obligation: `o[k]` evaluates `o` + // then `k`, and a literal `k` is already a value. + let object = std::mem::replace(object.as_mut(), Expr::Integer(0)); + *expr = Expr::PropertyGet { + // Synthesized: the literal was not written at a source span + // (phase 1 substituted it), so there is no member offset to + // carry. `0` is the established "no debug location" value on + // this node, and the `IndexGet` this replaces carried none + // either. + byte_offset: 0, + object: Box::new(object), + property, + }; + } + } + // `walk_expr_children_mut` does not descend into a closure's STATEMENT + // body; phase 1 has the same explicit arm for the same reason. + if let Expr::Closure { body, .. } = expr { + rewrite_stmts(body); + } + walk_expr_children_mut(expr, &mut rewrite_expr); +} + #[cfg(test)] mod tests { use super::*; @@ -385,4 +482,141 @@ mod tests { Stmt::Return(Some(Expr::Compare { right, .. })) if matches!(right.as_ref(), Expr::Integer(5)) )); } + + // ---- phase 2 (#10761): the literal-index rewrite ------------------- + + /// The module-level fold substitutes the literal, and phase 2 then moves + /// the read onto the SAME node `o["a"]` produces in source. Without + /// phase 2 this stays an `IndexGet` and codegen resolves it by name at + /// runtime — 618 instructions per read against 24. + #[test] + fn a_const_string_key_read_becomes_a_property_get() { + let mut m = Module::new("k.ts"); + m.init.push(Stmt::Let { + id: 3, + name: "K".to_string(), + ty: Type::String, + mutable: false, + init: Some(Expr::String("a".to_string())), + }); + m.functions.push(func( + 1, + vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(8)), + index: Box::new(Expr::LocalGet(3)), + }))], + )); + run(&mut m); + let Stmt::Return(Some(Expr::PropertyGet { + object, property, .. + })) = &m.functions[0].body[0] + else { + panic!("expected a PropertyGet, got {:?}", m.functions[0].body[0]); + }; + assert_eq!(property, "a"); + assert!(matches!(object.as_ref(), Expr::LocalGet(8))); + } + + /// GUARD WITNESS for `is_numeric_index_string`. An array index key must + /// keep `IndexGet` semantics; `arr["0"]` is a string-coerced ELEMENT read, + /// not a named one, and that is the disambiguator the spec itself uses. + /// Delete the guard in `rewrite_expr` and this assertion fails. + /// + /// Note honestly what this test is and is not: at RUNTIME both spellings + /// happen to resolve a numeric name on an Array, a TypedArray and a String + /// through the same ladder, so the removal is behaviour-neutral on every + /// receiver I could construct. What removing it does cost is measured — + /// `Int32Array[K]` with `const K = "1"` goes from 969 to 1343 instructions + /// per read (+38.5%), because the folded form leaves the element lane. + #[test] + fn an_array_index_key_is_not_folded() { + for key in ["0", "1", "42", "4294967294"] { + let mut m = Module::new("k.ts"); + m.functions.push(func( + 1, + vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(8)), + index: Box::new(Expr::String(key.to_string())), + }))], + )); + run(&mut m); + assert!( + matches!( + &m.functions[0].body[0], + Stmt::Return(Some(Expr::IndexGet { .. })) + ), + "key {key:?} must stay an IndexGet, got {:?}", + m.functions[0].body[0] + ); + } + } + + /// The keys the guard does NOT claim: a leading zero, a fraction, a sign + /// and the empty string are property NAMES, not indices, and must fold — + /// exactly as `member_tail.rs` folds them when written in source. + #[test] + fn a_non_index_numeric_looking_key_is_folded() { + for key in ["07", "1.5", "-1", "", "1e3", "NaN"] { + let mut m = Module::new("k.ts"); + m.functions.push(func( + 1, + vec![Stmt::Return(Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(8)), + index: Box::new(Expr::String(key.to_string())), + }))], + )); + run(&mut m); + assert!( + matches!( + &m.functions[0].body[0], + Stmt::Return(Some(Expr::PropertyGet { property, .. })) if property == key + ), + "key {key:?} must fold, got {:?}", + m.functions[0].body[0] + ); + } + } + + /// Phase 2 runs even when phase 1 folded nothing: a literal index can be + /// put there by the inliner, and `run` early-returns out of phase 1 when + /// the module declares no foldable const. + #[test] + fn the_rewrite_runs_with_no_module_consts_at_all() { + let mut m = Module::new("k.ts"); + m.init.push(Stmt::Expr(Expr::IndexGet { + object: Box::new(Expr::LocalGet(8)), + index: Box::new(Expr::String("name".to_string())), + })); + run(&mut m); + assert!(matches!( + &m.init[0], + Stmt::Expr(Expr::PropertyGet { property, .. }) if property == "name" + )); + } + + /// The receiver subtree is moved, not dropped: a nested read rewrites at + /// both levels and keeps its inner object. + #[test] + fn a_nested_literal_index_rewrites_at_every_level() { + let mut m = Module::new("k.ts"); + m.init.push(Stmt::Expr(Expr::IndexGet { + object: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(8)), + index: Box::new(Expr::String("outer".to_string())), + }), + index: Box::new(Expr::String("inner".to_string())), + })); + run(&mut m); + let Stmt::Expr(Expr::PropertyGet { + object, property, .. + }) = &m.init[0] + else { + panic!("expected outer PropertyGet, got {:?}", m.init[0]); + }; + assert_eq!(property, "inner"); + assert!(matches!( + object.as_ref(), + Expr::PropertyGet { property, .. } if property == "outer" + )); + } } diff --git a/test-files/test_gap_10761_const_key_property_reads.ts b/test-files/test_gap_10761_const_key_property_reads.ts new file mode 100644 index 0000000000..d742bb1684 --- /dev/null +++ b/test-files/test_gap_10761_const_key_property_reads.ts @@ -0,0 +1,232 @@ +// #10761 — a property read spelled `O[K]` with a hoisted `const K = "a"` must +// be observably identical to `O.a` and `O["a"]` in EVERY case, now that the +// module-const fold rewrites the folded `IndexGet { _, String }` into a +// `PropertyGet` (perry-transform/src/module_const_fold.rs, phase 2). +// +// Each case prints the three spellings side by side. A rewrite that changed +// ANY of [[Get]]'s obligations shows up as a divergence between columns, and +// a rewrite that changed the answer outright shows up against node, which runs +// this same file as the oracle. +// +// The one guard in the rewrite is `is_numeric_index_string`: an array index +// key must keep `IndexGet` semantics. Cases 15/16 are its witnesses — delete +// the guard and they print element values where they must print `undefined`. +const K = "a"; +const M = "missing"; +const NUM0 = "0"; +const NUM1 = "1"; +const NUM07 = "07"; +const FRAC = "1.5"; +const NEG = "-1"; +const EMPTY = ""; +const LEN = "length"; + +const out: string[] = []; +function show(label: string, a: unknown, b: unknown, c: unknown): void { + out.push(label + " | " + String(a) + " | " + String(b) + " | " + String(c)); +} +function trap(f: () => unknown): string { + try { + return "value:" + String(f()); + } catch (e) { + return "throw:" + (e instanceof TypeError ? "TypeError" : String(e)); + } +} + +// 1 — plain own data property +const o1: any = { a: 1, b: 2 }; +show("1 own-data", o1[K], o1["a"], o1.a); + +// 2 — own accessor installed by defineProperty +const o2: any = {}; +let getCalls = 0; +Object.defineProperty(o2, "a", { + get() { + getCalls++; + return 42; + }, + configurable: true, +}); +show("2 own-getter", o2[K], o2["a"], o2.a); +out.push("2 getter-call-count " + getCalls); + +// 3 — setter-only own accessor reads as undefined +const o3: any = {}; +Object.defineProperty(o3, "a", { set(_v: number) {}, configurable: true }); +show("3 setter-only", o3[K], o3["a"], o3.a); + +// 4 — accessor on the prototype chain +const proto4: any = {}; +Object.defineProperty(proto4, "a", { + get() { + return "from-proto"; + }, + configurable: true, +}); +const o4: any = Object.create(proto4); +show("4 proto-getter", o4[K], o4["a"], o4.a); + +// 5 — non-enumerable data descriptor +const o5: any = {}; +Object.defineProperty(o5, "a", { value: 5, enumerable: false, writable: true, configurable: true }); +show("5 nonenum-data", o5[K], o5["a"], o5.a); + +// 6 — non-writable, non-configurable +const o6: any = {}; +Object.defineProperty(o6, "a", { value: 6, writable: false, configurable: false }); +show("6 frozen-slot", o6[K], o6["a"], o6.a); + +// 7 — frozen object +const o7: any = Object.freeze({ a: 7 }); +show("7 frozen-obj", o7[K], o7["a"], o7.a); + +// 8 — sealed object +const o8: any = Object.seal({ a: 8 }); +show("8 sealed-obj", o8[K], o8["a"], o8.a); + +// 9 — delete then read +const o9: any = { a: 9, z: 0 }; +show("9 before-delete", o9[K], o9["a"], o9.a); +delete o9.a; +show("9 after-delete", o9[K], o9["a"], o9.a); + +// 10 — own shadows inherited +const proto10: any = { a: "proto" }; +const o10: any = Object.create(proto10); +show("10 inherited", o10[K], o10["a"], o10.a); +o10.a = "own"; +show("10 shadowed", o10[K], o10["a"], o10.a); +delete o10.a; +show("10 unshadowed", o10[K], o10["a"], o10.a); + +// 11 — setPrototypeOf after the site has run +const o11: any = {}; +show("11 no-proto", o11[K], o11["a"], o11.a); +Object.setPrototypeOf(o11, { a: "late-proto" }); +show("11 late-proto", o11[K], o11["a"], o11.a); + +// 12 — __proto__ assignment +const o12: any = {}; +show("12 pre-__proto__", o12[K], o12["a"], o12.a); +o12.__proto__ = { a: "via-dunder" }; +show("12 post-__proto__", o12[K], o12["a"], o12.a); + +// 13 — Proxy receiver: the trap must see the same key for all three spellings +const seen: string[] = []; +const p13: any = new Proxy( + { a: "target" }, + { + get(t: any, k: any) { + if (typeof k === "string") seen.push(k); + return k === "a" ? "trapped" : Reflect.get(t, k); + }, + }, +); +show("13 proxy", p13[K], p13["a"], p13.a); +out.push("13 trap-keys " + seen.join(",")); + +// 14 — nullish receivers must throw TypeError, not read undefined +const nul: any = null; +const undef: any = undefined; +out.push("14 null-const " + trap(() => nul[K])); +out.push("14 null-lit " + trap(() => nul["a"])); +out.push("14 null-dot " + trap(() => nul.a)); +out.push("14 undef-const " + trap(() => undef[K])); +out.push("14 undef-lit " + trap(() => undef["a"])); +out.push("14 undef-dot " + trap(() => undef.a)); + +// 15 — GUARD WITNESS: a canonical numeric key on an ARRAY is an element read, +// not a named read. `arr[NUM0]` must be the element; `arr[FRAC]`/`arr[NEG]`/ +// `arr[NUM07]`/`arr[EMPTY]` are names and must miss. +const arr: any = ["zero", "one", "two"]; +out.push("15 arr-0 " + String(arr[NUM0]) + " | " + String(arr["0"]) + " | " + String(arr[0])); +out.push("15 arr-1 " + String(arr[NUM1]) + " | " + String(arr["1"])); +out.push("15 arr-07 " + String(arr[NUM07]) + " | " + String(arr["07"])); +out.push("15 arr-frac " + String(arr[FRAC]) + " | " + String(arr["1.5"])); +out.push("15 arr-neg " + String(arr[NEG]) + " | " + String(arr["-1"])); +out.push("15 arr-empty " + String(arr[EMPTY]) + " | " + String(arr[""])); +out.push("15 arr-length " + String(arr[LEN]) + " | " + String(arr["length"]) + " | " + String(arr.length)); + +// 16 — GUARD WITNESS: a numeric-string OWN property on a plain object, with an +// array-index twin, so a fold that treats "0" as a name is visible. +const o16: any = { "0": "named-zero", a: 16 }; +out.push("16 obj-0 " + String(o16[NUM0]) + " | " + String(o16["0"]) + " | " + String(o16[0])); +const mixed: any = ["elem0"]; +mixed["0"] = "overwritten"; +out.push("16 mixed-0 " + String(mixed[NUM0]) + " | " + String(mixed[0]) + " len=" + mixed.length); + +// 17 — a missing key +const o17: any = { b: 1 }; +show("17 absent", o17[M], o17["missing"], o17.missing); + +// 18 — an accessor installed AFTER the read site has already executed +const o18: any = { a: "data" }; +show("18 data-first", o18[K], o18["a"], o18.a); +Object.defineProperty(o18, "a", { + get() { + return "now-accessor"; + }, + configurable: true, +}); +show("18 accessor-after", o18[K], o18["a"], o18.a); + +// 19 — a getter that mutates the receiver during the read +const o19: any = { z: 0 }; +Object.defineProperty(o19, "a", { + get() { + o19.z = o19.z + 1; + return o19.z; + }, + configurable: true, +}); +show("19 mutating-getter", o19[K], o19["a"], o19.a); + +// 20 — Symbol key congruence control (never folded; must still agree) +const SYM = Symbol.for("perry.10761"); +const o20: any = { [SYM]: "sym-value", a: 20 }; +out.push("20 symbol " + String(o20[SYM]) + " | " + String(o20[K])); + +// 21 — string receiver named read, and a numeric key on a string +const s21: any = "abc"; +out.push("21 str-length " + String(s21[LEN]) + " | " + String(s21["length"]) + " | " + String(s21.length)); +out.push("21 str-0 " + String(s21[NUM0]) + " | " + String(s21["0"]) + " | " + String(s21[0])); + +// 22 — class instance: own field, prototype method, prototype accessor +class C22 { + a = 22; + get g(): string { + return "getter"; + } + m(): string { + return "method"; + } +} +const GKEY = "g"; +const MKEY = "m"; +const c22: any = new C22(); +show("22 field", c22[K], c22["a"], c22.a); +show("22 proto-getter", c22[GKEY], c22["g"], c22.g); +out.push("22 proto-method " + String(typeof c22[MKEY]) + " | " + String(typeof c22["m"]) + " | " + String(typeof c22.m)); + +// 23 — the read in a hot loop, so the inline cache is primed and then broken +const o23: any = { a: 1 }; +let sum = 0; +for (let i = 0; i < 50; i++) sum = sum + o23[K]; +out.push("23 warm-sum " + sum); +Object.defineProperty(o23, "a", { + get() { + return 100; + }, + configurable: true, +}); +let sum2 = 0; +for (let i = 0; i < 5; i++) sum2 = sum2 + o23[K]; +out.push("23 post-accessor-sum " + sum2); + +// 24 — a polymorphic site: three different shapes through one const-key read +const shapes: any[] = [{ a: 1 }, { x: 0, a: 2 }, Object.create({ a: 3 })]; +let poly = ""; +for (let i = 0; i < shapes.length; i++) poly = poly + String(shapes[i][K]) + ","; +out.push("24 poly " + poly); + +console.log(out.join("\n")); From f0646e2595aac81c5161fe55192f805ebd0d1c2d Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sun, 20 Sep 2026 01:31:43 +0000 Subject: [PATCH 2/5] perf(codegen): stop disabling Ptr in entry bodies for a bug that was fixed in the runtime (#10769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RepselContextFlags::derive`'s `Entry` arm forced `allows_ptr_shape: false` and a `MODULE_INIT_CONTEXT` denial, on the stated grounds that "#6991 is an open rooting bug in exactly that position". #6991 is closed. It was fixed by #7249 (64c1f56fb), which placed `populate_global_this_builtins` inside a `GcSuppressScope` — a runtime fix, not a codegen one. The gate has since been guarding against a bug that no longer exists, and the effect was that a shape proof in an entry body was made, counted as a win in the optimiser report, and then dropped at every access site. The `Entry` arm now derives all three flags from their knobs like any other body. module-level const, loop at module level 110.00 -> 88.99 (-19.1%) node is 14.50 on the same fixture, so this does not reach parity; roughly 36 instructions of entry-body cost remain and are not this gate. The same body placed inside a function is the control and correctly does not move. The nine real programs do not move, and the mechanism was checked rather than assumed: `--opt-report` module-init denial mentions are identical on both arms for all nine, because none of them has a `Ptr` candidate in its entry body. `validate` and `resolve` do hold module-level const records, but they are read from inside functions, which globalizes them and puts them behind the separate storage limitation tracked as #7109. --- changelog.d/10769-entry-body-ptr-shape.md | 5 + crates/perry-codegen/src/expr/repsel_gates.rs | 53 +++++--- crates/perry-codegen/src/expr/slot_rep.rs | 30 ++-- .../test_gap_10769_entry_body_ptr_shape.ts | 128 ++++++++++++++++++ 4 files changed, 191 insertions(+), 25 deletions(-) create mode 100644 changelog.d/10769-entry-body-ptr-shape.md create mode 100644 test-files/test_gap_10769_entry_body_ptr_shape.ts diff --git a/changelog.d/10769-entry-body-ptr-shape.md b/changelog.d/10769-entry-body-ptr-shape.md new file mode 100644 index 0000000000..379e2f3fdf --- /dev/null +++ b/changelog.d/10769-entry-body-ptr-shape.md @@ -0,0 +1,5 @@ +**`Ptr` is no longer disabled in entry bodies for a bug that was fixed in the runtime.** + +`RepselContextFlags::derive`'s `Entry` arm forced `allows_ptr_shape: false` because *"#6991 is an open rooting bug in exactly that position"*. #6991 was closed by #7249, which put `populate_global_this_builtins` inside a `GcSuppressScope` — a runtime fix. The gate has been guarding a bug that no longer exists, and a shape proof in an entry body was being made, counted as a win, then dropped at every access site. + +A module-level `const` with its loop at module level goes **110.00 → 88.99 instructions per iteration (−19.1%)**. The same body inside a function is the control and correctly does not move. diff --git a/crates/perry-codegen/src/expr/repsel_gates.rs b/crates/perry-codegen/src/expr/repsel_gates.rs index 238914e954..76c35c06b2 100644 --- a/crates/perry-codegen/src/expr/repsel_gates.rs +++ b/crates/perry-codegen/src/expr/repsel_gates.rs @@ -63,7 +63,6 @@ use super::slot_rep::{ body_context_denial, canonical_i32_locals_enabled, canonical_str_locals_enabled, - MODULE_INIT_CONTEXT, }; /// `PERRY_STATIC_STRING_LOWERING` gate. Enabled by default; `=0`/`off`/`false` @@ -184,16 +183,33 @@ impl RepselContextFlags { ptr_shape_denial: denial, } } + // #10769: the entry body now derives all three flags exactly as an + // ordinary body does. It carries no structural denial of its own — + // module init is never rewritten into a generator state machine + // (see the `slot_rep::MODULE_INIT_CONTEXT` audit), so + // `body_context_denial`'s three reasons cannot arise here. + // + // The `Ptr` literal `false` that stood here was justified by + // #6991, "a compiled receiver goes stale across the + // globalThis-population collection". **#6991 is closed**, fixed by + // #7249 (`64c1f56fb`) in the RUNTIME, not here: + // `populate_global_this_builtins` now runs inside a + // `GcSuppressScope` because it builds an immortal graph through raw + // `*mut ObjectHeader` locals across its own ~1.15 MB of + // allocations. Its closing comment re-verified + // `test_gap_repsel_ptr_shape_locals` at 10/10 on the evacuating arm + // and 3/3 under `PERRY_GC_ZEAL=1`, at 3.4x the movement level the + // crash was observed at. + // + // A gate whose stated reason is a closed bug reads as a live + // constraint to the next person. It was read that way twice before + // it was removed. RepselBody::Entry => Self { allows_canonical_i32: gates.canonical_i32, allows_canonical_str: gates.canonical_str, - // Unconditionally off, regardless of `gates.ptr_shape`: the - // exclusion is structural (#6991), not a knob. Written as a - // literal so a future reader cannot mistake it for something - // `PERRY_PTR_SHAPE_LOCALS=1` could turn back on. - allows_ptr_shape: false, + allows_ptr_shape: gates.ptr_shape, canonical_denial: None, - ptr_shape_denial: Some(MODULE_INIT_CONTEXT), + ptr_shape_denial: None, }, } } @@ -300,16 +316,21 @@ mod tests { } } - /// The same property for the entry context, where `Ptr` is off for a - /// structural reason: the two canonical knobs must still move only - /// themselves, and the `Ptr` knob must move nothing (it is already - /// off). + /// #10769: the entry context now derives all three flags like any other + /// body. Each knob still moves exactly one flag — that is #7128's property, + /// restated for `Entry` — and no flag carries a structural denial, because + /// the entry body has none. + /// + /// GUARD WITNESS. Restore the literal `allows_ptr_shape: false` in the + /// `Entry` arm and the first assertion fails with + /// `(true, true, false) != (true, true, true)`; restore + /// `ptr_shape_denial: Some(MODULE_INIT_CONTEXT)` and the third fails. #[test] - fn entry_context_keeps_ptr_shape_off_and_names_the_rule() { + fn entry_context_derives_every_flag_like_an_ordinary_body() { let entry = RepselContextFlags::derive(ALL_ON, RepselBody::Entry); - assert_eq!(allows(&entry), (true, true, false)); + assert_eq!(allows(&entry), (true, true, true)); assert_eq!(entry.canonical_denial, None); - assert_eq!(entry.ptr_shape_denial, Some(MODULE_INIT_CONTEXT)); + assert_eq!(entry.ptr_shape_denial, None); for gates in [ RepselGates { @@ -326,10 +347,10 @@ mod tests { }, ] { let got = RepselContextFlags::derive(gates, RepselBody::Entry); - assert!(!got.allows_ptr_shape); - assert_eq!(got.ptr_shape_denial, Some(MODULE_INIT_CONTEXT)); assert_eq!(got.allows_canonical_i32, gates.canonical_i32); assert_eq!(got.allows_canonical_str, gates.canonical_str); + assert_eq!(got.allows_ptr_shape, gates.ptr_shape); + assert_eq!(got.ptr_shape_denial, None); } } diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index f8964cea22..8dc38ff77c 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -159,17 +159,29 @@ pub(crate) enum SlotRep { /// `register_module_globals_as_gc_roots`) reads `@perry_global_*` cells and /// never `ctx.locals`. /// -/// ## What is still excluded, and why +/// ## What was also excluded, and no longer is (#10769) /// -/// `Ptr` receiver proofs. Phase 5a reused +/// `Ptr` receiver proofs used to be excluded here too. Phase 5a reused /// `repsel_context_allows_canonical_i32` as its context gate, so lifting that -/// flag would silently have enabled guard-free `this.field` / `obj.field` -/// lowering in entry bodies as a side effect of an unrelated phase. That is not -/// a representation this issue measured, and #6991 is an open rooting bug in -/// exactly that position: a compiled receiver goes stale across the -/// `globalThis`-population collection, which runs around module init. So the -/// flag is split (`repsel_context_allows_ptr_shape`) and entry bodies keep -/// `Ptr` off, still naming this rule in `--opt-report`. +/// flag would have enabled guard-free `this.field` / `obj.field` lowering in +/// entry bodies as a side effect of an unrelated phase; the flag was split +/// (`repsel_context_allows_ptr_shape`) and `Entry` pinned its own arm off, +/// citing #6991 — "a compiled receiver goes stale across the +/// `globalThis`-population collection, which runs around module init". +/// +/// **#6991 is closed.** It was fixed by #7249 (`64c1f56fb`) in the runtime, not +/// by this gate: `populate_global_this_builtins` now runs inside a +/// `GcSuppressScope`, because it builds an immortal object graph through raw +/// `*mut ObjectHeader` locals held across its own ~1.15 MB of allocations, so +/// under an 8 MB heap limit minor #0 landed in the middle of it. The closing +/// comment re-verified `test_gap_repsel_ptr_shape_locals` at 10/10 on the +/// evacuating arm and 3/3 under `PERRY_GC_ZEAL=1`, at 3.4x the movement level +/// the crash was observed at. The entry arm now derives `allows_ptr_shape` from +/// its knob like every other body (`expr/repsel_gates.rs`). +/// +/// `MODULE_INIT_CONTEXT` is retained: it is still a rule name the +/// `--opt-report` renderer resolves, and removing a denial string would break +/// reports archived from older builds. pub(crate) const MODULE_INIT_CONTEXT: &str = "module_init_context"; /// Why an ordinary body context forbids canonical (i32/u32/Str) selection, or diff --git a/test-files/test_gap_10769_entry_body_ptr_shape.ts b/test-files/test_gap_10769_entry_body_ptr_shape.ts new file mode 100644 index 0000000000..5929bb3e88 --- /dev/null +++ b/test-files/test_gap_10769_entry_body_ptr_shape.ts @@ -0,0 +1,128 @@ +// #10769: `Ptr` in a PROGRAM-ENTRY / module-init body. +// +// The entry arm of `RepselContextFlags::derive` used to pin `allows_ptr_shape` +// off with a literal `false`, citing #6991 ("a compiled receiver goes stale +// across the globalThis-population collection, which runs around module init"). +// #6991 was closed by #7249, which fixed it in the RUNTIME by putting +// `populate_global_this_builtins` inside a `GcSuppressScope`. The gate is now +// derived from its knob like any other body. +// +// `test_gap_repsel_ptr_shape_locals` CANNOT witness that change: its +// `Ptr` selection count is identical with the gate on and off (18 +// selected / 11 denied both ways), because every one of its candidates is +// either inside a function or module-globalized. This file exists because that +// one does not reach the lifted gate. +// +// NOTE ON WHAT IS NOT HERE. `Object.freeze` and `Object.defineProperty` are +// deliberately absent: either one arms the module-wide 5.2 shape-barrier kill +// (`ModuleDispatchFacts::has_shape_barrier_sites`), which disables ALL +// `Ptr` promotion in the module. A first draft of this file included +// both and reported `0 selected / 15 denied` on BOTH arms -- it would have +// passed every GC run while witnessing nothing. Those cases belong in a module +// that is not trying to prove a shape; `test_gap_repsel_ptr_shape_barriers.ts` +// already owns them. +// +// Everything below is at TOP LEVEL on purpose — a binding read only from the +// entry body is not globalized, so it is a `Ptr` candidate there and +// nowhere else. Each section puts a collection point between the proof and the +// use, which is the exact hazard #6991 named: the object may MOVE, so the +// tagged-at-rest slot must be re-derived after every safepoint. + +const out: string[] = []; + +// 1. Provenance-proven class instance in the entry body, with allocation +// inside the loop so minors fire between the field reads. +class Pt { + x: number; + y: number; + tag: string; + constructor(x: number, y: number, tag: string) { + this.x = x; + this.y = y; + this.tag = tag; + } + norm(): number { + return this.x + this.y; + } +} +const p = new Pt(3, 4, "origin"); +let acc = 0; +const litter: number[][] = []; +for (let i = 0; i < 400; i++) { + // allocate so the nursery fills and the back-edge poll collects + litter.push([i, i + 1, i + 2]); + if (litter.length > 32) litter.shift(); + p.x = i; + acc = (acc + p.x + p.y + p.norm()) | 0; +} +out.push("1 class " + acc + " " + p.tag + " " + p.x + " " + p.y); + +// 2. Anon-shape record literal in the entry body, read and written across a +// call that allocates (a real safepoint between the proof and the use). +function churn(n: number): number { + const tmp: string[] = []; + for (let j = 0; j < n; j++) tmp.push("s" + j); + return tmp.length; +} +const rec = { key: "k", value: 0, count: 0 }; +let recAcc = 0; +for (let i = 0; i < 200; i++) { + rec.value = i; + const moved = churn(8); // allocates -> may collect -> `rec` may move + rec.count = rec.count + moved; + recAcc = (recAcc + rec.value + rec.count) | 0; +} +out.push("2 record " + recAcc + " " + rec.key + " " + rec.value + " " + rec.count); + +// 3. Builder pattern in the entry body: `const b = {}` then fields added. +const builder: any = {}; +builder.a = 1; +builder.b = 2; +let bAcc = 0; +for (let i = 0; i < 200; i++) { + litter.push([i]); + if (litter.length > 32) litter.shift(); + builder.a = i; + bAcc = (bAcc + builder.a + builder.b) | 0; +} +out.push("3 builder " + bAcc + " " + builder.a + " " + builder.b); + +// 4. A pointer-valued field written across a collection point — the write +// barrier and the re-derived receiver have to agree. +const holder: any = { inner: null, n: 0 }; +for (let i = 0; i < 200; i++) { + holder.inner = { v: i, pad: "x".repeat(i % 7) }; + churn(4); + holder.n = holder.n + holder.inner.v; +} +out.push("4 holder " + holder.n + " " + String(holder.inner.v)); + +// 5. The exclusions must stay byte-exact on the boxed/guarded protocol even +// with the gate lifted: a reassigned local, a closure-captured local, and +// an escaping local are all still ordinary. +let reassigned: any = { a: 1 }; +reassigned = { a: 2, b: 3 }; +out.push("5 reassigned " + reassigned.a + " " + String(reassigned.b)); + +const captured = { a: 10, b: 20 }; +const readCaptured = (): number => captured.a + captured.b; +captured.a = 11; +out.push("5 captured " + readCaptured()); + +const escaping = { a: 100, b: 200 }; +function consume(o: any): number { + o.a = o.a + 1; + return o.a + o.b; +} +out.push("5 escaping " + consume(escaping) + " " + escaping.a); + +// 8. A deep chain read in the entry body across allocation. +const root = { mid: { leaf: { v: 7 } }, n: 0 }; +for (let i = 0; i < 200; i++) { + litter.push([i, i]); + if (litter.length > 32) litter.shift(); + root.n = root.n + root.mid.leaf.v; +} +out.push("8 chain " + root.n + " " + root.mid.leaf.v); + +console.log(out.join("\n")); From a12aa134ee617de375ea8f8df5fb1a2cd64b6af2 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sat, 19 Sep 2026 23:49:52 +0000 Subject: [PATCH 3/5] perf(runtime): stop routing plain numbers through the slow arms of the string-coercion ladders (#10762) Four edits, all runtime, no codegen: `js_string_coerce` and `js_jsvalue_to_string_method` reached their plain-number arm last, through a seven-way jump table; `is_number()` is one range test and the exact complement of the arms it skips, so the number arm is hoisted ahead of them. `js_number_to_string`'s admission check forced LLVM to emit a 14-instruction saturating f64->u64 cast on a value already proven to be in 0..256, plus a redundant second bound check; the cheaper admission lets it emit a 4-instruction cast, and the cache-fill arm is outlined `#[cold]` so its inlined `format!` stops costing 15 instructions of prologue in the hit path. `format_number_into` gains a range-proven i32 arm. String(k%100) 190.0 -> 163.0 (-14.2%) n.toString() 536.3 -> 433.0 (-19.3%) `${n}` 433.3 -> 413.0 (-4.7%) String(k%1e6) 558.3 -> 540.3 (-3.2%) float 1146.6 -> 1136.6 (-0.9%) "" + n 264.5 -> 264.5 0.00% control (no conv) 82.0 -> 82.0 0.00% No row regresses. Both arms are flat within 2% across 20k->200k and 500k->5M. This does not reach parity with node or bun, and the remaining distance needs an ABI change rather than another pass: `"" + n` never allocates, because `js_string_concat_value_box` returns an f64 and packs a short result into SHORT_STRING_TAG, while the other three entry points are declared `-> *mut StringHeader` and must allocate a heap string for a three-byte result. --- changelog.d/10762-number-to-string-ladders.md | 7 ++ crates/perry-runtime/src/builtins/numbers.rs | 16 +++++ crates/perry-runtime/src/string/concat.rs | 29 ++++++++ crates/perry-runtime/src/string/format.rs | 67 +++++++++++++------ crates/perry-runtime/src/value/to_string.rs | 14 ++++ 5 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 changelog.d/10762-number-to-string-ladders.md diff --git a/changelog.d/10762-number-to-string-ladders.md b/changelog.d/10762-number-to-string-ladders.md new file mode 100644 index 0000000000..35fb2d5b6f --- /dev/null +++ b/changelog.d/10762-number-to-string-ladders.md @@ -0,0 +1,7 @@ +**Plain numbers no longer take the slow arms of the string-coercion ladders.** + +`js_string_coerce` and `js_jsvalue_to_string_method` reached their plain-number arm last, through a seven-way jump table, though `is_number()` is a single range test and the exact complement of the arms it skips. `js_number_to_string`'s admission check also forced a 14-instruction saturating `f64`→`u64` cast on a value already proven to be within the 256-entry small-integer cache. + +`n.toString()` **−19.3%** (536.3 → 433.0), `String(n)` **−14.2%** (190.0 → 163.0), template literal −4.7%, large integers −3.2%, floats −0.9%, `"" + n` unchanged. No row regresses. + +`n.toString()` was costing `String(n)` **plus exactly 103 instructions** at every value range — a four-frame dispatch detour through a thread-local one-shot — which is what this removes. diff --git a/crates/perry-runtime/src/builtins/numbers.rs b/crates/perry-runtime/src/builtins/numbers.rs index d2f8760cd8..98f1720b41 100644 --- a/crates/perry-runtime/src/builtins/numbers.rs +++ b/crates/perry-runtime/src/builtins/numbers.rs @@ -649,6 +649,22 @@ pub extern "C" fn js_number_coerce(value: f64) -> f64 { pub extern "C" fn js_string_coerce(value: f64) -> *mut StringHeader { let jsval = JSValue::from_bits(value.to_bits()); + // A plain IEEE double is the overwhelmingly common argument here — + // `String(n)` and every template substitution of a number land on it — and + // it was the LAST arm of the ladder below, so every one of them paid eight + // tag comparisons plus a jump table to reach the one line that answers it. + // `is_number()` is a single range test (perry's tags occupy the contiguous + // positive-qNaN band `0x7FF9..=0x7FFF`), and it is the exact complement of + // the arms it skips: undefined/null/bool are `0x7FFC`, short string + // `0x7FF9`, bigint `0x7FFA`, pointer `0x7FFD`, int32 `0x7FFE`, string + // `0x7FFF`. Every other bit pattern — including a JS handle (`0x7FFB`), a + // hole and a TDZ sentinel, none of which the ladder matches either — + // reaches the same `js_number_to_string` tail with or without this hoist, + // so the reorder is answer-for-answer identical on every input. + if jsval.is_number() { + return crate::string::js_number_to_string(value); + } + let result = if jsval.is_undefined() { "undefined".to_string() } else if jsval.is_null() { diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index ce30ed6839..09c047ebc5 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -1485,6 +1485,35 @@ fn concat_chain_sized(parts: *const f64, n: usize) -> *m /// of bytes written. #[inline] pub(crate) fn format_number_into(value: f64, buf: &mut [u8; 32]) -> usize { + // Integers that fit i32 are the bulk of every formatted number — loop + // counters, ids, counts, sizes, byte values, HTTP codes — and this arm + // decides them without the i64 arm's range test and without its + // `is_nan`/`is_infinite` pair. + // + // Both halves of the guard are load-bearing, and the SECOND one is + // load-bearing for SPEED as well as correctness: `abs() < 2^31` is what + // lets LLVM prove the `as i32` cannot overflow and emit a bare + // `cvttsd2si` instead of Rust's ~8-instruction SATURATING cast sequence. + // Written without it (guarding on an `(n as f64) == value` round trip + // instead) this arm MEASURED 7 instructions per call SLOWER than the code + // it replaced on 6-digit values, for exactly that reason. The i64 arm + // below gets the same proof from its own `abs() < 1e15`. + // + // `fract() == 0.0` alone already excludes NaN and +-Infinity (`fract` is + // `self - self.trunc()`, which is NaN for both, and NaN != 0.0), and + // `-0.0` passes it, converts to 0 and renders "0" — the spec answer, and + // the same one the `value == 0.0` arm below produces. + // + // Strictly additive: every value this accepts is exactly an i32, which the + // i64 arm would have handed to these very same `fast_itoa_u32` / + // `fast_itoa_i64` helpers. The bytes cannot differ. + if value.fract() == 0.0 && value.abs() < 2_147_483_648.0 { + let n = value as i32; + if n >= 0 { + return fast_itoa_u32(n as u32, buf); + } + return fast_itoa_i64(n as i64, buf); + } if value.fract() == 0.0 && value.abs() < 1e15 && !value.is_nan() && !value.is_infinite() { let n = value as i64; if (0..=999_999_999).contains(&n) { diff --git a/crates/perry-runtime/src/string/format.rs b/crates/perry-runtime/src/string/format.rs index e3d36204ca..f4290d9ee1 100644 --- a/crates/perry-runtime/src/string/format.rs +++ b/crates/perry-runtime/src/string/format.rs @@ -117,30 +117,24 @@ fn throw_if_bigint_digits(arg: f64) { #[no_mangle] pub extern "C" fn js_number_to_string(value: f64) -> *mut StringHeader { // Fast path: small non-negative integers use a cached string table. + // + // The admission test is `fract() == 0.0` plus an in-range check written so + // LLVM can prove the `as u32` cannot overflow and emit a bare + // `cvttsd2si`. The old `value as usize` — on a value the same condition + // had already proven to be in `0..256` — lowered to Rust's full SATURATING + // `f64 -> u64` sequence: 14 instructions of `cmov` fixup, a quarter of + // what a cache hit cost. `-0.0` passes (`-0.0 >= 0.0`), converts to 0 and + // returns "0", which is the spec answer for `String(-0)`. NaN and + // +-Infinity fail `fract() == 0.0` (`fract` is `self - self.trunc()`, + // which is NaN for both). if value.fract() == 0.0 && value >= 0.0 && value < SMALL_INT_CACHE_SIZE as f64 { - let idx = value as usize; - let cached = SMALL_INT_CACHE.with(|c| unsafe { (*c.get())[idx] }); + let idx = value as u32 as usize; + // SAFETY: the range test above proves `idx < SMALL_INT_CACHE_SIZE`. + let cached = SMALL_INT_CACHE.with(|c| unsafe { *(*c.get()).get_unchecked(idx) }); if !cached.is_null() { return cached; } - // Allocate and cache - let s = format!("{}", value as u64); - let ptr = js_string_from_bytes_longlived(s.as_bytes().as_ptr(), s.len() as u32); - unsafe { - // Mark as shared so it's never mutated in-place - (*ptr).refcount = 0; - // Mark as pinned so GC keeps it live for the lifetime of this - // thread's arena. Longlived-space (see the allocation above), so - // this does not arm the young-pin latch (#7645). - let gc_header = - (ptr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; - crate::gc::pin_object_non_young(gc_header); - } - SMALL_INT_CACHE.with(|c| unsafe { - // GC_STORE_AUDIT(ROOT): SMALL_INT_CACHE is scanned by scan_small_int_cache_roots_mut. - crate::gc::runtime_store_root_raw_mut_ptr_slot(&raw mut (*c.get())[idx], ptr); - }); - return ptr; + return small_int_cache_fill(idx); } // Format the number as a string per JS semantics, on the stack. @@ -149,6 +143,39 @@ pub extern "C" fn js_number_to_string(value: f64) -> *mut StringHeader { js_string_from_bytes(buf.as_ptr(), len as u32) } +/// Mint, pin and publish the canonical string for a small-int cache index. +/// +/// Genuinely cold: it runs at most once per index per thread — 256 times in +/// the entire life of a thread — yet inlined it put `format!`'s formatting +/// machinery, the GC pin and the root store into [`js_number_to_string`], +/// which cost every cached conversion six pushes and a 0x48-byte frame. +/// Outlined here rather than around the whole uncached tail on purpose: +/// wrapping the stack-buffer formatting path too MEASURED +11.7 instructions +/// per conversion on the float fixture, because a miss then paid an extra +/// call and re-ran the admission test. +#[cold] +#[inline(never)] +fn small_int_cache_fill(idx: usize) -> *mut StringHeader { + debug_assert!(idx < SMALL_INT_CACHE_SIZE); + let s = format!("{}", idx); + let ptr = js_string_from_bytes_longlived(s.as_bytes().as_ptr(), s.len() as u32); + unsafe { + // Mark as shared so it's never mutated in-place + (*ptr).refcount = 0; + // Mark as pinned so GC keeps it live for the lifetime of this + // thread's arena. Longlived-space (see the allocation above), so + // this does not arm the young-pin latch (#7645). + let gc_header = + (ptr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + crate::gc::pin_object_non_young(gc_header); + } + SMALL_INT_CACHE.with(|c| unsafe { + // GC_STORE_AUDIT(ROOT): SMALL_INT_CACHE is scanned by scan_small_int_cache_roots_mut. + crate::gc::runtime_store_root_raw_mut_ptr_slot(&raw mut (*c.get())[idx], ptr); + }); + ptr +} + /// ECMAScript `Number::toString` formatting, returning the Rust `String`. /// /// Shared by `js_number_to_string` (the `.toString()` path) and the diff --git a/crates/perry-runtime/src/value/to_string.rs b/crates/perry-runtime/src/value/to_string.rs index 6129eaeee6..80c79d24ba 100644 --- a/crates/perry-runtime/src/value/to_string.rs +++ b/crates/perry-runtime/src/value/to_string.rs @@ -1455,6 +1455,20 @@ pub(crate) unsafe fn coerce_validate_radix(radix_value: f64) -> Option { /// unchanged. #[no_mangle] pub extern "C" fn js_jsvalue_to_string_method(value: f64) -> *mut crate::string::StringHeader { + // `n.toString()` on a plain number is `Number::toString(n)` and nothing + // else, but it reached that answer through four frames: + // `to_string_method_impl` (nullish guard, pointer/regex probes, then a + // thread-local one-shot WRITE) -> `js_jsvalue_to_string` (which READS and + // clears that same one-shot, probes for a JS handle, then walks its own + // eight-arm tag ladder) -> `js_number_to_string`. None of it can change a + // plain double's answer: a number is never nullish, never a pointer, never + // a regex, and every arm of both ladders is keyed on a perry tag in the + // `0x7FF9..=0x7FFF` band that `is_number()` excludes by definition. The + // one-shot is only ever consumed by the object dispatch this value cannot + // reach, so not setting it leaves nothing stale behind. + if crate::value::JSValue::from_bits(value.to_bits()).is_number() { + return crate::string::js_number_to_string(value); + } // Explicit `x.toString()`: resolve `Object.prototype.toString` / an own // `toString`, never `[Symbol.toPrimitive]`. (#6373) to_string_method_impl(value, /* skip_to_primitive */ true) From c7c2f3bc61e5a9650226dba972ff731f37a4e8fc Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sun, 20 Sep 2026 02:33:09 +0000 Subject: [PATCH 4/5] perf(runtime): remove the toFixed cliff at dp >= 7 (#10770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toFixed(6)` cost 646 instructions and `toFixed(7)` cost 7,125 — a 10.7x jump for one more decimal place, while node and bun are flat across the range. Two causes, both of them a bound that had drifted from the thing it bounds: `spec_to_fixed` asked `format!("{x:.1100}")` on every input. 1100 is the smallest subnormal's worst case, so `(6.0).toFixed(7)` expanded 1100 decimal places through dragon4 and discarded 1093 of them. It now asks for the digits the value actually has. `POW10` was seven entries local to `fmt_fixed_int`, while the admission bound read `dp <= 6` a hundred lines away as though it were an overflow limit. It is now `POW10_FIXED` at module scope with 20 entries, and the doc comment states that the table's length *is* the bound — they are the same object rather than two constants that happen to agree. dp 0 524.2 -> 520.2 dp 2 592.6 -> 564.6 dp 6 646.0 -> 620.9 dp 7 7125.4 -> 633.1 dp 8 7159.1 -> 645.6 (12.34).toFixed(8) 12637.2 -> 596.6 (21.2x) node is 905-954 and bun 1016-1108 across the same range, so every row is now a win where dp >= 7 was a 7.5x loss. dp 0-6 also gained 4-5% because `10u64.pow(dp)` became a table load. 405,828 node-identical results across the fixture set, including 378,000 targeting the newly admitted inexact-product population. --- changelog.d/10770-tofixed-cliff.md | 7 + crates/perry-runtime/src/string/format.rs | 179 ++++++++++++++++++++-- scripts/gc_runtime_root_holders.json | 4 +- 3 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 changelog.d/10770-tofixed-cliff.md diff --git a/changelog.d/10770-tofixed-cliff.md b/changelog.d/10770-tofixed-cliff.md new file mode 100644 index 0000000000..24bceafb8a --- /dev/null +++ b/changelog.d/10770-tofixed-cliff.md @@ -0,0 +1,7 @@ +**`toFixed(7)` no longer costs 10.7× `toFixed(6)`.** + +`spec_to_fixed` asked `format!("{x:.1100}")` on every input — 1100 being the smallest subnormal's worst case — so `(6.0).toFixed(7)` expanded 1100 decimal places through dragon4 and discarded 1093. And `POW10` was seven entries local to `fmt_fixed_int` while the admission bound read `dp <= 6` a hundred lines away, as though it were an overflow limit rather than a table length. + +dp 7 goes **7125.4 → 633.1** and dp 8 **7159.1 → 645.6**, turning a 7.5× loss against node and bun into a win. A money-shaped `(12.34).toFixed(8)` goes **12637.2 → 596.6, 21.2×**. dp 0–6 gain 4–5% as well, because `10u64.pow(dp)` becomes a table load. + +The table is now `POW10_FIXED` at module scope with 20 entries, and its length *is* the admission bound rather than a second constant that happens to agree. diff --git a/crates/perry-runtime/src/string/format.rs b/crates/perry-runtime/src/string/format.rs index f4290d9ee1..6494fc05c8 100644 --- a/crates/perry-runtime/src/string/format.rs +++ b/crates/perry-runtime/src/string/format.rs @@ -347,12 +347,65 @@ pub extern "C" fn js_number_to_fixed(value: f64, decimals: f64) -> *mut StringHe // past 2^53, and the f64 rounding of the product corrupted the last digits. // Gate on the actual product so those defer to the exact `spec_to_fixed` // slow path. Refs #6079. - if value.abs() < 1e15 - && dp <= 6 - && value.abs() * (10u64.pow(dp as u32) as f64) < 9_007_199_254_740_992.0 - { - if let Some(n) = fmt_fixed_int(value, dp) { - return n; + // Admission for the integer fast path. + // + // The old bound was `dp <= 6`, justified as an i64-overflow limit but in + // fact set by a seven-entry `POW10`: the real exactness condition sits on + // the next line and `(6.0).toFixed(7)`, whose scaled product is 6e7 — + // twenty orders of magnitude inside it — was refused anyway and fell into + // the 1100-digit `spec_to_fixed`. That made `toFixed(7)` cost 11x + // `toFixed(6)` while node and bun are flat across dp (#10770). + // + // dp <= 6 keeps its EXISTING condition verbatim, so nothing already on the + // fast path changes admission, cost or output. + // + // dp 7..=19 is new, so it gets a PROOF instead of that heuristic: the + // scaled product must be exactly representable, checked with the FMA + // residual `value * scale - fl(value * scale)`. When that is zero, + // `scaled_raw` IS the true product, so `scaled_raw.round()` is exactly the + // spec's `n` (ECMA-262 21.1.3.3 negates first, then rounds half up on the + // magnitude, which is what `f64::round` does away from zero). When it is + // not zero the value is handed to the exact `spec_to_fixed` as before, so + // the worst case of a wrong answer is not available — only a slower one. + // `scale as f64` is exact for every table index (10^k is exact in f64 to + // k = 22), so the residual means what it says. + if dp < POW10_FIXED.len() { + let scale = POW10_FIXED[dp] as f64; + // Verbatim the condition `dp <= 6` already used, now applied at every + // `dp` the table covers. The only edit is reading the scale out of the + // table instead of recomputing `10u64.pow(dp)` at run time per call. + // + // An earlier revision of this change additionally required the scaled + // product to be EXACT for `dp > 6` (an FMA-residual test), on the + // theory that a newly opened range deserves a proof rather than the + // existing heuristic. Two measurements killed it: + // + // * It is REDUNDANT. `fmt_fixed_int`'s tie guard already refuses + // exactly the products that could round to the wrong integer, and + // it is dp-independent. A targeted hunt over 10,264,676 admitted + // probes — 3M random bit patterns plus every `dp` in 7..=19 swept + // 0..3 ULPs either side of a `.5` boundary — found the tie guard + // catching 2,170,707 of them and produced ZERO cases where the + // exactness test changed an answer. + // + // * It rejected the entire use case. Money is not exactly + // representable in binary: `(12.34).toFixed(8)` has an inexact + // scaled product and was refused, so currency and crypto amounts + // — the whole reason `dp >= 7` matters — stayed on the slow path + // at 10,227 Ir/op while the benchmark's exactly-representable + // `(k*1.5).toFixed(8)` showed 649. A fast path the real input + // cannot reach is the defect this campaign keeps finding; it does + // not become acceptable when it is mine. + // + // So: one rule for every `dp`, and the tie guard below is what makes + // it sound. Widening the magnitude bound IS witnessed — see the + // `2^53` compare in `fmt_fixed_int`, whose sabotage changes digits at + // dp 16..18. + let admissible = value.abs() < 1e15 && value.abs() * scale < 9_007_199_254_740_992.0; + if admissible { + if let Some(n) = fmt_fixed_int(value, dp) { + return n; + } } } @@ -365,15 +418,50 @@ pub extern "C" fn js_number_to_fixed(value: f64, decimals: f64) -> *mut StringHe js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) } +/// Powers of ten for the `toFixed` integer fast path, and the definition of +/// how far that path reaches. +/// +/// 10^19 is the largest power of ten a `u64` holds (`u64::MAX` is about +/// 1.845e19), and every entry is also exact as an `f64` (a double holds 10^k +/// exactly to k = 22). Both properties are load-bearing: `fmt_fixed_int` +/// divides by the `u64`, and `js_number_to_fixed`'s admission multiplies by +/// the `f64` and then asks whether that product was exact — a question that +/// only means anything while the scale itself is exact. +/// +/// THE TABLE'S LENGTH IS THE `dp` BOUND. It used to hold seven entries while +/// the bound was spelled `dp <= 6` a hundred lines away and justified as an +/// i64-overflow limit, which is how `toFixed(7)` came to cost 11x +/// `toFixed(6)` (#10770). Anything that changes how far the fast path reaches +/// belongs here, not there. +static POW10_FIXED: [u64; 20] = [ + 1, + 10, + 100, + 1_000, + 10_000, + 100_000, + 1_000_000, + 10_000_000, + 100_000_000, + 1_000_000_000, + 10_000_000_000, + 100_000_000_000, + 1_000_000_000_000, + 10_000_000_000_000, + 100_000_000_000_000, + 1_000_000_000_000_000, + 10_000_000_000_000_000, + 100_000_000_000_000_000, + 1_000_000_000_000_000_000, + 10_000_000_000_000_000_000, +]; + /// Hand-rolled `toFixed` formatter for the common case. Returns None if /// the value falls outside the fast-path's safe range; the caller falls /// back to `format!` in that case. #[inline] fn fmt_fixed_int(value: f64, dp: usize) -> Option<*mut StringHeader> { - // Powers of 10 up to 10^6 — kept small so the multiplication stays - // inside i64 even for `|value|` near 1e15. - static POW10: [u64; 7] = [1, 10, 100, 1_000, 10_000, 100_000, 1_000_000]; - let scale = POW10[dp]; + let scale = POW10_FIXED[dp]; // The multiplication `value * scale` can land on a half-integer in // two very different ways, which `toFixed` must round oppositely: @@ -420,7 +508,18 @@ fn fmt_fixed_int(value: f64, dp: usize) -> Option<*mut StringHeader> { // 1e15 + dp ≤ 6, so `scaled` is at most ~1e21 — outside i64 range. // Re-check after rounding: i64 max is ~9.22e18, so `scaled.abs() < 1e18` // is the actual safe bound. Bail to slow path if we overshoot. - if scaled.abs() >= 9_000_000_000_000_000_000.0 { + // 2^53, not 9e18. This is what bounds the 32-byte `buf` below, now that + // `dp` reaches 19 rather than 6: `abs_n < 2^53` is at most 16 digits, so + // `int_part` is at most `max(1, 16 - dp)` digits and the longest possible + // write is sign + 1 + '.' + 19 = 22 bytes. Tightening the existing compare + // rather than adding a length check keeps the bound free - computing the + // digit count with `ilog10` here MEASURED +12 Ir/call at dp = 2 and + // +37 at dp = 0. Nothing is newly refused: both arms of the caller's + // admission already require the product to be under 2^53. + // rather than : is checked directly + // above, so NaN is already excluded and the two forms agree (clippy + // neg_cmp_op_on_partial_ord). + if scaled.abs() >= 9_007_199_254_740_992.0 { return None; } // ECMA-262 §21.1.3.3 step 6 applies the sign from the ORIGINAL `x < 0`, not @@ -668,13 +767,63 @@ fn spec_to_exponential(value: f64, dp: usize) -> String { /// → `…001`) AND a precision artifact (`(0.015).toFixed(2)` → `0.01`, because the /// stored double is `0.01499…`) resolve on the real value — matching V8. Replaces /// Rust's `format!("{:.N}")`, which rounds half-to-even (banker's rounding). +/// Number of fractional decimal digits in the EXACT decimal expansion of a +/// finite `x >= 0`. +/// +/// A finite double is `m * 2^e` with `m` an odd integer. For `e >= 0` that is +/// an integer, so zero fractional digits; for `e < 0` it is +/// `m * 5^(-e) / 10^(-e)`, i.e. EXACTLY `-e` fractional digits and no more. +/// The worst case is 1074, for the smallest subnormal - and that worst case is +/// the only reason [`spec_to_fixed`] asked `format!` for 1100 places on every +/// input, including `6.0`, which needs none. +/// +/// Over-asking is harmless (the extra places are zeros); under-asking is not, +/// because the manual round-half-up in `spec_to_fixed` is correct only while +/// the expansion it reads is exact rather than itself rounded. Callers take +/// the MAX of this and `dp + 1`, which keeps the expansion exact AND keeps +/// that function's invariant that the fraction string is at least `dp + 1` +/// long (it indexes `frac[dp]` to decide the rounding). +fn exact_fraction_digits(x: f64) -> usize { + let bits = x.to_bits(); + let biased = ((bits >> 52) & 0x7FF) as i32; + let mantissa = bits & 0x000F_FFFF_FFFF_FFFF; + // Subnormals carry no implicit leading 1 and a fixed exponent; normals + // take the implicit bit and the 1075 = 1023 bias + 52 mantissa-bit shift. + let (m, e) = if biased == 0 { + (mantissa, -1074i32) + } else { + (mantissa | (1u64 << 52), biased - 1075) + }; + if m == 0 { + return 0; + } + // Normalize `m` to odd: each trailing zero bit is a factor of two that + // belongs in the exponent. This is what makes `6.0` cost 0 rather than 50. + let e = e + m.trailing_zeros() as i32; + if e >= 0 { + 0 + } else { + (-e) as usize + } +} + fn spec_to_fixed(value: f64, dp: usize) -> String { let neg = value.is_sign_negative() && value != 0.0; let x = value.abs(); - // Exact expansion: an f64 needs ≤767 significant decimal digits, and `dp` - // is range-checked to ≤100, so 1100 fraction digits always covers the - // rounding position (frac[dp]) exactly. Mirrors `spec_to_exponential`. - let full = format!("{x:.1100}"); + // Exact expansion, but only as long as THIS value actually is. 1100 places + // covers the smallest subnormal, which is the worst case in the whole + // domain and nothing like the common one: `(6.0).toFixed(7)` expanded to + // 1100 decimal places and discarded 1093 of them. The expansion runs + // through `flt2dec`'s dragon4 with a `Big32x40` bignum, so that is real + // work - 6,927 Ir/op against 646 for `toFixed(6)`, an 11x step for one + // more decimal place (#10770). + // + // `prec >= exact_fraction_digits(x)` keeps the expansion EXACT, so the + // manual round-half-up below still reads true digits, and `>= dp + 1` + // keeps `frac_str[dp]` in range. Mirrors `spec_to_exponential`, which + // still uses the fixed 1100. + let prec = exact_fraction_digits(x).max(dp + 1).min(1100); + let full = format!("{x:.prec$}"); let dot = full.find('.').unwrap_or(full.len()); let int_str = &full[..dot]; let frac_str = full.get(dot + 1..).unwrap_or(""); diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 9bcdfacef6..7bf143908e 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -1107,9 +1107,9 @@ }, { "file": "crates/perry-runtime/src/string/format.rs", - "name": "POW10", + "name": "POW10_FIXED", "verdict": "not_a_gc_pointer", - "why": "A [u64; 7] constant table of powers of ten." + "why": "A [u64; 20] constant table of powers of ten, holding 10^0..10^19 \u2014 plain integers, never an address, so the collector never sees a pointer here. Was POW10, a [u64; 7] local to fmt_fixed_int; hoisted to module scope by #10770 so that js_number_to_fixed's admission test and fmt_fixed_int itself read ONE table, because the old seven-entry length was what silently capped toFixed's fast path at dp <= 6." }, { "file": "crates/perry-runtime/src/string/mod.rs", From e3c6db1eac576fa3c070cac3034c01b8f3868732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 20 Sep 2026 07:23:28 +0200 Subject: [PATCH 5/5] chore: release merge train 234 as v0.5.1613 --- CLAUDE.md | 2 +- Cargo.lock | 136 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92da2cdafc..b8bd5d2b0e 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.1612 +**Current Version:** 0.5.1613 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index c53c1d522c..322b6d3b65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5565,7 +5565,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "base64 0.22.1", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-dispatch", "serde", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "cc", "libc", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "aho-corasick", "anyhow", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-hir", @@ -5671,7 +5671,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-hir", @@ -5679,7 +5679,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-dispatch", @@ -5688,7 +5688,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-hir", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "base64 0.22.1", @@ -5708,7 +5708,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-hir", @@ -5716,7 +5716,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "async-trait", "clap", @@ -5740,14 +5740,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "serde", "serde_json", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1612" +version = "0.5.1613" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "clap", @@ -5781,7 +5781,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "block2", "objc2", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "argon2", "perry-ffi", @@ -5800,7 +5800,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "bcrypt", "perry-ffi", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "rusqlite", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "scraper", @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "chrono", "cron", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "rust_decimal", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5850,7 +5850,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "perry-runtime", @@ -5858,14 +5858,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fetch" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "bytes", "lazy_static", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "bytes", @@ -5910,7 +5910,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "lazy_static", "perry-ffi", @@ -5920,7 +5920,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "chrono", "perry-ffi", @@ -5928,7 +5928,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "bson", "futures-util", @@ -5940,7 +5940,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "chrono", "perry-ffi", @@ -5952,7 +5952,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "bytes", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "lettre", "perry-ffi", @@ -5996,7 +5996,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "notify", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "printpdf", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "sqlx", @@ -6025,7 +6025,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "fast_image_resize", "image", @@ -6036,7 +6036,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "lazy_static", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-ffi", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-ffi", "perry-runtime", @@ -6074,7 +6074,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "futures-util", "lazy_static", @@ -6087,7 +6087,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "brotli", "flate2", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-api-manifest", @@ -6127,11 +6127,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1612" +version = "0.5.1613" [[package]] name = "perry-parser" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "perry-diagnostics", @@ -6144,7 +6144,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perex", "regex", @@ -6152,7 +6152,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "ahash", "base64 0.22.1", @@ -6210,14 +6210,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6301,21 +6301,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "dirs", "perry-ffi", @@ -6325,7 +6325,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "jni", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "rand 0.10.2", "serde", @@ -6350,7 +6350,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6373,7 +6373,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "block2", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "block2", @@ -6407,7 +6407,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1612" +version = "0.5.1613" [[package]] name = "perry-ui-test" @@ -6418,11 +6418,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1612" +version = "0.5.1613" [[package]] name = "perry-ui-tvos" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "block2", @@ -6439,7 +6439,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "block2", @@ -6456,7 +6456,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "block2", "libc", @@ -6470,7 +6470,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "libc", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "base64 0.22.1", "libc", @@ -6502,7 +6502,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "anyhow", "base64 0.22.1", @@ -6517,7 +6517,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1612" +version = "0.5.1613" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 0d512a309e..c35ee8b61b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -321,7 +321,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1612" +version = "0.5.1613" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"