From bc888d950fba806fb42cb6b8d420e18dd9a85fad Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 6 Jun 2026 22:41:40 +0200 Subject: [PATCH] feat: benchmark public YAML in browser WASM Add a live public YAML chooser and JavaScript timing for fetch, Swift WASM parse, throughput, and document counts. --- .github/workflows/wasm-site.yml | 5 + README.md | 8 +- Sources/PureYAMLStreamingWasmSmoke/main.swift | 17 +- WasmSite/index.html | 44 ++++- WasmSite/main.js | 157 ++++++++++++++++-- WasmSite/style.css | 53 +++++- 6 files changed, 255 insertions(+), 29 deletions(-) diff --git a/.github/workflows/wasm-site.yml b/.github/workflows/wasm-site.yml index d93a9b5..947af09 100644 --- a/.github/workflows/wasm-site.yml +++ b/.github/workflows/wasm-site.yml @@ -76,6 +76,7 @@ jobs: const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); await page.goto('http://127.0.0.1:8766/?v=' + Date.now()); + await page.selectOption('#sampleSelect', { index: 4 }); await page.click('#runButton'); await page.waitForFunction( () => document.querySelector('#status')?.textContent === 'Passed', @@ -83,9 +84,13 @@ jobs: { timeout: 60000 } ); const output = await page.locator('#output').textContent(); + const throughput = await page.locator('#throughputMetric').textContent(); if (!output.includes('"ok" : true') && !output.includes('"ok":true')) { throw new Error(output); } + if (!throughput.includes('/s')) { + throw new Error(`Expected throughput metric, got: ${throughput}`); + } await browser.close(); })(); NODE diff --git a/README.md b/README.md index 05b1ae0..96adb53 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ try parser.parseDocuments(from: url) { document in } ``` -## WASM Browser Smoke Site +## WASM Browser Benchmark Site Live site: @@ -68,8 +68,10 @@ bash scripts/serve-wasm-site.sh ``` Then open `http://localhost:8080` and click **Run WASM Test**. The page loads -`pureyaml-streaming-wasm-smoke.wasm`, runs a chunked document-stream parse in -the browser through a WASI shim, captures stdout, and renders pass/fail. +`pureyaml-streaming-wasm-smoke.wasm`, lets you choose from 10+ public real-world +YAML files, fetches the selected file in JavaScript, feeds the bytes to Swift +through WASI stdin, and reports fetch time, parse time, throughput, document +count, and stdout JSON. ## Relationship To PureYAML diff --git a/Sources/PureYAMLStreamingWasmSmoke/main.swift b/Sources/PureYAMLStreamingWasmSmoke/main.swift index c176e28..8502d98 100644 --- a/Sources/PureYAMLStreamingWasmSmoke/main.swift +++ b/Sources/PureYAMLStreamingWasmSmoke/main.swift @@ -4,13 +4,14 @@ import PureYAMLStreaming struct SmokeResult: Encodable { var ok: Bool + var inputByteCount: Int var documentCount: Int var firstName: String? var lastName: String? var message: String } -let yaml = Data( +let fallbackYAML = Data( """ --- name: browser-0 @@ -31,25 +32,29 @@ let yaml = Data( ) do { - let parser = PureYAMLStreaming.Parser(chunkSize: 7) + let input = CommandLine.arguments.count > 1 + ? FileHandle.standardInput.readDataToEndOfFile() + : Data() + let yaml = input.isEmpty ? fallbackYAML : input + let parser = PureYAMLStreaming.Parser(chunkSize: 31) let documents = try parser.collectDocuments(data: yaml) let names = documents.compactMap { document in stringValue("name", in: document.value) } - let ok = documents.count == 3 - && names.first == "browser-0" - && names.last == "browser-2" + let ok = !documents.isEmpty let result = SmokeResult( ok: ok, + inputByteCount: yaml.count, documentCount: documents.count, firstName: names.first, lastName: names.last, - message: ok ? "PureYAMLStreaming WASM smoke passed" : "PureYAMLStreaming WASM smoke failed", + message: ok ? "PureYAMLStreaming WASM parse passed" : "PureYAMLStreaming WASM parse found no documents", ) print(try renderJSON(result)) } catch { let result = SmokeResult( ok: false, + inputByteCount: 0, documentCount: 0, firstName: nil, lastName: nil, diff --git a/WasmSite/index.html b/WasmSite/index.html index fb395c1..c764d83 100644 --- a/WasmSite/index.html +++ b/WasmSite/index.html @@ -3,26 +3,56 @@ - PureYAMLStreaming WASM Smoke + PureYAMLStreaming WASM Benchmark

PureYAMLStreaming

-

WASM Streaming Smoke Test

+

Live WASM YAML Benchmark

- This page loads the Swift WASI build in the browser, runs a - document-streaming parse, captures stdout, and reports whether the - smoke test passed. + Pick a public real-world YAML file. The browser fetches it, feeds the + bytes to the Swift WASI executable through stdin, and times the + document-streaming parse live.

- + + + + +
Idle
+
+

Timing

+
+
+
Bytes
+
-
+
+
+
Fetch
+
-
+
+
+
Swift parse
+
-
+
+
+
Throughput
+
-
+
+
+
Documents
+
-
+
+
+
+

Output

-
Click "Run WASM Test".
+
Pick a YAML file and click "Run Benchmark".
diff --git a/WasmSite/main.js b/WasmSite/main.js index 6f549d7..fa4a7d0 100644 --- a/WasmSite/main.js +++ b/WasmSite/main.js @@ -6,29 +6,116 @@ import { } from "https://esm.sh/@bjorn3/browser_wasi_shim@0.3.0"; const runButton = document.querySelector("#runButton"); +const sampleSelect = document.querySelector("#sampleSelect"); +const urlInput = document.querySelector("#urlInput"); const statusElement = document.querySelector("#status"); const outputElement = document.querySelector("#output"); +const bytesMetric = document.querySelector("#bytesMetric"); +const fetchMetric = document.querySelector("#fetchMetric"); +const parseMetric = document.querySelector("#parseMetric"); +const throughputMetric = document.querySelector("#throughputMetric"); +const documentsMetric = document.querySelector("#documentsMetric"); const params = new URLSearchParams(location.search); const wasmURL = params.get("wasm") || "./pureyaml-streaming-wasm-smoke.wasm.gz"; +let modulePromise = null; + +const samples = [ + { + name: "GitHub REST API OpenAPI (9.5 MB)", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/github-rest-api.yaml", + }, + { + name: "Zoom OpenAPI (5.0 MB)", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/api-guru-zoom-openapi.yaml", + }, + { + name: "Stripe OpenAPI (3.7 MB)", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/api-guru-stripe-openapi.yaml", + }, + { + name: "cert-manager Helm values (65 KB)", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/cert-manager-values.yaml", + }, + { + name: "cert-manager CRD (44 KB)", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/cert-manager-certificate-crd.yaml", + }, + { + name: "Kubernetes Prow presubmits (14 KB)", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/prow-cluster-api-presubmits.yaml", + }, + { + name: "Prometheus config (12 KB)", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/prometheus-conf-good.yml", + }, + { + name: "USPTO OpenAPI", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/openapi-uspto.yaml", + }, + { + name: "OpenAPI petstore expanded", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/openapi-petstore-expanded.yaml", + }, + { + name: "Bitnami Apache values", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/bitnami-apache-values.yaml", + }, + { + name: "GitHub Actions Swift format", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/github-actions-swift-format.yml", + }, + { + name: "Docker Compose Prometheus/Grafana", + url: "https://raw.githubusercontent.com/mihaelamj/PureYAMLGeekbench/main/Fixtures/real-yaml/awesome-compose-prometheus-grafana-compose-yaml.yaml", + }, +]; + +for (const sample of samples) { + const option = document.createElement("option"); + option.value = sample.url; + option.textContent = sample.name; + sampleSelect.append(option); +} +urlInput.value = samples[0].url; runButton.addEventListener("click", () => { - runSmokeTest().catch((error) => { + runBenchmark().catch((error) => { renderFailure(error); }); }); -async function runSmokeTest() { +sampleSelect.addEventListener("change", () => { + urlInput.value = sampleSelect.value; +}); + +async function runBenchmark() { + const sourceURL = urlInput.value.trim(); + if (!sourceURL) { + throw new Error("Choose or enter a YAML URL first."); + } + runButton.disabled = true; statusElement.className = "status idle"; - statusElement.textContent = "Running..."; - outputElement.textContent = "Loading WASM..."; + statusElement.textContent = "Fetching YAML..."; + outputElement.textContent = ""; + resetMetrics(); - const wasmBytes = await loadWasmBytes(wasmURL); + const moduleLoadStart = performance.now(); + const module = await loadWasmModule(wasmURL); + const moduleLoadMS = performance.now() - moduleLoadStart; + + const fetchStart = performance.now(); + const response = await fetch(cacheBustedURL(sourceURL), { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Failed to fetch YAML: HTTP ${response.status}`); + } + const yamlBytes = new Uint8Array(await response.arrayBuffer()); + const fetchMS = performance.now() - fetchStart; let stdoutText = ""; let stderrText = ""; const fds = [ - new OpenFile(new File([])), + new OpenFile(new File(yamlBytes)), ConsoleStdout.lineBuffered((line) => { stdoutText += `${line}\n`; }), @@ -36,20 +123,33 @@ async function runSmokeTest() { stderrText += `${line}\n`; }), ]; - const wasi = new WASI([], [], fds); - const wasm = await WebAssembly.compile(wasmBytes); - const instance = await WebAssembly.instantiate(wasm, { + const wasi = new WASI(["pureyaml-streaming-wasm-smoke", sourceURL], [], fds); + statusElement.textContent = "Running Swift parser..."; + const parseStart = performance.now(); + const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasi.wasiImport, }); - wasi.start(instance); + const parseMS = performance.now() - parseStart; const parsed = parseSmokeOutput(stdoutText); const ok = parsed?.ok === true; + const throughput = yamlBytes.byteLength / (parseMS / 1000); statusElement.className = ok ? "status pass" : "status fail"; statusElement.textContent = ok ? "Passed" : "Failed"; + bytesMetric.textContent = formatBytes(yamlBytes.byteLength); + fetchMetric.textContent = formatDuration(fetchMS); + parseMetric.textContent = formatDuration(parseMS); + throughputMetric.textContent = `${formatBytes(throughput)}/s`; + documentsMetric.textContent = parsed?.documentCount ?? "-"; outputElement.textContent = [ + `url: ${sourceURL}`, + `moduleLoad: ${formatDuration(moduleLoadMS)}`, + `fetch: ${formatDuration(fetchMS)}`, + `parse: ${formatDuration(parseMS)}`, + `throughput: ${formatBytes(throughput)}/s`, + "", "stdout:", stdoutText || "(empty)", "", @@ -59,6 +159,13 @@ async function runSmokeTest() { runButton.disabled = false; } +async function loadWasmModule(url) { + if (!modulePromise) { + modulePromise = loadWasmBytes(url).then((bytes) => WebAssembly.compile(bytes)); + } + return modulePromise; +} + async function loadWasmBytes(url) { const response = await fetch(url); if (!response.ok) { @@ -75,6 +182,12 @@ async function loadWasmBytes(url) { return new Response(stream).arrayBuffer(); } +function cacheBustedURL(url) { + const parsedURL = new URL(url, location.href); + parsedURL.searchParams.set("_pureyaml_bench", `${Date.now()}-${Math.random().toString(36).slice(2)}`); + return parsedURL.href; +} + function parseSmokeOutput(text) { try { return JSON.parse(text); @@ -90,3 +203,27 @@ function renderFailure(error) { runButton.disabled = false; } +function resetMetrics() { + bytesMetric.textContent = "-"; + fetchMetric.textContent = "-"; + parseMetric.textContent = "-"; + throughputMetric.textContent = "-"; + documentsMetric.textContent = "-"; +} + +function formatDuration(milliseconds) { + if (milliseconds < 1000) { + return `${milliseconds.toFixed(1)} ms`; + } + return `${(milliseconds / 1000).toFixed(3)} s`; +} + +function formatBytes(byteCount) { + if (byteCount < 1024) { + return `${byteCount.toFixed(0)} B`; + } + if (byteCount < 1024 * 1024) { + return `${(byteCount / 1024).toFixed(1)} KiB`; + } + return `${(byteCount / 1024 / 1024).toFixed(2)} MiB`; +} diff --git a/WasmSite/style.css b/WasmSite/style.css index dc2028d..6ff88e2 100644 --- a/WasmSite/style.css +++ b/WasmSite/style.css @@ -10,12 +10,12 @@ body { margin: 0; min-height: 100vh; - display: grid; - place-items: center; + padding: 28px 0; } main { - width: min(920px, calc(100vw - 32px)); + width: min(1040px, calc(100vw - 32px)); + margin: 0 auto; display: grid; gap: 20px; } @@ -52,10 +52,29 @@ p { opacity: 0.82; } +label { + display: block; + margin: 18px 0 8px; + font-weight: 800; +} + +select, +input { + box-sizing: border-box; + width: 100%; + border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); + border-radius: 14px; + padding: 12px 14px; + background: Canvas; + color: CanvasText; + font: inherit; +} + button { appearance: none; border: 0; border-radius: 999px; + margin-top: 18px; padding: 12px 18px; font: inherit; font-weight: 700; @@ -86,6 +105,34 @@ button:disabled { background: color-mix(in srgb, #c21f39 30%, transparent); } +.metrics { + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 14px; +} + +.metrics div { + border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); + border-radius: 16px; + padding: 16px; + background: color-mix(in srgb, Canvas 96%, CanvasText 4%); +} + +dt { + margin: 0 0 8px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.65; +} + +dd { + margin: 0; + font: 700 24px/1.1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + pre { margin: 0; white-space: pre-wrap;