Skip to content

Commit 7a014b0

Browse files
committed
feat: add perf tracer utilities
1 parent ba2a0d7 commit 7a014b0

4 files changed

Lines changed: 216 additions & 0 deletions

File tree

docs/guide.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,28 @@ if (!result.success) {
176176
console.error(formatErrorStack(result));
177177
}
178178
```
179+
180+
## Perf tracing (optional)
181+
182+
For larger grammars it can be useful to measure where time goes. `createTracer`
183+
lets you wrap parsers and collect per-parser call counts, success/failure, input
184+
consumed, and total/max time.
185+
186+
```ts
187+
import {
188+
createTracer,
189+
formatTraceTable,
190+
seq,
191+
str,
192+
} from "@claudiu-ceia/combine";
193+
194+
const tr = createTracer();
195+
196+
const word = tr.wrap("word", str("hello"));
197+
const p = tr.wrap("seq", seq(word, str("!")));
198+
199+
const res = p({ text: "hello!", index: 0 });
200+
if (res.success) {
201+
console.log(formatTraceTable(tr.rows()));
202+
}
203+
```

mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./src/combinators.ts";
33
export * from "./src/parsers.ts";
44
export * from "./src/utility.ts";
55
export * from "./src/language.ts";
6+
export * from "./src/perf.ts";

src/perf.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { Parser, Result } from "./Parser.ts";
2+
3+
export type TraceRow = Readonly<{
4+
name: string;
5+
calls: number;
6+
success: number;
7+
failure: number;
8+
fatalFailure: number;
9+
consumed: number;
10+
timeMs: number;
11+
maxTimeMs: number;
12+
}>;
13+
14+
export type Tracer = Readonly<{
15+
wrap: <T>(name: string, p: Parser<T>) => Parser<T>;
16+
rows: () => TraceRow[];
17+
reset: () => void;
18+
}>;
19+
20+
const nowDefault = (): number => {
21+
const perf = (globalThis as { performance?: { now?: () => number } })
22+
.performance;
23+
return typeof perf?.now === "function" ? perf.now() : Date.now();
24+
};
25+
26+
type MutableRow = {
27+
name: string;
28+
calls: number;
29+
success: number;
30+
failure: number;
31+
fatalFailure: number;
32+
consumed: number;
33+
timeMs: number;
34+
maxTimeMs: number;
35+
};
36+
37+
const getOrCreateRow = (
38+
m: Map<string, MutableRow>,
39+
name: string,
40+
): MutableRow => {
41+
const existing = m.get(name);
42+
if (existing) return existing;
43+
const created: MutableRow = {
44+
name,
45+
calls: 0,
46+
success: 0,
47+
failure: 0,
48+
fatalFailure: 0,
49+
consumed: 0,
50+
timeMs: 0,
51+
maxTimeMs: 0,
52+
};
53+
m.set(name, created);
54+
return created;
55+
};
56+
57+
export const createTracer = (opts?: { now?: () => number }): Tracer => {
58+
const now = opts?.now ?? nowDefault;
59+
const m = new Map<string, MutableRow>();
60+
61+
return {
62+
wrap: <T>(name: string, p: Parser<T>): Parser<T> => {
63+
return (ctx) => {
64+
const row = getOrCreateRow(m, name);
65+
row.calls++;
66+
67+
const t0 = now();
68+
const res = p(ctx) as Result<T>;
69+
const dt = now() - t0;
70+
71+
row.timeMs += dt;
72+
if (dt > row.maxTimeMs) row.maxTimeMs = dt;
73+
74+
if (res.success) {
75+
row.success++;
76+
const consumed = res.ctx.index - ctx.index;
77+
if (consumed > 0) row.consumed += consumed;
78+
} else {
79+
row.failure++;
80+
if (res.fatal) row.fatalFailure++;
81+
}
82+
83+
return res;
84+
};
85+
},
86+
rows: (): TraceRow[] => {
87+
return [...m.values()]
88+
.map((r) => ({ ...r }))
89+
.sort((a, b) => b.timeMs - a.timeMs);
90+
},
91+
reset: (): void => {
92+
m.clear();
93+
},
94+
};
95+
};
96+
97+
export const formatTraceTable = (rows: TraceRow[]): string => {
98+
const headers = [
99+
"name",
100+
"calls",
101+
"ok",
102+
"fail",
103+
"fatal",
104+
"consumed",
105+
"timeMs",
106+
"maxMs",
107+
] as const;
108+
109+
const cells: string[][] = [
110+
[...headers],
111+
...rows.map((r) => [
112+
r.name,
113+
String(r.calls),
114+
String(r.success),
115+
String(r.failure),
116+
String(r.fatalFailure),
117+
String(r.consumed),
118+
r.timeMs.toFixed(3),
119+
r.maxTimeMs.toFixed(3),
120+
]),
121+
];
122+
123+
const widths = headers.map((_, i) =>
124+
Math.max(...cells.map((row) => row[i]!.length))
125+
);
126+
127+
const pad = (s: string, w: number) => s.padEnd(w, " ");
128+
const lines: string[] = [];
129+
lines.push(cells[0]!.map((c, i) => pad(c, widths[i]!)).join(" "));
130+
lines.push(widths.map((w) => "-".repeat(w)).join(" "));
131+
for (let i = 1; i < cells.length; i++) {
132+
lines.push(cells[i]!.map((c, j) => pad(c, widths[j]!)).join(" "));
133+
}
134+
135+
return lines.join("\n");
136+
};

tests/perf.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { assertEquals } from "@std/assert";
2+
import { createTracer, formatTraceTable } from "../src/perf.ts";
3+
import { failure, type Parser, success } from "../src/Parser.ts";
4+
5+
Deno.test("tracer counts calls and consumed input", () => {
6+
let t = 0;
7+
const tracer = createTracer({ now: () => ++t }); // deterministic
8+
9+
const ok1: Parser<string> = (ctx) =>
10+
success({ ...ctx, index: ctx.index + 2 }, "ok");
11+
const bad: Parser<string> = (ctx) => failure(ctx, "nope");
12+
13+
const p = tracer.wrap("ok1", ok1);
14+
const q = tracer.wrap("bad", bad);
15+
16+
const r1 = p({ text: "abcd", index: 0 });
17+
assertEquals(r1.success, true);
18+
const r2 = q({ text: "abcd", index: 2 });
19+
assertEquals(r2.success, false);
20+
21+
const rows = tracer.rows();
22+
assertEquals(rows.length, 2);
23+
24+
const okRow = rows.find((x) => x.name === "ok1")!;
25+
assertEquals(okRow.calls, 1);
26+
assertEquals(okRow.success, 1);
27+
assertEquals(okRow.failure, 0);
28+
assertEquals(okRow.consumed, 2);
29+
30+
const badRow = rows.find((x) => x.name === "bad")!;
31+
assertEquals(badRow.calls, 1);
32+
assertEquals(badRow.success, 0);
33+
assertEquals(badRow.failure, 1);
34+
});
35+
36+
Deno.test("formatTraceTable prints a header and rows", () => {
37+
const table = formatTraceTable([
38+
{
39+
name: "p",
40+
calls: 2,
41+
success: 1,
42+
failure: 1,
43+
fatalFailure: 0,
44+
consumed: 3,
45+
timeMs: 1.23456,
46+
maxTimeMs: 1.0,
47+
},
48+
]);
49+
50+
assertEquals(table.includes("name"), true);
51+
assertEquals(table.includes("calls"), true);
52+
assertEquals(table.includes("p"), true);
53+
assertEquals(table.includes("1.235"), true);
54+
});

0 commit comments

Comments
 (0)