Skip to content

Skip positions that cannot start a regexp match - #1653

Open
andreasrosdal wants to merge 4 commits into
quickjs-ng:masterfrom
nordstjernen-web:perf-regexp-prefilter
Open

Skip positions that cannot start a regexp match#1653
andreasrosdal wants to merge 4 commits into
quickjs-ng:masterfrom
nordstjernen-web:perf-regexp-prefilter

Conversation

@andreasrosdal

Copy link
Copy Markdown
Contributor

An unanchored pattern is compiled with a .*? prologue, so searching a non-matching subject re-enters lre_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/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 "𝌆".

Benchmarks

Best of 3, 880 KB subject, x86-64 -O2:

pattern master this PR
/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 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

suite before after
built-ins/RegExp 0/1879 0/1879
built-ins/String 0/1223 0/1223
annexB/built-ins/RegExp 0/36 0/36

🤖 Generated with Claude Code

https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn


Generated by Claude Code

claude and others added 4 commits August 6, 2026 18:06
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.
@bnoordhuis

Copy link
Copy Markdown
Contributor

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.

@andreasrosdal

Copy link
Copy Markdown
Contributor Author

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.

@andreasrosdal

Copy link
Copy Markdown
Contributor Author

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).

@andreasrosdal

Copy link
Copy Markdown
Contributor Author

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.

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.

4 participants