From d539726611efe422a0b282c3ff4a53f97d3eff72 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 22:05:52 +0800 Subject: [PATCH] Add Yoga benchmark comparisons --- .agents/docs/benchmark.md | 10 +- .../components/BenchmarkResults.vue | 196 ++++++++++-------- apps/website/benchmarks/index.md | 6 +- benchmarks/README.md | 8 +- benchmarks/package.json | 5 +- benchmarks/run.ts | 131 ++++++------ benchmarks/scenario.ts | 58 +++++- benchmarks/suite.ts | 75 +++++-- benchmarks/tsconfig.json | 4 +- benchmarks/worker.ts | 40 +++- .../yoga-coding-agent-chat/benchmark.ts | 153 ++++++++++++++ benchmarks/yoga-deep-tree/benchmark.ts | 77 +++++++ benchmarks/yoga-wide-tree/benchmark.ts | 58 ++++++ pnpm-lock.yaml | 12 ++ pnpm-workspace.yaml | 1 + vite.config.ts | 4 +- 16 files changed, 641 insertions(+), 197 deletions(-) create mode 100644 benchmarks/yoga-coding-agent-chat/benchmark.ts create mode 100644 benchmarks/yoga-deep-tree/benchmark.ts create mode 100644 benchmarks/yoga-wide-tree/benchmark.ts diff --git a/.agents/docs/benchmark.md b/.agents/docs/benchmark.md index 0feb2f6..97b20ff 100644 --- a/.agents/docs/benchmark.md +++ b/.agents/docs/benchmark.md @@ -1,7 +1,5 @@ # Benchmark Design -[VOUCHED @hyfdev 2026-08-16] - ## Purpose and comparison boundary The public benchmark measures TaffyJS as a JavaScript product in Node.js. Every timed operation starts at a public JavaScript API and includes the complete implementation cost beneath that call. JavaScript validation and conversion, wrapper and compatibility work, Node-API, emnapi, WASI, Wasm memory transfer, callbacks, output construction, redundant computation, and unnecessary data movement all remain part of the result. The benchmark must not bypass a public wrapper, call a private binding, substitute an internal Rust or C++ timer, preconvert data into a private representation, or subtract work in order to make implementations look more alike. @@ -9,7 +7,7 @@ The public benchmark measures TaffyJS as a JavaScript product in Node.js. Every The benchmark has two comparison groups: - The Taffy API group runs the same public benchmark source against `@taffyjs/node` and `@taffyjs/wasm`. -- The Yoga API group runs the same public Yoga-shaped benchmark source against `@taffyjs/yoga` and `yoga-layout`. A future `@taffyjs/yoga-wasm` target joins this group without changing the scenarios, runner, result format, or page structure. +- The Yoga API group runs the same public Yoga-shaped benchmark source against `@taffyjs/yoga`, `yoga-layout@3.2.1`, and `@taffyjs/yoga-wasm`. Only implementations completing a semantically equivalent user transaction receive a speed comparison. Validation happens outside the timed interval and must never normalize the measured path. An unsupported implementation is reported as unsupported rather than receiving a transformed workload. Different API groups do not produce a shared ranking, and the benchmark never combines unrelated scenarios into an overall score or winner. @@ -21,7 +19,7 @@ Every benchmark case is a scenario with a named question, input and scale, publi Scenario timing follows the transaction being investigated. An initial-layout scenario may create the public tree, compute layout, and read the outputs the consumer needs. A persistent-tree scenario may mutate an existing tree, recompute it, and read the affected outputs without rebuilding the tree. A batch scenario may create, compute, read or serialize, and finish one complete item. Fixture parsing, result validation, and harness bookkeeping stay outside the timed interval unless they are themselves the public operation named by the scenario. A total is measured directly rather than reconstructed by adding separately aggregated phase results. -The exact initial scenario set remains open until it is researched and reviewed. It must be fixed before the official run whose result will be published; scenarios must not be selected or modified in response to which implementation wins. +The current suite covers a modeled coding-agent chat transaction plus one representative 1,000-leaf wide tree and 511-node deep tree for each public API group. Captured application trees may join later as ordinary scenarios when every target can execute the same supported public transaction. The suite must be fixed before the official run whose result will be published; scenarios must not be selected or modified in response to which implementation wins. ## Repository ownership @@ -62,7 +60,7 @@ Local measurements belong under ignored `benchmarks/results/local/`. The website The published dataset is a self-contained snapshot of the facts needed to interpret its numbers, including the scenario name and parameters, measured transaction, targets, results, environment, and TaffyJS revision. It retains enough sample evidence to support the displayed comparison. Its concrete schema and file encoding remain implementation details. The dataset is committed because it is evidence for a public performance claim, not a distributable build artifact. -The benchmark page presents every scenario at the same level. Each applicable scenario shows a Taffy API comparison and, separately, a Yoga API comparison. It does not display browser results, a Diagnostics section, an overall score, an original-data download interface, or an empty column for a package that does not exist. Target columns come from the result's target list, so adding `@taffyjs/yoga-wasm` later is a target addition rather than a page redesign. +The benchmark page presents every scenario at the same level. Each applicable scenario shows either a Taffy API comparison or a Yoga API comparison without ranking unlike APIs together. It does not display browser results, a Diagnostics section, an overall score, an original-data download interface, or an empty column for a package that does not exist. Target columns come from each comparison group's target list. The page footer shows only the concise environment and source identity needed by a reader, including the Node.js version, operating system and benchmark machine, and TaffyJS commit. @@ -70,4 +68,4 @@ The page footer shows only the concise environment and source identity needed by First select and document the initial scenarios and their exact transactions. Then implement the top-level benchmark workspace and the two comparison groups, prove completion and semantic equivalence outside timing, run the complete suite on the fixed Node.js benchmark host, update the retained dataset, and let the website render it. Before publication, review the result for comparison validity, measurement stability, and avoidable harness complexity. -The current delivery implements only the Taffy API comparison between `@taffyjs/node` and `@taffyjs/wasm`. The Yoga API comparison, captured Yoga application trees, and `@taffyjs/yoga-wasm` remain follow-up work after the relevant package pull requests merge. The current work may establish the shared result boundary, but it must not publish empty Yoga targets or manufacture a compatibility implementation in advance. +The current delivery implements the Taffy API comparison between `@taffyjs/node` and `@taffyjs/wasm` plus the Yoga API comparison between `@taffyjs/yoga`, Yoga 3.2.1, and `@taffyjs/yoga-wasm`. Captured Yoga application trees remain optional follow-up scenarios rather than a prerequisite for comparing the shipped public packages. diff --git a/apps/website/.vitepress/components/BenchmarkResults.vue b/apps/website/.vitepress/components/BenchmarkResults.vue index 7cd4889..3cda560 100644 --- a/apps/website/.vitepress/components/BenchmarkResults.vue +++ b/apps/website/.vitepress/components/BenchmarkResults.vue @@ -29,6 +29,7 @@ interface BenchmarkScenario { interface BenchmarkComparisonGroup { readonly id: string; + readonly name: string; readonly targets: readonly BenchmarkTarget[]; readonly scenarios: readonly BenchmarkScenario[]; } @@ -63,35 +64,40 @@ if (report.source.dirty) { throw new Error("Published benchmark results must come from a clean worktree"); } -const comparisonGroup = report.comparisonGroups.find(({ id }) => id === "taffy-api"); -if (!comparisonGroup) { - throw new Error("Published benchmark results are missing the Taffy API comparison"); -} - -const nodeTarget = comparisonGroup.targets.find( - ({ packageName }) => packageName === "@taffyjs/node", -); -if (!nodeTarget) { - throw new Error("Published benchmark results are missing @taffyjs/node"); +if (report.comparisonGroups.length === 0) { + throw new Error("Published benchmark results contain no comparison groups"); } -const scenarios = comparisonGroup.scenarios.map((scenario) => { - const nodeResult = scenario.results.find(({ targetId }) => targetId === nodeTarget.id); - if (!nodeResult) { - throw new Error(`${scenario.id} is missing its @taffyjs/node result`); +const comparisonGroups = report.comparisonGroups.map((group) => { + const baselineTarget = group.targets[0]; + if (!baselineTarget) { + throw new Error(`${group.id} contains no benchmark targets`); } return { - ...scenario, - rows: comparisonGroup.targets.map((target) => { - const result = scenario.results.find(({ targetId }) => targetId === target.id); - if (!result) { - throw new Error(`${scenario.id} is missing its ${target.packageName} result`); + ...group, + baselineTarget, + scenarios: group.scenarios.map((scenario) => { + const baselineResult = scenario.results.find( + ({ targetId }) => targetId === baselineTarget.id, + ); + if (!baselineResult) { + throw new Error(`${scenario.id} is missing its ${baselineTarget.packageName} result`); } + return { - target, - result, - relativeThroughput: result.hz / nodeResult.hz, + ...scenario, + rows: group.targets.map((target) => { + const result = scenario.results.find(({ targetId }) => targetId === target.id); + if (!result) { + throw new Error(`${scenario.id} is missing its ${target.packageName} result`); + } + return { + target, + result, + relativeThroughput: result.hz / baselineResult.hz, + }; + }), }; }), }; @@ -140,69 +146,81 @@ const operatingSystem =

-

{{ scenario.name }}

-

{{ scenario.question }}

-

{{ scenario.description }}

- -
-
-

Timed transaction

-

{{ scenario.transaction }}

-
-
-

Scale

-
-
-
{{ formatParameterName(name) }}
-
{{ formatParameterValue(value) }}
-
-
-
-
+

{{ group.name }}

-
- - - - - - - - - - - - - - - - - - - - -
- {{ - scenario.name - }} - throughput and latency by package -
Packageops/sMean (ms)Median (ms) - Relative throughput - Node = 1.00× -
- {{ row.target.packageName }} - {{ throughputFormatter.format(row.result.hz) }}{{ durationFormatter.format(row.result.meanMs) }}{{ durationFormatter.format(row.result.medianMs) }}{{ throughputFormatter.format(row.relativeThroughput) }}×
-
+

{{ scenario.name }}

+

{{ scenario.question }}

+

{{ scenario.description }}

+ +
+
+

Timed transaction

+

{{ scenario.transaction }}

+
+
+

Scale

+
+
+
{{ formatParameterName(name) }}
+
{{ formatParameterValue(value) }}
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ {{ + group.name + }} + {{ + scenario.name + }} + throughput and latency by package +
Packageops/sMean (ms)Median (ms) + Relative throughput + {{ group.baselineTarget.label }} = 1.00× +
+ {{ row.target.packageName }} + {{ throughputFormatter.format(row.result.hz) }}{{ durationFormatter.format(row.result.meanMs) }}{{ durationFormatter.format(row.result.medianMs) }}{{ throughputFormatter.format(row.relativeThroughput) }}×
+
+
@@ -246,13 +264,21 @@ const operatingSystem = color: var(--vp-c-text-2); } -.benchmark-scenario { +.benchmark-group { margin-top: 44px; +} + +.benchmark-group > h2 { + margin: 0; +} + +.benchmark-scenario { + margin-top: 28px; padding-top: 34px; border-top: 1px solid var(--vp-c-divider); } -.benchmark-scenario h2 { +.benchmark-scenario > h3 { margin: 0; padding: 0; border: 0; @@ -288,7 +314,7 @@ const operatingSystem = border-left: 1px solid var(--vp-c-divider); } -.benchmark-boundary h3 { +.benchmark-boundary h4 { margin: 0 0 8px; font-size: 0.8rem; font-weight: 700; diff --git a/apps/website/benchmarks/index.md b/apps/website/benchmarks/index.md index 0e52954..c5cbaf4 100644 --- a/apps/website/benchmarks/index.md +++ b/apps/website/benchmarks/index.md @@ -2,10 +2,10 @@ import BenchmarkResults from "../.vitepress/components/BenchmarkResults.vue"; -# Node vs. Wasm +# Benchmarks -These results compare `@taffyjs/node` and `@taffyjs/wasm` in Node.js through the same public JavaScript API. Each timed end-to-end transaction starts after package import and includes public tree construction, JavaScript-to-native or JavaScript-to-Wasm data conversion, layout computation, and reading the requested layout results. The benchmark does not isolate engine time or subtract wrapper overhead. +These results compare complete public JavaScript transactions in Node.js. The Taffy API group compares `@taffyjs/node` with `@taffyjs/wasm`. The Yoga API group compares packages through the same Yoga-shaped API. Each transaction starts after package import and includes public tree construction, data conversion, layout computation, reading the requested layout results, and any lifecycle work required by that API. -Each scenario asks a different question and stands on its own. Relative throughput uses `@taffyjs/node` as the `1.00×` reference within that scenario; the results are not combined into an overall score or winner. +Each scenario asks a different question and stands on its own. Relative throughput uses the first target as the `1.00×` reference within its own API group. Results from different APIs or scenarios are not combined into an overall score or winner. diff --git a/benchmarks/README.md b/benchmarks/README.md index 5517bea..4e89ab8 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,6 +1,6 @@ # Benchmarks -This workspace measures complete public JavaScript transactions in Node.js. The current comparison uses the same Taffy API scenarios for `@taffyjs/node` and `@taffyjs/wasm`; it does not bypass either package's public wrapper or remove conversion and output costs. +This workspace measures complete public JavaScript transactions in Node.js. The Taffy API scenarios run unchanged against `@taffyjs/node` and `@taffyjs/wasm`. The Yoga API scenarios likewise run unchanged against `@taffyjs/yoga`, `yoga-layout@3.2.1`, and `@taffyjs/yoga-wasm`. Neither group bypasses a public wrapper or removes conversion and output costs. Run `vp run benchmark` from the repository root for a local measurement. It writes the result to the ignored `benchmarks/results/local/latest.json`; `vp run benchmark --scenario=` selects one or more exact scenario IDs for local research without adding more public tasks. Run `vp run benchmark:update-website` only for a complete publication run; it rejects scenario filters and atomically replaces `benchmarks/results/published.json` after every scenario and target succeeds. @@ -8,7 +8,7 @@ Local runs use one quick isolated worker round per target and scenario. A websit Each worker completes the configured warmup and requests garbage collection before timed sampling starts, giving each measurement the same initial heap condition. Garbage collection that occurs during the timed end-to-end transactions remains part of the measured cost. -Each scenario directory owns its workload and states the user transaction it measures. `run.ts` coordinates serial, isolated worker processes, validates equivalent output, captures the environment, and persists the complete result. Each worker imports exactly one target before timing and uses Tinybench for one scenario, so package initialization is excluded without letting target heaps or garbage collection contaminate one another. Yoga-shaped scenarios and targets will be added after their package work merges rather than appearing as placeholders now. +Each scenario directory owns its workload and states the user transaction it measures. `run.ts` coordinates serial, isolated worker processes, validates equivalent output within each API group, captures the environment, and persists the complete result. Each worker imports exactly one target before timing and uses Tinybench for one scenario, so package initialization is excluded without letting target heaps or garbage collection contaminate one another. Yoga scenarios include public Node creation, style setters, topology construction, layout calculation, computed-layout reads, and explicit release. The wide and deep categories follow the questions asked by [Taffy's upstream large-tree benchmarks](https://github.com/DioxusLabs/taffy/blob/3ce6bef173028b1cb33d782de8c262e6dd8edd39/benches/benches/flexbox.rs), but their boundary is intentionally different: Taffy upstream measures layout computation with a prepared tree, while this workspace measures public JavaScript tree construction, layout, and result reads as one transaction. The coding-agent chat case is explicitly a maintained model, not a captured production application. @@ -16,6 +16,6 @@ The current suite keeps one representative scale for each wide-tree and deep-tre ## Delivery status -- The current phase implements and validates the Taffy API comparison for native Node and WASI Wasm. +- The current phase implements and validates both comparison groups: native Node versus WASI Wasm through the Taffy API, and Taffy Yoga versus Yoga 3.2.1 versus Taffy Yoga Wasm through the Yoga API. - Before publishing the benchmark page, choose the official machine and Node.js version, review the sampling settings against repeated runs, generate the complete retained result, and render that self-contained result in the website. -- After the Yoga package work merges, add the Yoga API comparison and captured application trees through its own public transactions. Add a Yoga Wasm target only when that package exists. +- Captured application trees may be added later as ordinary scenarios when they can run the same supported public transaction across every target; they are not a prerequisite for the current comparison. diff --git a/benchmarks/package.json b/benchmarks/package.json index 0585119..7a3e2e2 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -5,7 +5,10 @@ "type": "module", "dependencies": { "@taffyjs/node": "workspace:*", - "@taffyjs/wasm": "workspace:*" + "@taffyjs/wasm": "workspace:*", + "@taffyjs/yoga": "workspace:*", + "@taffyjs/yoga-wasm": "workspace:*", + "yoga-layout": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/benchmarks/run.ts b/benchmarks/run.ts index 91f3f50..ca0efb2 100644 --- a/benchmarks/run.ts +++ b/benchmarks/run.ts @@ -6,17 +6,14 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { + BenchmarkComparisonGroup, BenchmarkProfile, + BenchmarkScenarioMetadata, + BenchmarkTarget, BenchmarkWorkerResult, SampledBenchmarkResult, - TaffyBenchmarkScenario, } from "./scenario.ts"; -import { - benchmarkProfiles, - taffyScenarios, - taffyTargets, - type TaffyBenchmarkTarget, -} from "./suite.ts"; +import { benchmarkComparisonGroups, benchmarkProfiles } from "./suite.ts"; const benchmarkDirectory = dirname(fileURLToPath(import.meta.url)); const repositoryDirectory = resolve(benchmarkDirectory, ".."); @@ -38,13 +35,13 @@ if (updateWebsite && requestedScenarioIds.length > 0) { throw new Error("benchmark:update-website requires the complete scenario suite"); } const requestedScenarioSet = new Set(requestedScenarioIds); -const selectedScenarios = - requestedScenarioSet.size === 0 - ? taffyScenarios - : taffyScenarios.filter(({ id }) => requestedScenarioSet.has(id)); +const knownScenarioIds = new Set(); +for (const group of benchmarkComparisonGroups) { + for (const scenario of group.scenarios) knownScenarioIds.add(scenario.id); +} for (const requestedScenarioId of requestedScenarioSet) { assert.ok( - selectedScenarios.some(({ id }) => id === requestedScenarioId), + knownScenarioIds.has(requestedScenarioId), `Unknown benchmark scenario ${requestedScenarioId}`, ); } @@ -60,13 +57,14 @@ function repositoryOutput(args: readonly string[]): string { } function runWorker( - target: TaffyBenchmarkTarget, - scenario: TaffyBenchmarkScenario, + group: BenchmarkComparisonGroup, + target: BenchmarkTarget, + scenario: BenchmarkScenarioMetadata, profile: BenchmarkProfile, ): BenchmarkWorkerResult { const output = execFileSync( process.execPath, - ["--expose-gc", workerPath, target.id, scenario.id, profile.id], + ["--expose-gc", workerPath, group.id, target.id, scenario.id, profile.id], { cwd: benchmarkDirectory, encoding: "utf8", @@ -77,10 +75,12 @@ function runWorker( } function assertWorkerResult( - target: TaffyBenchmarkTarget, - scenario: TaffyBenchmarkScenario, + group: BenchmarkComparisonGroup, + target: BenchmarkTarget, + scenario: BenchmarkScenarioMetadata, worker: BenchmarkWorkerResult, ): void { + assert.equal(worker.groupId, group.id, `${scenario.id} returned the wrong comparison group`); assert.equal(worker.targetId, target.id, `${scenario.id} returned the wrong target`); assert.equal(worker.scenarioId, scenario.id, `${target.id} returned the wrong scenario`); assert.ok( @@ -106,7 +106,7 @@ function assertWorkerResult( } function assertEquivalentChecksums( - scenario: TaffyBenchmarkScenario, + scenario: BenchmarkScenarioMetadata, workers: readonly BenchmarkWorkerResult[], ): void { const expected = workers[0]?.checksum; @@ -126,7 +126,7 @@ function median(values: readonly number[]): number { return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; } -function summarizeTarget(target: TaffyBenchmarkTarget, rounds: readonly SampledBenchmarkResult[]) { +function summarizeTarget(target: BenchmarkTarget, rounds: readonly SampledBenchmarkResult[]) { assert.ok(rounds.length > 0, `${target.id} produced no benchmark rounds`); return { targetId: target.id, @@ -148,8 +148,8 @@ function summarizeTarget(target: TaffyBenchmarkTarget, rounds: readonly SampledB } function assertPublicationStability( - scenario: TaffyBenchmarkScenario, - target: TaffyBenchmarkTarget, + scenario: BenchmarkScenarioMetadata, + target: BenchmarkTarget, profile: BenchmarkProfile, rounds: readonly SampledBenchmarkResult[], ): void { @@ -173,19 +173,20 @@ function assertPublicationStability( } function printScenario( - scenario: TaffyBenchmarkScenario, + group: BenchmarkComparisonGroup, + scenario: BenchmarkScenarioMetadata, results: readonly ReturnType[], ): void { - const nativeHz = results[0]?.hz; - assert.ok(nativeHz, `${scenario.id} is missing the native result`); - console.log(`\n${scenario.name}\n${scenario.question}`); + const baselineHz = results[0]?.hz; + assert.ok(baselineHz, `${scenario.id} is missing the baseline result`); + console.log(`\n${group.name} · ${scenario.name}\n${scenario.question}`); console.table( results.map((result) => ({ target: result.packageName, "ops/s": Math.round(result.hz).toLocaleString("en-US"), "mean (ms)": result.meanMs.toFixed(4), "median (ms)": result.medianMs.toFixed(4), - "vs native": `${(result.hz / nativeHz).toFixed(2)}x`, + relative: `${(result.hz / baselineHz).toFixed(2)}x`, rounds: result.roundCount, samples: result.sampleCount, "max rme": `${result.maxRelativeMarginOfError.toFixed(2)}%`, @@ -201,35 +202,50 @@ if (updateWebsite && source.dirty) { throw new Error("benchmark:update-website requires a clean worktree"); } -const scenarioResults = []; -for (const scenario of selectedScenarios) { - const workers: BenchmarkWorkerResult[] = []; - for (let round = 0; round < profile.rounds; round += 1) { - const targets = round % 2 === 0 ? taffyTargets : [...taffyTargets].reverse(); - for (const target of targets) { - const worker = runWorker(target, scenario, profile); - assertWorkerResult(target, scenario, worker); - workers.push(worker); +const comparisonGroupResults = []; +for (const group of benchmarkComparisonGroups) { + const selectedScenarios = group.scenarios.filter( + ({ id }) => requestedScenarioSet.size === 0 || requestedScenarioSet.has(id), + ); + if (selectedScenarios.length === 0) continue; + + const scenarioResults = []; + for (const scenario of selectedScenarios) { + const workers: BenchmarkWorkerResult[] = []; + for (let round = 0; round < profile.rounds; round += 1) { + const targets = round % 2 === 0 ? group.targets : [...group.targets].reverse(); + for (const target of targets) { + const worker = runWorker(group, target, scenario, profile); + assertWorkerResult(group, target, scenario, worker); + workers.push(worker); + } } + assertEquivalentChecksums(scenario, workers); + const results = group.targets.map((target) => { + const rounds = workers + .filter(({ targetId }) => targetId === target.id) + .map(({ result }) => result); + assert.equal(rounds.length, profile.rounds, `${scenario.id}/${target.id} missed a round`); + assertPublicationStability(scenario, target, profile, rounds); + return summarizeTarget(target, rounds); + }); + printScenario(group, scenario, results); + scenarioResults.push({ + id: scenario.id, + name: scenario.name, + question: scenario.question, + description: scenario.description, + transaction: scenario.transaction, + parameters: scenario.parameters, + results, + }); } - assertEquivalentChecksums(scenario, workers); - const results = taffyTargets.map((target) => { - const rounds = workers - .filter(({ targetId }) => targetId === target.id) - .map(({ result }) => result); - assert.equal(rounds.length, profile.rounds, `${scenario.id}/${target.id} missed a round`); - assertPublicationStability(scenario, target, profile, rounds); - return summarizeTarget(target, rounds); - }); - printScenario(scenario, results); - scenarioResults.push({ - id: scenario.id, - name: scenario.name, - question: scenario.question, - description: scenario.description, - transaction: scenario.transaction, - parameters: scenario.parameters, - results, + + comparisonGroupResults.push({ + id: group.id, + name: group.name, + targets: group.targets.map(({ id, label, packageName }) => ({ id, label, packageName })), + scenarios: scenarioResults, }); } @@ -245,14 +261,7 @@ const report = { cpu: cpus()[0]?.model ?? "unknown", }, profile, - comparisonGroups: [ - { - id: "taffy-api", - name: "Taffy API", - targets: taffyTargets.map(({ id, label, packageName }) => ({ id, label, packageName })), - scenarios: scenarioResults, - }, - ], + comparisonGroups: comparisonGroupResults, }; const outputPath = updateWebsite diff --git a/benchmarks/scenario.ts b/benchmarks/scenario.ts index 6f4c8a4..1e83327 100644 --- a/benchmarks/scenario.ts +++ b/benchmarks/scenario.ts @@ -1,17 +1,48 @@ import type { NodeId, TaffyTree } from "@taffyjs/node"; +import type { Node as YogaNode } from "@taffyjs/yoga"; export type TaffyApi = typeof import("@taffyjs/node"); +export type YogaApi = typeof import("@taffyjs/yoga"); -export interface TaffyBenchmarkScenario { +export interface BenchmarkScenarioMetadata { readonly id: string; readonly name: string; readonly question: string; readonly description: string; readonly transaction: string; readonly parameters: Readonly>; - createTransaction(api: TaffyApi): () => number; } +export interface BenchmarkScenario extends BenchmarkScenarioMetadata { + createTransaction(api: TApi): () => number; +} + +export type TaffyBenchmarkScenario = BenchmarkScenario; +export type YogaBenchmarkScenario = BenchmarkScenario; + +export interface BenchmarkTarget { + readonly id: string; + readonly label: string; + readonly packageName: string; +} + +interface BenchmarkComparisonGroupBase { + readonly name: string; + readonly targets: readonly BenchmarkTarget[]; +} + +export interface TaffyBenchmarkComparisonGroup extends BenchmarkComparisonGroupBase { + readonly id: "taffy-api"; + readonly scenarios: readonly TaffyBenchmarkScenario[]; +} + +export interface YogaBenchmarkComparisonGroup extends BenchmarkComparisonGroupBase { + readonly id: "yoga-api"; + readonly scenarios: readonly YogaBenchmarkScenario[]; +} + +export type BenchmarkComparisonGroup = TaffyBenchmarkComparisonGroup | YogaBenchmarkComparisonGroup; + export interface SampledBenchmarkResult { readonly hz: number; readonly meanMs: number; @@ -26,6 +57,7 @@ export interface SampledBenchmarkResult { } export interface BenchmarkWorkerResult { + readonly groupId: BenchmarkComparisonGroup["id"]; readonly targetId: string; readonly scenarioId: string; readonly checksum: number; @@ -84,3 +116,25 @@ export function readLayoutChecksum( return checksum; } + +export function readYogaLayoutChecksum(nodes: readonly YogaNode[]): number { + let checksum = nodes.length * 17; + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += 1) { + const layout = nodes[nodeIndex].getComputedLayout(); + const values = [ + layout.left, + layout.right, + layout.top, + layout.bottom, + layout.width, + layout.height, + ]; + + for (let valueIndex = 0; valueIndex < values.length; valueIndex += 1) { + checksum += values[valueIndex] * (nodeIndex + 1) * (valueIndex + 1); + } + } + + return checksum; +} diff --git a/benchmarks/suite.ts b/benchmarks/suite.ts index 588f579..fcd809b 100644 --- a/benchmarks/suite.ts +++ b/benchmarks/suite.ts @@ -1,13 +1,15 @@ import { codingAgentChatScenario } from "./coding-agent-chat/benchmark.ts"; import { deepTreeScenarios } from "./deep-tree/benchmark.ts"; -import type { BenchmarkProfile, TaffyBenchmarkScenario } from "./scenario.ts"; +import type { + BenchmarkComparisonGroup, + BenchmarkProfile, + TaffyBenchmarkComparisonGroup, + YogaBenchmarkComparisonGroup, +} from "./scenario.ts"; import { wideTreeScenarios } from "./wide-tree/benchmark.ts"; - -export interface TaffyBenchmarkTarget { - readonly id: string; - readonly label: string; - readonly packageName: string; -} +import { yogaCodingAgentChatScenario } from "./yoga-coding-agent-chat/benchmark.ts"; +import { yogaDeepTreeScenarios } from "./yoga-deep-tree/benchmark.ts"; +import { yogaWideTreeScenarios } from "./yoga-wide-tree/benchmark.ts"; export const benchmarkProfiles: readonly BenchmarkProfile[] = [ { @@ -36,21 +38,48 @@ export const benchmarkProfiles: readonly BenchmarkProfile[] = [ }, ]; -export const taffyScenarios: readonly TaffyBenchmarkScenario[] = [ - codingAgentChatScenario, - ...wideTreeScenarios, - ...deepTreeScenarios, -]; +export const taffyComparisonGroup: TaffyBenchmarkComparisonGroup = { + id: "taffy-api", + name: "Taffy API", + scenarios: [codingAgentChatScenario, ...wideTreeScenarios, ...deepTreeScenarios], + targets: [ + { + id: "node", + label: "Native Node", + packageName: "@taffyjs/node", + }, + { + id: "wasm", + label: "WASI Wasm", + packageName: "@taffyjs/wasm", + }, + ], +}; -export const taffyTargets: readonly TaffyBenchmarkTarget[] = [ - { - id: "node", - label: "Native Node", - packageName: "@taffyjs/node", - }, - { - id: "wasm", - label: "WASI Wasm", - packageName: "@taffyjs/wasm", - }, +export const yogaComparisonGroup: YogaBenchmarkComparisonGroup = { + id: "yoga-api", + name: "Yoga API", + scenarios: [yogaCodingAgentChatScenario, ...yogaWideTreeScenarios, ...yogaDeepTreeScenarios], + targets: [ + { + id: "taffy-yoga", + label: "Taffy Yoga", + packageName: "@taffyjs/yoga", + }, + { + id: "yoga-layout", + label: "Yoga 3.2.1", + packageName: "yoga-layout", + }, + { + id: "taffy-yoga-wasm", + label: "Taffy Yoga Wasm", + packageName: "@taffyjs/yoga-wasm", + }, + ], +}; + +export const benchmarkComparisonGroups: readonly BenchmarkComparisonGroup[] = [ + taffyComparisonGroup, + yogaComparisonGroup, ]; diff --git a/benchmarks/tsconfig.json b/benchmarks/tsconfig.json index 75b2658..8aeeb8c 100644 --- a/benchmarks/tsconfig.json +++ b/benchmarks/tsconfig.json @@ -8,7 +8,9 @@ "types": ["node"], "paths": { "@taffyjs/node": ["../packages/taffyjs-node/src/index.ts"], - "@taffyjs/wasm": ["../packages/taffyjs-node/src/index.ts"] + "@taffyjs/wasm": ["../packages/taffyjs-node/src/index.ts"], + "@taffyjs/yoga": ["../packages/taffyjs-yoga/src/index.ts"], + "@taffyjs/yoga-wasm": ["../packages/taffyjs-yoga/src/index.ts"] }, "strict": true, "noUnusedLocals": true, diff --git a/benchmarks/worker.ts b/benchmarks/worker.ts index 79f0834..4ffad6a 100644 --- a/benchmarks/worker.ts +++ b/benchmarks/worker.ts @@ -2,22 +2,43 @@ import assert from "node:assert/strict"; import { Bench } from "tinybench"; -import type { BenchmarkWorkerResult, TaffyApi } from "./scenario.ts"; -import { benchmarkProfiles, taffyScenarios, taffyTargets } from "./suite.ts"; +import type { + BenchmarkScenarioMetadata, + BenchmarkWorkerResult, + TaffyApi, + YogaApi, +} from "./scenario.ts"; +import { benchmarkComparisonGroups, benchmarkProfiles } from "./suite.ts"; -const [targetId, scenarioId, profileId] = process.argv.slice(2); -const target = taffyTargets.find(({ id }) => id === targetId); -const scenario = taffyScenarios.find(({ id }) => id === scenarioId); +const [groupId, targetId, scenarioId, profileId] = process.argv.slice(2); +const group = benchmarkComparisonGroups.find(({ id }) => id === groupId); const profile = benchmarkProfiles.find(({ id }) => id === profileId); +assert.ok(group, `Unknown benchmark comparison group ${groupId ?? ""}`); +const target = group.targets.find(({ id }) => id === targetId); assert.ok(target, `Unknown benchmark target ${targetId ?? ""}`); -assert.ok(scenario, `Unknown benchmark scenario ${scenarioId ?? ""}`); assert.ok(profile, `Unknown benchmark profile ${profileId ?? ""}`); -const api = (await import(target.packageName)) as TaffyApi; -const checksum = scenario.createTransaction(api)(); +const importedApi: unknown = await import(target.packageName); +let scenario: BenchmarkScenarioMetadata; +let createTransaction: () => () => number; +if (group.id === "taffy-api") { + const typedScenario = group.scenarios.find(({ id }) => id === scenarioId); + assert.ok(typedScenario, `Unknown benchmark scenario ${scenarioId ?? ""}`); + const api = importedApi as TaffyApi; + scenario = typedScenario; + createTransaction = () => typedScenario.createTransaction(api); +} else { + const typedScenario = group.scenarios.find(({ id }) => id === scenarioId); + assert.ok(typedScenario, `Unknown benchmark scenario ${scenarioId ?? ""}`); + const api = importedApi as YogaApi; + scenario = typedScenario; + createTransaction = () => typedScenario.createTransaction(api); +} + +const checksum = createTransaction()(); assert.ok(Number.isFinite(checksum), `${scenario.id}/${target.id} returned a non-finite checksum`); -const transaction = scenario.createTransaction(api); +const transaction = createTransaction(); const bench = new Bench({ ...profile.settings, throws: true }); let blackhole = 0; bench.add(target.id, () => { @@ -47,6 +68,7 @@ const medianMs = ? (sortedSamples[middle - 1] + sortedSamples[middle]) / 2 : sortedSamples[middle]; const output: BenchmarkWorkerResult = { + groupId: group.id, targetId: target.id, scenarioId: scenario.id, checksum, diff --git a/benchmarks/yoga-coding-agent-chat/benchmark.ts b/benchmarks/yoga-coding-agent-chat/benchmark.ts new file mode 100644 index 0000000..bd4fa82 --- /dev/null +++ b/benchmarks/yoga-coding-agent-chat/benchmark.ts @@ -0,0 +1,153 @@ +import type { Node } from "@taffyjs/yoga"; + +import { readYogaLayoutChecksum, type YogaApi, type YogaBenchmarkScenario } from "../scenario.ts"; + +type Yoga = YogaApi["default"]; + +const sessionCount = 18; +const messageCount = 32; + +export const yogaCodingAgentChatScenario: YogaBenchmarkScenario = { + id: "yoga-coding-agent-chat-initial-layout", + name: "Yoga coding-agent chat: initial layout", + question: + "How long does a Yoga consumer take to build, measure, lay out, read, and release a modeled coding-agent screen?", + description: + "A fixed desktop chat model contains a session sidebar, measured labels, a message transcript, and a composer. All targets execute the same public Yoga API calls.", + transaction: + "Create Config and Nodes, apply styles and constant intrinsic-size callbacks, build the tree, calculate layout, read every computed layout, then release the tree and Config.", + parameters: { + viewportWidth: 1280, + viewportHeight: 800, + sessionCount, + messageCount, + }, + createTransaction(api) { + const sessionContexts = Array.from({ length: sessionCount }, (_, index) => ({ + characters: 14 + ((index * 7) % 24), + lineHeight: 16, + })); + const messageContexts = Array.from({ length: messageCount }, (_, index) => ({ + characters: 56 + ((index * 37) % 280), + lineHeight: 18, + })); + + return () => { + const Yoga: Yoga = api.default; + const config = Yoga.Config.create(); + config.setPointScaleFactor(1); + const nodes: Node[] = []; + const createNode = (configure?: (node: Node) => void): Node => { + const node = Yoga.Node.createWithConfig(config); + nodes.push(node); + configure?.(node); + return node; + }; + const parent = (configure: (node: Node) => void, children: readonly Node[]): Node => { + const node = createNode(configure); + for (const [index, child] of children.entries()) node.insertChild(child, index); + return node; + }; + const fixedLeaf = (width: number, height: number): Node => + createNode((node) => { + node.setWidth(width); + node.setHeight(height); + }); + const measuredLeaf = (characters: number, lineHeight: number): Node => + createNode((node) => { + node.setFlexShrink(1); + node.setMinWidth(0); + node.setMeasureFunc(() => { + const naturalWidth = Math.max(8, characters * 7); + const width = Math.min(naturalWidth, 280); + return { + width, + height: Math.max(1, Math.ceil(naturalWidth / width)) * lineHeight, + }; + }); + }); + + const sessions = sessionContexts.map((context) => + parent( + (node) => { + node.setFlexDirection(api.FlexDirection.Row); + node.setHeight(32); + node.setPadding(api.Edge.Horizontal, 8); + node.setGap(api.Gutter.All, 6); + }, + [measuredLeaf(context.characters, context.lineHeight), fixedLeaf(18, 18)], + ), + ); + const sidebar = parent((node) => { + node.setWidth(264); + node.setPadding(api.Edge.All, 12); + node.setGap(api.Gutter.All, 8); + }, sessions); + + const messages = messageContexts.map((context, index) => { + const actions = parent( + (node) => { + node.setFlexDirection(api.FlexDirection.Row); + node.setHeight(20); + node.setGap(api.Gutter.All, 6); + }, + [fixedLeaf(42 + (index % 3) * 8, 20), fixedLeaf(52, 20)], + ); + const body = parent( + (node) => { + node.setFlexGrow(1); + node.setMinWidth(0); + node.setGap(api.Gutter.All, 6); + }, + [measuredLeaf(context.characters, context.lineHeight), actions], + ); + return parent( + (node) => { + node.setFlexDirection(api.FlexDirection.Row); + node.setGap(api.Gutter.All, 10); + }, + [fixedLeaf(32, 32), body], + ); + }); + const transcript = parent((node) => { + node.setFlexGrow(1); + node.setMinHeight(0); + node.setGap(api.Gutter.All, 12); + }, messages); + const composer = parent( + (node) => { + node.setFlexDirection(api.FlexDirection.Row); + node.setHeight(72); + node.setPadding(api.Edge.All, 12); + node.setGap(api.Gutter.All, 8); + }, + [measuredLeaf(24, 18), fixedLeaf(72, 40)], + ); + const main = parent( + (node) => { + node.setFlexGrow(1); + node.setMinWidth(0); + node.setPadding(api.Edge.All, 16); + node.setGap(api.Gutter.All, 12); + }, + [transcript, composer], + ); + const root = parent( + (node) => { + node.setFlexDirection(api.FlexDirection.Row); + node.setWidth(1280); + node.setHeight(800); + }, + [sidebar, main], + ); + + try { + root.calculateLayout(1280, 800, api.Direction.LTR); + return readYogaLayoutChecksum(nodes); + } finally { + root.freeRecursive(); + Yoga.Config.destroy(config); + } + }; + }, +}; diff --git a/benchmarks/yoga-deep-tree/benchmark.ts b/benchmarks/yoga-deep-tree/benchmark.ts new file mode 100644 index 0000000..e859b1b --- /dev/null +++ b/benchmarks/yoga-deep-tree/benchmark.ts @@ -0,0 +1,77 @@ +import type { Node } from "@taffyjs/yoga"; + +import { readYogaLayoutChecksum, type YogaBenchmarkScenario } from "../scenario.ts"; + +function createYogaDeepTreeScenario(leafCount: number): YogaBenchmarkScenario { + const nodeCount = leafCount * 2 - 1; + const depth = Math.log2(leafCount) + 1; + + return { + id: `yoga-deep-tree-${nodeCount}-nodes`, + name: `Yoga deep tree: ${nodeCount.toLocaleString("en-US")} nodes`, + question: `How does a complete Yoga public transaction scale for a balanced binary tree with ${depth} levels?`, + description: + "A deterministic binary hierarchy alternates row and column Flexbox containers while exercising public Node creation, style setters, topology, output reads, and release.", + transaction: + "Create Config and Nodes, build every hierarchy level, calculate layout, read every computed layout, then release the tree and Config.", + parameters: { + leafCount, + nodeCount, + depth, + branchingFactor: 2, + }, + createTransaction(api) { + return () => { + const Yoga = api.default; + const config = Yoga.Config.create(); + config.setPointScaleFactor(1); + const nodes: Node[] = []; + let level = Array.from({ length: leafCount }, () => { + const node = Yoga.Node.createWithConfig(config); + nodes.push(node); + node.setWidth(10); + node.setHeight(10); + node.setFlexGrow(1); + return node; + }); + let levelIndex = 0; + + while (level.length > 1) { + const nextLevel: Node[] = []; + for (let index = 0; index < level.length; index += 2) { + const node = Yoga.Node.createWithConfig(config); + nodes.push(node); + node.setFlexDirection( + levelIndex % 2 === 0 ? api.FlexDirection.Row : api.FlexDirection.Column, + ); + node.setFlexGrow(1); + node.setMinWidth(0); + node.setMinHeight(0); + node.setPadding(api.Edge.All, 1); + node.setGap(api.Gutter.All, 1); + node.insertChild(level[index], 0); + node.insertChild(level[index + 1], 1); + nextLevel.push(node); + } + level = nextLevel; + levelIndex += 1; + } + + const root = level[0]; + root.setWidth(1200); + root.setHeight(800); + try { + root.calculateLayout(1200, 800, api.Direction.LTR); + return readYogaLayoutChecksum(nodes); + } finally { + root.freeRecursive(); + Yoga.Config.destroy(config); + } + }; + }, + }; +} + +export const yogaDeepTreeScenarios: readonly YogaBenchmarkScenario[] = [ + createYogaDeepTreeScenario(256), +]; diff --git a/benchmarks/yoga-wide-tree/benchmark.ts b/benchmarks/yoga-wide-tree/benchmark.ts new file mode 100644 index 0000000..fc1bf65 --- /dev/null +++ b/benchmarks/yoga-wide-tree/benchmark.ts @@ -0,0 +1,58 @@ +import type { Node } from "@taffyjs/yoga"; + +import { readYogaLayoutChecksum, type YogaBenchmarkScenario } from "../scenario.ts"; + +function createYogaWideTreeScenario(leafCount: number): YogaBenchmarkScenario { + return { + id: `yoga-wide-tree-${leafCount}-leaves`, + name: `Yoga wide tree: ${leafCount.toLocaleString("en-US")} leaves`, + question: `How does a complete Yoga public transaction scale for a shallow root with ${leafCount.toLocaleString("en-US")} children?`, + description: + "A two-level wrapping Flexbox tree stresses public Yoga Node creation, style setters, child insertion, layout calculation, output reads, and release.", + transaction: + "Create Config and Nodes, apply styles, attach every leaf, calculate layout, read every computed layout, then release the tree and Config.", + parameters: { + leafCount, + nodeCount: leafCount + 1, + depth: 2, + }, + createTransaction(api) { + const leafWidths = [40, 52, 64, 76] as const; + + return () => { + const Yoga = api.default; + const config = Yoga.Config.create(); + config.setPointScaleFactor(1); + const nodes: Node[] = []; + const root = Yoga.Node.createWithConfig(config); + nodes.push(root); + root.setFlexDirection(api.FlexDirection.Row); + root.setFlexWrap(api.Wrap.Wrap); + root.setWidth(1200); + root.setPadding(api.Edge.All, 4); + root.setGap(api.Gutter.All, 2); + + for (let index = 0; index < leafCount; index += 1) { + const leaf = Yoga.Node.createWithConfig(config); + nodes.push(leaf); + leaf.setWidth(leafWidths[index % leafWidths.length]); + leaf.setHeight(16 + (index % leafWidths.length) * 2); + leaf.setFlexGrow(1); + root.insertChild(leaf, index); + } + + try { + root.calculateLayout(1200, undefined, api.Direction.LTR); + return readYogaLayoutChecksum(nodes); + } finally { + root.freeRecursive(); + Yoga.Config.destroy(config); + } + }; + }, + }; +} + +export const yogaWideTreeScenarios: readonly YogaBenchmarkScenario[] = [ + createYogaWideTreeScenario(1_000), +]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf4a612..60ecedf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ catalogs: vitepress: specifier: 2.0.0-alpha.19 version: 2.0.0-alpha.19 + yoga-layout: + specifier: 3.2.1 + version: 3.2.1 importers: @@ -262,6 +265,15 @@ importers: '@taffyjs/wasm': specifier: workspace:* version: link:../packages/taffyjs-wasm + '@taffyjs/yoga': + specifier: workspace:* + version: link:../packages/taffyjs-yoga + '@taffyjs/yoga-wasm': + specifier: workspace:* + version: link:../packages/taffyjs-yoga-wasm + yoga-layout: + specifier: 'catalog:' + version: 3.2.1 devDependencies: '@types/node': specifier: 'catalog:' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a62846..bfd25d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: vite: npm:@voidzero-dev/vite-plus-core@0.2.8 vite-plus: 0.2.8 vitepress: 2.0.0-alpha.19 + yoga-layout: 3.2.1 peerDependencyRules: allowAny: diff --git a/vite.config.ts b/vite.config.ts index dbc00db..bfbfc7f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -163,11 +163,11 @@ export default defineConfig({ }, benchmark: { command: "vp run taffyjs-benchmarks#benchmark", - dependsOn: ["build", "build:wasm"], + dependsOn: ["build", "build:yoga-wasm"], }, "benchmark:update-website": { command: "vp run taffyjs-benchmarks#benchmark:update-website", - dependsOn: ["build", "build:wasm"], + dependsOn: ["build", "build:yoga-wasm"], }, "dev:website": { command: "vp run @taffyjs/website#dev",