Buffer writes and conversions to UTF-16LE stored each ASCII byte as two
byte stores; the compiler cannot vectorize the loop because source and
destination may alias. On little-endian targets an ASCII run of eight
bytes is now widened with two 64-bit stores.
scr_u16_to_byte_c and scr_byte_to_u16_c run on every native charCodeAt,
substring and search result. Most calls are the identity of an ASCII
string or a hit in the cached ASCII run, but every call paid the prologue
of the full walk. Those two cases are now static inline, and the walk is a
separate function. A forward access up to 4 KiB past the cached run first
extends the run word by word (with 256 bytes of lookahead) instead of
walking from the cursor and re-centring a 128-byte window, which made a
scanner reading a long ASCII stretch of a non-ASCII text walk repeatedly.
A typed-array read at an unproven index joins the element with NaN for an
invalid index. For an integer element that join was `phi double [sitofp
x, NaN]`, which hides the integer from LLVM, and once `| 0` turned the NaN
arm into 0, InstCombine moved the `sitofp` of the result into the join
(`phi double [sitofp x, 0.0]`) where nothing folds it back: a loop over
`const c = units[i] | 0` converted every element to a double and back,
and compared it as a double.
An integer element now joins as `phi i32 [x, 0]` plus a validity bit and
converts after the join (`select valid, sitofp/uitofp x, NaN`). A declared
local whose every store is a bitwise result, and that a loop compares, also
keeps an i32 view: its stores already carry the integer, and compares of
the view stay integer compares, which also leaves the integer join with
more than one user, so the conversion is not folded into it.
The previous commit said the helper and the owner cleared the same huge
pages twice. Clean per-run page counters don't show that: with and without
the helper's heap mode a Sentry type check allocates the same number of
pages (1.07M). The cost is the page size: heap ranges the helper populates
before the owner advises them, and the partial huge pages at the ends of
each extension, stay on 4 KiB pages (Sentry 1816 instead of 1930 huge
pages, VS Code 2740 instead of 3051), next to the mmap-lock waits and the
smaller top pad. Also splits the THP-mode ternary to fit the line width.
The slow path of ToUint32 (NaN, the infinities and magnitudes above 2^53)
reduced the value with `frem`, which lowers to an `fmod` call. The call is
pure, so LLVM sometimes hoisted it above the range test onto the fast path
(65,865 calls in one tsc-ts parse).
A finite double above 2^53 is its integer mantissa shifted left by
exponent - 1075 >= 1, so its low 32 bits are the shifted mantissa's (none
once the shift reaches 64), negated for a negative value. NaN and the
infinities have the maximum exponent and give 0. The slow path is now
integer arithmetic on the double's bits with no branches.
Integer arithmetic with an int32 result range materialized its number
with `sitofp i64`, which LLVM does not know to be exact, so it could not
fold the conversion back for the next integer use or through the phis of
loop-carried locals: a counter written `i = (i + 1) | 0` converted to a
double and back on every iteration.
With int32 operands and result, `+`, `-` and `*` now emit `add/sub/mul
nsw i32` and convert with `sitofp i32`; other int32-range numbers convert
from the low half of their i64, and an int32-range read of a number uses
`fptosi ... to i32`. The nsw flag stays on the 32-bit operation (a
truncated 64-bit result would lose it), so IndVars can still widen such
counters into 64-bit address arithmetic.
Where THP is in madvise mode the heap policy (#873) moves glibc's brk heap
to huge pages once it has grown 32 MiB, the same point at which the prefault
helper (#871) starts faulting in every heap extension. Together they cost
more than they save: the helper populates an extension from its start, where
the owner is already carving, so both threads clear the same 2 MiB pages and
the kernel drops one copy; slab requests queue behind the multi-megabyte
heap populates; the owner's brk and madvise wait for the mmap lock those
populates hold; and the helper's 16 MiB top pad replaces the policy's 32 MiB.
In madvise mode the helper now leaves the heap alone (no polling, no top
pad, no heap populate) and keeps faulting ahead in the allocator's slices.
Heaps on small pages (THP never or absent, or always) keep the heap mode.
The read-only bag accessors made each tracked-field read site a nested
ternary with two accessor calls, and each method probe evaluated the bag
twice. Large JavaScript programs have a branch per class and property
(and per-class key tables copy the computed fallback), so three.js grew
from 703 MB to 946 MB of IR and its in-process compile ran out of the
2 GB worker heap in CI.
Move the absent-bag handling into shared helpers instead:
%dyn.class.readPropertiesOrEmpty is rebuilt in finalize like
%dyn.class.readProperties but returns a fresh, unstored empty object for
an instance without a bag, and %dyn.class.bagHas/bagHasComputed test a
key on a possibly undefined bag. Every read site is one call again, and
the three.js IR is back to 719 MB (2.16 GB peak RSS, main 2.15 GB).
The read-only bag accessor returns the receiver itself for non-instances,
so an undefined bag can also mean an undefined receiver. Substitute the
empty bag only for instances: a read through undefined must still throw
Node's "Cannot read properties of undefined" TypeError (1544, 2162, 2449,
js-optional-class-fields).
A class instance keeps its own dynamic properties in a lazily created bag
(`%dynProperties`). Untyped reads went through the bag accessor that
creates and stores the bag on first use: selecting a method checks the bag
for an own property shadowing it, and the named and computed fallbacks,
`in`/own probes, Reflect.get, for-in and accessor reads all looked their key
up there. So a pure read stored into the object. On a published object the
frozen-object guard rejects that store with "Cannot assign to read only
property '%dynProperties'", where Node reads without error.
Read-only bag accessors now never store. The untyped
%dyn.class.readProperties is rebuilt from the settled bag dispatch without
its lazy stores, and typed read helpers return a prototype-backed bag
instead of storing it. Every read path uses the read-only accessor: key
tests treat an absent bag as having no own keys, and lookups that must see
Object.prototype read from a fresh, unstored empty object. Writes keep the
storing accessor.
Found with tsc-ts at --checkers 8 on vercel/eve: a checker thread read
`host.commonSourceDirectory()` through an untyped interface value whose
object is the published Program.
scr_constrained_memory read /sys/fs/cgroup/memory.max, the root of the cgroup mount, and answered
0 for "max". Node's process.constrainedMemory() is libuv's uv_get_constrained_memory (libuv 1.51
in Node 24.15): the cgroup named by /proc/self/cgroup, the smaller of memory.max and memory.high
(v2) or of memory.limit_in_bytes and memory.soft_limit_in_bytes (v1, falling back to the
controller's root), with "max" and v1's page-aligned LONG_MAX read as UINT64_MAX and an unreadable
file making the answer 0. The two only agreed inside a cgroup namespace; in a nested cgroup or a
systemd scope the native build reported the root's limit (17179869184 in a 16 GB sandbox where Node
answers 18446744073709552000, or 2147483648 under memory.high=2G), so programs that size
themselves by it ran out of memory.
process.availableMemory() follows uv_get_available_memory too: under a constraint no larger than the
machine's memory, the constraint minus the cgroup's current usage; otherwise MemAvailable, then
sysinfo's freeram.
The corpus program prints the exact constraint, so Node and the native build must read the same
cgroup.
scr_byte_find called memchr for the needle's first byte and memcmp at
every hit. Path searches such as indexOf('/./') or indexOf('//') hit the
first byte at every separator, so each search paid two library calls per
path segment.
Haystacks of up to 256 bytes now test eight candidate starts per step for
the first two needle bytes with a SWAR zero-byte test (no false negatives;
false positives are rejected by the byte checks) and compare the rest only
at real two-byte matches. Longer haystacks keep memchr but check the
second byte inline before memcmp.
The tsc-ts parser (tRPC, parse only) runs 1.1% fewer instructions; string
search there drops from 111M to 82M instructions.
The new corpus program covers both strategies, every match offset around
the 8-byte steps and the 256-byte cut, near misses, the 0x01 false
positive pattern, and non-ASCII needles and haystacks.
scr_map_entry_str serves every string-key Map/Set read of generated code.
For a key with a cached hash in linear or compact-bucket storage it is now
a leaf: the filter test, the probe and the identity comparison run without
saving registers. A hash match on another string object tail-calls the
byte comparison; uncached hashes and wide tables take the complete lookup.
Growth rebuilds the bucket table from unchanged entries, so the filter it
already has stays valid; only compaction drops entries, and it now
recomputes the filter in its copy loop instead of a second pass.
String-key Map.get (reference and scalar values) and Map/Set has now share
the entry lookup through an internal sc_map_entry_str helper. On 64-bit
targets it reads the key's cached hash from its capacity word and returns
null when the map's filter lacks the key's bits, so most misses cost no
call. Keys without a cached hash and possible hits take scr_map_entry_str.
Every live entry's hash sets two bits of a new 64-bit ScrMap.filter, so a
lookup whose bits are not both set is absent without loading the bucket
table or the entries. String-keyed lookups in tsc-ts are mostly misses
(Map.get 67% on Excalidraw, has 87%), and the filter rejects 84-88% of
the misses on non-empty maps. Deletions leave their bits; rebuilds,
compaction and clear recompute the filter from the live entries.
Lookups also return the entry index straight from the probe instead of
reading the bucket again. The filter sits after nlive, so the backend's
%ScrMapIx fields for entries and the direct index move by one.
Integer induction counters live in unsigned 64-bit slots, so every read
as a number was a uitofp i64 (a 6-7 instruction sequence on x86-64) and
every nested start an fptoui. The runtime's push path also round-tripped
the array length through a double for its bound check.
The values are at most 2^32 - 1: on 64-bit targets convert counters with
sitofp and loop starts with fptosi, convert array lengths and validated
indices through int64_t in scr_array.c, and test the push bound on the
integer length. 32-bit targets keep the unsigned conversions.
tsc-ts Excalidraw: uitofp i64 sequences 32.5 M -> 4.0 M, -0.66%
instructions on top of the throw-path change.