feat: add WebGPU DAS backend
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGPU scaling benchmark · micarrsim</title>
|
||||
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
|
||||
<style>
|
||||
.benchmark-shell { width: min(980px, calc(100% - 32px)); margin: 40px auto; }
|
||||
.benchmark-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0; }
|
||||
.benchmark-output { min-height: 240px; padding: 16px; overflow: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); white-space: pre-wrap; }
|
||||
button { padding: 10px 16px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); background: var(--surface); cursor: pointer; }
|
||||
button:disabled { cursor: wait; opacity: .55; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="benchmark-shell">
|
||||
<p class="eyebrow">192-channel reproducible benchmark</p>
|
||||
<h1>WebGPU DAS scaling</h1>
|
||||
<p id="benchmark-status">fixture를 준비하고 있습니다…</p>
|
||||
<div class="benchmark-actions">
|
||||
<button id="run-benchmark" type="button">다시 실행</button>
|
||||
<button id="download-result" type="button" disabled>JSON 저장</button>
|
||||
<a href="/tests/">전체 테스트 열기</a>
|
||||
</div>
|
||||
<pre class="benchmark-output" id="benchmark-output" aria-live="polite"></pre>
|
||||
</main>
|
||||
<script type="module" src="/benchmarks/webgpu-scaling.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,183 @@
|
||||
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(() => {});
|
||||
Reference in New Issue
Block a user