ReviewOS

also looking at this

zig-utils/zig-regex

fix(vm): make findAll linear by stopping matchAt when no threads remain

#8
Merged DaliVana wants to merge fix/findall-quadratic into main
3 files +102 -0
tests/regression.zigmodified+88-0
Changes to tests/regression.zig
@@ -1,4 +1,5 @@
11const std = ;
2const builtin = ;
23const Regex = .Regex;
34const RegexError = .RegexError;
45
@@ -179,3 +180,90 @@ test "regression: double pattern compile and deinit" {
179180 try std.testing.expect(try regex.isMatch("def"));
180181 }
181182}
183
184// --- findAll quadratic blowup (issue: "findAll is O(n^2), not linear") ---
185//
186// Before the fix, matchAt kept iterating to input.len after all threads died,
187// and findAll restarted matchAt per match, so a scan with m matches over n
188// bytes cost ~O(n*m). The guard below doubles the input and checks the time
189// ratio: a linear scan stays near ~2x, the pre-fix quadratic path was ~4x and
190// growing. A ratio (not an absolute bound) keeps this independent of build
191// mode (Debug vs ReleaseFast) and machine speed.
192
193fn monotonicNs() u64 {
194 const clk: std.c.clockid_t = switch (builtin.os.tag) {
195 .macos, .ios, .tvos, .watchos, .visionos => .UPTIME_RAW,
196 else => .MONOTONIC,
197 };
198 var ts: std.c.timespec = undefined;
199 _ = std.c.clock_gettime(clk, &ts);
200 return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
201}
202
203const FindAllScan = struct { ns: u64, count: usize, first_start: usize, last_end: usize };
204
205fn timeFindAll(allocator: std.mem.Allocator, regex: *const Regex, n: usize) !FindAllScan {
206 const buf = try allocator.alloc(u8, n);
207 defer allocator.free(buf);
208 @memset(buf, '.');
209 var i: usize = 0;
210 while (i + 8 <= n) : (i += 64) @memcpy(buf[i .. i + 8], "Sherlock");
211
212 // Warm up (allocator caches, code paths), then take the min of a few runs
213 // to suppress scheduler noise without depending on absolute timing.
214 {
215 const warm = try regex.findAll(allocator, buf);
216 for (warm) |*m| {
217 var mm = m.*;
218 mm.deinit(allocator);
219 }
220 allocator.free(warm);
221 }
222
223 var best: u64 = std.math.maxInt(u64);
224 var count: usize = 0;
225 var first_start: usize = 0;
226 var last_end: usize = 0;
227 var rep: usize = 0;
228 while (rep < 3) : (rep += 1) {
229 const t0 = monotonicNs();
230 const matches = try regex.findAll(allocator, buf);
231 const dt = monotonicNs() - t0;
232 count = matches.len;
233 if (matches.len > 0) {
234 first_start = matches[0].start;
235 last_end = matches[matches.len - 1].end;
236 }
237 for (matches) |*m| {
238 var mm = m.*;
239 mm.deinit(allocator);
240 }
241 allocator.free(matches);
242 if (dt < best) best = dt;
243 }
244 return .{ .ns = best, .count = count, .first_start = first_start, .last_end = last_end };
245}
246
247test "regression: findAll scales linearly (no O(n^2) blowup)" {
248 const allocator = std.testing.allocator;
249
250 var regex = try Regex.compile(allocator, "Sherlock");
251 defer regex.deinit();
252
253 const n1: usize = 16 * 1024;
254 const n2: usize = 32 * 1024; // exactly 2x
255
256 const r1 = try timeFindAll(allocator, &regex, n1);
257 const r2 = try timeFindAll(allocator, &regex, n2);
258
259 // Correctness: a "Sherlock" every 64 bytes, found at the right places.
260 try std.testing.expectEqual(n1 / 64, r1.count);
261 try std.testing.expectEqual(n2 / 64, r2.count);
262 try std.testing.expectEqual(@as(usize, 0), r2.first_start);
263 try std.testing.expectEqual(((n2 - 8) / 64) * 64 + 8, r2.last_end);
264
265 // Linearity guard: doubling input must not more-than-triple the time.
266 // Linear ≈ 2x; the pre-fix quadratic path was ≈ 4x (and worsening).
267 try std.testing.expect(r1.ns > 0);
268 try std.testing.expect(r2.ns * 10 < r1.ns * 30); // r2/r1 < 3.0
269}