javascript for 🐜's, a tiny runtime with big ambitions antjs.org
ant docs exec-plans active array-arguments-runtime-invariants.md
9.7 kB · 184 lines
Markdown

Array and Arguments Runtime Invariants #

Status: active Last reviewed: 2026-10-07 Owner: theMackabu

Goal #

Make the Array.prototype builtins and the arguments object follow the spec (frozen, sealed and read-only-length arrays, generic array-likes, species, prototype holes) while keeping the dense fast paths at least as fast as before. This work changed invariants that the interpreter, the builtins and the JIT all rely on, across 6f37e4b5, 5ce71f87, ff765143, a6078a6f, e5ac3bfb, bda9e95b, 4c3c5803, e18fc303, c13ba358 and 4f15a028.

Scope #

src/ant.c (array builtins, arguments creation), src/descriptors.c, src/gc/objects.c (storage cache), include/object.h, include/isolate.h, and the JIT guards in src/jit/values.c and src/jit/emit_properties.c.

Invariants #

Arguments objects #

  • An arguments object is a kTypeArray with flags.arguments_object set (ANT_OBJECT_FLAG_ARGUMENTS) and Object.prototype as its proto. Its length is an ordinary data property, so it can be overwritten, deleted or redefined.
  • Arguments objects are cloned from a per-strictness template. Their in-object slots are fixed: ANT_ARGUMENTS_SLOT_LENGTH, ANT_ARGUMENTS_SLOT_CALLEE and ANT_ARGUMENTS_SLOT_ITERATOR (include/object.h). arguments_template asserts the template's shape matches. The JIT reads length from ANT_ARGUMENTS_SLOT_LENGTH and tests the flag through ANT_OBJECT_FLAGS_HIGH_BYTE. static_asserts pin the flag to the high byte, with only the RegExp brand above it, so the JIT's byte compare stays valid.
  • array_length_obj_ptr excludes arguments objects. Code that wants "an array whose length is the element count" must use it rather than array_obj_ptr.

Prototype chain #

  • array_proto_chain_plain(js, arr) is true when arr's proto is Array.prototype, Array.prototype has no elements, and the chain Array.prototype -> Object.prototype -> null has no exotic objects or index keys. The answer is cached per IC epoch in js->array_chain_plain_epoch. array_chain_revalidate sets guards_absence on both prototypes, so any property addition there bumps the epoch. Elements stored on Array.prototype don't change its shape, so the cache cannot cover them; that is why Array.prototype's length is checked on every call.
  • The JIT append guard checks the same conditions inline: the receiver's EXT/SEALED/FROZEN/EXOTIC flags, proto == Array.prototype, the epoch, and Array.prototype length.
  • array_hole_reads_proto decides whether a hole read must walk the chain.

Species and fresh results #

  • js->array_species_protector_invalid is set when Symbol.species is defined on Array. While it is clear, an array whose proto is Array.prototype creates results with the intrinsic Array (array_constructor_from_receiver).
  • array_species_create(js, receiver, len, &intrinsic) reports whether the result is a fresh intrinsic array. Fresh results are filled with array_result_set (direct store); others go through array_result_set_checked (CreateDataPropertyOrThrow).
  • flags.may_have_holes is set only when a hole can exist. map, slice and splice call array_mark_packed on fully written results instead of leaving them holey.

Mutability and length #

  • In-place fast paths are gated by one flags mask. pop and shift use array_mutable_dense (fast, dense length fits, not frozen, sealed, exotic or arguments); splice uses its own mask (extensible, not frozen, sealed or exotic). A read-only length is always frozen or exotic, so it fails these masks. Everything else falls to the generic paths.
  • A read-only length is represented as frozen, or exotic plus a registry descriptor (js_array_make_length_readonly, arr_length_readonly_desc). array_length_readonly tests the flags inline and calls the cold array_length_readonly_slow only for exotic arrays.
  • Generic mutators (array_pop_generic, array_shift_generic, array_splice_generic and the generic push) follow spec order and throw through array_set_index_throw, array_delete_index_throw, array_set_length_throw and array_move_range_throw, which use js_setprop_throw / js_setprop_index_throw (strict-mode Set).
  • String wrappers expose own length and index keys (js_try_get_string_own_exotic); array mutators throw on them (array_string_wrapper_length).
  • Array-likes: array_like_length_checked propagates ToLength errors, and index reads use full Get / HasProperty.

Storage cache #

  • Backing stores with a cached capacity (GC_ARRAY_STORAGE_SIZES: 2, 3, 4, 6, 8, 12, 16, 24, 32) are returned to a per-isolate free list (js->array_storage) when an array dies. The next pointer lives in slot 0 of the freed buffer.
  • One table (GC_ARRAY_STORAGE_FIT_CLASS) maps a capacity to the smallest class holding it. gc_array_storage_fit rounds with it; the class lookup only accepts a capacity equal to its class size, so a wrong entry can only round badly, never put a buffer in the wrong class.
  • Exact-size stores round with gc_array_storage_fit (the next class, under a third of the slots spare): arguments objects, listener snapshots, and the copies built by with/toSorted/toReversed/toSpliced (array_copy_spliced, sized exactly and filled in one pass).
  • js->alloc_bytes.arrays counts stores in use, kept by the storage functions rather than their callers; a release that would take it below zero is a fatal accounting bug. Cached stores are left out; only the major-GC array trigger adds them (gc_array_storage_cached_bytes, summed from the per-class counts), so a growing cache forces a major (and a trim) without skewing reclaim or heap-size estimates. The trigger's baseline (gc_array_limits_init) includes the stores the trim kept, so reusing them is not counted as growth.
  • gc_array_storage_trim runs after each major GC. It keeps as many buffers per class as were taken since the previous major GC and frees the rest, so idle retention stays bounded.
  • Growth allocates, copies all old_cap slots and releases the old store. Copying only the length would drop elements: multi-value push and concat write past the length and publish it at the end.

Decisions #

  • Epoch cache instead of re-walking the chain on every append: the walk was the dominant cost of push once the semantics were fixed.
  • Flags tested inline with cold helpers for read-only length: the first version (one out-of-line predicate) made js_setprop and pop codegen worse; the inline mask brought pop/shift 14% below the pre-change baseline.
  • fresh/intrinsic flag on species results instead of a per-store check in map: the per-store check showed up as a regression.
  • Hoisting the fill loop in alloc_array_with_proto_capacity lets it lower to a memset, which recovered the exact-capacity allocation regression.
  • Growth stays alloc-copy-release instead of realloc for large stores: on macOS realloc nearly doubled peak RSS on a 2M-element push loop (185 to 331 MB) and ran 2.5% slower.
  • Cached bytes stay out of alloc_bytes.arrays: counting them there made majors look unproductive (fewer majors, +21 MB peak RSS on a ring of 300k short-lived small arrays). A per-class cache cap was also rejected: it cost 47% on small-array churn.
  • The major array trigger's baseline counts the stores the trim kept. Without that, a kept cache above the growth allowance re-fired the trigger at every check: 80k promoted 32-slot arrays dying under steady short-lived 32-slot churn left 27 MB cached against a 16 MB limit, and the run did 33 majors instead of 1 (+2.6% cycles), each sweeping ~30k live objects with no array growth.
  • No 1-slot class: malloc gives 1 and 2 slots the same block on macOS and glibc, so it saved no memory and only split the free list.
  • Copies round with fit rather than to a power of two: append-after-copy showed no benefit from the extra room, and retained with copies used 14% less RSS.
  • toSpliced builds its result directly instead of copying and calling splice: it no longer reads skipped elements, converts its arguments before reading any, and skips the discarded removed array.

Validation #

  • Tests: tests/test_array_builtin_fast_paths.cjs (sections including speciesresult, packedresults, arraylikes, protochain), tests/test_array_readonly_length.cjs, tests/test_array_storage_reuse.cjs, tests/test_array_copy_methods.cjs, tests/test_arguments_object_semantics.cjs, tests/test_arguments_length_only.cjs. Expected values were generated with Node.
  • Measured by instruction count, no-PGO builds:
    • JIT append guard on the epoch cache: push -11%, append -14%, hole fill -19% per 64 stores.
    • storage cache: [] 22.1 -> 14.6 ns.
    • slice: 3,931 -> 1,162.
    • pop/shift mask fast paths: -14%.
    • array helpers against origin/master: 1.4-7.5x faster.
    • size classes and exact copies against master: retained arguments objects -11% RSS, retained with copies -14% RSS, 0/1-argument arguments -6%; small with copies +0.9% (about 11 instructions).

Follow-ups #

  • The per-store Array.prototype length check in the append guard could be folded into the epoch if element writes on Array.prototype bumped it.
  • splice on proxies goes through the generic path; it was not specifically measured.
  • reverse, copyWithin, fill and unshift on array-likes and frozen arrays have not been audited against Node.
  • Assigning to an index of a String wrapper has not been verified against Node.