Commit Graph
24 Commits
Author SHA1 Message Date
Chris Tate 6a1e5103f0 Array keys and properties: non-index number keys, Object.keys, console.log (#1769) 2026-10-22 22:00:27 -05:00
Chris Tate 09c132fb2e Merge main 2026-10-22 21:55:35 -05:00
Chris Tate e0f6f094bf Keep int32 slots for long number arrays with checked integer stores (#1931) 2026-10-22 21:46:03 -05:00
Chris Tate fe406429eb Keep int32 slots for long number arrays with checked integer stores
A local number[] candidate whose stores are exact integers but not all
proven int32s (a distance array updated with dist[v] = dist[u]! + 1), and
that is only ever allocated by new Array(n).fill(v), now holds SCR_ELEM_I32
slots when n is at least 4096. A shorter fill creates doubles, which fit a
typical L1 data cache. The first store of a value that is not an int32
widens the array to doubles in place (scr_arr_widen_i32).

An innermost loop that accesses such arrays tests their kinds once on
entry (backend/llvm/int32-kind-loops.ts). Its fast copy reads and writes
them as int32 arrays; a store that would widen one leaves for a copy that
re-executes it with kind tests and finishes the loop on doubles. When the
arrays already hold doubles, a copy accesses them as double arrays. Other
accesses test the kind each time.

Only these arrays take kind tests: every other array, and every program
without one, emits the same code as before.
2026-10-22 21:37:12 -05:00
Chris Tate 1501559d4a Read and write the "undefined" property for element indexes that are undefined
An element index the checker types as a number can hold undefined at run
time (an element read from a holey array). JavaScript then names the
array's "undefined" property, but the index converted with ToNumber, so
such reads and writes used the "NaN" property: Node prints `10 0,7,0,7`
for a program that sets `values[NaN] = 10` and then writes through two
holes, the compiled program printed `7 0,7,0,7`.

The runtime's noncanonical property table, which holds the properties
named by non-index numbers, now spells one NaN payload
(scr_arr_undefined_key) "undefined". At array element reads, writes,
compound and update writes, destructuring targets and writes in value
position, an index that can be undefined is that key when it is
undefined. The property stays a typed entry of the array, so the Int32[]
contract checks cover it like any element store.

A typed array cannot hold the property: a write through an undefined
index throws the catchable SC1090 error where Node adds it.

Fixes #1640.
2026-10-22 21:34:06 -05:00
Chris Tate 7b79d73a55 Store local number arrays of proven int32s in 4-byte slots
A local number[] that only compiled element operations reach, and whose
every stored element the int32 slot analysis proves an int32 (never -0),
is now stored as SCR_ELEM_I32: 4-byte int32 slots, with state bytes once
it holds a hole or undefined, as for every array. Element reads give
consumers an exact int32, so indexing with an element skips the double
round trip, and the slots take half the memory.

The runtime's dense slot accessors convert between the int32 slot and the
double, so its double-valued entry points serve int32 arrays unchanged;
growth, copies, fill and length changes use the 4-byte width.

ir/int32-arrays.ts proposes the candidates and inlines their element-reader
helper calls; the int32 slot analysis keeps the proven ones, and
selectInt32Arrays rewrites the module for exactly those, so a program
without an int32 array emits the same LLVM IR as before.
2026-10-22 20:52:38 -05:00
Chris Tate dcd86a17da Merge main 2026-10-22 20:52:05 -05:00
Chris Tate d4a7b82dd8 Free arrays whose elements need no release without a teardown
When the last reference to an array dropped, the runtime ran the generic
teardown: scr_rc_destroy's depth bookkeeping, an indirect call to the array
destroy, and a loop over the element slots. Arrays of numbers, booleans and
compact ProgramLifetime references release nothing, and neither do arrays of
other ProgramLifetime instances, whose release does nothing.

- scr_arr_release frees an acyclic array without an ext directly when its
  elements are numbers, booleans or compact references, or its descriptor
  has SCR_ARR_OPS_RELEASE_NOTHING. Every other array keeps the full
  teardown.
- The backend sets SCR_ARR_OPS_RELEASE_NOTHING in the descriptor's flags
  word on 64-bit targets for ProgramLifetime element types that are not
  compact, plain or as the pointer arm of a nullable union, in programs
  without lifetime regions. Registration copies it into a table indexed by
  the descriptor's registry id before the id is published. Descriptors
  without the flag read 0 and keep the full teardown.

Corpus 7401 drops arrays of ProgramLifetime instances, numbers and booleans
through every array operation, with holes and far indices, next to arrays of
ordinary objects, and matches Node.
2026-10-22 19:22:55 -05:00
Chris Tate 6fe5612e48 Merge main 2026-10-22 18:05:53 -05:00
Chris Tate b4a3bace60 Read compact array slots in dense loop fast copies, and their flag only on 64-bit targets 2026-10-22 13:08:42 -05:00
Chris Tate 4d7de03054 Store arrays of ProgramLifetime instances in 4-byte slots on Linux 2026-10-22 10:46:16 -05:00
Chris Tate 01939bc072 Give arrays state bytes only once they hold a hole or undefined
An array starts without per-slot state bytes. While it has none, every
index below its length is a present value in dense storage (len <= cap,
no sparse entries) and its hole-free prefix bound `filled` equals its
length, so its slots take 8 bytes per element (1 for booleans) instead
of 9 (2). The first hole or present undefined below the length, a length
set past the elements, or a sparse entry gives it states
(scr_arr_add_states), which it keeps; SCR_ARR_STATES in `spare` records
it, and the element ops id moves up one bit.

Runtime readers take scr_arr_dense_state or scr_arr_scan_states, and
writers keep the elements contiguous from 0 or give the array states
before raising its length. A length set on an empty array with inline
slots reserves its slots and states in one allocation.

Emitted reads keep their shape: they load a state only at or past
`filled`, which an array without states never reaches. A store past
`filled` tests the flag and, without states, appends at the length; push
and pop write a state byte only when the array has states. map, fused
map and slice pipelines that keep holes, and concat's element copy
reserve the result's storage, fill it, and set its length afterwards,
so a result without holes is filled by appends and gets no states. The
typed-view cache refresh moves the flag with the storage it swaps, and
tagged-template arrays carry no state bytes.
2026-10-22 08:41:08 -05:00
Chris Tate 054cb50ea4 Array keys and properties: non-index number keys, Object.keys, console.log 2026-10-22 02:31:20 -05:00
Chris Tate 4fee347ca0 Address review feedback on array named properties 2026-10-21 17:57:00 -05:00
Chris Tate 238b08352b Keep named properties written to arrays through checked views 2026-10-21 17:00:19 -05:00
Chris Tate 1d6f26a071 Give arrays of a known final length exact dense capacity 2026-10-21 13:26:50 -05:00
Chris Tate 96f31cbc18 Shrink the array header to 32 bytes
An array's element ops are a registry id in `spare` instead of a pointer
field: descriptors get their id on their first array (emitted descriptors
are writable for it), and a program with more element types than the id
holds keeps the rest in the array's out-of-line storage.

The out-of-line storage (sparse indices, noncanonical numeric properties,
match metadata) is no longer a header field. An array that has it keeps
its pointer in the 8 bytes before its dense allocation and sets
SCR_ARR_HAS_EXT; such an array always has a dense allocation of its own
(inline slots move out, an empty array gets the prefix alone), and every
allocation, reallocation and free of the dense storage accounts for the
prefix.

Loops over elements look the ops up once, and the state lookup below the
dense capacity reads no out-of-line storage.
2026-10-21 13:04:07 -05:00
Chris Tate f58c321c07 Sort by several keys without calling a chained comparator
A comparator that chains key differences with `||`, such as
`(b.score ?? 0) - (a.score ?? 0) || a.id - b.id`, ran through the
comparator sort: TimSort called the closure once per comparison. Each
term orders one number field, ascending or descending, and a term that is
0 or NaN falls through to the next, so the chain orders elements by their
keys with the first deciding. With no key NaN or -0 that order is
consistent, and a stable sort by the keys gives the comparator sort's
permutation.

The recognizer now accepts such chains of up to eight terms over number
fields of records and class instances, and a key may read an optional
number field with a number default (`x.k ?? 0`), also as a single key. The
runtime reads and checks every key of every element before it moves a
slot, then runs stable keyed passes from the last key to the first. A NaN
or -0 key, or a chain with any other term, keeps the comparator sort.
2026-10-21 08:31:35 -05:00
Chris Tate 1d6bd1ea04 Shrink the array header to 48 bytes
ScrArr stores len and cap as 32-bit fields and no longer stores the
state pointer. The dense states share the values' allocation and run
backward from its end: the state of slot i is at
data + cap * (slot width + 1) - 1 - i. Removing slots from the front
(shift, splice(0, n)) advances data and lowers cap, which leaves every
remaining state at its address; the head room size, needed only to free
or reallocate the block, is kept in the vacated slot before data. On
64-bit targets the header goes from 64 to 48 bytes, and from 36 to 32
bytes on wasm32. Array lengths were already limited to 2^32 - 1.

The emitted code loads len and cap as i32 and zero-extends them, and
computes state addresses from data and cap. Runtime loops compute the
end of the states once per call. Local array reads load data once for
both the state and value paths. A local or strict element read whose
index is a loop's induction variable reads slots above the hole-free
prefix through the runtime: an inline state check there makes LLVM keep
-1 - i as a second induction variable, which costs a register in the
loop.

Bulk operations do not read back states they just wrote: an append to
an array whose hole-free prefix reaches its length moves the prefix bound
to the new length, a copy from below the source's prefix writes value
states without reading the source's, and releasing a string array skips
the state checks below the prefix. Copies of up to eight slots and of
short state runs use overlapping word moves instead of a call. Emitted
element access computes the state address only on the state-checked
path. Without these, programs that build many small arrays with split or
slice ran 2-4% slower than with the separate state pointer.

Integer loops over an array's length compare the counter with the i32
length field in 32 bits; the counter never exceeds 2^32 - 1 there. The
array-length stability proof accepts Map and Set reads (get, has, size)
and checked union extractions (`x!`), which run no user code, so loops
that look up maps keep the dense fast copy, which tests the hole-free
prefix once before the loop.

Small constructions with the small allocator request 6 slots, so their
blocks stay 112 bytes, now with 7 inline slots (6 after a collector
header). With 96-byte blocks of 5 slots, programs that free and reuse
many small arrays ran 15-20% slower.

RUNTIME_ABI_VERSION goes from 22 to 23.
2026-10-20 00:21:45 -05:00
Chris Tate 1b40157fed Keep pending match metadata in the array's metadata slot
A pending match result now stores its shared match source as a dynamic
handle in ScrArrExt.metadata, next to its start index, instead of in a
separate field with its own release hook. Array teardown already
releases metadata, so scr_arr_ext_free and the publish check keep their
previous code, and a static hello world links the same array code as on
main.
2026-10-18 18:25:56 -05:00
Chris Tate 526415a91c Give matchAll rows and exec results their index, input and groups
Match results record their metadata lazily. The results of one exec or
matchAll call share a source (the subject and the regex), and each
result keeps its start index. index, input and a null-prototype groups
object are built when a result is converted to a dynamic value; .index
and .input on a match slice read the source directly.

matchAll rows are allocated inline, like exec results. A drain whose
rows are read only through their elements, .length, the .groups
projection and the companion index uses matchAllCaptures or
matchAllCapturesInto, whose rows carry no metadata.
2026-10-18 14:43:40 -05:00
Malte UblandClaude Opus 5.5 4aa5d6cbf8 Give dense arrays head room so shift, splice(0, n) and unshift runs cost amortized O(1)
shift() moved every remaining element down one slot, making queue drains
quadratic (100k elements: 0.85 s; 1M: no result in 30 s). Dense arrays of
8-byte slots with their own allocation now remove front elements by
advancing data/present and lowering cap; the vacated slots stay in the
allocation as head room. A flag in `spare` marks it and its size is
implied by the layout (present == data + cap + head), so every reader,
including inline element access in emitted code, is unchanged. Growth
moves the elements back to the start of the allocation (in place only
when that leaves half the moved count free, else geometrically), a
drained array starts over at the front, unshift reuses head room, and an
array unshift already had to move once reserves head room for half its
length. scr_arr_outline drops head room before compiled storage swaps.
Booleans, inline slots, sparse arrays and static arrays keep the old
behavior.

Scaling probes: shift/BFS/splice(0,1)/deque drains and unshift runs go
from exponent ~2.0 to ~1.0 (shift 1e5 847 ms -> 1.6 ms); runtime bench
geomean 1.0045 (neutral).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:13:53 -07:00
Malte Ubl c863eaa5a7 Speed up regex exec results and parseInt of numeric captures
exec/match results now allocate their slots inline with the array header
(scr_arr_new_inline): one allocation and one free per match instead of a
header plus a separately grown dense block. The capture fill loop keeps
the storage pointers in registers instead of reloading them after every
slot store.

parseInt(digits, 10) of a short all-digit string (1-15 digits, the shape
of numeric fields and regex captures) now converts directly. The general
path no longer divides per digit to detect u64 overflow (one bound per
call; the exact bignum fallback is unchanged) and skips fmod for integral
radixes in 2..36.

The new corpus program pins match-array mutation past the captured length
(push, length, unshift, sort, splice, reverse, slice) with index/input,
and parseInt across lengths, signs, prefixes, overflow and radix forms.
2026-10-15 04:46:38 -07:00
Chris Tate 4d54f6c4d8 Split scr_runtime.h into per-module runtime headers
scr_runtime.h now includes 48 module headers (scr_core.h, scr_rc.h,
scr_string.h, scr_dyn.h, scr_http.h, ...) in dependency order and holds no
declarations. Each module header holds whole sections of the former file,
includes the headers whose names it uses, and compiles on its own.

Declarations, macros, struct layouts and inline bodies are unchanged. Five
blocks move to join their module: the class-hierarchy vtable header (into
scr_error.h, after the string core), the node:timers/promises pair (into
scr_async.h), the JSON.parse entry points and the JSON output buffer (into
scr_json.h, after the dyn declarations) and the EventTarget block (after
scr_json.h). No moved block uses or declares a name that a block it moves
past uses or declares. The string methods keep their position after the
class objects in scr_string_methods.h, because their struct ScrDyn
parameters rely on the file-scope struct ScrDyn declaration there.

The runtime ABI scan reads scr_runtime.h with its module headers expanded
in include order. A runtime test compiles every module header on its own.
scripts/runtime-header-hunks.mjs retargets a diff of the single-file
header onto the module headers.
2026-10-15 02:06:23 -05:00