Files
retoorandClaude Sonnet 5 e4fc067aa6 Eliminate the Pike VM's per-thread allocation, add a real BMH literal
prefilter, research and reject a lazy DFA cache

Researched two production Pike-VM-family engines directly (RE2's
nfa.cc, rust-lang/regex's regex-automata), fetched and read, not
recalled, specifically for what explains the two weaknesses the last
benchmark found (a*b's nullable loop, dense finditer): both avoid
per-thread malloc/free, RE2 via a Thread free list, regex-automata via
a flat SlotTable indexed directly by NFA state. Also researched
Hyperscan's Teddy (SIMD literal matching) and set it aside: it needs
platform-specific intrinsics, in tension with this project's
single-file, portable-C, simplicity-over-performance priority: a
portable Boyer-Moore-Horspool skip was judged proportionate where
Teddy was not.

Rewrote the Pike VM's memory model accordingly. PikeThread's owned
int64_t* is gone; a committed thread's capture row now lives at a
fixed offset in PikeList.table, indexed directly by instruction PC
(Cox's one-thread-per-PC invariant already made this index unique, so
no allocation is needed to store or discard one). Transient rows
needed mid-closure, before the eventual terminal PC is known, come
from ScratchPool, a fixed block with an explicit free list; a plain
bump/decrement counter was tried first and proven incorrect by hand
before being written into the file (an OP_SPLIT's second branch's row
can outlive several non-branching pushes that reuse an *earlier* row
without reallocating it, which only a real free list handles safely
regardless of release order). The closure stack is pre-sized once
instead of grown by realloc on demand. All three, plus a reusable
best-match buffer and the prefilter below, bundle into one PikeEngine,
built once per top level Pattern_/re_ call and reused across every
match found within it, never cached on PatternImpl itself (that would
make concurrent Pattern_search calls on the same compiled Pattern from
different threads race on shared state, breaking the existing
no-synchronization-needed guarantee for a Pattern nothing mutates).

Extended the literal prefilter from a single leading character to the
full mandatory literal prefix, with a real Boyer-Moore-Horspool
bad-character skip table for BINARY/ASCII mode (UTF8 keeps a
without-skip fallback: a byte-indexed table cannot cover code points
past 0x10FFFF). The skip-ahead only ever applies to where a new
unanchored start is injected, never to advancing sp itself while a
thread from an earlier start position is still alive.

Researched and did not build a lazy DFA state cache (memoizing a
live-instruction-set-plus-byte transition). Not an omission: RE2's own
lazy DFA cannot track submatch boundaries, the same structural reason
applies here, since every call wants at least group 0's span, and a
cached transition only answers whether a match is possible, not which
path was taken; using one would need a two-phase architecture deserving
its own research-and-plan pass. Checked empirically too: a*b, the case
such a cache would help most, has at most two live instructions at any
position for its whole run, so there is no repeated state worth
caching in the first place.

Verified: full 3,252-case suite (four clean runs), a whitebox
dual-engine cross-check extended to also cover finditer/split/BINARY
mode (30,000 + 3,334 + 2,500 comparisons, zero mismatches), a targeted
suite for the new prefilter machinery including the classic
Boyer-Moore-Horspool overlapping-suffix correctness trap (12/12), two
clean AddressSanitizer/UndefinedBehaviorSanitizer passes on each (a
third run of each hit the same pre-existing sandbox flake already
documented, confirmed unrelated by retrying clean).

Measured: literal search went from 0.64x of the backtracking engine's
time (already ahead) to 0.03x (~33x faster), and against POSIX
<regex.h> from roughly 11x slower to roughly 2x *faster* than glibc
outright; a*b (no prefilter benefit at all) improved from 3.45x slower
than backtracking to 1.97x, from the allocation fix alone; dense
finditer over [0-9]+/\w+ improved from 3.2x/5.6x slower to 1.7x/2.8x.
Full tables and citations in concept.md 7.10; README.md, docs/API.md,
USAGE.md, and bench_vs_posix.c's own printed summary updated
throughout with the corrected numbers and the full history, not just
the final ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjuMk8kY9SDus1wWe2K9xY
2026-09-14 18:49:21 +00:00

472 lines
19 KiB
C

/* bench_vs_posix - a direct, measured comparison between this library and
* the C standard library's own regex engine (POSIX <regex.h>,
* regcomp/regexec, glibc's implementation on the platform this is built
* on). Every other example in this directory demonstrates a feature; this
* one instead demonstrates a limitation, honestly, at scale: regexx is a
* roughly 2000-line single-file backtracking interpreter (concept.md
* Section 7.3) built for Python `re` compatibility and convenience,
* competing against a DFA-backed engine that has been the default regex
* implementation on every Linux system for decades. Nothing here is
* rigged to make either engine look better; every pattern used is valid
* POSIX ERE syntax AND valid regexx syntax (no \d/\w/\s, no POSIX bracket
* classes, no lazy quantifiers), so both engines run the identical
* pattern text, and every scenario reports both engines' actual timing,
* including the ones where POSIX wins by a wide margin.
*
* Six scenarios, each at multi-megabyte or multi-hundred-thousand-line
* scale:
* A. A literal needle near the end of a large non-matching haystack
* (tests raw linear-scan throughput).
* B. Extracting every number from a large text (tests iterated-match
* throughput, not just a single search).
* C. `a*b` against a large non-matching run of 'a' characters (the
* pattern shape README.md documents this library's precomputed
* skip-ahead tables were built specifically to fix).
* D. `(a+)+b` against a non-matching run of 'a' characters (the textbook
* ReDoS shape): the plain form, and, for regexx only, since POSIX ERE
* has no equivalent syntax at all, the atomic-group form that avoids
* it (examples/redos_atomic.c covers this one feature in isolation).
* E. Case-insensitive alternation over a large text with many matches.
* F. Anchored, per-line matching over several hundred thousand lines
* (the shape examples/ascii_logparse.c and rxgrep.c's line mode
* both use for real).
*
* A POSIX regex operates on a NUL-terminated C string with no notion of
* "here is how many bytes, keep going past an embedded NUL if there are
* any"; regexx's BINARY mode (examples/binary_scan.c) exists specifically
* because POSIX <regex.h> cannot do that at all, at any speed, which is a
* capability difference this file does not attempt to benchmark, since
* there is no POSIX baseline to measure against for it.
*/
#define _POSIX_C_SOURCE 199309L
#include "regexx.h"
#include <regex.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
/* Iterated matching against POSIX <regex.h> is done with REG_STARTEND
* (a widely available GNU/BSD extension to POSIX regexec, unconditionally
* declared by glibc's regex.h with no feature-test macro needed, unlike
* the rest of that header's GNU-only surface) rather than by advancing a
* `const char *` pointer and calling plain regexec() repeatedly. The
* pointer-advancing approach was tried first and measured to be quadratic
* in practice: without REG_STARTEND, regexec() has no way to know where
* the searchable string ends except by scanning for a NUL byte, so every
* call after the first rescans however much of the buffer remains,
* turning "run this pattern m times over an n-byte buffer" into O(n*m)
* instead of O(n). REG_STARTEND instead takes explicit [rm_so, rm_eo)
* byte offsets into the original, fixed buffer pointer, so no rescan
* happens and no NUL terminator is consulted at all (this is also,
* incidentally, the mechanism a POSIX-based program would use to search a
* buffer containing embedded NUL bytes, though this file does not
* otherwise exercise that). Deliberately not built with _GNU_SOURCE: that
* macro additionally exposes glibc's older re_search/re_match API, whose
* declarations collide with this project's own re_search/re_match
* (regexx.h, Python-exact naming, concept.md Section 9), so this file
* only enables what REG_STARTEND itself needs. */
static double now_seconds(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
typedef struct {
const char *label;
double regexx_time;
double posix_time;
int have_posix; /* 0 for the one regexx-only row (atomic group) */
long regexx_count; /* matches found, or 0/1 for a single search */
long posix_count;
const char *unit; /* what regexx_time/posix_time are measuring, for the table */
} Result;
#define MAX_RESULTS 16
static Result results[MAX_RESULTS];
static int nresults = 0;
static Result *add_result(const char *label, const char *unit) {
Result *r = &results[nresults++];
memset(r, 0, sizeof *r);
r->label = label;
r->unit = unit;
r->have_posix = 1;
return r;
}
/* ---- Scenario A: literal needle near the end of a large haystack ---- */
static void scenario_a(void) {
size_t n = 40u * 1024 * 1024;
char *buf = malloc(n + 1);
memset(buf, 'x', n);
const char *needle = "needle_in_a_haystack_42";
size_t nl = strlen(needle);
memcpy(buf + n - nl - 100, needle, nl); /* near, not at, the very end */
buf[n] = '\0';
Result *r = add_result("A: literal search, 40MB haystack", "search");
PatternError err; memset(&err, 0, sizeof err);
Pattern *pat = re_compile(needle, nl, ASCII, &err);
Input *in = Input_from_buffer((const uint8_t *)buf, n);
Match m; memset(&m, 0, sizeof m);
double t0 = now_seconds();
int rc = Pattern_search(pat, in, 0, -1, &m);
r->regexx_time = now_seconds() - t0;
r->regexx_count = (rc == 1);
if (rc == 1) Match_free(&m);
Input_free(in);
Pattern_free(pat);
regex_t re;
regcomp(&re, needle, REG_EXTENDED | REG_NOSUB);
regmatch_t pm;
t0 = now_seconds();
int prc = regexec(&re, buf, 0, &pm, 0);
r->posix_time = now_seconds() - t0;
r->posix_count = (prc == 0);
regfree(&re);
free(buf);
}
/* ---- Scenario B: extract every number from a large text ---- */
static void print_count_cb(void *ctx, const Match *m) {
(void)m;
long *n = ctx;
(*n)++;
}
static void scenario_b(void) {
size_t target = 20u * 1024 * 1024;
size_t cap = target + 4096;
char *buf = malloc(cap);
size_t len = 0;
unsigned long seed = 1; /* unsigned: wraps well-defined on overflow, unlike signed long */
while (len < target) {
seed = seed * 1103515245u + 12345u;
int v = (int)((seed >> 16) & 0xFFFF);
len += (size_t)snprintf(buf + len, cap - len, "id%d val%d code%d ", v, v * 3, v % 97);
}
buf[len] = '\0';
Result *r = add_result("B: extract all numbers, 20MB text", "matches");
const char *pattern = "[0-9]+";
PatternError err; memset(&err, 0, sizeof err);
Pattern *pat = re_compile(pattern, strlen(pattern), ASCII, &err);
Input *in = Input_from_buffer((const uint8_t *)buf, len);
long count = 0;
double t0 = now_seconds();
Pattern_finditer(pat, in, 0, -1, print_count_cb, &count);
r->regexx_time = now_seconds() - t0;
r->regexx_count = count;
Input_free(in);
Pattern_free(pat);
regex_t re;
regcomp(&re, pattern, REG_EXTENDED);
long pcount = 0;
size_t start = 0;
regmatch_t pm;
t0 = now_seconds();
while (start <= len) {
pm.rm_so = (regoff_t)start;
pm.rm_eo = (regoff_t)len;
if (regexec(&re, buf, 1, &pm, REG_STARTEND | (start == 0 ? 0 : REG_NOTBOL)) != 0) break;
pcount++;
size_t mso = (size_t)pm.rm_so, meo = (size_t)pm.rm_eo;
start = (meo > mso) ? meo : meo + 1;
}
r->posix_time = now_seconds() - t0;
r->posix_count = pcount;
regfree(&re);
free(buf);
}
/* ---- Scenario C: a*b, non-matching, over a large run of 'a' ---- */
static void scenario_c(void) {
size_t n = 20u * 1024 * 1024;
char *buf = malloc(n + 1);
memset(buf, 'a', n);
buf[n] = '\0';
Result *r = add_result("C: a*b non-match, 20MB of 'a'", "search");
const char *pattern = "a*b";
PatternError err; memset(&err, 0, sizeof err);
Pattern *pat = re_compile(pattern, strlen(pattern), ASCII, &err);
Input *in = Input_from_buffer((const uint8_t *)buf, n);
Match m; memset(&m, 0, sizeof m);
double t0 = now_seconds();
int rc = Pattern_search(pat, in, 0, -1, &m);
r->regexx_time = now_seconds() - t0;
r->regexx_count = (rc == 1);
if (rc == 1) Match_free(&m);
Input_free(in);
Pattern_free(pat);
regex_t re;
regcomp(&re, pattern, REG_EXTENDED | REG_NOSUB);
regmatch_t pm;
t0 = now_seconds();
int prc = regexec(&re, buf, 0, &pm, 0);
r->posix_time = now_seconds() - t0;
r->posix_count = (prc == 0);
regfree(&re);
free(buf);
}
/* ---- Scenario D: (a+)+b, the textbook ReDoS shape, non-matching ---- */
static void scenario_d(void) {
size_t n = 22000;
char *buf = malloc(n + 1);
memset(buf, 'a', n);
buf[n] = '\0';
Result *r = add_result("D1: (a+)+b non-match, plain, 22000 'a'", "search");
const char *pattern = "(a+)+b";
PatternError err; memset(&err, 0, sizeof err);
Pattern *pat = re_compile(pattern, strlen(pattern), ASCII, &err);
Input *in = Input_from_buffer((const uint8_t *)buf, n);
Match m; memset(&m, 0, sizeof m);
double t0 = now_seconds();
int rc = Pattern_search(pat, in, 0, -1, &m);
r->regexx_time = now_seconds() - t0;
r->regexx_count = (rc == 1);
if (rc == 1) Match_free(&m);
Input_free(in);
Pattern_free(pat);
regex_t re;
regcomp(&re, pattern, REG_EXTENDED | REG_NOSUB);
regmatch_t pm;
t0 = now_seconds();
int prc = regexec(&re, buf, 0, &pm, 0);
r->posix_time = now_seconds() - t0;
r->posix_count = (prc == 0);
regfree(&re);
/* Second row: the atomic-group rewrite. POSIX ERE has no atomic
* group or possessive quantifier syntax at all (not "slower", simply
* inexpressible), so this row has no POSIX column. */
Result *r2 = add_result("D2: (?>a+)+b non-match, atomic, same input", "search");
r2->have_posix = 0;
const char *pattern2 = "(?>a+)+b";
PatternError err2; memset(&err2, 0, sizeof err2);
Pattern *pat2 = re_compile(pattern2, strlen(pattern2), ASCII, &err2);
Input *in2 = Input_from_buffer((const uint8_t *)buf, n);
Match m2; memset(&m2, 0, sizeof m2);
t0 = now_seconds();
int rc2 = Pattern_search(pat2, in2, 0, -1, &m2);
r2->regexx_time = now_seconds() - t0;
r2->regexx_count = (rc2 == 1);
if (rc2 == 1) Match_free(&m2);
Input_free(in2);
Pattern_free(pat2);
free(buf);
}
/* ---- Scenario E: case-insensitive alternation, many matches ---- */
static void scenario_e(void) {
const char *animals[] = { "Cat", "DOG", "bird", "FiSh", "snake" };
size_t target = 12u * 1024 * 1024;
size_t cap = target + 4096;
char *buf = malloc(cap);
size_t len = 0;
int i = 0;
while (len < target) {
len += (size_t)snprintf(buf + len, cap - len, "%s and some filler text here. ", animals[i % 5]);
i++;
}
buf[len] = '\0';
Result *r = add_result("E: case-insensitive alternation, 12MB", "matches");
const char *pattern = "(cat|dog|bird|fish|snake)";
PatternError err; memset(&err, 0, sizeof err);
Pattern *pat = re_compile(pattern, strlen(pattern), ASCII | IGNORECASE, &err);
Input *in = Input_from_buffer((const uint8_t *)buf, len);
long count = 0;
double t0 = now_seconds();
Pattern_finditer(pat, in, 0, -1, print_count_cb, &count);
r->regexx_time = now_seconds() - t0;
r->regexx_count = count;
Input_free(in);
Pattern_free(pat);
regex_t re;
regcomp(&re, pattern, REG_EXTENDED | REG_ICASE);
long pcount = 0;
size_t start = 0;
regmatch_t pm;
t0 = now_seconds();
while (start <= len) {
pm.rm_so = (regoff_t)start;
pm.rm_eo = (regoff_t)len;
if (regexec(&re, buf, 1, &pm, REG_STARTEND | (start == 0 ? 0 : REG_NOTBOL)) != 0) break;
pcount++;
size_t mso = (size_t)pm.rm_so, meo = (size_t)pm.rm_eo;
start = (meo > mso) ? meo : meo + 1;
}
r->posix_time = now_seconds() - t0;
r->posix_count = pcount;
regfree(&re);
free(buf);
}
/* ---- Scenario F: anchored per-line matching, many lines ---- */
static void scenario_f(void) {
long nlines = 400000;
size_t cap = (size_t)nlines * 64;
char *buf = malloc(cap);
size_t len = 0;
for (long i = 0; i < nlines; i++) {
const char *level = (i % 10 == 0) ? "ERROR" : (i % 3 == 0) ? "WARN" : "INFO";
len += (size_t)snprintf(buf + len, cap - len, "%s message number %ld here\n", level, i);
}
buf[len] = '\0';
Result *r = add_result("F: '^ERROR', 400000 lines", "matches");
const char *pattern = "^ERROR";
PatternError err; memset(&err, 0, sizeof err);
Pattern *pat = re_compile(pattern, strlen(pattern), ASCII, &err);
long rcount = 0;
double t0 = now_seconds();
{
size_t start = 0;
for (size_t pos = 0; pos <= len; pos++) {
if (pos == len || buf[pos] == '\n') {
size_t linelen = pos - start;
Input *lin = Input_from_buffer((const uint8_t *)(buf + start), linelen);
Match m; memset(&m, 0, sizeof m);
if (Pattern_search(pat, lin, 0, -1, &m) == 1) { rcount++; Match_free(&m); }
Input_free(lin);
start = pos + 1;
}
}
}
r->regexx_time = now_seconds() - t0;
r->regexx_count = rcount;
Pattern_free(pat);
regex_t re;
regcomp(&re, pattern, REG_EXTENDED | REG_NOSUB);
long pcount = 0;
t0 = now_seconds();
{
char *line = malloc(256);
size_t start = 0;
for (size_t pos = 0; pos <= len; pos++) {
if (pos == len || buf[pos] == '\n') {
size_t linelen = pos - start;
if (linelen < 256) {
memcpy(line, buf + start, linelen);
line[linelen] = '\0';
regmatch_t pm;
if (regexec(&re, line, 0, &pm, 0) == 0) pcount++;
}
start = pos + 1;
}
}
free(line);
}
r->posix_time = now_seconds() - t0;
r->posix_count = pcount;
regfree(&re);
free(buf);
}
static void bar(double frac, char *out, int width) {
int filled = (int)(frac * width);
if (filled > width) filled = width;
if (filled < 0) filled = 0;
for (int i = 0; i < width; i++) out[i] = (i < filled) ? '#' : '.';
out[width] = '\0';
}
int main(void) {
printf("regexx vs POSIX <regex.h>: measured, not estimated.\n");
printf("Every pattern below is valid syntax for both engines; both run\n");
printf("against the identical subject text. Times are wall-clock seconds\n");
printf("(CLOCK_MONOTONIC), one run each, on whatever machine this runs on.\n\n");
scenario_a();
scenario_b();
scenario_c();
scenario_d();
scenario_e();
scenario_f();
printf("%-42s %12s %12s %10s %8s\n", "scenario", "regexx", "POSIX", "ratio", "match?");
for (int i = 0; i < nresults; i++) {
Result *r = &results[i];
char barbuf[41];
if (r->have_posix) {
double ratio = r->posix_time > 0 ? r->regexx_time / r->posix_time : 0;
printf("%-42s %10.4fs %10.4fs %9.1fx %8s\n",
r->label, r->regexx_time, r->posix_time, ratio,
(r->regexx_count == r->posix_count) ? "agree" : "DIFFER");
bar(r->regexx_time / (r->regexx_time + r->posix_time), barbuf, 40);
printf(" regexx [%s] POSIX [", barbuf);
bar(r->posix_time / (r->regexx_time + r->posix_time), barbuf, 40);
printf("%s]\n", barbuf);
} else {
printf("%-42s %10.4fs %12s %9s %8s\n",
r->label, r->regexx_time, "n/a", "n/a", "n/a");
printf(" (no POSIX ERE equivalent syntax exists for an atomic group)\n");
}
}
printf("\nWhat this measures and does not measure: regexx is Python `re`\n"
"compatible and dispatches every pattern here (all backreference-,\n"
"lookaround-, and atomic-group-free) to its Pike VM (README.md \"The\n"
"Pike VM\", concept.md 7.2/7.7/7.9/7.10), a Thompson-NFA simulation\n"
"with an allocation-free thread model (a flat, PC-indexed table for\n"
"live threads, no malloc/free in the search loop at all) and a real\n"
"Boyer-Moore-Horspool literal prefilter, researched directly against\n"
"RE2's and rust-lang/regex's own production engines and critically\n"
"scoped, not copied wholesale (concept.md 7.10 records what was tried\n"
"and rejected, including a lazy DFA state cache); glibc's <regex.h> is\n"
"a mature, DFA-backed engine with decades of its own optimization and\n"
"a much smaller feature set (no named groups, no lazy quantifiers, no\n"
"lookaround, no atomic groups, and, structurally, no way to search\n"
"past an embedded NUL byte at all, docs/API.md Section 1.3 vs\n"
"examples/binary_scan.c). Scenario A, a plain literal, is regexx's\n"
"best case for the reason above and now runs roughly 2x *faster* than\n"
"glibc, a full reversal from an initial roughly 22x slower (before the\n"
"Pike VM existed), through roughly 70x slower (the Pike VM without any\n"
"prefilter), through roughly 11x-13x slower (a single-character\n"
"prefilter only); that full history is the honest shape of iterating\n"
"on this, not a straight line. B (starts with a class, no Boyer-Moore-\n"
"Horspool skip applies, only the allocation fix does) sits at roughly\n"
"4x slower, down from roughly 7x; C's a*b (a nullable loop, structurally\n"
"un-prefilterable) sits at roughly 14x slower, down from roughly 27x,\n"
"from the allocation fix alone. What remains on B and C is genuinely no\n"
"lazy DFA state caching, a choice concept.md 7.10 explains rather than\n"
"a gap left unexamined.\n\n"
"Scenario D inverts entirely, and is the actual point of building this\n"
"engine at all: (a+)+b has no backreference, so it is Pike VM eligible,\n"
"and a Thompson-NFA simulation has no notion of \"try one split, then\n"
"backtrack and try another\" to begin with, so the classic nested-\n"
"quantifier ambiguity this pattern is famous for causing simply does\n"
"not exist for this engine; row D1 now runs this pattern faster than\n"
"POSIX <regex.h> itself, with no atomic group, not merely fast enough\n"
"to stop being the slowest thing on this page. Row D2 (still with no\n"
"POSIX equivalent) is retained for comparison, not because D1 needs\n"
"fixing anymore: examples/redos_atomic.c's Part 2 shows a pattern this\n"
"specific improvement cannot reach (a backreference forces the older\n"
"engine regardless of shape), which is where an atomic group remains\n"
"the pattern author's own necessary tool, not something automatic.\n");
return 0;
}