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.
This commit is contained in:
parent
36065fe572
commit
bc888d950f
5
.github/workflows/wasm-site.yml
vendored
5
.github/workflows/wasm-site.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -3,26 +3,56 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PureYAMLStreaming WASM Smoke</title>
|
||||
<title>PureYAMLStreaming WASM Benchmark</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="card">
|
||||
<p class="eyebrow">PureYAMLStreaming</p>
|
||||
<h1>WASM Streaming Smoke Test</h1>
|
||||
<h1>Live WASM YAML Benchmark</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<button id="runButton" type="button">Run WASM Test</button>
|
||||
<label for="sampleSelect">Public YAML example</label>
|
||||
<select id="sampleSelect"></select>
|
||||
<label for="urlInput">URL</label>
|
||||
<input id="urlInput" type="url" autocomplete="off" />
|
||||
<button id="runButton" type="button">Run Benchmark</button>
|
||||
<div id="status" class="status idle">Idle</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Timing</h2>
|
||||
<dl class="metrics">
|
||||
<div>
|
||||
<dt>Bytes</dt>
|
||||
<dd id="bytesMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fetch</dt>
|
||||
<dd id="fetchMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Swift parse</dt>
|
||||
<dd id="parseMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Throughput</dt>
|
||||
<dd id="throughputMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Documents</dt>
|
||||
<dd id="documentsMetric">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Output</h2>
|
||||
<pre id="output">Click "Run WASM Test".</pre>
|
||||
<pre id="output">Pick a YAML file and click "Run Benchmark".</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
157
WasmSite/main.js
157
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`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user