Skip to content

Keep regexp.zig as the JS matcher and speed up exec - #7

Open
aneryu wants to merge 30 commits into
mainfrom
cursor/v8-irregexp-e733
Open

Keep regexp.zig as the JS matcher and speed up exec#7
aneryu wants to merge 30 commits into
mainfrom
cursor/v8-irregexp-e733

Conversation

@aneryu

@aneryu aneryu commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Engineering Signoff

  • No fixture-shaped parser, emitter, VM, or builtin shortcut was added.
  • No new broad anyerror, empty catch, @ts-ignore, or test weakening was introduced.
  • No public API, validation boundary, skip, or exclude changed without a failing scenario and exit criterion.
  • Object/shape/GC/IC ownership rules were preserved, or the owning design note was updated.
  • Durable validation evidence is recorded in this PR, the commit message, or the owning issue.

Summary

JS RegExp compile and exec use src/libs/regexp.zig (QuickJS-style bytecode). The V8 Irregexp C++ island is not in this tree or the production link.

This branch first wired Irregexp, then switched back: stripped zjs grew ~718 KiB for a matcher-only gain, while \p{} coverage the original engine already had would have been lost, and remaining bench-v8 time after matcher peeps is match-array construction.

What lands:

  • JS compile/exec through regexp_adapter.ziglibs/regexp.zig
  • Trusted exec reads operands without error unions; latin1 backtracker is force-inlined; 64-slot inline backtrack/undo stack
  • Greedy class8 / not_class8 loops consume the whole run in a latin1 byte loop. Extra length is one greedy_shrink chain, deferred on ExecState so a successful match at greedy max does not push a frame.
  • The first in-place shrink now parks the rest of that chain and clears greedy_pending. Leaving pending armed let a later * consume given-back letters as extra [a-z]+ iterations (test262 S15.10.2.8_A3_T32 / T33, ARM64 CI).
  • Consecutive-char fusion (new chars8 opcode, in-arm peel, and a+loop_class8_g) was tried and dropped: each grew or perturbed the always-inlined latin1 backtracker enough to lose on url-exec/url-test.
  • Group-0 exec match arrays skip the N-capture path; header is parsed once per exec
  • test() still does not build a match object
  • \p{} / \P{} work again (they failed at Irregexp compile time)
  • Short latin1 slices (length 2..32) interned in a 32-slot JSRuntime cache and reused from stringSliceValue. Repeated exec on the same subject dups capture strings instead of createSlice each time. Not a sliced-string view; the 12-byte JSString layout is unchanged. lreExecBacktrack is untouched.

Follow-up after review: intern size constants are file-private so the embedder surface grows by one method (recentLatin1Slice). The JSRuntime public decl pin is 169. LIMITATIONS.md records intern vs still-eager longer/UTF-16 copies.

Validation

  • zig build test-core --summary new -- "short latin1 slices intern" (1/1)
  • zig build test-exec --summary new -- "interned latin1 captures" (1/1)
  • zig build test-exec --summary new -- "repeated URL matches" (1/1)
  • zig build test-exec --summary new -- "nested greedy class8" (1/1)
  • zig build test --summary new -- "JavaScript RegExp adapter" (6/6)
  • zig build test-exec --summary new (504/504)
  • zig build test-embedding --summary new (18/18)
  • zig build checkpoint-gate --summary all (pre-CI-fix pin commit)
  • git diff --check
  • ./zig-out/bin/run-test262 -c test262.conf -t 4 -d test262/test/built-ins/RegExp (0/1879)
  • ./zig-out/bin/run-test262 -c test262.conf -t 4 -d test262/test 0 100000 (0/49778 errors, passed 44584)
  • zig build engine-production-gate --summary all

ReleaseFast on this x86_64 host (taskset -c 0; not published ARM). A/B vs frozen intern bin /tmp/zjs-slice-intern.

Unstripped zjs: HEAD matcher 29,850,096 · intern 29,872,384 (+22 KiB debug).

Microbench medians, ms, lower better (200k url/class/long, 400k tiny):

case old matcher Irregexp switchback HEAD matcher intern this PR
url-exec 174 137 125 125 116 116
url-test 98 55 48 47 47 46
tiny-exec 86 81 85 87 86 86
tiny-test 51 44 50 52 50 50
class-exec-g 127 121 126 126 126 126
class-replace 85 81 84 89 88 88
long-class-x (/[a-z]+x/ on 64 as) 46 32 32 32
isolated url-exec ~116 129 120 116

url-test stays at greedy-max-no-frame. Isolated 9-run url-exec after the T32/T33 pending fix is 116 vs intern 118.

Further matcher peeps that add code to the always-inlined lreExecBacktrack currently lose on the URL microbench. Do not retry those without a new mechanism.

Rollback Notes

Revert this branch. main already uses regexp.zig for JS RegExp; this PR is exec/JS-path speedups on that engine.

Open in Web Open in Cursor 

cursoragent and others added 8 commits August 24, 2026 15:22
Provide RegExpShim types, Zone allocation, compile/exec C ABI, and a
dual-width bytecode blob so the imported interpreter and bytecode
compiler build without V8, ICU, or SpiderMonkey.

Co-authored-by: Aneryu <cyz169@gmail.com>
Keep member Flags bitwise operators so ClassRanges and regexp::Flags
both compile cleanly, and cover sticky plus UTF-16 subjects.

Co-authored-by: Aneryu <cyz169@gmail.com>
Link the standalone C++ island as zjs_irregexp, wrap the C ABI in
libs/irregexp.zig, and store IRRX blobs in the existing latin1 bytecode
slot. Flag parsing and character-class helpers stay in libs/regexp.zig.

Co-authored-by: Aneryu <cyz169@gmail.com>
…Errors.

V8's parser is iterative, so the historical QuickJS stack-overflow fixtures
would otherwise become generic syntax errors or successful compiles. Enforce
a compile-time nest budget and map V8 stack/size messages to StackOverflow.

Co-authored-by: Aneryu <cyz169@gmail.com>
V8 EmitClassRanges clamps shared AST character sets to 0xFF when compiling
the Latin-1 bytecode. Reusing that tree for UC16 made /./ reject every
code unit above Latin-1, including BMP characters and supplementary pairs.

Co-authored-by: Aneryu <cyz169@gmail.com>
Covers /./ on a supplementary pair and a BMP character, plus /./u
consuming the pair as one code point.

Co-authored-by: Aneryu <cyz169@gmail.com>
@aneryu
aneryu marked this pull request as ready for review August 24, 2026 16:01

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c24db8472f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vendor/irregexp/zjs_irregexp.cpp Outdated
Comment on lines +335 to +336
WriteU16(&names_section, static_cast<uint16_t>(utf8.size()));
names_section.insert(names_section.end(), utf8.begin(), utf8.end());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve named-group lengths beyond 16 bits

When a valid capture name encodes to 65,536 or more UTF-8 bytes, this cast truncates its length while the following line still appends the complete name. The Zig metadata reader then treats the first name as shorter (zero bytes at exactly 65,536), parses the remaining name bytes as subsequent records, and omits or corrupts properties in exec(...).groups and indices.groups. Either widen the blob length field or reject overlong names before emitting a malformed names section.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Comment thread vendor/irregexp/RegExpShim.h Outdated
Comment on lines +989 to +992
void* AllocateSegment(size_t n) {
void* p = std::malloc(n);
if (!p) FATAL("AccountingAllocator OOM");
return p;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return OOM instead of aborting the process

Under memory pressure while compiling a large regular expression, zone growth reaches this allocation path and calls FATAL when malloc fails, terminating the embedding process rather than returning ZJS_IRREGEXP_OOM; the other std::vector/make_unique allocations have the same problem because the library is built with -fno-exceptions. Consequently the Zig error.OutOfMemory handling and runtime OOM boundary cannot catch native compiler exhaustion. Route shim allocations through a fallible boundary that propagates the ABI's OOM status.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

cursoragent and others added 11 commits August 24, 2026 17:25
Per-exec Isolate construction plus bytecode and subject copies dominated
tiny-match cost, which is why V8 --jitless stayed much faster than this
interpreter. Exec now reuses one Isolate per thread, runs against views of
the IRRX bytecode and caller subject, enables computed-goto dispatch, and
4-aligns bytecode so operand loads stay defined.

Co-authored-by: Aneryu <cyz169@gmail.com>
Keep the V8 C++ compiler and IRRX blob format. Exec now runs in Zig so
matches no longer pay Isolate/Handle/C-ABI costs on every call. C++ exec
stays linked for differential checks.

Co-authored-by: Aneryu <cyz169@gmail.com>
The module comment still described the C ABI exec path after the
interpreter translation.

Co-authored-by: Aneryu <cyz169@gmail.com>
The interpreter heap-allocated a 64-slot ArrayList on every match and
re-checked PC/opcode/register bounds in ReleaseFast. Use a SmallVector-style
inline stack, native operand loads, and trusted dispatch for compiler-emitted
bytecode; Debug still rejects corrupt streams. test() no longer materializes
capture slots it never reads.

Co-authored-by: Aneryu <cyz169@gmail.com>
Replace the while/switch loop with Zig's labeled `switch` so each opcode
arm `continue :dispatch`s the next opcode. That is the structured analog
of V8's computed-goto interpreter and does not use `@call(.always_tail)`.

Co-authored-by: Aneryu <cyz169@gmail.com>
`try decode(...)` wrapped every arm's continue in an error union and
blocked the jump-table lowering Zig's labeled switch is for. ReleaseFast
now continues with `@enumFromInt`; Debug still validates then panics on
corrupt bytecode.

Co-authored-by: Aneryu <cyz169@gmail.com>
Keep dispatch as a labeled switch, but address bytecode with a pointer
so jumps are base+label instead of a slice index, drop try from
jump/register helpers, and skip with a comptime opcode size. Force-inline
the latin1 interpreter into runInterp so the jump-table body is not a
per-match call.

Co-authored-by: Aneryu <cyz169@gmail.com>
bench-v8 RegExp is almost all exec(), and match-array construction
dominates the remaining gap versus test(). Build the common group-0
result without the noinline N-capture path, parse the IRRX header once
per exec, and load the next labeled-switch opcode before DISPATCH the
way V8 ADVANCE/DECODE does.

Co-authored-by: Aneryu <cyz169@gmail.com>
Fold leftover jumpTo/skip/decodeAt sequences into the DECODE helpers,
drop the duplicate copyRegisters branch, keep the group-0 match-array
constructor private, and describe eager capture strings in LIMITATIONS
instead of claiming the JS object layer is unchanged.

Co-authored-by: Aneryu <cyz169@gmail.com>
JS exec no longer goes through the C++ Isolate, so tests should not
claim isolate reuse. Call irregexp directly from the adapter, and
document that zjs_irregexp_exec is a differential check.

Co-authored-by: Aneryu <cyz169@gmail.com>
The Irregexp island added ~718 KiB stripped for a matcher-only gain
that still left match-array construction as the bench-v8 wall, while
dropping \\p{} coverage the QuickJS-style engine already had.

JS compile/exec uses libs/regexp.zig again. Trusted exec reads
operands without error unions, force-inlines the latin1 backtracker,
and keeps a 64-slot inline backtrack/undo stack. Group-0 match arrays
and one-shot header parse stay on the JS path. vendor/irregexp is
removed from the tree and the production link.

Co-authored-by: Aneryu <cyz169@gmail.com>
@cursor cursor Bot changed the title Wire V8 Irregexp into zjs compile and exec Keep regexp.zig as the JS matcher and speed up exec Aug 25, 2026
cursoragent and others added 8 commits August 25, 2026 05:17
loop_class8_g / loop_not_class8_g used to push a split frame per extra
character. A successful URL match never pops those frames. Consume the
whole run, push one greedy_shrink chain, and match latin1 class8 against
the 16-byte bitmap without promoting bytes >= 128.

Co-authored-by: Aneryu <cyz169@gmail.com>
Keep one pending shrink on ExecState so a successful match at greedy
max does not push a backtrack frame. A later loop_class8_g commits the
previous pending as a single greedy_shrink chain. Latin1 consume stays
a byte loop; bytes >= 128 are not in class.

Co-authored-by: Aneryu <cyz169@gmail.com>
Irregexp CHECK_N_CHARS avoids a dispatch per literal byte. Emit chars8
for adjacent case-sensitive latin1 .char atoms (://, hello), match with
memcmp, and keep scan_until_char8 on the first byte of a fused run.
Quantified atoms and /i stay as separate char ops.

Co-authored-by: Aneryu <cyz169@gmail.com>
A dedicated chars8 opcode grew the inlined latin1 interpreter and
slowed url-test. Stay in the existing .char/.char_i arm: after a match,
if the next opcode is the same, consume it without returning to the
switch. Latin1 .char compares the subject byte directly.

Co-authored-by: Aneryu <cyz169@gmail.com>
Peeling .char runs in the existing arm, like the chars8 opcode, grew
lreExecBacktrack enough to lose a few ms on url-test and tiny-test
without beating hello/proto. Keep the literal/quantifier adapter tests.

Co-authored-by: Aneryu <cyz169@gmail.com>
a+ / a* used the generic split quantifier, so a successful run still
pushed one frame per extra character. Reuse loop_class8_g with a
singleton bitmap for case-sensitive bytes < 128; shrink and latin1
consume stay on the existing class8 path. Bytes >= 128 and /i stay
on the generic wrap.

Co-authored-by: Aneryu <cyz169@gmail.com>
Mapping a+/a* onto loop_class8_g made long a+ runs match the class8
shrink path (77ms to 33ms) but the extra compile-unit code slowed
url-exec ~6% on the same binary. bench-v8 RegExp is the URL pattern.

Co-authored-by: Aneryu <cyz169@gmail.com>
Repeated exec on the same subject was allocating a fresh createSlice copy
per capture. Cache 32 content-addressed latin1 strings of length 2..32 on
JSRuntime and reuse them from stringSliceValue.

Co-authored-by: Aneryu <cyz169@gmail.com>
cursoragent and others added 3 commits August 25, 2026 07:34
allocation_count is live allocations, so a cache eviction that frees then
creates is a net zero. Keep an extra ref so the test can compare pointers.

Co-authored-by: Aneryu <cyz169@gmail.com>
Keep intern size constants file-private so the embedder surface grows by
one method, bump the snapshot pin 168 -> 169, and record that short latin1
captures intern while longer and UTF-16 slices still copy.

Co-authored-by: Aneryu <cyz169@gmail.com>
Leaving greedy_pending armed let a later `*` consume given-back
letters as extra `[a-z]+` iterations, so S15.10.2.8_A3_T32/T33
captured "p" instead of "netscap". URL success-at-max still pushes
nothing.

Co-authored-by: Aneryu <cyz169@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants