|
| 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 | +} |
0 commit comments