import { distance3 } from "/packages/acoustics-core/math.js"; import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; import { computeDasWebGpu } from "/packages/webgpu-kernels/das.js"; const status = document.querySelector("#benchmark-status"); const output = document.querySelector("#benchmark-output"); const runButton = document.querySelector("#run-benchmark"); const downloadButton = document.querySelector("#download-result"); const query = new URLSearchParams(location.search); const requestedRuns = Number.parseInt(query.get("runs") ?? "3", 10); const runCount = Number.isInteger(requestedRuns) ? Math.min(5, Math.max(1, requestedRuns)) : 3; let latestResult = null; async function loadJson(path) { const response = await fetch(path); if (!response.ok) throw new Error(`${path}를 불러오지 못했습니다 (${response.status}).`); return response.json(); } function median(values) { const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; } function round(value, digits = 3) { const scale = 10 ** digits; return Math.round(value * scale) / scale; } function cloneExperiment(experiment, size) { return { ...experiment, id: `single-tone-${size}x${size}`, scan: { ...experiment.scan, columns: size, rows: size }, beamforming: { ...experiment.beamforming, backend: "webgpu" }, }; } function downloadJson(value, filename) { const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 0); } async function runBenchmark() { runButton.disabled = true; downloadButton.disabled = true; document.documentElement.dataset.benchmarkStatus = "running"; delete document.documentElement.dataset.benchmarkError; latestResult = null; globalThis.__micarrsimBenchmarkResult = null; output.textContent = ""; try { const [arrayDefinition, scene, baseExperiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); status.textContent = "192채널 마이크 신호를 한 번 합성하고 있습니다…"; const signalStartedAt = performance.now(); const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition }); const signalGenerationMs = performance.now() - signalStartedAt; let adapterInfo = null; status.textContent = "WebGPU pipeline을 예열하고 있습니다…"; await computeDasWebGpu({ channels: synthesis.channels, sampleRate: synthesis.sampleRate, arrayDefinition, scene, experiment: cloneExperiment(baseExperiment, 16), }); const resolutions = []; for (const size of [50, 100, 200]) { const samples = []; for (let runIndex = 0; runIndex < runCount; runIndex += 1) { status.textContent = `${size}×${size} · ${runIndex + 1}/${runCount} 실행 중…`; const result = await computeDasWebGpu({ channels: synthesis.channels, sampleRate: synthesis.sampleRate, arrayDefinition, scene, experiment: cloneExperiment(baseExperiment, size), onProgress: ({ stage, completedUnits, totalUnits }) => { if (stage === "beamforming" && totalUnits) { status.textContent = `${size}×${size} · ${runIndex + 1}/${runCount} · GPU ${completedUnits}/${totalUnits}`; } }, }); adapterInfo = result.gpu.adapterInfo; const localizationErrorM = distance3(result.bands[0].peak.position, scene.sources[0].position); samples.push({ totalMs: result.timingMs.total, stftMs: result.timingMs.stft, gpuWallMs: result.timingMs.gpuWall, beamformingMs: result.timingMs.beamforming, localizationErrorM, complexMacCount: result.workload.complexMacCount, estimatedGpuWorkingBytes: result.gpu.batchPlan.estimatedGpuWorkingBytes, binBatchSize: result.gpu.batchPlan.binBatchSize, pointBatchSize: result.gpu.batchPlan.pointBatchSize, }); } resolutions.push({ grid: `${size}x${size}`, pointCount: size * size, runs: runCount, medianTotalMs: round(median(samples.map((sample) => sample.totalMs))), medianStftMs: round(median(samples.map((sample) => sample.stftMs))), medianGpuWallMs: round(median(samples.map((sample) => sample.gpuWallMs))), medianBeamformingMs: round(median(samples.map((sample) => sample.beamformingMs))), localizationErrorM: round(median(samples.map((sample) => sample.localizationErrorM)), 6), complexMacCount: samples[0].complexMacCount, estimatedGpuWorkingMiB: round(samples[0].estimatedGpuWorkingBytes / 1024 ** 2), binBatchSize: samples[0].binBatchSize, pointBatchSize: samples[0].pointBatchSize, samples: samples.map((sample) => ({ totalMs: round(sample.totalMs), stftMs: round(sample.stftMs), gpuWallMs: round(sample.gpuWallMs), beamformingMs: round(sample.beamformingMs), })), }); output.textContent = JSON.stringify(resolutions, null, 2); } const baselineGpuMs = resolutions[0].medianGpuWallMs; for (const resolution of resolutions) { resolution.gpuWallScalingVs50 = round(resolution.medianGpuWallMs / baselineGpuMs, 2); } latestResult = { schemaVersion: "1.0.0", kind: "micarrsim.webgpu-benchmark", createdAt: new Date().toISOString(), fixture: { arrayId: arrayDefinition.id, microphoneCount: arrayDefinition.microphones.length, sceneId: scene.id, durationSeconds: scene.duration, sampleRate: scene.sampleRate, fftSize: baseExperiment.stft.fftSize, hopSize: baseExperiment.stft.hopSize, selectedBandHz: baseExperiment.frequencyBands.map(({ minHz, maxHz }) => [minHz, maxHz]), }, environment: { userAgent: navigator.userAgent, adapterInfo, timingKind: "wall-clock", note: "GPU wall time starts after host packing/writeBuffer calls and waits for queued transfers, two compute passes, and readback; total also includes device/pipeline setup and CPU STFT.", }, signalGenerationMs: round(signalGenerationMs), warmupGrid: "16x16", resolutions, }; output.textContent = JSON.stringify(latestResult, null, 2); globalThis.__micarrsimBenchmarkResult = latestResult; document.documentElement.dataset.benchmarkStatus = "pass"; status.textContent = "benchmark 완료"; downloadButton.disabled = false; } catch (error) { document.documentElement.dataset.benchmarkStatus = "fail"; document.documentElement.dataset.benchmarkError = error.message; status.textContent = `benchmark 실패: ${error.message}`; output.textContent = error instanceof Error ? error.stack : String(error); throw error; } finally { runButton.disabled = false; } } runButton.addEventListener("click", () => runBenchmark().catch(() => {})); downloadButton.addEventListener("click", () => { if (latestResult) downloadJson(latestResult, "micarrsim-webgpu-benchmark.json"); }); if (query.get("autorun") !== "0") runBenchmark().catch(() => {});