Something went wrong. Try again.
javascript for 🐜's, a tiny runtime with big ambitions antjs.org
Something went wrong. Try again.
9.7 kB · 184 lines
Markdown
at master
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
argumentsobject is akTypeArraywithflags.arguments_objectset (ANT_OBJECT_FLAG_ARGUMENTS) andObject.prototypeas its proto. Itslengthis 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_CALLEEandANT_ARGUMENTS_SLOT_ITERATOR(include/object.h).arguments_templateasserts the template's shape matches. The JIT readslengthfromANT_ARGUMENTS_SLOT_LENGTHand tests the flag throughANT_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_ptrexcludes arguments objects. Code that wants "an array whoselengthis the element count" must use it rather thanarray_obj_ptr.
Prototype chain #
array_proto_chain_plain(js, arr)is true whenarr's proto isArray.prototype,Array.prototypehas no elements, and the chainArray.prototype -> Object.prototype -> nullhas no exotic objects or index keys. The answer is cached per IC epoch injs->array_chain_plain_epoch.array_chain_revalidatesetsguards_absenceon both prototypes, so any property addition there bumps the epoch. Elements stored onArray.prototypedon't change its shape, so the cache cannot cover them; that is whyArray.prototype'slengthis 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, andArray.prototypelength. array_hole_reads_protodecides whether a hole read must walk the chain.
Species and fresh results #
js->array_species_protector_invalidis set whenSymbol.speciesis defined onArray. While it is clear, an array whose proto isArray.prototypecreates results with the intrinsicArray(array_constructor_from_receiver).array_species_create(js, receiver, len, &intrinsic)reports whether the result is a fresh intrinsic array. Fresh results are filled witharray_result_set(direct store); others go througharray_result_set_checked(CreateDataPropertyOrThrow).flags.may_have_holesis set only when a hole can exist.map,sliceandsplicecallarray_mark_packedon fully written results instead of leaving them holey.
Mutability and length #
- In-place fast paths are gated by one flags mask.
popandshiftusearray_mutable_dense(fast, dense length fits, not frozen, sealed, exotic or arguments);spliceuses its own mask (extensible, not frozen, sealed or exotic). A read-onlylengthis always frozen or exotic, so it fails these masks. Everything else falls to the generic paths. - A read-only
lengthis represented as frozen, or exotic plus a registry descriptor (js_array_make_length_readonly,arr_length_readonly_desc).array_length_readonlytests the flags inline and calls the coldarray_length_readonly_slowonly for exotic arrays. - Generic mutators (
array_pop_generic,array_shift_generic,array_splice_genericand the genericpush) follow spec order and throw througharray_set_index_throw,array_delete_index_throw,array_set_length_throwandarray_move_range_throw, which usejs_setprop_throw/js_setprop_index_throw(strict-mode Set). - String wrappers expose own
lengthand index keys (js_try_get_string_own_exotic); array mutators throw on them (array_string_wrapper_length). - Array-likes:
array_like_length_checkedpropagates 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_fitrounds 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 bywith/toSorted/toReversed/toSpliced(array_copy_spliced, sized exactly and filled in one pass). js->alloc_bytes.arrayscounts 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_trimruns 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_capslots and releases the old store. Copying only the length would drop elements: multi-valuepushandconcatwrite 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
pushonce the semantics were fixed. - Flags tested inline with cold helpers for read-only length: the first
version (one out-of-line predicate) made
js_setpropandpopcodegen worse; the inline mask broughtpop/shift14% below the pre-change baseline. fresh/intrinsic flag on species results instead of a per-store check inmap: the per-store check showed up as a regression.- Hoisting the fill loop in
alloc_array_with_proto_capacitylets it lower to amemset, which recovered the exact-capacity allocation regression. - Growth stays alloc-copy-release instead of
reallocfor large stores: on macOSreallocnearly 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
fitrather than to a power of two: append-after-copy showed no benefit from the extra room, and retainedwithcopies used 14% less RSS. toSplicedbuilds its result directly instead of copying and callingsplice: it no longer reads skipped elements, converts its arguments before reading any, and skips the discardedremovedarray.
Validation #
- Tests:
tests/test_array_builtin_fast_paths.cjs(sections includingspeciesresult,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/shiftmask 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
withcopies -14% RSS, 0/1-argumentarguments-6%; smallwithcopies +0.9% (about 11 instructions).
- JIT append guard on the epoch cache:
Follow-ups #
- The per-store
Array.prototypelength check in the append guard could be folded into the epoch if element writes onArray.prototypebumped it. spliceon proxies goes through the generic path; it was not specifically measured.reverse,copyWithin,fillandunshifton 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.