From 04fc8872414b136634260ae4bced7661abfd709a Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Fri, 23 Oct 2026 12:10:03 -0500 Subject: [PATCH] render benchmarks on vitest --- .gitignore | 1 + packages/ui/bench/README.md | 31 ++++ packages/ui/bench/helpers.ts | 253 ++++++++++++++++++++++++++++++ packages/ui/bench/render.bench.ts | 66 ++++++++ packages/ui/package.json | 1 + pnpm-lock.yaml | 38 ++--- pnpm-workspace.yaml | 10 +- 7 files changed, 369 insertions(+), 31 deletions(-) create mode 100644 packages/ui/bench/README.md create mode 100644 packages/ui/bench/helpers.ts create mode 100644 packages/ui/bench/render.bench.ts diff --git a/.gitignore b/.gitignore index e0ec283..0d0e0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # build artifacts dist +tmp/ # macOS specific files .DS_Store diff --git a/packages/ui/bench/README.md b/packages/ui/bench/README.md new file mode 100644 index 0000000..50d3afe --- /dev/null +++ b/packages/ui/bench/README.md @@ -0,0 +1,31 @@ +# UI render benchmarks + +Run the full Vitest `createUI` frame benchmark matrix with: + +```sh +pnpm --filter @clack/ui bench +``` + +`createUI()` and fixture construction are outside the timed +sample. The initial-render comparison measures starting `ui.main()` through its first +output callback. Each update workload compares six tree shapes at 100, 500, 1,000, and +2,000 component elements: + +- **Grass:** one root with all components as direct children. +- **Bamboo:** a single deep component chain. +- **Bush:** a broad shape that branches into up to two children at each step. +- **Evergreen:** a strong central trunk with side branches along its length. +- **Maple:** one trunk, four main branches, sixteen smaller branches, then leaves + distributed across those branches. +- **Sequoia:** a trunk chain containing three quarters of the components, with a + branching crown of four main branches, sixteen smaller branches, and leaves. + +Each component also has a text leaf. Update workloads are: +- one value update +- 100 batched value updates +- adding one component, +- and adding a 100-component subtree. + +New components and their text nodes are constructed inside the timed mutation, +not during fixture setup to mimic that work being done in a normal TUI setup. +Measurements are not written to a terminal and do not include that timing. diff --git a/packages/ui/bench/helpers.ts b/packages/ui/bench/helpers.ts new file mode 100644 index 0000000..05eca85 --- /dev/null +++ b/packages/ui/bench/helpers.ts @@ -0,0 +1,253 @@ +/* oxlint-disable bombshell-dev/exported-function-async */ +import { PassThrough, Writable } from 'node:stream'; +import type { Document, Element, Text } from '../src/dom.ts'; +import { createUI, type UI } from '../src/ui.ts'; + +/** Wait until an asynchronous render callback has completed. */ +export async function waitFor(predicate: () => boolean): Promise { + const deadline = performance.now() + 30_000; + while (!predicate()) { + if (performance.now() >= deadline) throw new RenderTimeoutError(); + await new Promise((resolve) => setImmediate(resolve)); + } +} + +// see the readme for explanation of tree shapes +export const shapes = ['grass', 'bamboo', 'bush', 'evergreen', 'maple', 'sequoia'] as const; +export type TreeShape = (typeof shapes)[number]; +export const workloads = [ + 'single value update', + 'batch 100 value updates', + 'add one component', + 'add 100-component subtree', +] as const; + +interface WorkloadFixture { + document: Document; + root: Element; + texts: Text[]; +} + +export interface UIWorkloadFixture extends WorkloadFixture { + ui: UI; + input: PassThrough; + output: Writable; + completedWrites(): number; + start(): Promise; + close(): Promise; +} + +/** Apply one deterministic mutation to a benchmark fixture. */ +export function applyWorkload({ + fixture, + workload, + iteration, +}: { + fixture: WorkloadFixture; + workload: (typeof workloads)[number]; + iteration: number; +}): void { + switch (workload) { + case 'single value update': + fixture.texts[0]!.data = `updated ${iteration}`; + break; + case 'batch 100 value updates': { + const start = Math.max(0, fixture.texts.length - 100); + for (let index = start; index < fixture.texts.length; index++) { + fixture.texts[index]!.data = `batch ${iteration} component ${index}`; + } + break; + } + case 'add one component': { + const addedComponent = fixture.document.createElement('document'); + addedComponent.appendChild(fixture.document.createTextNode('added component')); + fixture.root.appendChild(addedComponent); + break; + } + case 'add 100-component subtree': { + const addedSubtree = fixture.document.createElement('document'); + addedSubtree.appendChild(fixture.document.createTextNode('added subtree root')); + for (let index = 0; index < 99; index++) { + const component = fixture.document.createElement('document'); + component.appendChild(fixture.document.createTextNode(`added subtree component ${index}`)); + addedSubtree.appendChild(component); + } + fixture.root.appendChild(addedSubtree); + break; + } + default: + throw new UnsupportedBenchmarkOptionError('workload', workload); + } +} + +/** Create an in-memory UI and its workload tree without starting rendering. */ +export async function setupUI( + nodeCount: number, + shape: (typeof shapes)[number], +): Promise { + const input = new PassThrough(); + let writes = 0; + const output = new Writable({ + write(_chunk, _encoding, callback) { + setImmediate(() => { + writes++; + callback(); + }); + }, + }); + const ui = await createUI({ input, output, width: 80, height: 24 }); + const { texts } = createTree({ document: ui.document, nodeCount, shape }); + const initialWrites = writes; + let started = false; + return { + document: ui.document, + root: ui.document.documentElement, + texts, + ui, + input, + output, + completedWrites: () => writes, + async start() { + if (started) return; + started = true; + void ui.main(); + await waitFor(() => writes > initialWrites); + }, + async close() { + await ui[Symbol.asyncDispose](); + input.destroy(); + output.destroy(); + }, + }; +} + +/** Build one tree shape with the requested component count. */ +export function createTree({ + document, + nodeCount, + shape, +}: { + document: Document; + nodeCount: number; + shape: TreeShape; +}): { components: Element[]; texts: Text[] } { + const root = document.documentElement; + const nodes = Array.from({ length: nodeCount }, () => document.createElement('document')); + const texts = nodes.map((_, index) => document.createTextNode(`component ${index}`)); + + switch (shape) { + case 'grass': + for (let index = 0; index < nodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + root.appendChild(nodes[index]!); + } + break; + case 'bamboo': { + let parent = root; + for (let index = 0; index < nodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + parent.appendChild(nodes[index]!); + parent = nodes[index]!; + } + break; + } + case 'bush': + for (let index = 0; index < nodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + const parentIndex = Math.floor((index - 1) / 2); + (nodes[parentIndex] ?? root).appendChild(nodes[index]!); + } + break; + case 'evergreen': { + const trunkNodeCount = Math.ceil(nodeCount / 3); + for (let index = 0; index < trunkNodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + (index === 0 ? root : nodes[index - 1]!).appendChild(nodes[index]!); + } + for (let index = trunkNodeCount; index < nodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + const trunkIndex = (index - trunkNodeCount) % trunkNodeCount; + nodes[trunkIndex]!.appendChild(nodes[index]!); + } + break; + } + case 'maple': { + const mainBranchCount = 4; + const minorBranchesPerMain = 4; + nodes[0]!.appendChild(texts[0]!); + root.appendChild(nodes[0]!); + + for (let index = 1; index <= mainBranchCount; index++) { + nodes[index]!.appendChild(texts[index]!); + nodes[0]!.appendChild(nodes[index]!); + } + + const minorBranchStart = mainBranchCount + 1; + const leafStart = minorBranchStart + mainBranchCount * minorBranchesPerMain; + for (let index = minorBranchStart; index < leafStart; index++) { + nodes[index]!.appendChild(texts[index]!); + const mainBranchIndex = 1 + Math.floor((index - minorBranchStart) / minorBranchesPerMain); + nodes[mainBranchIndex]!.appendChild(nodes[index]!); + } + + for (let index = leafStart; index < nodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + const minorBranchIndex = + minorBranchStart + ((index - leafStart) % (leafStart - minorBranchStart)); + nodes[minorBranchIndex]!.appendChild(nodes[index]!); + } + break; + } + case 'sequoia': { + const trunkNodeCount = Math.floor((nodeCount * 3) / 4); + const crownRootIndex = trunkNodeCount - 1; + for (let index = 0; index < trunkNodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + (index === 0 ? root : nodes[index - 1]!).appendChild(nodes[index]!); + } + + const mainBranchCount = 4; + const minorBranchesPerMain = 4; + const mainBranchStart = trunkNodeCount; + const minorBranchStart = mainBranchStart + mainBranchCount; + const leafStart = minorBranchStart + mainBranchCount * minorBranchesPerMain; + for (let index = mainBranchStart; index < minorBranchStart; index++) { + nodes[index]!.appendChild(texts[index]!); + nodes[crownRootIndex]!.appendChild(nodes[index]!); + } + for (let index = minorBranchStart; index < leafStart; index++) { + nodes[index]!.appendChild(texts[index]!); + const mainBranchIndex = + mainBranchStart + Math.floor((index - minorBranchStart) / minorBranchesPerMain); + nodes[mainBranchIndex]!.appendChild(nodes[index]!); + } + for (let index = leafStart; index < nodeCount; index++) { + nodes[index]!.appendChild(texts[index]!); + const minorBranchIndex = + minorBranchStart + ((index - leafStart) % (leafStart - minorBranchStart)); + nodes[minorBranchIndex]!.appendChild(nodes[index]!); + } + break; + } + default: + throw new UnsupportedBenchmarkOptionError('tree shape', shape); + } + + return { components: nodes, texts }; +} + +class RenderTimeoutError extends Error { + constructor() { + super('Timed out waiting for a rendered frame'); + this.name = 'RenderTimeoutError'; + } +} + +class UnsupportedBenchmarkOptionError extends Error { + readonly code = 'UNSUPPORTED_BENCHMARK_OPTION'; + + constructor(option: string, value: unknown) { + super(`Unsupported benchmark ${option}: ${String(value)}`); + this.name = 'UnsupportedBenchmarkOptionError'; + } +} diff --git a/packages/ui/bench/render.bench.ts b/packages/ui/bench/render.bench.ts new file mode 100644 index 0000000..effe7c9 --- /dev/null +++ b/packages/ui/bench/render.bench.ts @@ -0,0 +1,66 @@ +import { test } from 'vitest'; +import { + applyWorkload, + setupUI, + shapes, + waitFor, + workloads, + type UIWorkloadFixture, +} from './helpers.ts'; + +const nodeCounts = [100, 500, 1_000, 2_000] as const; +const benchmarkOptions = { + time: 300, + iterations: 10, + warmupTime: 50, + warmupIterations: 2, +}; + +const cases = nodeCounts.flatMap((nodeCount) => + shapes.map((shape) => ({ name: `${shape} (${nodeCount})`, nodeCount, shape })), +); + +test('initial render', async ({ bench }) => { + const entries = cases.map((benchmarkCase) => { + let fixture: UIWorkloadFixture; + return bench( + benchmarkCase.name, + { + async beforeEach() { + fixture = await setupUI(benchmarkCase.nodeCount, benchmarkCase.shape); + }, + async afterEach() { + await fixture.close(); + }, + }, + async () => fixture.start(), + ); + }); + await bench.compare(...entries, benchmarkOptions); +}); + +test.for(workloads)('%s', async (workload, { bench }) => { + const entries = cases.map((benchmarkCase) => { + let fixture: UIWorkloadFixture; + let iteration = 0; + return bench( + benchmarkCase.name, + { + async beforeEach() { + fixture = await setupUI(benchmarkCase.nodeCount, benchmarkCase.shape); + await fixture.start(); + iteration = 0; + }, + async afterEach() { + await fixture.close(); + }, + }, + async () => { + const writeCount = fixture.completedWrites(); + applyWorkload({ fixture, workload, iteration: iteration++ }); + await waitFor(() => fixture.completedWrites() > writeCount); + }, + ); + }); + await bench.compare(...entries, benchmarkOptions); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 6d3cb5f..0b1b0bf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ }, "scripts": { "dev": "bsh dev", + "bench": "NODE_OPTIONS=--experimental-strip-types vitest bench bench/render.bench.ts --reporter=verbose --experimental.viteModuleRunner=false", "build": "bsh build --dts", "format": "bsh format", "lint": "bsh lint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37a7903..68f3a5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@bomb.sh/tty': - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.10.0 + version: 0.10.0 importers: @@ -34,7 +34,7 @@ importers: dependencies: '@bomb.sh/tty': specifier: 'catalog:' - version: 0.7.0 + version: 0.10.0 packages: @@ -45,8 +45,8 @@ packages: resolution: {integrity: sha512-iaWws8S5kMuS6zImKXPSguUoQYA5M8XlK4ObVJI5jvubNeSlCI6feGPwex9GHyO+eIsi6izv/swO1ceO2MWEvw==} hasBin: true - '@bomb.sh/tty@0.7.0': - resolution: {integrity: sha512-htTavc3o84vR6uSoyF+Y9u81GG8NoTJkwOovahcNOY4nVeiM86vodCHdPvAXoj2ucmx04frpwMrOJ4ipLSSJfw==} + '@bomb.sh/tty@0.10.0': + resolution: {integrity: sha512-ZOfMnVMhlik9CtSv5NJV4409T6SitS9GC93YZrbq1Be1nmbv83gf5wQHdOc+ic9QOb2kgpgAXKrMV6WiHLSTPA==} engines: {node: '>= 22'} '@emnapi/core@1.10.0': @@ -1436,10 +1436,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -1529,10 +1525,6 @@ packages: resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} engines: {node: '>=20.0.0'} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} - engines: {node: '>=18'} - tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -1753,7 +1745,7 @@ snapshots: oxfmt: 0.74.0 oxlint: 1.89.0 publint: 0.3.21 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tsdown: 0.23.1(oxc-resolver@11.20.0)(publint@0.3.21)(typescript@7.0.2)(unrun@0.2.39) typescript: 7.0.2 vitest: 5.0.5(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.1)) @@ -1790,7 +1782,7 @@ snapshots: - vite-plus - vue-tsc - '@bomb.sh/tty@0.7.0': {} + '@bomb.sh/tty@0.10.0': {} '@emnapi/core@1.10.0': dependencies: @@ -2516,9 +2508,9 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 formatly@0.3.0: dependencies: @@ -2549,13 +2541,13 @@ snapshots: knip@6.16.1: dependencies: - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.7) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.7.0 oxc-parser: 0.133.0 oxc-resolver: 11.20.0 - picomatch: 4.0.4 + picomatch: 4.0.7 smol-toml: 1.6.1 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 @@ -2728,8 +2720,6 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.4: {} - picomatch@4.0.7: {} playwright-core@1.64.1: @@ -2849,14 +2839,12 @@ snapshots: tinybench@6.2.2: {} - tinyexec@1.2.4: {} - tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b086959..c295565 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,13 +1,11 @@ packages: - - 'packages/*' - - 'examples/*' - + - packages/* + - examples/* catalog: - '@bomb.sh/tty': '^0.7.0' - + '@bomb.sh/tty': ^0.10.0 onlyBuiltDependencies: - node-pty - publicHoistPattern: - oxlint - oxfmt + - vitest