A regular expression library for Zig with PCRE semantics, four engines behind one API, and a JIT for x86-64 and aarch64.
README.md

Benchmarks #

./bench/run.sh generates a deterministic ~4MB log-like corpus, builds every harness it can (zregex ReleaseFast, glibc POSIX ERE and PCRE2 via zig cc, plus Python re and Perl), runs the same benchmark set through each, and prints a merged table. Each harness emits impl \t bench \t best_ms \t count; match counts are cross-checked in the "matches" column (a single value means all implementations agree).

Snapshot (2026-09-02, Linux, corpus 4.2 MB, times in ms, best of 5, with the first-byte prefilter, lazy capture handling, the lazy DFA, the fused backtracker, and the x86-64 JIT with SSE2/AVX2 run scanning):

benchmark zregex pcre2 pcre2-jit python perl posix
literal 1.8 (jit) 4.6 0.19 1.8 1.7 6.5
ci_literal 3.0 (jit) 1986.4 0.19 16.5 2.7 8.4
date 2.3 (jit) 2.8 0.61 41.8 5.2 248.4
email 5.2 (jit) 86.2 6.38 88.9 40.0 245.4
alt 12.5 (jit) 30.1 4.92 24.4 17.5 289.1
ing_suffix 11.4 (jit) 207.6 6.96 66.2 56.4 1017.9
spanning 0.08 (jit) 0.12 0.14 1.1 1.4 10.4
groups 6.5 (jit) 131.1 5.72 152.9 42.7 297.2
lookahead 5.2 (jit) 302.6 8.98 176.6 41.9 —
backref 7.4 (jit) 134.2 17.95 127.0 182.3 —
pathological 0.01 (pike) ERROR 26.18 299.2 0.01 0.01

Between this snapshot and the previous one (2026-08-31), caseless matching became Unicode rather than ASCII — (?i)s now also matches the long s, as PCRE does — which briefly cost ci_literal about six percent until the JIT learned to compile the two-compares-plus-rare-helper shape such classes take; the row now sits within noise of the old ASCII fold, at full Unicode semantics. Match iteration also gained PCRE's empty-match retry and a step budget covering the whole iteration, whose bookkeeping is the small movement on ing_suffix, the row with fifty thousand matches. Everything else is unchanged within run-to-run noise; an interleaved A/B against the previous snapshot's commit put the total across all rows at under one percent.

The parenthesised engine is the one Regex.engine selected. zregex is fastest of the interpreted engines on nine of eleven rows (Perl edges it out on the two plain-literal searches), and beats PCRE2's JIT outright on five: email, spanning, lookahead, backref, and pathological. "pathological" is (a+)+$ against "a"*22 + "!": PCRE2's interpreter aborts with a match-limit error and Python backtracks for ~280ms. The JIT spends a bounded number of native steps on it, bails, and the Pike VM answers — the design's core guarantee, kept even with a backtracking JIT in front.

Six layers shape these numbers. The first-byte prefilter (computed at compile time from the program) skips start positions that cannot begin a match: it took the sparse-match rows from a flat ~165 ms down to single-digit ms (spanning: 165 -> 0.5). Lazy capture handling removed group 0's save instructions and replaced the Pike VM's cloned capture arrays with immutable prepend-only chains. The lazy DFA (an RE2-style memoized Pike VM over a compressed codepoint alphabet) handles the dense-candidate patterns the prefilter cannot help: it finds the match span at one table transition per codepoint, and the Pike VM extracts captures from just the tiny window where the match can start. email and groups now beat interpreted PCRE2 and Python (groups: 615 ms at baseline -> 72). A selectivity gate keeps 1-2 byte prefilters on the pure memchr+Pike path, where no automaton can compete. The backtracker fuses single-element repeats (\w+ is one instruction, one range-retry frame instead of a frame per character), evaluates single-codepoint lookarounds without recursion, and — when a pattern leads with a greedy unbounded repeat and has no backrefs — skips the whole consumed run after a failed attempt, turning the classic quadratic leading-\w+ scan linear (lookahead: 512 -> 63 ms). An ASCII fast path in the UTF-8 decoder, found while profiling the backtracker, cut every engine's inner loop by 2-3x (email: 69 -> 25 ms, groups: 73 -> 28 ms). Backref-aware memoization prunes proven-failing backtracker states, taming classic ReDoS patterns ((a+)+\1$ on 40 chars: step-budget error -> 0.5 ms correct answer) at zero cost to split-free programs. Required-literal scanning makes greedy-repeat retries jump straight to occurrences of the literal the continuation must match next (a char or single-char lookahead), and lead-run skipping applies dynamically to backref patterns whenever a failed attempt never executed a backref — together taking backref from 232 to 49 ms and lookahead from 63 to 43.

Finally the JIT compiles patterns to native code, taking over wherever few start positions get tried — literals, digits, alternations, and everything behind a backreference or lookaround. Dense scans stay on the lazy DFA, which reads each byte once where a backtracker re-reads it. Two things carry the JIT's repeat-heavy rows: consume loops scan sixteen bytes per iteration with SSE2, and greedy repeats no longer leave a retry frame when the continuation must match a literal the repeat itself never matches (retries only ever resume inside the consumed run, so \w+@ can never find an @ there). Together those took lookahead to 5.5 ms and backref to 7.5.

The JIT gets its own copy of the program, compiled with repeats fused. That sounds like a detail and was worth 5-12x: fusion had been enabled only for patterns using a backreference or lookaround, so everything else reached the JIT as split-loops — a backtrack frame per character, no repeat instruction for the vector scanner to accelerate, and a bail to the interpreter on any run longer than the 256-frame stack. With a fused program email went from 62 to 5.2 ms, groups from 75 to 6.3, and ing_suffix from 58 to 11.

Which engine runs is now decided by whether the JIT's backtracking is structurally bounded: a fused repeat leaves one frame however long the run is, so a program whose loops are all fused can only push frames in proportion to the pattern, never the input. Programs with an unfused loop — (?:ab|cd)+, and especially an ambiguous one like (\w+|\d+)+x, where the JIT explores exponentially many ways to split a run — go to the lazy DFA, which is linear by construction. That single rule routes every benchmark correctly and is what keeps pathological at 0.00 ms.

The generator has two backends, x86-64 and aarch64, sharing the runtime layout, the helpers, and every analysis that decides what to compile; only instruction selection differs. AArch64 needs two things x86-64 does not: freshly written code must be pushed out of the data cache and the stale lines invalidated in the instruction cache before it can be executed, and NEON has no equivalent of pmovmskb, so the run scanner narrows its sixteen comparison lanes to sixteen nibbles with shrn and finds the first set one with rbit+clz. The suite runs for both architectures — the aarch64 one under QEMU, in CI as well — and the two agree match for match.

AVX2 is used when the CPU and OS support it (checked with CPUID and XGETBV, since the OS must also be saving YMM state), which doubles the block and, being three-operand, drops the register copies the SSE2 form needs. On this corpus that is worth 2-8% — its tokens are short words, so a run usually ends inside the first block either way. It earns much more when runs are long: on synthetic input with 512-byte runs, 7.7 GB/s with SSE2 against 12.1 GB/s with AVX2. Finally the JIT compiles patterns to native code, taking over wherever few start positions get tried — literals, digits, alternations, and everything behind a backreference or lookaround (backref: 49 -> 16 ms, lookahead: 63 -> 15). Dense scans stay on the lazy DFA, which reads each byte once where a backtracker re-reads it. The JIT budget is what pathological shows: (a+)+$ spends a bounded number of native steps, bails, and the Pike VM answers — 0.26 ms instead of PCRE2's hard error or Python's 283 ms. Costs remain flat and predictable where backtrackers swing wildly in both directions.

Across platforms #

.github/workflows/ci.yaml runs this harness on every platform the JIT has a distinct path for, with --builtin so each measures a corpus generated from the same seed. Times in ms, best of 5, on GitHub-hosted runners — shared and noisy, so read the columns against each other rather than against the tuned numbers above:

benchmark x86-64 linux aarch64 linux aarch64 macOS x86-64 Windows
literal 2.8 3.1 4.1 8.7
ci_literal 5.1 3.2 4.2 10.3
date 4.4 2.7 2.7 12.7
email 8.4 9.2 8.6 48.6
alt 19.5 15.5 17.9 38.6
ing_suffix 18.1 17.4 16.9 82.4
spanning 0.15 0.33 0.12 0.60
groups 10.1 10.1 8.2 46.4
lookahead 8.1 8.8 8.1 69.4
backref 12.0 11.0 13.9 77.8
pathological 0.01 0.01 0.01 0.01
engine jit jit jit pike/backtrack

Match counts are identical in all four columns, across two architectures, three operating systems, and both the native-code and interpreter paths.

The Windows column above predates the Windows JIT backend, which makes it a clean measure of what native code is worth: the identical library with the interpreters doing all the work ran ing_suffix at 82 ms against 18, email at 49 against 8, and backref at 78 against 12 — 4-6x — while pathological was unchanged, the lazy DFA having always been the one answering there. Windows now JITs too, and lands with the others.

The aarch64 macOS column is the first execution of the MAP_JIT path anywhere: jit_available=true, and every row selected the JIT, so Apple's contract is being satisfied. Note that GitHub's runners execute unsigned processes; a shipping app under the hardened runtime still needs the com.apple.security.cs.allow-jit entitlement, and without it these rows would look like the Windows column rather than fail.