Keep regexp.zig as the JS matcher and speed up exec - #7
Conversation
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>
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>
Co-authored-by: Aneryu <cyz169@gmail.com>
There was a problem hiding this comment.
💡 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".
| WriteU16(&names_section, static_cast<uint16_t>(utf8.size())); | ||
| names_section.insert(names_section.end(), utf8.begin(), utf8.end()); |
There was a problem hiding this comment.
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 👍 / 👎.
| void* AllocateSegment(size_t n) { | ||
| void* p = std::malloc(n); | ||
| if (!p) FATAL("AccountingAllocator OOM"); | ||
| return p; |
There was a problem hiding this comment.
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 👍 / 👎.
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>
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>
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>
Engineering Signoff
anyerror, emptycatch,@ts-ignore, or test weakening was introduced.Summary
JS
RegExpcompile and exec usesrc/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
zjsgrew ~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:
regexp_adapter.zig→libs/regexp.zigclass8/not_class8loops consume the whole run in a latin1 byte loop. Extra length is onegreedy_shrinkchain, deferred onExecStateso a successful match at greedy max does not push a frame.greedy_pending. Leaving pending armed let a later*consume given-back letters as extra[a-z]+iterations (test262S15.10.2.8_A3_T32/T33, ARM64 CI).chars8opcode, in-arm peel, anda+→loop_class8_g) was tried and dropped: each grew or perturbed the always-inlined latin1 backtracker enough to lose on url-exec/url-test.execmatch arrays skip the N-capture path; header is parsed once per exectest()still does not build a match object\p{}/\P{}work again (they failed at Irregexp compile time)JSRuntimecache and reused fromstringSliceValue. Repeatedexecon the same subjectdups capture strings instead ofcreateSliceeach time. Not a sliced-string view; the 12-byteJSStringlayout is unchanged.lreExecBacktrackis untouched.Follow-up after review: intern size constants are file-private so the embedder surface grows by one method (
recentLatin1Slice). TheJSRuntimepublic decl pin is 169.LIMITATIONS.mdrecords 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 allReleaseFast 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):
/[a-z]+x/on 64as)url-teststays 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
lreExecBacktrackcurrently lose on the URL microbench. Do not retry those without a new mechanism.Rollback Notes
Revert this branch.
mainalready usesregexp.zigfor JS RegExp; this PR is exec/JS-path speedups on that engine.