Skip to content

perf(json): tape materialization allocates field_count=0, so every 5+-field parsed record spills to overflow storage #7267

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions