File size: 9,536 Bytes
444a402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#!/usr/bin/env node
/* bench-compiled.mjs: is a compiled DSSL page as fast as the same page written as plain CSS?
 *
 * "Startup within 10% of equivalent CSS; churn at parity." The page is 4000 cards, each with a title, a paragraph and a tag.
 * Three versions of it are measured in real Chrome, interleaved (so drift hits all three the same), many times each:
 *
 *   hand      CSS written by hand, literal values, no custom properties: the best plain CSS can do
 *   compiled  the same design as DSSL, through `dssl build` (zero-specificity :where() selectors, var() tokens)
 *   inlined   the same, built with --inline-tokens (each token's value written in place of var())
 *   engine    the same DSSL, applied by the runtime engine (inline styles). Reported, never asserted: it is the slow lane.
 *
 * startup  = navigation start to the first paint of the finished page. For the engine that is the moment it says it is ready,
 *            because its first paint is the unstyled page
 * load     = the load event
 * churn    = flip a class on every card, then force style recalculation and layout; median of 60 flips
 * restyle  = change something on <body> that every card inherits from, then force style and layout
 *
 *   node scripts/bench-compiled.mjs [--runs 21] [--cards 4000]        exit 1 if the inlined build is more than 10% behind hand */
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { chromium } from 'playwright-core';
import { toCSS } from '../public/dssl/index.js';
import { findChrome, ROOT } from './lib/browser-harness.mjs';

const arg = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? Number(process.argv[i + 1]) : d; };
const RUNS = arg('--runs', 21), CARDS = arg('--cards', 4000), FLIPS = 60;   // the engine gets 8 flips: it is far slower per flip and would take an hour

const DSSL = `tokens { color { ink: #1a1426; card: #ffffff; line: #dcd7e6; accent: #d1006f; muted: #5d5570 } }
.grid { grid: auto-fill 180; gap: space.3; padding: space.3 }
.card { background: color.card; border: 1 solid color.line; radius: md; padding: space.3; shadow: sm; column: gap 6; transition: 150 }
.card:hover { shadow: md; translate: 0 -2 }
.card.on { background: color.accent; color: white }
.title { font-weight: 700; font-size: 15; color: color.ink }
.text { color: color.muted; font-size: 13; line-height: 1.5 }
.tag { padding: 2 8; radius: full; background: color.accent/12; color: color.accent; font-size: 11 }
@media (width < 700) { .grid { grid-template-columns: 1fr } }`;

// the same design, by hand: literal values, the way a person writes CSS who has no tokens
const HAND = `.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; padding: 12px }
.card { background: #ffffff; border: 1px solid #dcd7e6; border-radius: 14px; padding: 12px; box-shadow: 0 1px 2px color-mix(in oklab, #1a1426 8%, transparent), 0 1px 3px color-mix(in oklab, #1a1426 6%, transparent); display: flex; flex-direction: column; gap: 6px; transition: all 150ms ease }
.card:hover { box-shadow: 0 4px 8px color-mix(in oklab, #1a1426 10%, transparent); translate: 0 -2px }
.card.on { background: #d1006f; color: white }
.title { font-weight: 700; font-size: 15px; color: #1a1426 }
.text { color: #5d5570; font-size: 13px; line-height: 1.5 }
.tag { padding: 2px 8px; border-radius: 9999px; background: color-mix(in oklab, #d1006f 12%, transparent); color: #d1006f; font-size: 11px }
@media (width < 700px) { .grid { grid-template-columns: 1fr } }`;
const compiled = toCSS(DSSL, { banner: false }).css;
const inlined = toCSS(DSSL, { banner: false, inlineTokens: true }).css;

const cards = Array.from({ length: CARDS }, (_, i) => `<div class="card"><div class="title">Card ${i}</div><p class="text">Some text that wraps onto a second line when the column is narrow enough, to give layout real work.</p><span class="tag">tag ${i % 9}</span></div>`).join('');
const doc = (head) => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><title>bench</title>${head}<body style="margin:0;font:14px system-ui"><div class="grid">${cards}</div>`;
const pages = {
  '/hand': doc(`<style>${HAND}</style>`),
  '/compiled': doc(`<style>${compiled}</style>`),
  '/inlined': doc(`<style>${inlined}</style>`),
  '/engine': doc(`<style type="text/dssl">${DSSL}</style><script type="module" src="/dssl/boot.js"></script>`),
};
const server = createServer((req, res) => {
  const u = req.url.split('?')[0];
  if (pages[u]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[u]); }
  if (u.startsWith('/dssl/')) { try { const b = readFileSync(path.join(ROOT, 'public', u)); res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(b); } catch { /* 404 */ } }
  res.writeHead(404); res.end();
}).listen(0);
const port = server.address().port;

const median = (a) => { const s = [...a].sort((x, y) => x - y); return s[Math.floor(s.length / 2)]; };
const browser = await chromium.launch({ executablePath: findChrome(), headless: true, args: ['--no-sandbox', '--disable-gpu', '--hide-scrollbars'] });

async function once(kind) {
  const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' });
  const p = await ctx.newPage();
  await p.addInitScript(() => { window.addEventListener('dssl-ready', () => { window.__ready = performance.now(); }); });
  await p.goto(`http://127.0.0.1:${port}/${kind}`, { waitUntil: 'load' });
  if (kind === 'engine') { await p.waitForFunction('window.DSSL && document.documentElement.classList.contains("dssl-ready")'); await p.waitForTimeout(150); }
  const r = await p.evaluate(async ({ flips, engine }) => {
    const out = {};
    const frame0 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
    await frame0(); await new Promise((r) => setTimeout(r, 60));
    const fcp = performance.getEntriesByName('first-contentful-paint')[0];
    const nav = performance.getEntriesByType('navigation')[0];
    out.fcp = engine ? (window.__ready || NaN) : (fcp ? fcp.startTime : NaN); out.load = nav.loadEventEnd;
    const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
    const els = [...document.querySelectorAll('.card')];
    const flip = [], inh = [];
    for (let i = 0; i < flips; i++) {
      const t0 = performance.now();
      for (const el of els) el.classList.toggle('on');
      if (engine) await frame(); else void document.body.offsetHeight;      // the engine restyles on a later frame; CSS restyles at the read
      void document.body.offsetHeight;
      flip.push(performance.now() - t0);
    }
    for (let i = 0; i < flips; i++) {
      const t0 = performance.now();
      document.body.style.fontSize = i % 2 ? '14px' : '15px';
      void document.body.offsetHeight;
      inh.push(performance.now() - t0);
    }
    const med = (a) => { const s = [...a].sort((x, y) => x - y); return s[Math.floor(s.length / 2)]; };
    out.flip = med(flip.slice(Math.min(5, flips - 3))); out.restyle = med(inh.slice(Math.min(5, flips - 3)));
    return out;
  }, { flips: kind === 'engine' ? 8 : FLIPS, engine: kind === 'engine' });
  await ctx.close();
  return r;
}

const results = { hand: [], compiled: [], inlined: [], engine: [] };
await once('hand');                                                                  // warm the browser, discard
for (let i = 0; i < RUNS; i++) for (const k of i % 2 ? ['engine', 'inlined', 'compiled', 'hand'] : ['hand', 'compiled', 'inlined', 'engine']) results[k].push(await once(k));
await browser.close(); server.close();

const col = (k, f) => { const v = results[k].map((r) => r[f]).filter((x) => Number.isFinite(x)); return v.length ? median(v) : NaN; };
const rows = [['startup (first paint, ms)', 'fcp'], ['load event (ms)', 'load'], ['churn: flip a class on every card (ms)', 'flip'], ['restyle: change an inherited value (ms)', 'restyle']];
console.log(`\n${CARDS} cards, ${RUNS} runs each, medians, interleaved. Chrome ${browser.version()}\n`);
console.log('                                              hand   compiled  vs hand   inlined  vs hand    engine  vs hand');
let worst = 0, worstInl = 0;
const pct = (a, b) => ((a / b) - 1) * 100;
const fmt = (v) => (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
for (const [label, f] of rows) {
  const h = col('hand', f), c = col('compiled', f), n = col('inlined', f), e = col('engine', f);
  if (f === 'fcp' || f === 'flip' || f === 'restyle') { worst = Math.max(worst, pct(c, h)); worstInl = Math.max(worstInl, pct(n, h)); }
  console.log(`${label.padEnd(42)} ${h.toFixed(1).padStart(7)} ${c.toFixed(1).padStart(9)} ${fmt(pct(c, h)).padStart(8)} ${n.toFixed(1).padStart(9)} ${fmt(pct(n, h)).padStart(8)} ${e.toFixed(1).padStart(9)} ${(pct(e, h) >= 0 ? '+' : '') + pct(e, h).toFixed(0) + '%'}`);
}
const bytes = (s) => Buffer.byteLength(s);
console.log(`\nstylesheet size: hand ${bytes(HAND)} B, compiled ${bytes(compiled)} B, inlined ${bytes(inlined)} B (the build also carries the token table and every default token)`);
console.log(`\nworst gap to hand-written CSS on startup, churn and restyle:  compiled ${fmt(worst)}   inlined ${fmt(worstInl)}`);
const verdict = worstInl <= 10;
console.log(`inlined build: ${verdict ? 'within 10%' : 'MORE than 10% behind'} (the claim is about the compiled page at its fastest; the default build keeps tokens as variables so they can change)`);
process.exit(verdict ? 0 : 1);