Skip to content

Commit aca513f

Browse files
committed
bench: add mitata harness for @glimmer/syntax parse/normalize/precompile
Reproducible side-by-side benchmark of preprocess, normalize (ASTv1 → ASTv2), and full precompile() against a control worktree. Run via: pnpm bench:syntax # standalone pnpm bench:syntax -- --control-dir /path/to/main-checkout Size ladder matches the PR #21314 body (462 / 1494 / 4482 / 32868 chars, the last sized to match Discourse's admin-user/index.gjs). Fixtures live under bench/fixtures/; large and extra-large are medium × 3 / × 22. Emits mitata's standard ms/iter + boxplot + summary output, plus a µs/char summary table across the size ladder that makes the O(n²) → O(n) flattening visible at a glance.
1 parent 095ed2d commit aca513f

3 files changed

Lines changed: 296 additions & 3 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"scripts": {
3838
"actions-up": "pnpm dlx actions-up",
3939
"bench": "node ./bin/benchmark.mjs",
40+
"bench:syntax": "node --expose-gc --import @swc-node/register/esm-register packages/@glimmer/syntax/bench/syntax.bench.mjs",
4041
"build:js": "rollup --config",
4142
"build:types": "node types/publish.mjs",
4243
"build": "npm-run-all build:*",
@@ -122,6 +123,7 @@
122123
"glob": "^8.0.3",
123124
"globals": "^16.0.0",
124125
"kill-port-process": "^3.2.1",
126+
"mitata": "^1.0.34",
125127
"mocha": "^11.0.0",
126128
"npm-run-all2": "^8.0.0",
127129
"prettier": "^3.5.3",
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/* global process, console */
2+
/* eslint-disable no-console */
3+
4+
/**
5+
* Mitata benchmark for `@glimmer/syntax` parse (`preprocess`), normalize
6+
* (ASTv1 → ASTv2 — where the loc-conversion hot path lives), and full
7+
* `@glimmer/compiler` `precompile()`.
8+
*
9+
* Standalone (experiment only):
10+
* pnpm bench:syntax
11+
*
12+
* Side-by-side vs a control checkout (e.g. a git worktree on main):
13+
* pnpm bench:syntax -- --control-dir /tmp/ember-main
14+
*
15+
* The control dir must have its own `pnpm install` done (so its
16+
* `@glimmer/syntax` has working workspace-dep resolution).
17+
*
18+
* Sizes:
19+
* small — ~1.5k chars (route-template fragment)
20+
* medium — small × 3 (~4.5k chars)
21+
* large — small × 22 (~33k chars, scale of the largest real route
22+
* templates, e.g. Discourse's admin-user/index.gjs)
23+
*/
24+
25+
import { resolve } from 'node:path';
26+
import { pathToFileURL } from 'node:url';
27+
import { bench, boxplot, do_not_optimize as doNotOptimize, run, summary } from 'mitata';
28+
29+
import {
30+
normalize as experimentNormalize,
31+
preprocess as experimentPreprocess,
32+
src as experimentSrc,
33+
} from '../index.ts';
34+
import { precompile as experimentPrecompile } from '../../compiler/index.ts';
35+
36+
// ---------------------------------------------------------------------------
37+
// CLI args — --control-dir <path> loads @glimmer/syntax from a sibling checkout
38+
// ---------------------------------------------------------------------------
39+
40+
const argv = process.argv.slice(2);
41+
const ctrlIdx = argv.indexOf('--control-dir');
42+
const CONTROL_DIR = ctrlIdx !== -1 ? resolve(argv[ctrlIdx + 1]) : null;
43+
44+
let controlPreprocess = null;
45+
let controlNormalize = null;
46+
let controlSrc = null;
47+
let controlPrecompile = null;
48+
if (CONTROL_DIR) {
49+
const syntaxEntry = pathToFileURL(resolve(CONTROL_DIR, 'packages/@glimmer/syntax/index.ts')).href;
50+
const syntax = await import(syntaxEntry);
51+
controlPreprocess = syntax.preprocess;
52+
controlNormalize = syntax.normalize;
53+
controlSrc = syntax.src;
54+
55+
const compilerEntry = pathToFileURL(
56+
resolve(CONTROL_DIR, 'packages/@glimmer/compiler/index.ts')
57+
).href;
58+
const compiler = await import(compilerEntry);
59+
controlPrecompile = compiler.precompile;
60+
}
61+
62+
// ---------------------------------------------------------------------------
63+
// Fixtures (inline so the whole bench is one file)
64+
// ---------------------------------------------------------------------------
65+
66+
const SMALL = `<div class='user-profile {{if this.isPremium "premium"}}'>
67+
<header class='profile-header'>
68+
<img src={{this.avatarUrl}} alt={{this.username}} class='avatar' />
69+
<h2>{{this.displayName}}</h2>
70+
<p class='bio'>{{this.bio}}</p>
71+
{{#if this.isOwnProfile}}
72+
<button {{on 'click' this.editProfile}}>Edit Profile</button>
73+
{{/if}}
74+
</header>
75+
<nav class='profile-tabs'>
76+
{{#each this.tabs as |tab|}}
77+
<button
78+
class='tab {{if (eq tab.id this.activeTab) "active"}}'
79+
{{on 'click' (fn this.setTab tab.id)}}
80+
>
81+
{{tab.label}}{{#if tab.count}}<span class='count'>{{tab.count}}</span>{{/if}}
82+
</button>
83+
{{/each}}
84+
</nav>
85+
<section class='profile-content'>
86+
{{#if (eq this.activeTab 'posts')}}
87+
{{#each this.posts as |post|}}
88+
<article class='post-card'>
89+
<h3>{{post.title}}</h3><p>{{post.excerpt}}</p>
90+
<footer><time>{{post.createdAt}}</time><span>{{post.views}} views</span></footer>
91+
</article>
92+
{{else}}
93+
<p class='empty-state'>No posts yet.</p>
94+
{{/each}}
95+
{{else if (eq this.activeTab 'followers')}}
96+
{{#each this.followers as |follower|}}
97+
<div class='follower-card'>
98+
<img src={{follower.avatar}} alt={{follower.name}} />
99+
<span>{{follower.name}}</span>
100+
<button {{on 'click' (fn this.followUser follower.id)}}>
101+
{{if follower.isFollowing 'Unfollow' 'Follow'}}
102+
</button>
103+
</div>
104+
{{/each}}
105+
{{/if}}
106+
</section>
107+
</div>
108+
`;
109+
110+
const FIXTURES = {
111+
small: SMALL,
112+
medium: SMALL.repeat(3),
113+
large: SMALL.repeat(22),
114+
};
115+
116+
const SIZES = ['small', 'medium', 'large'];
117+
// Per-size iteration counts — each bench() body runs `iters` calls per sample.
118+
const ITERS = { small: 200, medium: 50, large: 10 };
119+
120+
// ---------------------------------------------------------------------------
121+
// JIT warm-up
122+
// ---------------------------------------------------------------------------
123+
124+
const WARMUP_ROUNDS = 5;
125+
for (const size of SIZES) {
126+
const src = FIXTURES[size];
127+
for (let i = 0; i < WARMUP_ROUNDS; i++) {
128+
experimentPreprocess(src);
129+
experimentNormalize(new experimentSrc.Source(src));
130+
experimentPrecompile(src);
131+
if (controlPreprocess) {
132+
controlPreprocess(src);
133+
controlNormalize(new controlSrc.Source(src));
134+
controlPrecompile(src);
135+
}
136+
}
137+
}
138+
globalThis.gc?.();
139+
140+
// ---------------------------------------------------------------------------
141+
// Register benchmarks. We also track metadata keyed by bench name so we can
142+
// compute a µs/char summary table after mitata is done.
143+
// ---------------------------------------------------------------------------
144+
145+
const meta = new Map(); // name -> { phase, size, chars, iters, variant }
146+
147+
function reg(name, phase, size, chars, iters, variant, fn) {
148+
meta.set(name, { phase, size, chars, iters, variant });
149+
bench(name, fn);
150+
}
151+
152+
const PHASES = ['parse', 'normalize', 'precompile'];
153+
154+
for (const size of SIZES) {
155+
const src = FIXTURES[size];
156+
const iters = ITERS[size];
157+
const chars = src.length;
158+
159+
globalThis.gc?.();
160+
161+
for (const phase of PHASES) {
162+
const expName = `${phase.padEnd(9)} ${size} (${chars}c)${controlPreprocess ? ' (experiment)' : ''}`;
163+
const ctlName = `${phase.padEnd(9)} ${size} (${chars}c) (control)`;
164+
165+
const expFn = (() => {
166+
switch (phase) {
167+
case 'parse':
168+
return () => {
169+
for (let i = 0; i < iters; i++) doNotOptimize(experimentPreprocess(src));
170+
};
171+
case 'normalize':
172+
return () => {
173+
for (let i = 0; i < iters; i++)
174+
doNotOptimize(experimentNormalize(new experimentSrc.Source(src)));
175+
};
176+
case 'precompile':
177+
return () => {
178+
for (let i = 0; i < iters; i++) doNotOptimize(experimentPrecompile(src));
179+
};
180+
}
181+
})();
182+
183+
if (controlPreprocess) {
184+
const ctlFn = (() => {
185+
switch (phase) {
186+
case 'parse':
187+
return () => {
188+
for (let i = 0; i < iters; i++) doNotOptimize(controlPreprocess(src));
189+
};
190+
case 'normalize':
191+
return () => {
192+
for (let i = 0; i < iters; i++)
193+
doNotOptimize(controlNormalize(new controlSrc.Source(src)));
194+
};
195+
case 'precompile':
196+
return () => {
197+
for (let i = 0; i < iters; i++) doNotOptimize(controlPrecompile(src));
198+
};
199+
}
200+
})();
201+
202+
boxplot(() => {
203+
summary(() => {
204+
reg(ctlName, phase, size, chars, iters, 'control', ctlFn);
205+
reg(expName, phase, size, chars, iters, 'experiment', expFn);
206+
});
207+
});
208+
} else {
209+
reg(expName, phase, size, chars, iters, 'experiment', expFn);
210+
}
211+
}
212+
}
213+
214+
const result = await run({ colors: false, throw: true });
215+
216+
// ---------------------------------------------------------------------------
217+
// µs/char summary table — surfaces the O(n²) → O(n) flattening of per-char
218+
// cost across the size ladder. mitata stats.avg is in nanoseconds per sample
219+
// (where each sample runs `iters` inner calls).
220+
// ---------------------------------------------------------------------------
221+
222+
const stats = {}; // phase -> size -> { control_us_per_char, experiment_us_per_char }
223+
for (const trial of result.benchmarks) {
224+
const m = meta.get(trial.alias);
225+
if (!m) continue;
226+
const avgNs = trial.runs[0]?.stats?.avg;
227+
if (!avgNs) continue;
228+
const usPerCall = avgNs / 1000 / m.iters;
229+
const usPerChar = usPerCall / m.chars;
230+
stats[m.phase] ??= {};
231+
stats[m.phase][m.size] ??= {};
232+
stats[m.phase][m.size][m.variant] = usPerChar;
233+
}
234+
235+
function fmt(n) {
236+
if (n === undefined) return ' — ';
237+
return n.toFixed(3).padStart(7);
238+
}
239+
240+
// Derive isolated `normalize-only` cost (ASTv1 → ASTv2 work alone) by
241+
// subtracting the parse cost — `normalize()` itself calls `preprocess()`
242+
// internally, so the direct measurement includes both phases. This is how
243+
// the PR #21314 body's "normalize phase alone" column is derived.
244+
stats['normalize-only'] = {};
245+
for (const size of SIZES) {
246+
const n = stats.normalize?.[size];
247+
const p = stats.parse?.[size];
248+
if (!n || !p) continue;
249+
stats['normalize-only'][size] = {};
250+
for (const v of ['control', 'experiment']) {
251+
if (n[v] !== undefined && p[v] !== undefined) {
252+
stats['normalize-only'][size][v] = Math.max(0, n[v] - p[v]);
253+
}
254+
}
255+
}
256+
257+
const REPORT_PHASES = ['parse', 'normalize', 'normalize-only', 'precompile'];
258+
259+
console.log('\n' + '━'.repeat(72));
260+
console.log('PER-CHAR COST (µs/char) — flat = linear, rising = super-linear');
261+
console.log('━'.repeat(72));
262+
263+
for (const phase of REPORT_PHASES) {
264+
if (!stats[phase]) continue;
265+
console.log(`\n${phase}:`);
266+
const variants = controlPreprocess ? ['control', 'experiment'] : ['experiment'];
267+
const header =
268+
'size'.padEnd(14) +
269+
variants.map((v) => v.padStart(12)).join('') +
270+
(controlPreprocess ? 'speedup'.padStart(12) : '');
271+
console.log(header);
272+
console.log('─'.repeat(header.length));
273+
for (const size of SIZES) {
274+
const row = stats[phase][size];
275+
if (!row) continue;
276+
const cells = variants.map((v) => fmt(row[v])).join(' ');
277+
let speedup = '';
278+
if (controlPreprocess && row.control && row.experiment) {
279+
speedup = `${(row.control / row.experiment).toFixed(2)}×`.padStart(12);
280+
}
281+
console.log(size.padEnd(14) + cells + speedup);
282+
}
283+
}

pnpm-lock.yaml

Lines changed: 11 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)