Found while fixing #7264 (PR #7265). Perf, not correctness.
json_tape.rs::materialize_object allocates every object with js_object_alloc(0, 0) and then adds each property with js_object_set_field_by_name:
let obj = crate::object::js_object_alloc(0, 0);
...
crate::object::js_object_set_field_by_name(obj, key_ptr, value);
js_object_alloc_with_parent reserves max(field_count, INLINE_SLOT_FLOOR) slots — with field_count = 0 that is the floor, currently 4. Slots 4..N therefore go to overflow storage (spill.rs), and field_count stays pinned at 4. So every JSON record with 5 or more properties — the single most common API shape there is — keeps its 5th and later values out of line, paid on every read and every write, plus a per-object spill entry that every GC cycle visits, rekeys and finalizes.
The direct parser does not have this problem: both parse_object and parse_object_shaped pass the real key count to js_object_alloc_class_inline_keys, so everything lands inline.
Measurement
benchmarks/json_polyglot/bench_field_access.ts (10k records × 5 fields, 50 iterations; nested is index 4, i.e. the first overflow slot, and the workload reads it every iteration), same binary, --profile perry-dev:
default (tape) 2950–2983 ms
PERRY_JSON_TAPE=0 870–877 ms
3.4×. Some of that gap is other tape work, but the overflow lookup is on the hot read path by construction here, and #7265 puts it on the stringify path as well (correctly — the values genuinely live there).
Suggested fix
Count the object's top-level keys before allocating. The tape already carries what is needed: each KIND_OBJ_START/KIND_ARR_START entry stores its matching end index in link, so a top-level walk from idx to end_idx that counts KIND_KEY and skips nested values via link is O(top-level entries). Then js_object_alloc(0, key_count) and every field lands inline.
This is a behaviour change to the parser (allocation sizing, field_count in the header, spill-map population) rather than a correctness fix, which is why #7265 left it alone. Worth benchmarking on its own.
Note the interaction with #6712: the floor moved 8 → 4, which doubled the population of records that spill. Anything that measured tape-path JSON before 2026-07-20 was measuring a materially different allocation profile.
Found while fixing #7264 (PR #7265). Perf, not correctness.
json_tape.rs::materialize_objectallocates every object withjs_object_alloc(0, 0)and then adds each property withjs_object_set_field_by_name:js_object_alloc_with_parentreservesmax(field_count, INLINE_SLOT_FLOOR)slots — withfield_count = 0that is the floor, currently 4. Slots 4..N therefore go to overflow storage (spill.rs), andfield_countstays pinned at 4. So every JSON record with 5 or more properties — the single most common API shape there is — keeps its 5th and later values out of line, paid on every read and every write, plus a per-object spill entry that every GC cycle visits, rekeys and finalizes.The direct parser does not have this problem: both
parse_objectandparse_object_shapedpass the real key count tojs_object_alloc_class_inline_keys, so everything lands inline.Measurement
benchmarks/json_polyglot/bench_field_access.ts(10k records × 5 fields, 50 iterations;nestedis index 4, i.e. the first overflow slot, and the workload reads it every iteration), same binary,--profile perry-dev:3.4×. Some of that gap is other tape work, but the overflow lookup is on the hot read path by construction here, and #7265 puts it on the stringify path as well (correctly — the values genuinely live there).
Suggested fix
Count the object's top-level keys before allocating. The tape already carries what is needed: each
KIND_OBJ_START/KIND_ARR_STARTentry stores its matching end index inlink, so a top-level walk fromidxtoend_idxthat countsKIND_KEYand skips nested values vialinkis O(top-level entries). Thenjs_object_alloc(0, key_count)and every field lands inline.This is a behaviour change to the parser (allocation sizing,
field_countin the header, spill-map population) rather than a correctness fix, which is why #7265 left it alone. Worth benchmarking on its own.Note the interaction with #6712: the floor moved 8 → 4, which doubled the population of records that spill. Anything that measured tape-path JSON before 2026-07-20 was measuring a materially different allocation profile.