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
24 changes: 13 additions & 11 deletions benchmarks/bench_dynamic_property_keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@
// node 36 ms 21 ms 1.7x
// perry 1487 ms 1321 ms 1.1x
//
// The delete penalty is the number the "objects that defeat shapes need a
// dictionary mode" argument rests on — and perry's is LOWER than node's. Adding
// a dictionary representation would therefore be a large investment aimed at a
// tail perry does not have.
// That ledger was accurate at the time. After the dynamic-write campaigns and
// tombstone deletes became default-on, however, the two columns inverted
// (Mac mini, current main at #9065 filing, min-of-7):
//
// The second column is the real gap: ~60x on plain overwrite. Profiling this
// binary puts the time in `js_array_get_f64`, `try_read_tracked_gc_header`,
// `shape_descriptor_by_id` + `shape_descriptor_ensure_with_generation` (two
// hash lookups per access on the hot path), and `js_put_value_set_dyn_ic_miss`
// — i.e. inline-cache misses and shape-table probes, not deletion.
// engine delete_heavy overwrite_only delete penalty
// node 30 ms 19 ms 1.6x
// perry 981 ms 13 ms 75.5x
//
// Perry's overwrite loop now beats node; delete-driven shape identity is the
// remaining gap. The ratio that originally argued against dictionary-style
// handling is now the strongest evidence for stable-token, per-key-validated
// churn shapes (#9064/#9065).
//
// Keep both columns when changing this file: the ratio is what refutes the
// dictionary-mode premise, and the absolute is what tracks the real gap.
// Keep BOTH dated ledgers when changing this file. They record a real inversion
// in where the cost lives, not an error in the original measurement.

function deleteHeavy(n: number): number {
const o: Record<string, number> = {};
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/9065-small-object-churn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Improved repeated add/delete churn on small objects. A first delete now forks
an owned tombstoned key layout instead of compacting a transition-cache-shared
layout back to empty, allowing subsequent stable-token delete and append paths
to keep their inline caches alive.

This removes the per-iteration keys-array allocation loop while preserving the
existing bounded append/squeeze behavior and both flag states.
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ pub(crate) fn lower_generic_property_get(
.block()
.icmp_eq(I64, &val_hit_bits, crate::nanbox::TAG_HOLE_I64);
ctx.block()
.cond_br(&hit_deleted, &call_label, &hit_live_label);
.cond_br(&hit_deleted, &miss_label, &hit_live_label);

ctx.current_block = hit_live_idx;
crate::expr::emit_typed_feedback_record_call(
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/tests/native_proof_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15276,8 +15276,8 @@ fn nested_same_shape_object_writes_version_one_through_four_fields() {
rejected
.matches("call double @js_put_value_set_ic_miss")
.count(),
20,
"the bounded rejection must preserve all four cache miss entries for all five semantic write sites:\n{rejected}"
25,
"the bounded rejection must preserve all five fallback entries for all five semantic write sites:\n{rejected}"
);

let mut nonfinite_body = loop_body(1);
Expand Down
69 changes: 31 additions & 38 deletions crates/perry-runtime/src/json/stringify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ pub unsafe fn ptr_is_tracked_heap_object(ptr: *const u8) -> bool {
}
}

/// Decode an object keys-array slot without assuming heap-string storage.
/// Dynamic SSO writes keep short property names inline; module-slot shapes may
/// still carry the legacy validated raw `StringHeader` pointer form.
#[inline]
unsafe fn object_key_str<'a>(
key_bits: u64,
sso: &'a mut [u8; crate::value::SHORT_STRING_MAX_LEN],
) -> Option<&'a str> {
let key = JSValue::from_bits(key_bits);
if let Some(bytes) = crate::string::js_string_key_bytes(key, sso) {
return std::str::from_utf8(bytes).ok();
}
if ptr_is_tracked_heap_object(key_bits as *const u8) {
return str_from_header(key_bits as *const StringHeader);
}
None
}

pub(crate) unsafe fn is_object_pointer(ptr: *const u8) -> bool {
// A small-handle-band id (revocable-Proxy id, fetch/zlib/stream handle) is
// never a real ObjectHeader; reading its `keys_array` field would deref
Expand Down Expand Up @@ -1292,22 +1310,6 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de
// key (#5909) and to write the property name below.
let key_f64 = key_at(f);
let key_bits = key_f64.to_bits();
let key_tag = key_bits & 0xFFFF_0000_0000_0000;
let key_ptr = if key_tag == STRING_TAG || key_tag == POINTER_TAG {
(key_bits & POINTER_MASK) as *const StringHeader
} else if ptr_is_tracked_heap_object(key_bits as *const u8) {
// Untagged raw key pointer (#3576 module-slot shape). It must be
// VALIDATED, not assumed: this arm previously accepted anything
// that was not STRING_TAG/POINTER_TAG and dereferenced it, so a
// key slot holding a NaN-boxed immediate — observed as
// `0x7FFC_0000_0000_0010` — was read as a `StringHeader`
// (byte_len at +4, data at +0x14) and SIGSEGV'd. Same bug class as
// #7447, same predicate: decide by GC allocation membership, which
// is dereference-free, rather than by bit pattern.
key_bits as *const StringHeader
} else {
std::ptr::null()
};

// SerializeJSONProperty step 2 (#5909): apply a heap-valued member's
// `toJSON` HERE, before the comma/key are written, so a member whose
Expand All @@ -1318,7 +1320,8 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de
// unreadable, so pass "" as it did before.
let mut member_probed = false;
if (field_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG || is_raw_pointer(field_bits) {
set_to_json_key_str(str_from_header(key_ptr).unwrap_or(""));
let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN];
set_to_json_key_str(object_key_str(key_bits, &mut key_sso).unwrap_or(""));
if let Some(resolved) = member_to_json(field_val) {
let rb = resolved.to_bits();
if rb == TAG_UNDEFINED || is_closure_value(rb) || is_symbol_value(rb) {
Expand All @@ -1335,31 +1338,21 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de
}
}

// `member_to_json` above may have run a user `toJSON` — a GC /
// evacuation point that moves the key string. The raw `key_ptr`
// captured before it is then dangling, so re-derive it from the rooted
// object header before the property name is written. Without this, a
// member whose `toJSON` forces a copying-minor GC (#5909's pre-key
// member probe) emitted a corrupted key
// (gc::tests …test_json_stringify_object_rederives_fields_after_tojson_minor_gc).
let key_ptr = if member_probed {
let kb = key_at(f).to_bits();
let kt = kb & 0xFFFF_0000_0000_0000;
if kt == STRING_TAG || kt == POINTER_TAG {
(kb & POINTER_MASK) as *const StringHeader
} else {
kb as *const StringHeader
}
} else {
key_ptr
};

if !first {
buf.push(',');
}
first = false;

if let Some(key_str) = str_from_header(key_ptr) {
// `member_to_json` may have collected and moved a heap key. Re-read
// the slot through the rooted object after that call; SSO keys remain
// self-contained and use the same decoder.
let current_key_bits = if member_probed {
key_at(f).to_bits()
} else {
key_bits
};
let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN];
if let Some(key_str) = object_key_str(current_key_bits, &mut key_sso) {
// A key can itself contain `"`/`\`/control characters (e.g. a
// `Symbol`-adjacent computed key or `Object.defineProperty`
// literal name) — must go through the same escaper as string
Expand Down Expand Up @@ -1413,7 +1406,7 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de
// `bigint_apply_to_json`, which reads the pending `toJSON` key, so
// record this member's key first (#5909).
if val_tag == BIGINT_TAG {
set_to_json_key_str(str_from_header(key_ptr).unwrap_or(""));
set_to_json_key_str(object_key_str(current_key_bits, &mut key_sso).unwrap_or(""));
}
write_number(buf, field_val);
}
Expand Down
23 changes: 9 additions & 14 deletions crates/perry-runtime/src/json/stringify_shape_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ pub(crate) unsafe fn build_shape_prefix_template(first_elem_bits: u64) -> Option
let keys_elements =
(keys_arr as *const u8).add(std::mem::size_of::<crate::ArrayHeader>()) as *const f64;
let mut prefixes: Vec<String> = Vec::with_capacity(shape_fields as usize);
let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN];
for f in 0..shape_fields {
let key_bits = (*keys_elements.add(f as usize)).to_bits();
// A tombstoned key slot (#9029, flag-gated deletes): the hole's bits
Expand All @@ -202,13 +203,9 @@ pub(crate) unsafe fn build_shape_prefix_template(first_elem_bits: u64) -> Option
if key_bits == crate::value::TAG_HOLE {
return None;
}
let key_tag = key_bits & 0xFFFF_0000_0000_0000;
let key_ptr = if key_tag == STRING_TAG || key_tag == POINTER_TAG {
(key_bits & POINTER_MASK) as *const StringHeader
} else {
key_bits as *const StringHeader
};
let key_str = str_from_header(key_ptr)?;
let key_bytes =
crate::string::js_string_key_bytes(JSValue::from_bits(key_bits), &mut key_sso)?;
let key_str = std::str::from_utf8(key_bytes).ok()?;
let needs_escape = key_str.bytes().any(|b| b == b'"' || b == b'\\' || b < 0x20);
let mut prefix = String::with_capacity(key_str.len() + 4);
prefix.push(if f == 0 { '{' } else { ',' });
Expand Down Expand Up @@ -293,13 +290,11 @@ unsafe fn set_to_json_key_for_template_field(keys_arr: *mut crate::ArrayHeader,
let keys_elements =
(keys_arr as *const u8).add(std::mem::size_of::<crate::ArrayHeader>()) as *const f64;
let key_bits = (*keys_elements.add(f)).to_bits();
let key_tag = key_bits & 0xFFFF_0000_0000_0000;
let key_ptr = if key_tag == STRING_TAG || key_tag == POINTER_TAG {
(key_bits & POINTER_MASK) as *const StringHeader
} else {
key_bits as *const StringHeader
};
set_to_json_key_str(str_from_header(key_ptr).unwrap_or(""));
let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN];
let key_str = crate::string::js_string_key_bytes(JSValue::from_bits(key_bits), &mut key_sso)
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.unwrap_or("");
set_to_json_key_str(key_str);
}

/// Fast emission path for an object element that matches the cached shape
Expand Down
Loading
Loading