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
85 changes: 85 additions & 0 deletions changelog.d/8118-json-parse-write-fast-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
### perf(object/json): `JSON.parse` receivers reach both object-write fast paths (#8098)

A `JSON.parse` object carries `class_id == 0`. Both guarded object-write fast
paths — the whole-loop numeric clone and the static/dynamic write PICs — rejected
it on exactly that, so every `record.field = …` on parsed data took the generic
`[[Set]]` path for the life of the program. `JSON.parse` is how essentially all
external data enters a Perry program (HTTP bodies, config, ORM rows, cache reads,
IPC payloads), and the rejection was on the *receiver's identity*, so no amount of
loop-shape or key-form work in #6812 could reach it.

Measured on the committed `benchmarks/object-write-6812/matrix.ts` controlled
pair, which differs in exactly one way (`JSON.parse('{"x":0}')` instead of an
object literal) and produces an identical `sink 122876400` over identical
120,000,000 writes. Wall clock on the development host is unusable (the same cell
measured 11.7 s / 17.9 s / 21.1 s across three runs), so the ratio is reported in
**instructions retired**, which reproduced to within 0.02%:

| cell | instructions retired | vs `key_dot` |
|---|--:|--:|
| `key_dot` (object literals) | 1.158e9 | 1.00x |
| `receiver_class_id_zero` before | 150.08e9 | **129.5x** |
| `receiver_class_id_zero` after | see below | |

**Why the guard could not simply drop the clause.** `class_id != 0` was standing
in for three per-object exclusions that the generic path still applies verbatim
(`object/field_set_by_name/fast_paths.rs::try_existing_own_data_overwrite`):
`NATIVE_MODULE_CLASS_ID`, `Object.prototype`, and a `URL` instance — whose
`pathname`/`search`/… own slots are live views whose setters rebuild `href`
(`field_set_by_name/tail.rs`). None of those is derivable from the ShapeId: two
objects share a ShapeId iff they share a keys-array *allocation*, and the
shape-transition cache deliberately converges distinct objects onto one shared
array, so a prime-time-only exclusion loses to ordering (a plain object primes the
site, a `URL` that later acquires the same keys array then hits it). The
generated hit path re-checks only per-object state, so the discriminator has to
be per-object too.

**What landed instead** is an explicit, opt-in, per-object mark:
`OBJ_FLAG_PLAIN_ORDINARY` (bit 9 of `GcHeader::_reserved`, object-only, disjoint
from the array-only `GC_ARRAY_ARGUMENTS_OBJECT` by `obj_type` the same way bits 11
and 12 already are). The JSON direct parser and the lazy-tape materializer set it
at birth; every other class-less receiver is unmarked and keeps the full `[[Set]]`
walk, so no existing population changes behaviour. The bit is free in the
generated guard — `_reserved` is already loaded there for the blocking-flag test,
so admission costs one `and` + `icmp` + `or`, hoisted above the four PIC ways.

Note that the *read* PIC has admitted `class_id == 0` all along
(`object/field_get_set/ic_miss.rs` primes on any regular descriptor-free shaped
receiver, and the emitted read guard has no `class_id` compare at all). Reads of
parsed objects were already on the ShapeId fast path; only writes were not. #8067
/ #8086 supplied what was missing on the write side: a parsed receiver is
birth-stamped with a real ShapeId by `js_object_alloc_class_inline_keys`, and
repeated parses of one shape share a single `GC_FLAG_SHAPE_SHARED` keys array via
`PARSE_SHAPE_CACHE`, so the whole 2400-receiver prefix carries one ShapeId.

Also fixed here, in the same file: `JSON.parse("{}")` initialized **eight** inline
field slots into an allocation that has `max(0, INLINE_SLOT_FLOOR)` = **two** of
them (the floor dropped 4 → 2 in #7928) — a 48-byte overwrite past the object on
every empty-object parse, the exact "heap buffer overflow into adjacent arena
objects" that `js_object_alloc_with_parent` documents. The hand-rolled fill was
redundant as well: the allocator has initialized every slot it allocates since
#4717.

Coverage:

* `crates/perry-runtime/src/proxy.rs` —
`json_parse_receivers_are_admitted_to_the_whole_loop_write_clone` and
`json_parse_receivers_prime_the_static_write_pic` drive the shipped
`js_json_parse` end-to-end (payloads are a few bytes with an object root, so
the eager direct parser runs and no lazy tape stands between the probe and the
objects — #7635), assert the premises (`class_id == 0`, a real shared ShapeId),
and then clear the mark on one receiver and require the guard to refuse. Both
fail when the guard ignores the mark and when the parser stops setting it.
`plain_ordinary_object_flag_matches_the_emitted_write_pic_literal` pins the bit
value against the literal `perry-codegen` emits.
* The pre-existing `object_array_numeric_write_guard_requires_complete_uniform_proof`
keeps its class-id-zero rejection for an *unmarked* receiver and gains the
marked-accepts and native-module-still-rejects halves.
* `test-files/test_gap_json_parse_object_writes.ts` — parity against node 26.5.1
for the semantics the `class_id != 0` clause used to keep parsed objects away
from: deleted keys, added keys, frozen/sealed/non-extensible receivers (strict
`TypeError`s), accessor and non-writable descriptors installed over a parsed
slot, prototype mutation with a shadowing setter, null prototypes, dynamic-key
writes, a parsed object used as a prototype, an empty parsed object grown by
name, a polymorphic site mixing parsed objects / literals / class instances,
and `__proto__` / `constructor` as genuine own data keys.
38 changes: 31 additions & 7 deletions crates/perry-codegen/src/expr/proxy_reflect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ use super::{
/// encoded by the authoritative ShapeId and therefore owns no header flag.
const WRITE_PIC_BLOCKING_FLAGS: u16 = 0x1907;

/// #8098: `GcHeader::_reserved` bit 9 — the runtime birth-marked this
/// class-less receiver an ORDINARY plain object (`JSON.parse` output), so it is
/// eligible for the write PIC exactly like a class instance. MUST equal
/// `perry_runtime::gc::OBJ_FLAG_PLAIN_ORDINARY`; the runtime pins the value in
/// `proxy::tests::plain_ordinary_object_flag_matches_the_emitted_write_pic_literal`.
/// It is deliberately NOT in `WRITE_PIC_BLOCKING_FLAGS` — this bit ADMITS a
/// receiver, the blocking mask REJECTS one.
const PLAIN_ORDINARY_OBJ_FLAG: u16 = 0x200;

/// The NaN-boxed `undefined` literal, for an absent optional operand.
fn undefined_literal() -> String {
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
Expand Down Expand Up @@ -550,8 +559,17 @@ fn lower_put_value_static_write_ic(
let class_addr = ctx.block().add(I64, &safe_target, "4");
let class_ptr = ctx.block().inttoptr(I64, &class_addr);
let class_id = ctx.block().load(I32, &class_ptr);
let class_nonzero = ctx.block().icmp_ne(I32, &class_id, "0");
let has_class = ctx.block().icmp_ne(I32, &class_id, "0");
let not_native_module = ctx.block().icmp_ne(I32, &class_id, "-2");
// #8098: a class-less receiver qualifies when the runtime birth-marked it
// an ordinary plain object. `reserved` is already loaded above for the
// blocking-flag test, so this costs one `and` + `icmp` + `or`, computed
// once here and reused by all four ways (this block dominates them).
let plain_ordinary_bits = ctx
.block()
.and(I16, &reserved, &PLAIN_ORDINARY_OBJ_FLAG.to_string());
let plain_ordinary = ctx.block().icmp_ne(I16, &plain_ordinary_bits, "0");
let receiver_kind_ok = ctx.block().or(I1, &has_class, &plain_ordinary);

// The write PIC uses the same single ShapeId token domain as the read PIC.
let shape_id_addr = ctx.block().add(I64, &safe_target, "8");
Expand All @@ -574,7 +592,7 @@ fn lower_put_value_static_write_ic(
let mut hit = ctx.block().and(I1, &heap_candidate, &gc_object);
hit = ctx.block().and(I1, &hit, &not_forwarded);
hit = ctx.block().and(I1, &hit, &flags_clear);
hit = ctx.block().and(I1, &hit, &class_nonzero);
hit = ctx.block().and(I1, &hit, &receiver_kind_ok);
hit = ctx.block().and(I1, &hit, &not_native_module);
hit = ctx.block().and(I1, &hit, &token_match);
hit = ctx.block().and(I1, &hit, &token_nonzero);
Expand All @@ -598,7 +616,7 @@ fn lower_put_value_static_write_ic(
let mut hit2 = ctx.block().and(I1, &heap_candidate, &gc_object);
hit2 = ctx.block().and(I1, &hit2, &not_forwarded);
hit2 = ctx.block().and(I1, &hit2, &flags_clear);
hit2 = ctx.block().and(I1, &hit2, &class_nonzero);
hit2 = ctx.block().and(I1, &hit2, &receiver_kind_ok);
hit2 = ctx.block().and(I1, &hit2, &not_native_module);
hit2 = ctx.block().and(I1, &hit2, &token2_match);
hit2 = ctx.block().and(I1, &hit2, &token_nonzero);
Expand All @@ -618,7 +636,7 @@ fn lower_put_value_static_write_ic(
let mut hit3 = ctx.block().and(I1, &heap_candidate, &gc_object);
hit3 = ctx.block().and(I1, &hit3, &not_forwarded);
hit3 = ctx.block().and(I1, &hit3, &flags_clear);
hit3 = ctx.block().and(I1, &hit3, &class_nonzero);
hit3 = ctx.block().and(I1, &hit3, &receiver_kind_ok);
hit3 = ctx.block().and(I1, &hit3, &not_native_module);
hit3 = ctx.block().and(I1, &hit3, &token3_match);
hit3 = ctx.block().and(I1, &hit3, &token_nonzero);
Expand All @@ -638,7 +656,7 @@ fn lower_put_value_static_write_ic(
let mut hit4 = ctx.block().and(I1, &heap_candidate, &gc_object);
hit4 = ctx.block().and(I1, &hit4, &not_forwarded);
hit4 = ctx.block().and(I1, &hit4, &flags_clear);
hit4 = ctx.block().and(I1, &hit4, &class_nonzero);
hit4 = ctx.block().and(I1, &hit4, &receiver_kind_ok);
hit4 = ctx.block().and(I1, &hit4, &not_native_module);
hit4 = ctx.block().and(I1, &hit4, &token4_match);
hit4 = ctx.block().and(I1, &hit4, &token_nonzero);
Expand Down Expand Up @@ -867,8 +885,14 @@ fn lower_put_value_dyn_ic_inline(
let class_addr = ctx.block().add(I64, &t_handle, "4");
let class_ptr = ctx.block().inttoptr(I64, &class_addr);
let class_id = ctx.block().load(I32, &class_ptr);
let class_nonzero = ctx.block().icmp_ne(I32, &class_id, "0");
let has_class = ctx.block().icmp_ne(I32, &class_id, "0");
let not_native_module = ctx.block().icmp_ne(I32, &class_id, "-2");
// #8098: see the static-key PIC above.
let plain_ordinary_bits = ctx
.block()
.and(I16, &reserved, &PLAIN_ORDINARY_OBJ_FLAG.to_string());
let plain_ordinary = ctx.block().icmp_ne(I16, &plain_ordinary_bits, "0");
let receiver_kind_ok = ctx.block().or(I1, &has_class, &plain_ordinary);
let shape_id_addr = ctx.block().add(I64, &t_handle, "8");
let shape_id_ptr = ctx.block().inttoptr(I64, &shape_id_addr);
let raw_shape_id = ctx.block().load(I32, &shape_id_ptr);
Expand All @@ -885,7 +909,7 @@ fn lower_put_value_dyn_ic_inline(
let token_nonzero = ctx.block().icmp_ne(I64, &shape_token, "0");
let mut ok = ctx.block().and(I1, &gc_object, &not_forwarded);
ok = ctx.block().and(I1, &ok, &flags_clear);
ok = ctx.block().and(I1, &ok, &class_nonzero);
ok = ctx.block().and(I1, &ok, &receiver_kind_ok);
ok = ctx.block().and(I1, &ok, &not_native_module);
ok = ctx.block().and(I1, &ok, &token_match);
ok = ctx.block().and(I1, &ok, &token_nonzero);
Expand Down
18 changes: 18 additions & 0 deletions crates/perry-runtime/src/gc/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,24 @@ pub(crate) const GC_ARRAY_RAW_F64_LAYOUT: u16 = 0x80;
/// meaningful for `GC_TYPE_ARRAY`; it lets `util.types.isArgumentsObject`
/// distinguish Perry's internal `arguments` arrays from user rest arrays.
pub(crate) const GC_ARRAY_ARGUMENTS_OBJECT: u16 = 0x200;
/// #8098: this `GC_TYPE_OBJECT` allocation is an ORDINARY plain object. It has
/// no class, but it also carries none of the per-object `[[Set]]` semantics a
/// class-less receiver may otherwise have — a `URL`'s `pathname`/`search`/…
/// slots are live views whose setters rebuild `href`, `Object.prototype` is the
/// realm intrinsic, and native-module receivers dispatch. Only a runtime birth
/// site that has established the receiver is ordinary may set this; it is what
/// admits `JSON.parse` output to the object-write fast paths, whose generated
/// hit paths re-test this exact bit on every store, so a ShapeId shared with an
/// unmarked population can never carry one population's cached slot into
/// another's.
///
/// Bit 9 — only meaningful for `GC_TYPE_OBJECT`, disjoint from the array-only
/// `GC_ARRAY_ARGUMENTS_OBJECT` by `obj_type` (its sole reader goes through
/// `array::header::array_gc_header`, which refuses any header that is not
/// `GC_TYPE_ARRAY`), the same sharing bits 11 and 12 already use. The value
/// MUST match `PLAIN_ORDINARY_OBJ_FLAG` in
/// `perry-codegen/src/expr/proxy_reflect.rs`, which emits it as a literal.
pub const OBJ_FLAG_PLAIN_ORDINARY: u16 = 0x200;
/// #6011: every element slot in `[0, length)` holds either canonical raw-f64
/// number bits or `TAG_HOLE` — the hole-tolerant sibling of
/// `GC_ARRAY_RAW_F64_LAYOUT`. Set when `new Array(n)` hole-initializes a
Expand Down
22 changes: 16 additions & 6 deletions crates/perry-runtime/src/json/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,10 @@ impl<'a> DirectParser<'a> {
shape.field_count,
shape.keys_array,
);
// #8098: parsed records are ordinary plain objects — no class, but an
// authoritative ShapeId and no per-object [[Set]] semantics — so mark
// them eligible for the object-write fast paths.
crate::object::mark_object_plain_ordinary(js_obj);
// Initialize all fields to undefined so JSON with missing
// fields returns `undefined` for absent properties (matches
// spec: access to absent own property returns undefined).
Expand Down Expand Up @@ -650,12 +654,16 @@ impl<'a> DirectParser<'a> {
let keys: [*const StringHeader; 0] = [];
let keys_arr = self.parse_shape_keys_array_hot(&keys);
let js_obj = crate::object::js_object_alloc_class_inline_keys(0, 0, 0, keys_arr);
let fields_ptr =
(js_obj as *mut u8).add(std::mem::size_of::<crate::ObjectHeader>()) as *mut JSValue;
for i in 0..8 {
// GC_STORE_AUDIT(INIT): empty JSON object fields are initialized before parse publication.
std::ptr::write(fields_ptr.add(i), JSValue::undefined());
}
// #8098: see `parse_object_shaped`.
crate::object::mark_object_plain_ordinary(js_obj);
// NOTE: no hand-rolled slot fill here. The allocator has written
// `undefined` into every slot it allocated since #4717. The fill
// this replaces was a leftover from when that was the caller's job,
// and it wrote EIGHT slots — `js_object_alloc_class_inline_keys(0,
// 0, 0, …)` allocates `max(0, INLINE_SLOT_FLOOR)` = 2 of them (the
// floor dropped 4 -> 2 in #7928), so `JSON.parse("{}")` overwrote 48
// bytes past the object: the exact "heap buffer overflow into
// adjacent arena objects" `js_object_alloc_with_parent` warns about.
parse_root_restore(saved_roots);
return JSValue::object_ptr(js_obj as *mut u8);
}
Expand Down Expand Up @@ -726,6 +734,8 @@ impl<'a> DirectParser<'a> {
self.parse_shape_keys_array_hot(&inline_keys[..inline_len])
};
let js_obj = crate::object::js_object_alloc_class_inline_keys(0, 0, field_count, keys_arr);
// #8098: see `parse_object_shaped`.
crate::object::mark_object_plain_ordinary(js_obj);
let alloc_field_count =
std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR);
let fields_ptr =
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-runtime/src/json_tape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,10 @@ unsafe fn materialize_object(
) -> JSValue {
let field_count = count_object_fields(source, *idx, end_idx);
let obj = crate::object::js_object_alloc(0, 0);
// #8098: a lazily materialized tape record is `JSON.parse` output too — the
// >1 KB top-level-array payloads (HTTP bodies, ORM result sets) that the
// eager `DirectParser` never sees all arrive through here.
crate::object::mark_object_plain_ordinary(obj);
let obj_handle = scope.root_raw_mut_ptr(obj);
json_tape_safepoint(JsonTapeSafepoint::MaterializeObjectRooted, obj as usize);
let obj = obj_handle.get_raw_mut_ptr::<crate::object::ObjectHeader>();
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/json_tape/iterative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ unsafe fn finish_frame(frame: BuildFrame) -> Option<JSValue> {
}
let field_count = u32::try_from(keys.len()).ok()?;
let object = crate::object::js_object_alloc(0, 0);
// #8098: a tape-materialized record is `JSON.parse` output too.
crate::object::mark_object_plain_ordinary(object);
crate::object::reserve_object_spill(object as usize, field_count);
for (key, value) in keys.into_iter().zip(values) {
crate::object::js_object_set_field_by_name(
Expand Down
26 changes: 26 additions & 0 deletions crates/perry-runtime/src/object/alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ pub extern "C" fn js_object_alloc_null_proto(class_id: u32, field_count: u32) ->
ptr
}

/// #8098: mark `obj` as an ORDINARY plain object — class-less, but with no
/// per-object `[[Set]]` semantics of its own, so the object-write fast paths
/// may treat it exactly like a class instance.
///
/// The mark is deliberately OPT-IN and set at BIRTH. `class_id == 0` is not a
/// sufficient condition: a `URL` instance, `Object.prototype`, a module
/// namespace, and a native-module receiver are all class-less, and the write
/// guards used to exclude the whole class-less population wholesale rather than
/// reason about them (`proxy/put_value.rs`, and the same three exclusions in
/// `field_set_by_name/fast_paths.rs::try_existing_own_data_overwrite`). Only a
/// birth site that has established its receiver is ordinary calls this; every
/// other class-less receiver keeps taking the full `[[Set]]` walk.
///
/// The bit lives in `GcHeader::_reserved`, which survives evacuation
/// (`gc/copying.rs` and `gc/oldgen.rs` carry the word across), is preserved by
/// the survival-age (`0x0038`) and layout-state (`0xC000`) updates, and is
/// already loaded by the generated write PIC for its blocking-flag test.
#[inline]
pub(crate) unsafe fn mark_object_plain_ordinary(obj: *mut ObjectHeader) {
if obj.is_null() {
return;
}
let gc = (obj as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader;
(*gc)._reserved |= crate::gc::OBJ_FLAG_PLAIN_ORDINARY;
}

/// `Object(value)` plain-call coercion (#3149, ECMAScript §20.1.1.1 / ToObject).
///
/// Takes and returns a NaN-boxed JSValue (`f64`):
Expand Down
Loading
Loading