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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions changelog.d/7905-dynamic-string-length-generic-tower.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 83 additions & 15 deletions crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, &not_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
Expand Down Expand Up @@ -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))
}
97 changes: 97 additions & 0 deletions crates/perry-codegen/src/expr/property_get/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,3 +529,100 @@ fn generic_property_get_slot_load_is_reached_only_through_every_guard() {
);
}
}

/// A module whose init reads `o.<property>` 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}"
Comment on lines +621 to +626

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scope the helper assertion to the SSO block.

The current search includes the invalid-receiver path. That path always calls js_object_get_field_by_name_f64. The test can pass if a change removes the required non-length SSO helper call.

Proposed test fix
     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());
     assert!(
-        ir[sso..].contains("js_object_get_field_by_name_f64"),
+        sso_body[..sso_end].contains("js_object_get_field_by_name_f64"),
         "a non-`length` SSO read must still call the by-name helper:\n{ir}"
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}"
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());
assert!(
sso_body[..sso_end].contains("js_object_get_field_by_name_f64"),
"a non-`length` SSO read must still call the by-name helper:\n{ir}"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/property_get/tests.rs` around lines 621 - 626,
Restrict the by-name helper assertion in the SSO test to the valid SSO receiver
block identified by `pget.recv_sso`, excluding the invalid-receiver path. Update
the searched IR range so `js_object_get_field_by_name_f64` must appear within
that SSO block itself.

);
}
70 changes: 70 additions & 0 deletions test-files/test_gap_dynamic_string_length_generic_tower.ts
Original file line number Diff line number Diff line change
@@ -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));
Loading