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
build.zigmodified+3-0
Changes to build.zig
@@ -330,6 +330,9 @@ pub fn build(b: *std.Build) void {
330330 },
331331 }),
332332 });
333 // The findAll-quadratic regression test uses libc's monotonic clock for a
334 // time bound (Zig 0.16 std.time has no Timer/nanoTimestamp).
335 regression_tests.root_module.link_libc = true;
333336 const run_regression_tests = b.addRunArtifact(regression_tests);
334337 test_step.dependOn(&run_regression_tests.step);
335338
src/vm.zigmodified+11-0
Changes to src/vm.zig
@@ -227,6 +227,17 @@ pub const VM = struct {
227227 next_threads.clearRetainingCapacity();
228228
229229 pos += 1;
230
231 // No live threads remain. matchAt only ever seeds the start-state
232 // thread (at start_pos), and threads can only be spawned by cloning
233 // existing ones, so once the set is empty no further match can
234 // begin from this start_pos — the longest match found so far
235 // (last_match) is final. Without this break the loop walks every
236 // remaining byte doing nothing; because findAll restarts matchAt
237 // per match, that dead-walk is what makes findAll O(n^2). Any
238 // accepting state reached on the consumed input was already
239 // recorded by the accept check at the top of the loop.
240 if (current_threads.items.len == 0) break;
230241 }
231242
232243 // Return the last (longest) match we found
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}