Commit Graph
73 Commits
Author SHA1 Message Date
Chris Tate 0b483e8650 Map UTF-16 indexes of long non-ASCII strings in constant time and quick-check normalize
- Index walks (slice, substring, substr, startsWith/endsWith with a
  position, lastIndexOf results) on a non-ASCII receiver of 64 KiB or
  more: once they would have paid for one pass over it, the receiver's
  index entry keeps every unit's byte offset (a 32-bit base per 64 units
  and one byte per unit), and indexes map with two loads. Before that, a
  walk within 64 units of the hot cursor starts from it, without the index
  search. The index entry stays 128 bytes on 64-bit targets.
- lastIndexOf on non-ASCII receivers tests the needle's first byte before
  comparing, as the ASCII path does.
- normalize: a quick check (UAX #15) returns the receiver when every code
  point is inert for the form: unchanged when normalized alone, combining
  class 0, and, in the ranges checked, never the second element of a
  composition. Other text normalizes segment by segment, each starting at
  a starter, so runs of inert code points copy as they are and a lone
  precomposed letter's result is kept per form. The per-code-point facts
  come from unicode_normalize, 256 code points at a time.
2026-10-25 21:04:29 -05:00
Chris Tate 15a183026f Allocate the shared two-byte characters from the system allocator
The one-character strings of U+0080..U+07FF that string iteration shares
between threads were allocated from the calling thread's memory. On Linux a
worker that exits without sharing anything returns that memory, so the table
kept strings that a later read found discarded. They now come from the system
allocator, as other state that outlives a thread does.
2026-10-25 13:27:58 -05:00
Chris Tate a88fdacbe2 Iterate strings over their bytes
for-of, spread, Array.from, yield* and [Symbol.iterator]() over a string
walked a UTF-16 index. Each step mapped the index to a byte offset, built
the character, and measured its UTF-16 length to advance.

They now walk a byte cursor over the WTF-8 data:
- The cpAtByte intrinsic reads the character that starts at the cursor.
  ASCII bytes take the one-character table inline; other characters call
  scr_str_cp_at_byte. byteLength advances the cursor by the character's
  byte count. A lone surrogate is its own three-byte character, so it
  stays a one-unit character, as in Node.
- Spread and Array.from of a string call scr_str_code_points, which
  counts the characters, allocates the array once and fills it.
- The one-character strings of U+0080..U+07FF are made immortal on first
  use and shared, as the ASCII ones are static.

The cpAt intrinsic, which no lowering emits any more, is removed.
2026-10-25 13:00:18 -05:00
Chris Tate 63a99cc4d9 Convert a loop's string receiver to UTF-16 at once only when the loop can read enough of it
A versioned loop over a non-ASCII string passes the range of indexes it
reads: a counted loop's start and limit, or a while loop's counter and
bound. The runtime converts the string at once when that range has at
least one unit per 16 bytes of it, and otherwise counts the loop as one
indexed read, so a loop over a line of a long source text no longer
keeps a UTF-16 copy of all of it. The publish call is weighted as
unlikely, since loops re-entered over one string skip it.
2026-10-25 07:37:50 -05:00
Chris Tate cac5a19fc0 Fill Buffers with multi-byte values, swap byte order and decode utf16le a word at a time
- fill with a string or Buffer value longer than one byte: writes the
  value once, then doubles the filled prefix with memcpy, as Node does.
  The value is copied with memmove, so a value that is a view of the
  buffer being filled gives Node's result; the previous per-byte loop
  read such a view while overwriting it. The loop also computed a
  division per byte.
- swap16, swap32 and swap64: one load, byte swap and store per group,
  through a local data pointer.
- toString("utf16le" | "ucs2"): four units below 0x80 copy as four bytes,
  and other units outside the surrogate range are written inline (a
  three-byte sequence as one four-byte store). Surrogates keep the
  previous path. The result records its UTF-16 length (one unit per
  input unit), or is marked ASCII, so `.length` does not walk it.
2026-10-25 05:58:42 -05:00
Chris Tate add5f4869b Convert a loop's string receiver to UTF-16 at once, and publish it only when the scan holds another string 2026-10-24 22:53:15 -05:00
Chris Tate 43ff8a1122 Inline destructured literal results and read ASCII slice ranges inline
- A function whose whole body is `return [a, b]` or `return { a, b }`,
  with number, boolean or string fields, is now inlined where its result
  is only destructured (`const [head, tail] = split(text, at)`). The
  parameters are declared from the arguments, then each field straight
  into its destructured binding, in the literal's order; an unread field
  still evaluates into a binding of its own. No record is built, and a
  slice field read only through its length or char codes becomes a
  scalar slice that copies nothing. Arguments may own their value, but
  as for numeric records nothing inside an argument may hold a
  temporary of its own.
- A scalar slice of a proven-ASCII string computes its range inline:
  the bounds clamp over the byte length exactly as scr_str_slice_range
  does, and no boundary can split a surrogate pair. Other strings keep
  the runtime call, which gains the same ASCII fast path.
- value-records' tuples section, `[head, tail] = split(...)` read only
  through lengths, goes from two heap slices per step to none.
2026-10-24 17:35:25 -05:00
Malte UblandClaude Opus 5.5 7b8a07e0f2 Search proven-ASCII strings backwards by byte in lastIndexOf
lastIndexOf built the receiver's UTF-16 index map and converted positions
both ways even when the receiver is proven ASCII, where byte offsets are
UTF-16 indexes (indexOf already has this fast path). A short id such as
"bk_00042".lastIndexOf("_") cost about 200 instructions; the Hono
fixture's list endpoint does it 80 times per request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:04:00 -07: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 dfdc610c59 Replace every match of a block in one-character replace chains
Chains of global one-character replacements (the HTML-escape idiom) run
as one scr_str_replace_chars call. It searched for the next match eight
bytes at a time and started the search over after each replacement.

It now reads 16-byte blocks with SSE2 or NEON (8-byte words elsewhere on
little-endian hosts) and replaces every match of a block before reading
the next one. The table of characters and replacements is kept for the
next call with the same immortal operands, which are the literals the
compiler passes, instead of being rebuilt from the replacement text on
every call.
2026-10-21 17:36:58 -05:00
Chris Tate ea4d570e59 Speed up split, indexOf, slice and Number() on ASCII fields
Number() of a string with an optional sign and 1 to 15 digits returns
the integer directly; every other string keeps the full StringToNumber
grammar, out of line.

split() with a separator collects its first batch of pieces before
creating the result, so the result is an inline array of exactly that
size, filled in place. A split's default limit skips the uint32
conversion, and pieces of a proven-ASCII string are created ASCII,
including the scratch pieces of split loops.

indexOf() from the start of a proven-ASCII string searches bytes
directly, and slice() of one skips the UTF-16 index map. The one-byte
case of the byte search reaches memchr without a stack frame.
2026-10-21 16:12:52 -05:00
Chris Tate fdc6c4ec5d Convert string index arguments without libm calls
The runtime's ToIntegerOrInfinity for string index arguments (slice,
substring, charAt, indexOf, startsWith, padStart, repeat and the others
in scr_string.c) converts through int64 instead of calling trunc, which
is a libm call on baseline x86-64. Each slice made two such calls.
2026-10-21 09:11:25 -05:00
Chris Tate 009e73e873 Cache zone offsets and UTC date fields, and return small integers' text without allocating (#1475) 2026-10-21 00:13:25 -05:00
Chris Tate 87e51e6c71 Read non-ASCII scan receivers inline in worker executables and keep units for longer texts
Indexed reads of a non-ASCII string (charCodeAt, length) become loads
once the runtime holds its UTF-16 units, but emitted code read the
published scan receiver only where runtime state is ordinary global
storage, so worker executables called into the runtime for every read.
On ELF targets their thread-locals live in the executable's static TLS
block, so the receiver is now declared thread-local there and read with
one thread-pointer-relative load. The unit array was also limited to
receivers of 2^20 units; source texts of several megabytes, which
scanners read in full, now get one too.

Adds the unicode-lexer runtime benchmark workload: a lexer over a
2.7M-unit module whose comments and strings are in several languages.
2026-10-20 14:11:26 -05:00
Chris Tate ba8849a269 Speed up native string character tests, optional-string switches, fromCharCode and long-string length
`s.charAt(i) === "c"`, and `s[i] === "c"` where the index is proven in
bounds, built a one-character string to compare with the literal. When
the literal is one UTF-16 code unit, the comparison now reads
`s.charCodeAt(i)` and compares the number. charCodeAt converts the index
the same way charAt does, and an index out of range answers NaN, which
equals no unit, as "" equals no one-unit literal. A switch on
`s.charAt(i)` whose case tests are all one-unit literals switches on the
code unit the same way.

A switch on a `string | undefined` value with string literal cases, such
as an array element or an optional field, lowered to a chain of union
equalities. It now tests for undefined, which matches no case, and
dispatches the string through the string switch: by byte length, then by
the literal's leading bytes.

`String.fromCharCode(a, b, ...)` with up to sixteen arguments passes the
codes to the runtime in a stack array instead of a heap f64[], and up to
64 code units encode into a stack buffer instead of a heap scratch block.

`.length` of a string that is not ASCII counts its UTF-16 units. The
string index keeps four entries, so a program that read the lengths of
more long texts than that in turn (the text of every source file) walked
each whole string on every read. Lengths of non-ASCII strings longer than
64 bytes now go into a 1024-slot table keyed by address, allocated by
the first length it records. A slot is dropped when the string is freed
or reallocated and when an append changes it in place, which is where
the index drops its facts.
2026-10-20 01:11:36 -05:00
Chris Tate 097b6b4c86 Cache zone offsets and UTC date fields, and return small integers' text without allocating
Local-time getters converted every new Date with a localtime call. The
runtime now keeps the zone's offset over an interval of instants: when
the offset and DST flag at an instant equal those 19 days later, every
instant between has them too (zones do not change offset twice within
19 days; V8's date cache relies on the same span), so consecutive Dates
convert without a localtime call each. The interval grows as nearby
instants confirm the same offset and restarts on a change of
process.env.TZ.

UTC getters keep the thread's last decomposition, like the local ones,
so reading several fields of one Date decomposes it once.

Converting an integer from 0 to 99 to a string returns an immortal
static string instead of formatting and allocating one.
2026-10-19 23:50:28 -05:00
Chris Tate 53af3dc930 Compare strings in UTF-16 code-unit order
Relational string operators (`<`, `<=`, `>`, `>=`) compared the UTF-8
bytes, which is code-point order. Node compares UTF-16 code units, so the
results differed for characters above U+FFFF against U+E000..U+FFFF and
for lone surrogates against surrogate pairs. The default `sort()`
comparator, `util.inspect` with `sorted: true` and dynamic comparisons
already used UTF-16 order; the default comparator of a dynamic array's
`sort()` did not.

Typed comparisons and the dynamic array sort now use `scr_str_cmp_u16`,
and the IR's `strCmp` node no longer has a `utf16` flag. The two orders
agree unless both of the first differing bytes are at least 0xED, so:

- the inline wrapper decides differing first words, or last words of
  equal lengths up to 16 bytes, in byte order when neither word has a
  byte of at least 0xED (a SWAR test). Other differing words, and the
  first bytes of shorter strings, decide by their first differing byte:
  in byte order when one of the two is below 0xED, and by plane when both
  are lead bytes from 0xEE to 0xF4 (a supplementary character orders
  below U+E000..U+FFFF);
- otherwise a proven-ASCII string on either side compares with `memcmp`
  (`scr_str_cmp`), and other pairs call `scr_str_cmp_u16_bytes`. It finds
  the first differing byte with words up to byte 32, then with 32-byte
  SSE2 or NEON blocks where the target has them. The call is marked as
  only reading its arguments, so repeated comparisons of one pair merge.
- `scr_str_cmp_u16` itself takes the same `memcmp` path for proven-ASCII
  strings.

The native compiler sorts record fields with `<` and checks the order
with `sort()`, so it reported SC9001 for 5888-inspect-sorted.ts, whose
object has the keys "\u{1F600}" and "\uffff". 5888 runs in the
native-frontend self-host check. Adds corpus 6023.
2026-10-19 18:10:54 -05:00
Malte UblandClaude Opus 5.5 06b0bb2931 Write borrowed string slices into stack buffers
A slice read only by lending consumers (string equality and ordering,
length/charCodeAt/indexOf/includes/startsWith/endsWith, Number/unary
plus/parseInt/parseFloat, typed string-keyed Map/Set get/has/set/add/
delete keys, string switch discriminants, and string parameters their
callee only lends) is written by scr_str_slice_scratch into a per-site
stack buffer as an immortal ASCII ScrStr marked as scratch (cap < len),
instead of a heap allocation. Stable locals initialized by such a slice
qualify when every read lends. Map/Set insertion stores a heap copy of a
scratch key, and probes cache the key hash in the scratch header, so the
counting idiom allocates only for new keys. Longer, non-ASCII, and
interned results keep the owned slice; coroutines and debug builds are
unchanged. Slice bounds are normalized without a libm trunc call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:13:11 -07:00
Chris Tate f19b2cffe1 Make regex exec results, parseInt of captures, and UTF-8 file reads cheaper (#1128) 2026-10-16 08:11:29 -05:00
Chris Tate ee001858b0 Concatenate a string and a number without the general parts path
`"p" + n` and other string-plus-number concatenations whose left string
cannot grow in place (a literal or a shared string) went through the
general mixed concatenation, which sizes and copies every part through a
loop over a scratch table. The result is now the head's bytes followed by
the number's text in one exactly sized string. A number's text completes
no surrogate pair, so no seam needs joining. A uniquely owned head still
appends in place.
2026-10-16 07:32:17 -05:00
Malte Ubl b99e2d2558 Visit only selected captures when building regex exec results
A spans result (the compiler proved only some captures are read) used to
run the full per-capture decision for every group. Slots now start as
present undefined with a zero payload, and only the selected and lazy
capture bits are visited; full results visit every capture as before.
The hole-free prefix is measured after the fill.

String-array teardown keeps its storage pointers in registers across the
release calls (releasing a string runs no user code), and regex group
parseInt takes the short-decimal path inline (shared with
scr_parse_int_bytes as scr_parse_int_short_digits) with signed
conversions where the values are known to be far below 2^53.
2026-10-15 04:47:04 -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 416311f2c5 Append to strings in place for fields, concat chains and character codes
The runtime appends to a string in place when the left operand is
uniquely owned, but three common forms never got there:

- `obj.field += s` on a plain class or record field kept a second
  reference to the old value, so every append copied the whole string.
  The compound assignment now reads a stored string field into a mutable
  local, stores "" into the field (no code runs in between: both operands
  are already strings), runs the self-append `old = old + rhs` and stores
  the result.
- `s = s + a + b` copied per append: the self-append recognized only
  `s = s + x`. A concat chain whose leftmost operand is the target now
  takes the remaining operands as the suffix, in the same order.
- `s += String.fromCharCode(c)` built a one-character string first.
  scr_str_concat_move_char_code appends the character, in place for ASCII.

Self-appends also borrow the suffix instead of retaining it, and a
discarded compound assignment no longer retains and releases its result.
2026-10-15 06:17:38 -05:00
Chris Tate 80cc39a7dd Compare ASCII localeCompare strings eight bytes at a time
The single-pass ASCII comparison now reads both strings a word at a time
when the covered ASCII bytes are TAB..CR and 0x20..0x7E, as the generated
table has them. A word of bytes 0x20..0x7E needs no per-byte coverage
check, equal words have equal primaries, and the rest of both strings is
validated by words after the first differing primary. A non-ASCII byte
answers at once that the strings need the general path; a word with a
tab, line break or another control goes to the per-byte pass. Runs
shorter than a word are read as the eight bytes ending at their last
byte, which stay inside the string object because the header precedes
the data.

The byte-equality check moves after the primary pass on the ASCII path,
where it only runs when every primary ties, and the scan and element-list
path moves out of line, so the ASCII path no longer sets up the element
buffers' stack frame.
2026-10-15 01:59:15 -05:00
Chris Tate d544a8efe8 Cover the combining Latin letters and vulgar fractions in localeCompare
The localeCompare table now covers U+0363-U+036F, U+00BC-U+00BE and
U+2044 FRACTION SLASH, so strings containing them follow Node's collation
instead of code-point order. A combining Latin letter is its letter's
primary at the letter's secondary, with a tertiary weight between the
letter's case forms where Node places it, and the combining class it
shares with the diacritics it reorders with. A vulgar fraction is the
elements of its compatibility decomposition (1, U+2044, 4), each with the
tertiary weight Node places the fraction at among that position's forms,
so it orders after both 1⁄4 and ¹⁄4. The generator's check adds the
fractions' slash and superscript spellings and the combining letters
around each diacritic. SC6003 and the limitations page describe the new
coverage.

The generated lookup indexes one dense table for every code point below
U+0370, with the gaps between ranges marked uncovered, and checks the
ranges above it (now U+2044) after that, so a Latin lookup is one
comparison instead of up to four.
2026-10-15 01:18:54 -05:00
Chris Tate 820c4eaf77 Match Node's collation for l·, U+0130 and ligatures
The generated localeCompare table now agrees with Node on spellings that
tie at the primary and secondary levels. "l·" and "L·" collate as one
unit, equal to U+0140 and U+013F, through a contraction table. Precomposed
characters take their decompositions' elements after the case weights are
assigned, so U+0130 equals I + U+0307. Ligatures whose spelled-out letters
tie with them (ij and ij) get the case weight Node places them at among
their first letter's case forms, and ligatures with their own secondary
(Æ) keep their own case. The generator's check now also compares every
covered character's decompositions, case forms and ligature spellings in
four contexts. A per-thread class table lets the lazy primary comparison
find characters that need element lists with one lookup.
2026-10-14 23:49:11 -05:00
Chris Tate 481988156e Compare localeCompare strings without building element lists
String.prototype.localeCompare keeps its results and gets fast paths for
the common cases. Byte-equal strings compare equal at once. ASCII strings
compare their primary weights in one pass from a per-thread table and
read the secondary and tertiary weights only when every primary is equal.
Latin text whose marks carry no primary weight compares primaries
lazily, decoding only until the first difference. Other text, and every
tie at the primary level, uses the existing element lists, which no
longer zero their inline buffers or check capacity per element. The
backend lends both strings to the call instead of retaining them.
2026-10-14 21:53:39 -05:00
Chris Tate 7252e667f5 Match Node's per-architecture parseInt rounding (#993) 2026-10-14 21:19:06 -05:00
Chris Tate abd77dfcf3 Match Node's per-architecture parseInt rounding and record both answers in the string oracle
For radixes other than 2, 4, 8, 10, 16 and 32, V8 folds each 32-bit digit
chunk with `value * multiplier + part`. Node's arm64 builds fuse that
multiply-add into one rounding and its x64 builds round twice, so long
inputs such as parseInt("9007199254740993", 36) answer
1.9896986116031812e+24 on arm64 and 1.9896986116031807e+24 on x64. The
runtime followed the same split only through the C compiler's default
contraction, and the committed string oracle held one architecture's
answers: regenerating it on x64 replaced the two arm64 values, failing the
string oracle tests on macOS arm64.

scr_radix_fold now writes out each form: fma() on aarch64, and an unfused
multiply and add with contraction disabled elsewhere, so the result matches
the same architecture's Node under any contraction setting. The generator
records the architecture-dependent cases once per architecture
("parseInt.arm64" / "parseInt.x64") from a listed table and checks the
current machine's answer against Node, so the committed file is the same
whichever machine generates it. The C harness runs the cases for the
architecture it was built for.
2026-10-14 19:57:00 -05:00
Chris Tate 9621b895ce Define new runtime globals with initializers so library localization hides them
scr_cyc_types, scr_str_scan and scr_wtf8_lone were tentative definitions.
The macOS runtime build emits tentative definitions as COMMON symbols, and
Mach-O `ld -r` writes COMMON symbols back out as commons, which
-exported_symbols_list does not demote. Every runtime-localized library
archive therefore defined them next to its declared API, failing the
library-multi M1 and M7 symbol checks on macOS. Linux clang defaults to
-fno-common, so the same declarations were ordinary definitions there and
the checks passed.

Initialize the three globals explicitly, as scr_sa and
scr_stdio_write_hook already are, and add a runtime test that compiles
every runtime source with -fcommon in the plain and worker configurations
and expects no COMMON symbols, so a new tentative definition fails on
Linux too.
2026-10-14 19:48:01 -05:00
Chris Tate bd9a994060 Add the legacy url API and url.domainToASCII/domainToUnicode 2026-10-14 08:10:02 -05:00
Chris Tate d99e97cb70 Merge main into lone surrogate strings 2026-10-13 22:20:07 -05:00
Chris Tate 7f4f2a76fc Speed up regex replace, match and capture reads 2026-10-13 20:30:01 -05:00
Chris Tate 844ef14771 Keep lone surrogates in strings 2026-10-13 19:48:11 -05:00
Chris Tate 1aeb04241f Reuse regex literal sites and scan plain patterns and string replacements directly 2026-10-13 07:48:24 -05:00
Chris Tate 31c0530337 Keep integers visible to LLVM and take the ASCII fast paths of text conversion (#898) 2026-10-12 16:21:02 -05:00
Malte Ubl bcad9b3892 Inline the ASCII paths of UTF-16 and byte offset conversion
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.
2026-10-12 13:28:30 -07:00
Chris Tate 8c4bb3277c Merge main into perf/string-search-pairs 2026-10-12 13:16:39 -05:00
Chris Tate 7e6294ca0f Make scriptc compilation native-only 2026-10-12 12:25:30 -05:00
Malte Ubl 5201ab9340 Find multi-byte needles without a memchr call per first-byte hit
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.
2026-10-12 07:22:54 -07:00
Malte Ubl 3487d17348 Publish deeply frozen plain-data graphs to workers without copying
A postMessage or workerData graph whose objects and arrays are all
frozen, ordinary and hold only primitives, strings and such containers
is now shared by pointer: every node and string becomes immortal
(rc == SIZE_MAX, so retains, releases and the cycle collector skip it
and nothing frees it), strings cache their key hash first, and the
nodes leave the sender's cycle buffers. Posting an already published
root is O(1). Anything else is cloned as before; SCRIPTC_PUBLISH=0
turns publication off and SCRIPTC_PUBLISH_TRACE=1 reports each choice.

With the receiver freezing what it receives (Node's clone is not
frozen) and no identity comparison across messages, the behavior
matches Node's structured clone. Published nodes leave the RC audit's live counts, since they are never
freed, and the small-integer dyn cache becomes process-wide (filled
before main): a published graph can outlive the thread that created its
nodes, and the thread-local table died with its thread.

The new corpus program pins that
contract both ways, including a worker-published graph read after the
worker exited and a shallowly frozen graph that must still be cloned.
2026-10-10 00:57:58 -05:00
Malte Ubl 031adc005a Move the old value into string self-concatenation
`s += x` (canonically `s = s + x`) on a string binding took a retained
snapshot of `s`, evaluated the suffix, released the binding's value so the
snapshot became unique, concatenated (appending in place), and released
the snapshot: a retain and two releases around every append. tsc-ts's
typeListKey alone made 3.4M such releases (plus 2.1M on literal temps).

When the binding is an unboxed local that the suffix does not mention,
nothing but this function's own expressions can read or write it, so
reading it after the suffix gives the left side's value. The old value now
moves into scr_str_concat_move / scr_str_concat_mixed_move, which consume
their head (appending in place when it is unique), and the result takes
its place in the binding: one runtime call instead of four. Boxed
bindings, suffixes that read the local, and expression-position updates
keep the general path.

Corpus 4562 covers aliases that must keep their value, literal heads,
number suffixes, throwing suffixes, self-reading suffixes, expression
position, captured bindings, and long ASCII and non-ASCII appends.

tsc-ts self-check (median of 9, interleaved): 1.854 -> 1.804 s (-2.7%),
check 1.471 -> 1.428 s.
2026-10-09 10:10:25 -07:00
Malte Ubl af2521f688 Copy short strings inline and drop index entries of proven-ASCII strings
String construction copied every span with a libc memcpy call, and
scr_str_new scanned the copied bytes for ASCII in a second pass. Spans of
at most 16 bytes (identifiers, map keys, number text) now move as two
overlapping fixed-size words, and scr_str_new tests those same words for
ASCII, so the fact is free for short strings; longer spans keep the bulk
copy and a chunked scan that stops at the first non-ASCII byte.

A scan that proves a heap string ASCII now also drops its index-cache
entry: a proven-ASCII string never owns one, because every UTF-16 mapper
answers it by identity and re-checks the header bit after extending an
index. Releasing such a string therefore skips the cache purge, and a
large text proven ASCII once (a JSON document's length, a source file)
no longer keeps every later string release walking the cache. Immortal
strings cannot record the bit and keep their entries as before.
2026-10-09 10:10:22 -07:00
Malte Ubl 302e4da3ba Cache proven ASCII in the string header and read it inline
JavaScript string reads use UTF-16 indices over scriptc's UTF-8 storage,
so charCodeAt, length, slice, substring and indexOf looked every receiver
up in a small global index cache (scr_sidx) on each call, and a fresh
short receiver evicted an entry and rescanned its bytes. In the tsc-ts
self-check that lookup and its mapping were the largest string cost.

Bit 31 of the capacity word now records that a string is proven all
ASCII, where units are bytes. A clear bit means "not known", so the fact
only needs to be exact where it is set: scr_str_new and its users prove
it while copying, concatenations combine their operands' bits, number
text, ASCII slices and repeat inherit it, emitted literals and runtime
statics carry it in their capacity word, and an index-cache scan that
proves ASCII records it. scr_str_hash_forget clears it with the cached
key hash, so every in-place mutation drops it; an in-place append that
keeps the string ASCII sets it again. Capacity stops at 2^31 - 1 bytes,
still above the UTF-8 size of any string V8 can represent.

Proven-ASCII receivers skip the index cache in every UTF-16 mapper. Sites
for length and charCodeAt call small internal helpers (string-reads.ts)
that LLVM inlines: a header load and, for an in-range index, one byte
load; everything else defers to the runtime with unchanged semantics.
For non-ASCII receivers the cache remembers its last hit (validated by
owner and tier), charCodeAt answers sequential scans from the cached
ASCII span before any other work, and appends that keep a string ASCII
no longer walk the cache.
2026-10-09 10:10:21 -07:00
Malte Ubl 692756fd66 Format numbers straight into string concatenations
`s + n`, multi-part concatenations with number operands, and `s += a + n`
materialized each number as a temporary heap string, and a compound
suffix as a second string, before copying them into the result. A new
runtime entry, scr_str_concat_mixed, takes string and number operands
together and formats numbers directly into the result. Given a head, it
follows scr_str_concat's left-operand rule: appended in place when the
caller's borrow is its only reference, it has room, and it is not also an
operand.

The self-concatenation assignment now flattens its suffix and appends
every operand to the snapshot in one call, so tsc-ts's typeListKey loop
(`key += (i === 0 ? '' : ',') + types[i].id`) appends in place without
allocating per element. Operands still evaluate left to right; converting
a number has no effects, so formatting it at the call keeps JavaScript
order. A corpus program pins Number::toString shapes, evaluation order,
self-referencing suffixes, aliases taken mid-append and Unicode text.
2026-10-09 10:10:21 -07:00
Chris Tate 3e41bcbfd0 Make compile blockers and Node differences actionable (#786) 2026-10-09 10:30:31 -05:00
Malte Ubl e7eef59ec1 Fast paths for number<->string conversion (Number/parseFloat, toFixed, two-decimal String)
String to number: Number(str), unary +, and parseFloat now convert short
decimal spellings with Clinger's exact fast path (scr_decimal_scan in
scr_number.c): significant digits m <= 2^53 and a decimal exponent within
+-22 (plus the folded range up to 22 + 15) need one correctly rounded
multiply or divide, bit-identical to strtod. ToNumber validates and
accumulates in the same pass; everything else still goes through the copied
span and strtod. Whitespace trimming in Number(), parseInt, and parseFloat
skips the UTF-8 decode for ASCII non-whitespace.

toFixed: the exact-binary-value digit generation moves from scr_lib.c into
scr_number.c (scr_f64_to_fixed). For f <= 22, mantissa * 5^f fits a
64x64->128 multiply, so the spec's n is one shift plus the dropped-half bit
whenever it fits 64 bits. Digits render two at a time instead of through
snprintf. The 16-limb bignum remains the fallback.

Number to string (H44): a non-integral x below 2^46 that is the double
nearest to m/100 prints as m/100 directly instead of running Ryu. Below 2^46
ulp(x) < 0.01, so m/100 is the unique shortest round-tripping decimal. The
correctly rounded division (double)m / 100 == x is the proof. This covers
JSON.stringify, templates, String(), join, and console output.

Tests: Node oracles for every path. number-cases.txt gains money-like values,
the 2^46 cutoff, and one-ulp neighbors. tonumber-cases.txt gains fast-path
boundaries plus 20k seeded short decimals. The new parsefloat-cases.txt pins
scr_parse_float, and the new fixed-cases.txt/test_fixed.c pin toFixed,
including the 2^64 fast-path limit and f up to 100. Fuzz modes cover all four
paths. Existing case lines are unchanged. Corpus 3170 pins the paths
end-to-end against Node.

Linux x64 sandbox, A/B against perf/stack/06-h23-layout-noise,
--layouts=4 --runs=16 (run 3 of 3; geomean -6.0% / -6.7% / -6.3%):

| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 236.2 | 204.2 | -13.5% | -19.8% .. +2.1% | neutral | +0.0% | 488.62 | 0.42x |
| regex-logs | 176.2 | 168.8 | -4.2% | -10.1% .. +0.3% | neutral | +0.0% | 281.23 | 0.60x |
| ast-interp | 280.0 | 273.1 | -2.5% | -5.6% .. +1.4% | neutral | +2.8% | 273.05 | 1.00x |
| records-sort | 952.4 | 939.4 | -1.4% | -5.0% .. +5.6% | neutral | +0.0% | 289.87 | 3.24x |
| template-render | 78.9 | 77.3 | -2.1% | -6.2% .. +1.2% | neutral | +0.0% | 246.71 | 0.31x |
| functional-pipeline | 338.7 | 348.8 | +3.0% | -6.5% .. +24.0% | neutral | +0.1% | 339.84 | 1.03x |
| async-pipeline | 207.3 | 207.8 | +0.2% | -5.3% .. +3.8% | neutral | +0.0% | 105.57 | 1.97x |
| word-graph | 302.0 | 300.1 | -0.6% | -5.1% .. +12.9% | neutral | +3.0% | 600.48 | 0.50x |
| alloc-trees | 336.3 | 332.4 | -1.1% | -6.2% .. +2.6% | neutral | +0.1% | 144.73 | 2.30x |
| numeric-kernels | 49.7 | 49.8 | +0.1% | -3.4% .. +6.5% | neutral | +4.0% | 143.73 | 0.35x |
| validate-errors | 179.1 | 182.9 | +2.1% | -5.8% .. +9.1% | neutral | +0.0% | 727.78 | 0.25x |
| csv-numbers | 349.0 | 233.8 | -33.0% | -37.5% .. -26.5% | faster | +0.1% | 541.77 | 0.43x |
| log-summary | 171.6 | 135.7 | -20.9% | -23.3% .. -17.7% | faster | +1.8% | 313.2 | 0.43x |
| inventory-report | 411.9 | 384.7 | -6.6% | -18.1% .. +5.5% | neutral | +2.7% | 521.27 | 0.74x |

Size: +64..+136 bytes per executable. The +1.8..+4.0% rows are one 4 KiB
page crossing (two for inventory-report).
2026-10-08 15:49:53 -05:00
Malte Ubl f7ac13e87f Cache string Map key hashes in the string header
On 64-bit little-endian targets ScrStr's capacity word is split into a
32-bit cap and a 32-bit cached Map key hash (header stays 24 bytes).
Heap strings fill the hash on first lookup; every in-place mutation path
(unique concat, split scratch reuse, regrow, spare reuse) clears it.
Immortal strings are never written: compiler literals and the runtime's
one-character/empty statics carry the hash precomputed (string-key-hash.ts
mirrors scr_key_hash; both sides pin the same vectors).

scr_key_hash seeds by length multiplicatively: XORing the length into the
low bits cancelled against the last byte ("ab" and "abc" collided).

Map string lookups inline the probe into each getter and reject linear
entries by hash; set specializes its probe/overwrite by lookup kind with a
shared append path.

Tests: runtime map.test.ts (pinned vectors, statics, in-place append
invalidation), string-key-hash.test.ts, corpus 3141-map-string-key-hash
(plain + sanitized; mutation-checked against both invalidation paths).
2026-10-08 15:40:53 -05:00
Malte Ubl 70dfdbd61e Route strings, arrays and emitted records through the small-object allocator
Move the allocator into scr_alloc.c with inline fast paths in scr_runtime.h;
strings, array headers/slot buffers, and emitted acyclic records/classes
(scr_rt_calloc/scr_rt_free) now use it. Adds scr_alloc.test.{c,ts}.
2026-10-08 15:01:26 -05:00
Chris Tate a8bc0f6abf Optimize scalar string access and numeric calls
- Avoid materializing scalar string slices and preserve index metadata.
- Specialize known callbacks and numeric call boundaries.
- Lower proved integer remainders without changing number semantics.
2026-10-07 15:39:05 -05:00