Files
regexx/examples/bench_vs_posix.c
T
retoorandClaude Sonnet 5 02743cd959 Add six feature examples and a direct benchmark against POSIX <regex.h>
Each new program under examples/ isolates one distinct feature rather
than being a general purpose tool like the existing rxgrep.c:
binary_scan.c (raw byte-range classes including an embedded NUL and
an embedded 0x0A), utf8_scripts.c (\w across Latin/Greek/Cyrillic/CJK
text, code point versus byte offsets), ascii_logparse.c (named groups
against structured log text), redos_atomic.c (atomic groups and
possessive quantifiers timed directly against the unprotected form of
the textbook (a+)+b ReDoS shape), empty_match_rule.c (CPython's
undocumented empty-match retry rule, verified: \d*? against
"123abc456" gives 16 matches, not 9), and large_file_search.c
(Input_from_file's mmap-backed reading on a generated 100MB file,
with elapsed time and peak RSS printed). Every example was compiled
and run while writing it; the claims in each file's top comment are
checked against its own output, not written by hand and left
unverified.

Also adds examples/bench_vs_posix.c, a direct, honestly reported
comparison against the C standard library's own <regex.h>
(regcomp/regexec) on six scenarios at multi-megabyte or
multi-hundred-thousand-line scale, using only pattern syntax valid
for both engines so they run the identical pattern text. glibc's
DFA-backed engine wins five of six scenarios by 2x-35x, which is the
expected outcome of a roughly 2000-line backtracking interpreter
built for Python `re` compatibility competing against a mature,
heavily optimized engine with a much smaller feature set; the sixth
scenario has no POSIX equivalent at all (an atomic group). Every
scenario's match count is cross-checked between the two engines as an
independent correctness signal beyond the existing CPython-derived
test suite.

Two real issues were found and fixed while building this benchmark,
not left in: iterating regexec() over an advancing string pointer is
quadratic in practice (no way to bound the search without an implicit
NUL-scan on every call), fixed by using REG_STARTEND instead; and a
signed integer overflow (undefined behavior, caught by UBSan) in the
benchmark's own pseudo-random text generator, fixed by using an
unsigned accumulator.

README.md and USAGE.md gain pointers to examples/README.md (the new
per-example index) and a "Benchmarks" section summarizing the
POSIX comparison honestly, including where it loses. The Makefile
gains a `make examples` target building all seven programs.

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

447 lines
17 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 a backtracking\n"
"interpreter built for Python `re` semantics and the three data modes\n"
"(concept.md Section 7.3); glibc's <regex.h> is a mature, DFA-backed\n"
"engine with decades of optimization behind it and a much smaller\n"
"feature set (no named groups, no lazy quantifiers, no lookaround, no\n"
"atomic groups, and, structurally, no way to search past an embedded\n"
"NUL byte at all, docs/API.md Section 1.3 vs examples/binary_scan.c).\n"
"Scenario D is the sharpest illustration of the trade-off this library\n"
"makes: memoized backtracking (README.md \"Implementation status\")\n"
"keeps a Python-compatible engine off exponential time, not off\n"
"quadratic time, for a pattern shape a DFA-backed engine with no\n"
"backreference support handles without either problem; the correct\n"
"response to that specific gap is the atomic group in row D2, the same\n"
"fix a Python `re` user would reach for, not a claim that this engine\n"
"matches a DFA's asymptotic behavior in general, which it does not.\n");
return 0;
}