Skip to content

Commit d0fc98c

Browse files
perf(regex): REG_NOSUB short-circuits to boolean DFA, skips offset search (bd-9rymao)
With REG_NOSUB, regexec reports only match/no-match (pmatch is never filled), yet regex_exec ran the full O(n*m) leftmost_start + run_from offset PikeVM search and then discarded the offsets — work glibc avoids (it answers booleans with a DFA). LEVER: for nosub patterns on the PikeVM path with no literal prefix, short-circuit to the engine's exact membership pass `any_match` (a lazy DFA for position- independent patterns) and return Some(empty)/None. any_match has neither false negatives nor false positives (reaching Accept proves a real match path; it is already trusted as execute()'s sound prescan), so it equals execute().is_some() on every input. Literal-prefix patterns keep execute()'s SIMD memmem jump, so they do not regress. Post-fix (release, 2 KiB subject), nosub regexec vs glibc: [0-9]+ : fl/glibc 0.96 [a-z]+ : fl/glibc 0.95 (fl now matches/beats glibc; session-4 measured this offset-computing path at 1.54x SLOWER than glibc). Correctness: NEW conformance_diff_regex_nosub.rs — curated battery + 60,000 random ERE patterns/inputs, REG_EXTENDED|REG_NOSUB, boolean decision vs the LIVE glibc oracle, 0 divergences. conformance_diff_regex (8) green — non-nosub path unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 80f140c commit d0fc98c

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#![cfg(target_os = "linux")]
2+
#![allow(unsafe_code)] // live host-glibc regcomp/regexec oracle
3+
4+
//! Differential test for the REG_NOSUB fast path in the regex engine.
5+
//!
6+
//! With REG_NOSUB, `regexec` reports only the boolean match/no-match decision
7+
//! (pmatch is never filled). frankenlibc short-circuits that to the exact
8+
//! membership pass (`any_match`, a lazy DFA for position-independent patterns)
9+
//! instead of computing and discarding the leftmost-longest capture offsets.
10+
//! This fuzzes the boolean decision against the LIVE host glibc oracle over
11+
//! randomized ERE patterns + inputs (and a curated battery), asserting the
12+
//! match/no-match decision agrees on every case — proving the fast path is
13+
//! semantically identical to a full search.
14+
15+
use std::ffi::{CString, c_char, c_int, c_void};
16+
17+
unsafe extern "C" {
18+
fn regcomp(preg: *mut c_void, pattern: *const c_char, cflags: c_int) -> c_int;
19+
fn regexec(
20+
preg: *const c_void,
21+
string: *const c_char,
22+
nmatch: usize,
23+
pmatch: *mut c_void,
24+
eflags: c_int,
25+
) -> c_int;
26+
fn regfree(preg: *mut c_void);
27+
}
28+
29+
const REG_EXTENDED: c_int = 1;
30+
const REG_NOSUB: c_int = 8;
31+
32+
#[repr(C, align(16))]
33+
struct Preg([u8; 256]);
34+
35+
struct Lcg(u64);
36+
impl Lcg {
37+
fn next(&mut self) -> u64 {
38+
self.0 = self
39+
.0
40+
.wrapping_mul(6364136223846793005)
41+
.wrapping_add(1442695040888963407);
42+
self.0
43+
}
44+
fn below(&mut self, n: usize) -> usize {
45+
(self.next() >> 11) as usize % n
46+
}
47+
}
48+
49+
/// Compile + boolean-exec under REG_EXTENDED|REG_NOSUB. Returns Some(matched) if
50+
/// the pattern compiled, None if it was rejected (so both engines can agree on
51+
/// invalidity by skipping).
52+
fn run(
53+
comp: unsafe extern "C" fn(*mut c_void, *const c_char, c_int) -> c_int,
54+
exec: unsafe extern "C" fn(*const c_void, *const c_char, usize, *mut c_void, c_int) -> c_int,
55+
free: unsafe extern "C" fn(*mut c_void),
56+
pat: &CString,
57+
inp: &CString,
58+
) -> Option<bool> {
59+
let mut preg = Preg([0u8; 256]);
60+
let c = unsafe { comp(preg.0.as_mut_ptr() as *mut c_void, pat.as_ptr(), REG_EXTENDED | REG_NOSUB) };
61+
if c != 0 {
62+
return None;
63+
}
64+
let e = unsafe { exec(preg.0.as_ptr() as *const c_void, inp.as_ptr(), 0, std::ptr::null_mut(), 0) };
65+
unsafe { free(preg.0.as_mut_ptr() as *mut c_void) };
66+
Some(e == 0)
67+
}
68+
69+
fn check(pat: &str, inp: &str, divs: &mut Vec<String>) {
70+
let (Ok(cp), Ok(ci)) = (CString::new(pat), CString::new(inp)) else {
71+
return;
72+
};
73+
let fl = run(
74+
frankenlibc_abi::string_abi::regcomp,
75+
frankenlibc_abi::string_abi::regexec,
76+
frankenlibc_abi::string_abi::regfree,
77+
&cp,
78+
&ci,
79+
);
80+
let gl = run(regcomp, regexec, regfree, &cp, &ci);
81+
// Only compare when both engines accepted the pattern.
82+
if let (Some(f), Some(g)) = (fl, gl)
83+
&& f != g
84+
&& divs.len() < 30
85+
{
86+
divs.push(format!("pat={pat:?} inp={inp:?}: fl_match={f} glibc_match={g}"));
87+
}
88+
}
89+
90+
#[test]
91+
fn regex_nosub_boolean_matches_glibc() {
92+
let mut divs = Vec::new();
93+
94+
// Curated: class/wildcard-leading patterns (the short-circuited no-literal-
95+
// prefix path), anchors, alternation, empty/nullable, and literal-prefix
96+
// (the path that stays on execute()).
97+
let curated: &[(&str, &str)] = &[
98+
("[0-9]+", "abc 123 def"),
99+
("[a-z]+", "ABC"),
100+
("^foo", "foobar"),
101+
("bar$", "foobar"),
102+
("(quick|slow) brown", "the quick brown fox"),
103+
("a*", ""),
104+
(".*", "anything"),
105+
("x?y?z?", "q"),
106+
("[[:digit:]]+", "no digits here"),
107+
("fox", "the quick brown fox"),
108+
("^$", ""),
109+
("^$", "x"),
110+
("(ab)+", "ababab"),
111+
("colou?r", "color"),
112+
("[^a-z]+", "abc"),
113+
("end$", "the end"),
114+
];
115+
for (p, i) in curated {
116+
check(p, i, &mut divs);
117+
}
118+
119+
// Randomized ERE patterns + inputs.
120+
let mut r = Lcg(0x9e37_79b9_7f4a_7c15);
121+
let toks = [
122+
"a", "b", ".", "[0-9]", "[a-z]", "[^x]", "*", "+", "?", "|", "(", ")", "^", "$", "x", "1",
123+
"[[:alpha:]]", "c",
124+
];
125+
for _ in 0..60_000 {
126+
let plen = 1 + r.below(7);
127+
let mut pat = String::new();
128+
for _ in 0..plen {
129+
pat.push_str(toks[r.below(toks.len())]);
130+
}
131+
let ilen = r.below(12);
132+
const ALPHA: &[u8] = b"abcx019 ";
133+
let inp: String = (0..ilen).map(|_| ALPHA[r.below(ALPHA.len())] as char).collect();
134+
check(&pat, &inp, &mut divs);
135+
}
136+
137+
assert!(
138+
divs.is_empty(),
139+
"REG_NOSUB boolean decision diverged from glibc (showing up to 30):\n{}",
140+
divs.join("\n")
141+
);
142+
}

crates/frankenlibc-core/src/string/regex.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3159,6 +3159,27 @@ fn regex_exec_byte_slots(compiled: &CompiledRegex, input: &[u8], eflags: i32) ->
31593159
compiled.icase,
31603160
);
31613161

3162+
// REG_NOSUB fast path: only the boolean match/no-match decision is
3163+
// observable (pmatch is never filled), so skip the O(n*m) leftmost_start +
3164+
// run_from offset search and answer with the exact membership pass — a lazy
3165+
// DFA for position-independent patterns. `any_match` has neither false
3166+
// negatives nor false positives (reaching an `Accept` proves a real match
3167+
// path exists; it is already trusted as `execute`'s sound prescan), so it
3168+
// agrees with `execute().is_some()` on every input. Skipped when a literal
3169+
// prefix is present: `execute` already jumps straight to occurrences via
3170+
// SIMD memmem there, which beats seeding a thread at every position.
3171+
if compiled.nosub && compiled.literal_prefix.is_none() {
3172+
let notbol = eflags & REG_NOTBOL != 0;
3173+
let noteol = eflags & REG_NOTEOL != 0;
3174+
let mut visited = vec![0u64; compiled.nfa.len()];
3175+
let mut generation = 0u64;
3176+
return if vm.any_match(notbol, noteol, &mut visited, &mut generation) {
3177+
Some(Vec::new())
3178+
} else {
3179+
None
3180+
};
3181+
}
3182+
31623183
vm.execute()
31633184
}
31643185

0 commit comments

Comments
 (0)