feat: add WebGPU DAS backend

This commit is contained in:
Codex
2026-08-28 11:26:19 +09:00
parent 08fcf578de
commit d5f7530620
15 changed files with 2136 additions and 29 deletions
+183
View File
@@ -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(() => {});