mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-30 06:42:43 +00:00
perf(r): binary-search character classes in the linear regex matcher
inSet() scanned a class's ranges from the front on every state visit, but the search loop charged one unit per visit regardless of class size, so a wide class multiplied real work the budget never counted. Ranges are sorted, merged and disjoint for every CharSet (normalise() for written classes, constants and complement() for escapes), so a binary search is equivalent. Charged work is unchanged. Tests pin the reads per visit, the edges of a large class and the charged work counters.
This commit is contained in:
parent
edc969f5e9
commit
edbb097616
2 changed files with 100 additions and 5 deletions
|
|
@ -988,13 +988,24 @@ function holdsIn(assertion: number, context: number): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `c` is in `set`. Every CharSet's ranges are sorted, merged and disjoint (a
|
||||
* class goes through {@link normalise}; the escape classes are constants or their
|
||||
* {@link complement}), so a binary search finds the one range that could hold `c`. A
|
||||
* linear scan here would make each state visit cost a whole class, work the budget
|
||||
* does not count.
|
||||
*/
|
||||
function inSet(set: CharSet, c: number): boolean {
|
||||
let hit = false;
|
||||
const ranges = set.ranges;
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const r = ranges[i];
|
||||
if (c < r[0]) break; // ranges are sorted
|
||||
if (c <= r[1]) {
|
||||
let lo = 0;
|
||||
let hi = ranges.length - 1;
|
||||
let hit = false;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const r = ranges[mid];
|
||||
if (c < r[0]) hi = mid - 1;
|
||||
else if (c > r[1]) lo = mid + 1;
|
||||
else {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -606,3 +606,87 @@ describe('the start-state filter is charged to the work budget', () => {
|
|||
expect(budget.spent).toBeGreaterThanOrEqual(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a class membership test does not scan the class', () => {
|
||||
/** The body of a class of `ranges` ranges, each two units wide with a gap of two between. */
|
||||
const classBody = (ranges: number): string => {
|
||||
let body = '';
|
||||
for (let i = 0; i < ranges; i++) {
|
||||
const lo = 0x100 + i * 4;
|
||||
body += `${String.fromCharCode(lo)}-${String.fromCharCode(lo + 1)}`;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
/** `([<class>]?){states}Z`: `states` states each holding a `ranges`-range class. */
|
||||
const wideClassInLoop = (ranges: number, states: number): string =>
|
||||
`([${classBody(ranges)}]?){${states}}Z`;
|
||||
|
||||
/** A name made of the last range's first unit, the worst case for a scan from the front. */
|
||||
const lastRangeName = (ranges: number, length: number): string =>
|
||||
String.fromCharCode(0x100 + (ranges - 1) * 4).repeat(length);
|
||||
|
||||
/**
|
||||
* Replace every class's ranges with a view that counts element reads. This reaches into
|
||||
* the compiled matcher on purpose: it makes the cost of a membership test a count of
|
||||
* reads, which no machine speed can change.
|
||||
*/
|
||||
const countRangeReads = (matcher: LinearRegex): { reads: number } => {
|
||||
const counter = { reads: 0 };
|
||||
const sets = new Set<{ ranges: unknown }>();
|
||||
for (const entry of (matcher as unknown as { arg: unknown[] }).arg) {
|
||||
if (entry !== null && typeof entry === 'object') sets.add(entry as { ranges: unknown });
|
||||
}
|
||||
for (const set of sets) {
|
||||
set.ranges = new Proxy(set.ranges as object, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === 'string' && /^\d+$/.test(key)) counter.reads++;
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
return counter;
|
||||
};
|
||||
|
||||
it('reads a logarithmic number of ranges per state visit', () => {
|
||||
const ranges = 1000;
|
||||
const matcher = compileLinearRegex(wideClassInLoop(ranges, 150)) as LinearRegex;
|
||||
expect(matcher).not.toBeNull();
|
||||
const counter = countRangeReads(matcher);
|
||||
|
||||
expect(matcher.test(lastRangeName(ranges, 80))).toBe(false);
|
||||
|
||||
// Every visit tests the class once, and the start-state filter adds the same again.
|
||||
// A scan from the front would read some 500 ranges a visit; a binary search reads
|
||||
// at most ceil(log2(1001)) = 10.
|
||||
expect(matcher.work).toBeGreaterThan(10_000);
|
||||
expect(counter.reads).toBeLessThanOrEqual(matcher.work * 10);
|
||||
});
|
||||
|
||||
it('still decides membership exactly at the edges of a large class', () => {
|
||||
const ranges = 1000;
|
||||
const matcher = compileLinearRegex(`^[${classBody(ranges)}]$`) as LinearRegex;
|
||||
expect(matcher).not.toBeNull();
|
||||
const at = (i: number, offset: number): string => String.fromCharCode(0x100 + i * 4 + offset);
|
||||
for (const i of [0, 1, 499, 500, ranges - 2, ranges - 1]) {
|
||||
expect(matcher.test(at(i, 0))).toBe(true);
|
||||
expect(matcher.test(at(i, 1))).toBe(true);
|
||||
expect(matcher.test(at(i, 2))).toBe(false); // the gap between two ranges
|
||||
expect(matcher.test(at(i, 3))).toBe(false);
|
||||
}
|
||||
expect(matcher.test('ÿ')).toBe(false); // below every range
|
||||
expect(matcher.test(at(ranges, 0))).toBe(false); // above every range
|
||||
});
|
||||
|
||||
it('keeps the work counters of the class scan it charges for', () => {
|
||||
// Charged work is part of the budget contract and must not follow the lookup's speed.
|
||||
const wide = compileLinearRegex(wideClassInLoop(1000, 200)) as LinearRegex;
|
||||
const narrow = compileLinearRegex(wideClassInLoop(1, 200)) as LinearRegex;
|
||||
for (const suffix of ['0', '1', '2', '3', '4']) {
|
||||
expect(wide.test(lastRangeName(1000, 100) + suffix)).toBe(false);
|
||||
expect(narrow.test(lastRangeName(1, 100) + suffix)).toBe(false);
|
||||
}
|
||||
expect(wide.work).toBe(1_802_914);
|
||||
expect(narrow.work).toBe(404_314);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue