Skip positions that cannot start a regexp match - #1653
Conversation
An unanchored pattern is compiled with a `.*?` prologue, so a search over a
non-matching subject re-enters lre_exec_backtrack() at every position in the
string and walks the whole pattern before failing. On a 880 KB subject that
is millions of interpreter dispatches to conclude that a literal is absent.
Read the first mandatory element of the pattern once, before the search, and
summarise what a match must start with: nothing usable, an anchor, a single
character, or a 256-bit bitmap of the Latin-1 characters that can start one
plus a flag for "anything >= U+0100 might". The search loop then advances
through the subject in C -- memchr() for the single-character case -- and only
enters the matcher at positions that survive the filter. An anchored pattern
tries position 0 and stops.
The filter is derived from the bytecode, so it follows whatever the compiler
emitted: REOP_char/char32 give the character case, the case-insensitive
variants and REOP_range* give the bitmap (canonicalising when the range is
case-insensitive), REOP_line_start gives the anchor, and the save/register
housekeeping opcodes are stepped over. Anything else leaves the filter unset
and the old path runs unchanged. Sticky patterns are excluded, since they do
not search.
Positions inside a surrogate pair are rejected when the subject is scanned by
code point, so /\udf06/u still fails to match "𝌆".
Best of 3, 880 KB subject, x86-64 -O2:
/needle/ (no match) 2401 ms -> 91 ms
/zebra jumps/ (rare first char) 2361 ms -> 91 ms
/[0-9]{4}-[0-9]{2}/ (class start) 3208 ms -> 223 ms
/xyzzy/i (case-insensitive, no match) 1341 ms -> 146 ms
/^the quick brown fux/ (anchored) 2021 ms -> 0 ms
replace(/fox/g) 540 ms -> 283 ms
/^delta/m (filter declines) 564 ms -> 637 ms
The last line is a path the filter does not handle, so the slowdown is code
layout rather than added work.
built-ins/RegExp (1879), built-ins/String (1223) and annexB/built-ins/RegExp
(36) are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn
The prefilter may only skip input positions that truly cannot start a match, so the test targets the places where the set of possible first characters is easy to get wrong: case folding that crosses the Latin-1 boundary (the Kelvin sign and long s fold into ASCII, but only under the u flag), ranges that stop just below or reach just past U+00FF, `^` with and without the m flag, the sticky flag, astral characters and lone surrogates. It also covers the first elements that cannot be summarised at all -- assertions, lookaround, alternation, a quantified first atom -- and that a failed attempt leaves no captures behind for a later one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6eRbuuuCujKgQkrHgvMrc
The search skips positions that cannot begin a match, so the tests are
about the set of possible first characters staying a superset of the
truth. Added the shapes that set is easiest to get wrong and that the
first round did not reach:
- a NUL first character, where the 8-bit search is a memchr() and NUL is
the one byte a C string scan would stop at rather than find, over both
an 8-bit and a 16-bit buffer
- dotAll, which changes what the first element accepts
- inline modifier groups, where the first element's foldedness is not
the pattern's
- the v flag's set operations, string literals and negation
- lastIndex landing in the middle of a surrogate pair, at every index of
an astral string, with and without the u flag
- long 16-bit and astral buffers whose only candidate is the last
position
- full width digits and letters, which are neither \d nor \w
All of them pass unchanged on master, which is the point: the search is
an optimisation and must not be observable.
|
I'm going to guess the summary is AI-generated? A shining example of clarity and lucidity it is not. I had a quick look at the code but that didn't really tell me much either. Please explain in a simple and succinct manner the what, the why and the how. |
|
It makes regular expressions much faster. It was made by Claude to make quickjs fast on Northstar web browser. That's what I know now. I can give more details soon. |
|
What — the non-sticky prologue (split_goto_first +6 / any / goto -11) re-enters the backtracking interpreter at every input position; the PR derives the set of characters a match can start with from the body's first consuming opcode and runs that outer loop in C, skipping positions that can't start a match. Why — a rejected position currently costs a full interpreter entry, and failing searches over long strings are the browser's actual workload (your benchmark table). How — re_prefilter_init() walks past the zero-width bookkeeping opcodes and emits one of ANCHORED / CHAR (memchr) / BITMAP (256-bit Latin-1 map + a wide flag) / NONE (unchanged fallback). Detailed section: structured as proof obligations — (1) the fast path is entered only on a byte-exact match of the prologue, and is safe even for adversarial deserialized bytecode; (2) with an always-accepting filter the C loop is operationally identical to the bytecode loop, checked on four points (leftmost-first order from split_goto_first, the GET_CHAR step, the memset reproducing the interpreter's capture unwind, timeout polling); (3) S ⊆ A proved per filter kind, including why wide = true is necessary for _i (U+212A→k, U+017F→s), and why wide = ignore_case || last_high >= 0x100 is exact below 0x100 and sound above it; (4) re_is_code_point_start() and the surrogate-boundary argument; (5) progress and bounds — including why the opcode walk can't run off the end (REOP_match is unrecognised). |
|
Instead of firing up the whole regex engine at every single character of the string just to have it fail, the PR peeks at the first thing the pattern must match, then uses a fast memchr/bitmap scan to jump straight to the characters that could actually start a match — same results, ~25x less wasted work. |
An unanchored pattern is compiled with a
.*?prologue, so searching a non-matching subject re-enterslre_exec_backtrack()at every position and walks the whole pattern before failing. On a 880 KB subject that is millions of interpreter dispatches just to conclude a literal is absent.Read the first mandatory element of the pattern once, before the search, and summarise what a match must start with: nothing usable, an anchor, a single character, or a 256-bit bitmap of the Latin-1 characters that can start one plus a flag for anything ≥ U+0100 might. The search loop then advances through the subject in C —
memchr()for the single-character case — and only enters the matcher at positions that survive the filter. An anchored pattern tries position 0 and stops.The filter is derived from the bytecode, so it follows whatever the compiler emitted:
REOP_char/char32give the character case, the case-insensitive variants andREOP_range*give the bitmap (canonicalising when the range is case-insensitive),REOP_line_startgives the anchor, and the save/register housekeeping opcodes are stepped over. Anything else leaves the filter unset and the old path runs unchanged. Sticky patterns are excluded, since they do not search.Positions inside a surrogate pair are rejected when the subject is scanned by code point, so
/\udf06/ustill fails to match"𝌆".Benchmarks
Best of 3, 880 KB subject, x86-64
-O2:/needle/(no match)/zebra jumps/(rare first char)/[0-9]{4}-[0-9]{2}/(class start)/xyzzy/i(case-insensitive, no match)/^the quick brown fux/(anchored)replace(/fox/g)/^delta/m(filter declines)The last row is a path the filter does not handle — it leaves the old code untouched — so the slowdown is code layout rather than added work. It reproduces across runs; happy to look at it further if that matters.
Testing
built-ins/RegExpbuilt-ins/StringannexB/built-ins/RegExp🤖 Generated with Claude Code
https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn
Generated by Claude Code