From e5ac9cdb5d15956f3dcd1a06ba475e6618464fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 06:42:07 +0200 Subject: [PATCH] perf(codegen): serve `.length` on an untyped string receiver inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `property_get.rs` already emits a fully runtime-guarded three-arm string-`length` dispatch (SSO length byte / heap `utf16_len` load / property-semantic slow call), but it is gated on `is_string_expr` — a compile-time proof. A receiver the front end cannot type therefore lands in `lower_generic_property_get`, where a heap string can never be served: the inline cache requires a `GC_TYPE_OBJECT` receiver by construction (#72). Every such read missed to `js_object_get_field_ic_miss` and walked a ladder built for objects, decoding the key with `str::from_utf8` at three levels before reaching the string arm. On `gc-handoff/apps/pipeline.ts` that one read was 9.7% of the program as a call-graph subtree. The generic tower now splits both string tags out at `.length` sites, after the typed-feedback observation so a mixed object/string site still records every receiver. Everything that is not a string keeps the tower, paying one compare and one branch. pipeline 0.9192x, shapes 0.8985x, asyncpipe 0.9912x (instructions retired; the 14 corpus binaries that come out byte-identical measure 0.998-1.0004). --- ...905-dynamic-string-length-generic-tower.md | 47 +++++++++ .../src/expr/property_get/generic_dispatch.rs | 98 ++++++++++++++++--- .../src/expr/property_get/tests.rs | 97 ++++++++++++++++++ ...gap_dynamic_string_length_generic_tower.ts | 70 +++++++++++++ 4 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 changelog.d/7905-dynamic-string-length-generic-tower.md create mode 100644 test-files/test_gap_dynamic_string_length_generic_tower.ts diff --git a/changelog.d/7905-dynamic-string-length-generic-tower.md b/changelog.d/7905-dynamic-string-length-generic-tower.md new file mode 100644 index 0000000000..3587ab6c13 --- /dev/null +++ b/changelog.d/7905-dynamic-string-length-generic-tower.md @@ -0,0 +1,47 @@ +### Performance + +- **`pipeline` 0.9192×, `shapes` 0.8985×, `asyncpipe` 0.9912× (instructions + retired) — a `.length` read on a receiver codegen could not prove is a string + paid the whole object property ladder.** `property_get.rs` already emits a + three-arm string-`length` dispatch (SSO length byte / heap `utf16_len` load / + property-semantic slow call) and that dispatch is *fully runtime-guarded* — + it tests the NaN-box tag and only takes an inline arm for a value that IS a + string. It was gated on `is_string_expr`, a compile-time proof. A receiver the + front end cannot type (`rec.tag.length` where `rec` is an object-literal type, + a JSON `any`, an array element) therefore landed in + `lower_generic_property_get`, where a heap string can never be served: the + inline cache requires a `GC_TYPE_OBJECT` receiver by construction (#72). Every + such read missed to `js_object_get_field_ic_miss` and walked a ladder built + for objects — a closure-magic deref, buffer and typed-array registry probes, + then `js_object_get_field_by_name`'s own dispatch, which decoded the key with + `str::from_utf8` again before reaching the string arm. On + `gc-handoff/apps/pipeline.ts` that one read was 9.7 % of the program as a + call-graph subtree. + + The generic tower now splits both string tags out at `.length` sites: a heap + string (`0x7FFF`) loads `utf16_len` at payload offset 0 through the same + `safe_load_i32_from_ptr` the proven-string lowering uses, and an SSO receiver + (`0x7FF9`) extracts the inline length byte instead of calling + `js_object_get_field_by_name_f64`. Everything else keeps the tower unchanged, + and the split sits after the typed-feedback observation so a mixed + object/string site still records every receiver. A non-string receiver pays + one compare and one branch, and only where the key is `length`. + + Sound by construction: a primitive string's `length` is non-writable, + non-configurable and cannot be shadowed by an own property, and both string + tags are disjoint from `POINTER_TAG` — this short-circuits a value the runtime + ladder computed identically. Same shape as #7753 (array `.length` in the miss + handler) and #7890 (declared array reads reaching the inline `.length`), one + receiver type over. + + Validated with two compilers against one pinned runtime pair (the change is + codegen-only; both `libperry_{runtime,stdlib}.a` compare identical): 19/19 + corpus programs exit 0 and are byte-identical to `node`, `cmp` across arms + reads 14 identical / 5 differ and the 5 are exactly the programs that read + `.length` through the generic tower, the other 14 measure 0.998–1.0004 + instructions retired, and `iso_miss` still reports `checksum 437840 misses 0` + under `PERRY_GC_PROTECT_FROMSPACE` and `PERRY_GC_VERIFY_EVACUATION`. + `test-files/test_gap_dynamic_string_length_generic_tower.ts` feeds the same + call site a string, an array, array-like objects with numeric and non-numeric + `length`, a function, a typed array, a number and both nullish values, and + requires node-identical output including the catchable TypeError. diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index 0cd4adaa06..5f2a62336b 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -153,6 +153,33 @@ pub(crate) fn lower_generic_property_get( let invalid_label = ctx.block_label(invalid_idx); let class_ref_label = ctx.block_label(class_ref_idx); let final_merge_label = ctx.block_label(final_merge_idx); + // `.length` on a receiver whose static type is not a proven string. + // + // The three-arm string-length dispatch in `property_get.rs` (SSO length + // byte / heap `utf16_len` load / property-semantic slow call) is already + // fully RUNTIME-guarded — it tests the NaN-box tag and only takes an + // inline arm for a value that IS a string — yet it is gated on + // `is_string_expr`, a compile-time proof. A receiver the front end cannot + // type (`rec.tag.length` where `rec` is an object-literal type, a JSON + // `any`, an array element) therefore lands in this generic tower instead, + // where a heap string can never be served: the PIC requires a + // GC_TYPE_OBJECT receiver by construction (#72), so EVERY such read misses + // to `js_object_get_field_ic_miss` and walks a ladder built for objects — + // closure-magic deref, buffer and typed-array registry probes, then + // `js_object_get_field_by_name`'s own dispatch, which decodes the key with + // `str::from_utf8` again before reaching the string arm. On `pipeline.ts` + // that one read was ~9% of total run time. + // + // Both string tags are disjoint from POINTER_TAG, so serving them here is + // a pure short-circuit: a primitive string's `length` is non-writable and + // non-configurable, cannot be shadowed by an own property, and is exactly + // what the runtime ladder computes. Everything else keeps the tower. + let inline_string_length = property == "length"; + let strlen_heap_idx = if inline_string_length { + Some(ctx.new_block("pget.strlen_heap")) + } else { + None + }; // #7883: the POINTER/STRING test goes FIRST, and the two rare tags are // discriminated in a cold block off its false edge. The three tag classes // are pairwise disjoint — `is_valid` is `(tag & 0xFFFD) == 0x7FFD`, true @@ -205,6 +232,22 @@ pub(crate) fn lower_generic_property_get( ], ); + // Split the heap-string receiver off before the PIC. Placed AFTER the + // typed-feedback observation on purpose: the site keeps recording every + // receiver it sees, so a mixed object/string site cannot be mis-profiled + // as monomorphic-object by the arm that is no longer traced here. + if let Some(heap_idx) = strlen_heap_idx { + let strlen_heap_label = ctx.block_label(heap_idx); + let not_string_idx = ctx.new_block("pget.recv_obj"); + let not_string_label = ctx.block_label(not_string_idx); + let is_heap_string = + ctx.block() + .icmp_eq(I64, &obj_tag, crate::nanbox::STRING_TAG_TOP16_I64); + ctx.block() + .cond_br(&is_heap_string, &strlen_heap_label, ¬_string_label); + ctx.current_block = not_string_idx; + } + // Issue #51: monomorphic inline cache. Per-site `[8 x i64]` global // holds [shape_token, cached_slot_index, primed_epoch, ...unused]. // The fast path compares the receiver's discriminated shape token @@ -757,24 +800,49 @@ pub(crate) fn lower_generic_property_get( // the PIC entirely (PIC would read garbage memory). The // key handle has already been extracted above. ctx.current_block = sso_idx; - let sso_val = ctx.block().call( - DOUBLE, - "js_object_get_field_by_name_f64", - &[(I64, &obj_bits), (I64, &key_handle)], - ); + let sso_val = if inline_string_length { + // `.length` of an SSO string is the length byte in bits 40..47 of the + // NaN-box itself — the same extract `js_object_get_field_by_name_f64` + // performs, minus the call and the key decode. + let len_shifted = ctx.block().lshr(I64, &obj_bits, "40"); + let len_byte = ctx.block().and(I64, &len_shifted, "255"); + ctx.block().uitofp(I64, &len_byte, DOUBLE) + } else { + ctx.block().call( + DOUBLE, + "js_object_get_field_by_name_f64", + &[(I64, &obj_bits), (I64, &key_handle)], + ) + }; let sso_end_label = ctx.block().label.clone(); ctx.block().br(&final_merge_label); + // Heap string `.length`: `utf16_len` is the leading `u32` of + // `StringHeader` — the identical load the proven-string lowering in + // `property_get.rs` emits (`strlen.heap`). `safe_load_i32_from_ptr` + // keeps a sub-page handle off the load. + let strlen_heap_arm = if let Some(heap_idx) = strlen_heap_idx { + ctx.current_block = heap_idx; + let len_i32 = ctx.block().safe_load_i32_from_ptr(&obj_handle); + let heap_len = ctx.block().uitofp(I32, &len_i32, DOUBLE); + let heap_end_label = ctx.block().label.clone(); + ctx.block().br(&final_merge_label); + Some((heap_len, heap_end_label)) + } else { + None + }; + // Outer merge joins PIC result + invalid-receiver undefined - // + SSO result + class-ref dispatch result. + // + SSO result + class-ref dispatch result (+ heap-string `.length`). ctx.current_block = final_merge_idx; - Ok(ctx.block().phi( - DOUBLE, - &[ - (&pic_val, &pic_end_label), - (&undef_val, &invalid_end_label), - (&sso_val, &sso_end_label), - (&class_ref_result, &class_ref_end_label), - ], - )) + let mut incoming: Vec<(&str, &str)> = vec![ + (&pic_val, &pic_end_label), + (&undef_val, &invalid_end_label), + (&sso_val, &sso_end_label), + (&class_ref_result, &class_ref_end_label), + ]; + if let Some((heap_len, heap_end_label)) = strlen_heap_arm.as_ref() { + incoming.push((heap_len, heap_end_label)); + } + Ok(ctx.block().phi(DOUBLE, &incoming)) } diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index 92be586635..d6e2922827 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -529,3 +529,100 @@ fn generic_property_get_slot_load_is_reached_only_through_every_guard() { ); } } + +/// A module whose init reads `o.` where `o` is an `Any` local — the +/// generic tower, same shape as `module_with_nullish_read` but with a +/// caller-chosen key. +fn module_reading(property: &str) -> Module { + let mut m = Module::new("read.ts"); + m.init = vec![ + Stmt::Let { + id: 1, + name: "o".to_string(), + ty: perry_hir::types::Type::Any, + mutable: false, + init: Some(Expr::Undefined), + }, + Stmt::Expr(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: property.to_string(), + byte_offset: 0, + }), + ]; + m.init_kind = ModuleInitKind::Eager; + m +} + +fn emit_read(property: &str) -> String { + String::from_utf8(compile_module(&module_reading(property), ir_opts(false, None)).unwrap()) + .expect("LLVM IR should be UTF-8") +} + +/// A `.length` read whose receiver codegen cannot prove is a string must still +/// serve a string inline. +/// +/// The proven-string lowering in `property_get.rs` already emits a +/// runtime-guarded three-arm dispatch, but it is gated on `is_string_expr` — a +/// compile-time proof. Without a proof the read lands in this tower, where a +/// heap string can never hit the PIC (it requires a GC_TYPE_OBJECT receiver by +/// construction, #72) and every read pays the full +/// `js_object_get_field_ic_miss` object ladder. Assert BOTH string arms exist: +/// the heap block, and the SSO arm's inline length-byte extract in place of the +/// `js_object_get_field_by_name_f64` call. +#[test] +fn generic_length_read_serves_a_string_inline() { + let ir = emit_read("length"); + assert!( + ir.contains("\npget.strlen_heap"), + "a `.length` read must split heap strings off before the PIC:\n{ir}" + ); + // 32767 = STRING_TAG >> 48. The split must test the tag, not something the + // optimiser could fold away. + assert!( + ir.contains("icmp eq i64") && ir.contains("32767"), + "the heap-string split must compare the receiver tag to STRING_TAG:\n{ir}" + ); + let sso = ir + .find("\npget.recv_sso") + .unwrap_or_else(|| panic!("expected an SSO receiver block:\n{ir}")); + let sso_body = &ir[sso..]; + let sso_end = sso_body[1..] + .find("\n\n") + .map(|i| i + 1) + .unwrap_or(sso_body.len()); + let sso_body = &sso_body[..sso_end]; + assert!( + sso_body.contains("lshr i64") && sso_body.contains(", 40"), + "the SSO arm must extract the inline length byte, not call the \ + by-name helper:\n{sso_body}" + ); + assert!( + !sso_body.contains("js_object_get_field_by_name_f64"), + "the SSO `.length` arm must not call back into the runtime:\n{sso_body}" + ); + // Everything that is NOT a string keeps the tower. + assert!( + ir.contains("@perry_ic_") && ir.contains("js_object_get_field_ic_miss"), + "non-string receivers must still reach the inline PIC and its miss \ + handler:\n{ir}" + ); +} + +/// The short-circuit is keyed on the property name: any other key on a string +/// receiver (`s.charCodeAt`, `s.constructor`) still needs the runtime, so no +/// other read may grow the string blocks. +#[test] +fn generic_non_length_read_keeps_the_whole_tower() { + let ir = emit_read("charCodeAt"); + assert!( + !ir.contains("pget.strlen_heap"), + "only `.length` may take the inline string arm:\n{ir}" + ); + let sso = ir + .find("\npget.recv_sso") + .unwrap_or_else(|| panic!("expected an SSO receiver block:\n{ir}")); + assert!( + ir[sso..].contains("js_object_get_field_by_name_f64"), + "a non-`length` SSO read must still call the by-name helper:\n{ir}" + ); +} diff --git a/test-files/test_gap_dynamic_string_length_generic_tower.ts b/test-files/test_gap_dynamic_string_length_generic_tower.ts new file mode 100644 index 0000000000..c898aa49ae --- /dev/null +++ b/test-files/test_gap_dynamic_string_length_generic_tower.ts @@ -0,0 +1,70 @@ +// `.length` read through a receiver the front end cannot type — the generic +// property-get tower, not the proven-string lowering. +// +// The tower now serves a NaN-boxed string (SSO and heap) inline instead of +// missing the object inline-cache and walking the runtime's object ladder. +// That short-circuit is only sound if EVERY other receiver still takes the +// tower unchanged, so this exercises the same call site with a string, an +// array, array-like objects with numeric and non-numeric `length`, a function, +// a typed array, a plain number, an object with no `length` at all, and both +// nullish values (which must throw a catchable TypeError). + +type Box = { payload: any; label: string }; + +function boxed(value: any): Box { + return { payload: value, label: "box" }; +} + +// `box.payload` is `any`, so `.length` here lowers through the generic tower. +function readLength(value: any): any { + const box = boxed(value); + return box.payload.length; +} + +function show(label: string, value: any): void { + const length = readLength(value); + console.log(label, String(length), typeof length, length === undefined); +} + +// SSO string (short) and heap string (long, and a concat result). +show("sso", "abc"); +show("empty", ""); +show("heap", "0123456789012345678901234567890123456789"); +show("concat", "t:" + "alpha"); +show("non-ascii", "héllo\u{1F600}"); + +show("array", ["a", "b"]); +show("array-like number", { length: 7, 0: "z" }); +show("array-like string", { length: "seven" }); +show("no length", { other: 1 }); +show("number", 42); +show("boolean", true); +show("typed array", new Uint8Array(3)); + +function twoArgs(a: any, b: any): void { + void a; + void b; +} +show("function", twoArgs); + +for (const value of [null, undefined]) { + try { + readLength(value); + console.log("nullish", String(value), "no throw"); + } catch (error) { + const caught = error as Error; + console.log( + "nullish", + String(value), + caught.constructor.name + ": " + caught.message, + ); + } +} + +// The same site, hot and monomorphic on strings — this is the `pipeline.ts` +// shape (`rec.tag.length` where `rec` is an object-literal type). +let total = 0; +for (let i = 0; i < 200; i++) { + total = total + readLength("t:" + (i % 3 === 0 ? "alpha" : "be")); +} +console.log("total", String(total));