Files
regexx/examples/bench_vs_posix.c
T
retoorandClaude Sonnet 5 5ae68d4ea6 Add a literal prefilter to the Pike VM, profile its memory with Massif
Closed two of the three gaps the previous commit's honest self-review
left open (the third, full streaming/bounded-memory input, remains
out of scope for this pass and is still documented as such).

Literal prefilter (regexx.c, pike_find): when the NFA-only Prog's
first instruction is a mandatory OP_CHAR or OP_CLASS, a pattern
beginning with a required literal or class rather than a nullable
loop or a leading assertion, injecting a fresh unanchored start
thread at a position that instruction would reject is certain to die
on the very next pike_step call regardless; checking that identical
condition before injecting rather than after changes nothing about
which threads ever exist, only how much wasted work is done finding
out. This targeted exactly examples/bench_vs_posix.c's worst
regression: the literal-search scenario went from roughly 70x slower
than POSIX <regex.h> (up from roughly 22x before the Pike VM existed)
down to roughly 11x-13x, better than the original pre-Pike-VM number;
number extraction (starts with a class) improved more modestly; a*b
(starts with a nullable loop, structurally unhelped) is unchanged, as
expected. Verified with the full 3,252-case suite, three clean
AddressSanitizer/UndefinedBehaviorSanitizer passes, and a rerun of
the whitebox dual-engine cross-check (24,000 match/fullmatch/search
plus ~2,700 finditer comparisons between the two engines on the same
compiled patterns, zero mismatches).

Memory profiling (concept.md 7.9): Valgrind/Massif on the same
adversarial, prefilter-proof pattern shape (a*b, nullable leading
loop) used for the backtracking engine's own worst case, for a fair
comparison. Peak heap was almost entirely the 10MB input buffer
itself; the Pike VM's own contribution was roughly 12KB, confirming
the design's O(instruction count x group count), input-length-
independent memory bound actually holds for the v1 implementation,
not only on paper. concept.md Section 10's table, which had only a
"not yet profiled" caveat for this row before, is updated with the
measured result.

README.md, docs/API.md, USAGE.md, and bench_vs_posix.c's own printed
summary are updated throughout with the corrected numbers, rather
than left describing the pre-prefilter regression as current.

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

467 lines
18 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), a Thompson-NFA simulation with a\n"
"literal prefilter (added after this benchmark first found scenario A\n"
"regressed the most, concept.md 7.9) but still no lazy DFA state\n"
"caching or allocation pooling; glibc's <regex.h> is a mature,\n"
"DFA-backed engine with decades of that exact kind of optimization\n"
"behind it and a much smaller feature set (no named groups, no lazy\n"
"quantifiers, no lookaround, no atomic groups, and, structurally, no\n"
"way to search past an embedded NUL byte at all, docs/API.md Section\n"
"1.3 vs examples/binary_scan.c). Scenarios A-C measure what remains of\n"
"that missing optimization layer: this build was previously measured\n"
"on this engine's backtracking predecessor plus its own precomputed\n"
"skip-ahead tables (README.md \"Implementation status\") at roughly\n"
"2x-25x against POSIX on these same three scenarios; the Pike VM\n"
"without a prefilter regressed that to roughly 8x-70x, and the\n"
"prefilter closed most of that back up for A (a plain literal, its\n"
"best case) and part of it for B (starts with a class, not a single\n"
"literal); C's a*b starts with a nullable loop no prefilter can help,\n"
"and is unchanged. What remains (roughly 7x-27x here) is genuinely no\n"
"lazy DFA caching or allocation pooling yet, not a defect.\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;
}