also looking at this
fix(vm): make findAll linear by stopping matchAt when no threads remain
#8
3 files
+102
-0
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | const std = ; |
| 2 | const builtin = ; | |
| 2 | 3 | const Regex = .Regex; |
| 3 | 4 | const RegexError = .RegexError; |
| 4 | 5 | |
| @@ -179,3 +180,90 @@ test "regression: double pattern compile and deinit" { | ||
| 179 | 180 | try std.testing.expect(try regex.isMatch("def")); |
| 180 | 181 | } |
| 181 | 182 | } |
| 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 | ||
| 193 | fn 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 | ||
| 203 | const FindAllScan = struct { ns: u64, count: usize, first_start: usize, last_end: usize }; | |
| 204 | ||
| 205 | fn 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 | ||
| 247 | test "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, ®ex, n1); | |
| 257 | const r2 = try timeFindAll(allocator, ®ex, 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 | } | |