Skip to content

Commit 5bbe4a3

Browse files
author
Ralph Küpper
committed
perf(json): right-size tape object spill buffers
1 parent 2d6633f commit 5bbe4a3

8 files changed

Lines changed: 197 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
### perf(json): right-size tape-materialized object spill buffers (#7267)
2+
3+
JSON tape materialization previously allocated every object at the two-slot
4+
inline floor, then let the first overflowing property create a general-purpose
5+
array with 16 slots of growth headroom. Five-field records therefore carried a
6+
16-slot side allocation even though the tape already knew their final width.
7+
8+
Both the recursive lazy materializer and the iterative deep-input materializer
9+
now reserve an exact-width spill buffer before storing fields. The recursive
10+
path counts only the current object's keys by hopping across nested container
11+
links; the iterative path reuses the key count it has already collected. The
12+
primary object deliberately remains at `INLINE_SLOT_FLOOR`: sizing its inline
13+
allocation to every key was benchmarked in #7267 and regressed the named field
14+
access workload.
15+
16+
On `benchmarks/json_polyglot/bench_field_access.ts`, eight interleaved
17+
`perry-dev` runs with `PERRY_NO_AUTO_OPTIMIZE=1` reduced median time from
18+
1266.5 ms to 1138.5 ms (-10.1%) and peak RSS from 309.3 MiB to 294.3 MiB
19+
(-4.8%), with identical checksums. A direct-parser control was unchanged
20+
(934.5 ms vs 935.5 ms median), isolating the improvement to tape-backed object
21+
materialization.

crates/perry-runtime/src/array/alloc.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,34 @@ pub extern "C" fn js_array_alloc_with_length(capacity: u32) -> *mut ArrayHeader
140140
ptr
141141
}
142142

143+
/// Allocate an exact-sized holey array for runtime-owned side storage.
144+
///
145+
/// Unlike [`js_array_alloc_with_length`], this does not add
146+
/// [`MIN_ARRAY_CAPACITY`] growth headroom. Callers must know their final width;
147+
/// the spill buffer used by JSON tape materialization does, and padding every
148+
/// parsed object to 16 side slots would otherwise dominate the object itself.
149+
pub(crate) fn js_array_alloc_with_length_exact(capacity: u32) -> *mut ArrayHeader {
150+
let ptr = arena_alloc_gc(
151+
array_byte_size(capacity as usize),
152+
8,
153+
crate::gc::GC_TYPE_ARRAY,
154+
) as *mut ArrayHeader;
155+
156+
unsafe {
157+
(*ptr).length = capacity;
158+
(*ptr).capacity = capacity;
159+
let elements_ptr = (ptr as *mut u8).add(std::mem::size_of::<ArrayHeader>()) as *mut u64;
160+
for i in 0..capacity as usize {
161+
// GC_STORE_AUDIT(POINTER_FREE): TAG_HOLE is a non-pointer sentinel for fresh array slots.
162+
std::ptr::write(elements_ptr.add(i), crate::value::TAG_HOLE);
163+
}
164+
clear_array_numeric_layout(ptr);
165+
crate::gc::layout_init_pointer_free(ptr as *mut u8);
166+
}
167+
168+
ptr
169+
}
170+
143171
/// Runtime path for `Array(value)` / `new Array(value)`.
144172
///
145173
/// A single Number argument is interpreted as an array length and must be a

crates/perry-runtime/src/array/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ mod subclass_tests;
3434
#[cfg(test)]
3535
mod tests;
3636

37-
pub(crate) use self::alloc::{array_length_range_error, js_array_alloc_pointer_elements};
37+
pub(crate) use self::alloc::{
38+
array_length_range_error, js_array_alloc_pointer_elements, js_array_alloc_with_length_exact,
39+
};
3840
pub use self::alloc::{
3941
js_array_alloc, js_array_alloc_literal, js_array_alloc_with_length,
4042
js_array_alloc_with_length_longlived, js_array_constructor_single, js_array_create,

crates/perry-runtime/src/json_tape.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,9 +669,12 @@ unsafe fn materialize_object(
669669
idx: &mut usize,
670670
end_idx: usize,
671671
) -> JSValue {
672+
let field_count = count_object_fields(source, *idx, end_idx);
672673
let obj = crate::object::js_object_alloc(0, 0);
673674
let obj_handle = scope.root_raw_mut_ptr(obj);
674675
json_tape_safepoint(JsonTapeSafepoint::MaterializeObjectRooted, obj as usize);
676+
let obj = obj_handle.get_raw_mut_ptr::<crate::object::ObjectHeader>();
677+
crate::object::reserve_object_spill(obj as usize, field_count);
675678
while *idx < end_idx {
676679
let Some(key_entry) = source.entry(*idx) else {
677680
break;
@@ -699,6 +702,32 @@ unsafe fn materialize_object(
699702
JSValue::object_ptr(obj as *mut u8)
700703
}
701704

705+
/// Count only this object's keys, hopping over nested values through their
706+
/// matching-container links. The walk allocates nothing, so it is safe for a
707+
/// lazy source whose backing pointers may be refreshed through a GC handle.
708+
unsafe fn count_object_fields(source: &TapeSource<'_, '_>, mut idx: usize, end_idx: usize) -> u32 {
709+
let mut count = 0u32;
710+
while idx < end_idx {
711+
let Some(key) = source.entry(idx) else {
712+
break;
713+
};
714+
if key.kind != KIND_KEY {
715+
break;
716+
}
717+
count = count.saturating_add(1);
718+
idx += 1;
719+
let Some(value) = source.entry(idx) else {
720+
break;
721+
};
722+
if value.kind == KIND_OBJ_START || value.kind == KIND_ARR_START {
723+
idx = value.link as usize + 1;
724+
} else {
725+
idx += 1;
726+
}
727+
}
728+
count
729+
}
730+
702731
unsafe fn materialize_array(
703732
source: &TapeSource<'_, '_>,
704733
scope: &crate::gc::RuntimeHandleScope,

crates/perry-runtime/src/json_tape/iterative.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ unsafe fn finish_frame(frame: BuildFrame) -> Option<JSValue> {
4040
if keys.len() != values.len() {
4141
return None;
4242
}
43+
let field_count = u32::try_from(keys.len()).ok()?;
4344
let object = crate::object::js_object_alloc(0, 0);
45+
crate::object::reserve_object_spill(object as usize, field_count);
4446
for (key, value) in keys.into_iter().zip(values) {
4547
crate::object::js_object_set_field_by_name(
4648
object,

crates/perry-runtime/src/json_tape_tests.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,47 @@ fn tape_top_level_scalars() {
115115
assert_eq!(build_tape(b"null").unwrap().entries.len(), 1);
116116
}
117117

118+
#[test]
119+
fn recursive_materializer_reserves_exact_spill_per_object_depth() {
120+
let input = br#"{"a":1,"nested":{"n0":0,"n1":1,"n2":2,"n3":3,"n4":4},"b":2}"#;
121+
let tape = build_tape(input).expect("valid tape");
122+
let nested_key = crate::string::js_string_from_bytes(b"nested".as_ptr(), 6);
123+
124+
crate::gc::gc_suppress();
125+
let value = unsafe { materialize(&tape, input) };
126+
let object = (value.bits() & crate::value::POINTER_MASK) as *const crate::ObjectHeader;
127+
let nested = crate::object::js_object_get_field_by_name(object, nested_key);
128+
let nested = (nested.bits() & crate::value::POINTER_MASK) as *const crate::ObjectHeader;
129+
130+
unsafe {
131+
assert_eq!(
132+
(*object).field_count,
133+
crate::object::INLINE_SLOT_FLOOR as u32,
134+
"known width must not enlarge the primary object"
135+
);
136+
let spill =
137+
crate::object::test_spill_buffer_addr(object as usize) as *const crate::ArrayHeader;
138+
assert!(!spill.is_null());
139+
assert_eq!((*spill).capacity, 3, "count only outer-object keys");
140+
assert_eq!((*spill).length, 3);
141+
142+
assert_eq!(
143+
(*nested).field_count,
144+
crate::object::INLINE_SLOT_FLOOR as u32
145+
);
146+
let nested_spill =
147+
crate::object::test_spill_buffer_addr(nested as usize) as *const crate::ArrayHeader;
148+
assert!(!nested_spill.is_null());
149+
assert_eq!(
150+
(*nested_spill).capacity,
151+
5,
152+
"reserve the nested width exactly"
153+
);
154+
assert_eq!((*nested_spill).length, 5);
155+
}
156+
crate::gc::gc_unsuppress();
157+
}
158+
118159
#[test]
119160
fn iterative_materializer_preserves_nested_objects_arrays_and_duplicate_keys() {
120161
let input = br#"{"a":[1,true,"x"],"a":{"b":2}}"#;
@@ -136,6 +177,28 @@ fn iterative_materializer_preserves_nested_objects_arrays_and_duplicate_keys() {
136177
crate::json::parse_root_restore(saved_roots);
137178
}
138179

180+
#[test]
181+
fn iterative_materializer_reserves_exact_spill_without_widening_object() {
182+
let input = br#"{"f0":0,"f1":1,"f2":2,"f3":3,"f4":4}"#;
183+
let tape = build_tape(input).expect("valid tape");
184+
185+
crate::gc::gc_suppress();
186+
let value = unsafe { materialize_iterative(&tape.entries, input) }.expect("materializes");
187+
let object = (value.bits() & crate::value::POINTER_MASK) as *const crate::ObjectHeader;
188+
unsafe {
189+
assert_eq!(
190+
(*object).field_count,
191+
crate::object::INLINE_SLOT_FLOOR as u32
192+
);
193+
let spill =
194+
crate::object::test_spill_buffer_addr(object as usize) as *const crate::ArrayHeader;
195+
assert!(!spill.is_null());
196+
assert_eq!((*spill).capacity, 5);
197+
assert_eq!((*spill).length, 5);
198+
}
199+
crate::gc::gc_unsuppress();
200+
}
201+
139202
/// `TapeEntry` is 12 bytes (u32 + u8 + padding + u32). Keeping
140203
/// this compact matters for tape-size parity with parse output:
141204
/// a 1 MB JSON blob with ~20k tokens should build a ~240 KB tape,

crates/perry-runtime/src/object/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ mod regex_proto_thunks;
130130
mod spill;
131131
pub(crate) use spill::{
132132
learned_inline_field_count, learned_inline_fields_hot_addr, overflow_get, overflow_set,
133+
reserve_object_spill,
133134
};
134135
#[cfg(test)]
135136
use spill::{object_spill_enabled, spill_capable_owner, spill_get, SPILL_MAX_FIELD_INDEX};

crates/perry-runtime/src/object/spill.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,56 @@ pub(crate) fn spill_set(obj_ptr: usize, field_index: usize, vbits: u64) {
152152
}
153153
}
154154

155+
/// Reserve exact-width overflow storage for a freshly allocated object whose
156+
/// final key count is already known.
157+
///
158+
/// Dynamic objects normally discover their width one property at a time, so
159+
/// the first overflow store uses an array with general-purpose growth
160+
/// headroom. JSON tapes already encode the matching container boundary and can
161+
/// count an object's top-level keys before materializing it. Keeping the
162+
/// primary object at [`INLINE_SLOT_FLOOR`] avoids the measured regression from
163+
/// widening every object, while an exact spill avoids padding every record's
164+
/// side allocation to [`crate::array::MIN_ARRAY_CAPACITY`].
165+
pub(crate) fn reserve_object_spill(obj_ptr: usize, field_count: u32) {
166+
if !object_spill_enabled()
167+
|| field_count as usize > SPILL_MAX_FIELD_INDEX
168+
|| unsafe { !spill_capable_owner(obj_ptr) }
169+
{
170+
return;
171+
}
172+
173+
unsafe {
174+
let obj = obj_ptr as *mut ObjectHeader;
175+
let inline_capacity =
176+
std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32);
177+
if field_count <= inline_capacity {
178+
return;
179+
}
180+
181+
let scope = crate::gc::RuntimeHandleScope::new();
182+
let obj_handle = scope.root_raw_mut_ptr(obj);
183+
object_meta_ensure(obj);
184+
185+
let obj = obj_handle.get_raw_mut_ptr::<ObjectHeader>();
186+
let meta = (*obj).meta;
187+
if (*meta).spill != 0 {
188+
return;
189+
}
190+
191+
let spill = crate::array::js_array_alloc_with_length_exact(field_count);
192+
let obj = obj_handle.get_raw_mut_ptr::<ObjectHeader>();
193+
let meta = (*obj).meta;
194+
if (*meta).spill == 0 {
195+
(*meta).spill = spill as u64;
196+
crate::gc::runtime_write_barrier_slot(
197+
meta as usize,
198+
&(*meta).spill as *const _ as usize,
199+
spill as u64,
200+
);
201+
}
202+
}
203+
}
204+
155205
#[cfg(test)]
156206
pub(crate) type SpillSafepointHook = fn(usize);
157207

0 commit comments

Comments
 (0)