From d5f7530620e7e7623ceb025fc5b6fb698fee691a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 11:26:19 +0900 Subject: [PATCH] feat: add WebGPU DAS backend --- benchmarks/webgpu-scaling.html | 30 + benchmarks/webgpu-scaling.js | 183 +++++ docs/benchmarks/README.md | 23 + .../webgpu-intel-gen9-2026-08-28.json | 90 +++ packages/acoustics-core/das.js | 60 +- packages/webgpu-kernels/README.md | 40 +- packages/webgpu-kernels/compute.js | 58 ++ packages/webgpu-kernels/das.js | 656 ++++++++++++++++++ packages/webgpu-kernels/das.wgsl.js | 414 +++++++++++ packages/webgpu-kernels/device.js | 170 +++++ packages/webgpu-kernels/index.js | 3 + tests/integration/cpu-baseline.test.js | 8 + tests/integration/webgpu.test.js | 176 +++++ tests/test-runner.js | 2 + tests/unit/webgpu.test.js | 252 +++++++ 15 files changed, 2136 insertions(+), 29 deletions(-) create mode 100644 benchmarks/webgpu-scaling.html create mode 100644 benchmarks/webgpu-scaling.js create mode 100644 docs/benchmarks/README.md create mode 100644 docs/benchmarks/webgpu-intel-gen9-2026-08-28.json create mode 100644 packages/webgpu-kernels/compute.js create mode 100644 packages/webgpu-kernels/das.js create mode 100644 packages/webgpu-kernels/das.wgsl.js create mode 100644 packages/webgpu-kernels/device.js create mode 100644 packages/webgpu-kernels/index.js create mode 100644 tests/integration/webgpu.test.js create mode 100644 tests/unit/webgpu.test.js diff --git a/benchmarks/webgpu-scaling.html b/benchmarks/webgpu-scaling.html new file mode 100644 index 0000000..98cb928 --- /dev/null +++ b/benchmarks/webgpu-scaling.html @@ -0,0 +1,30 @@ + + + + + + WebGPU scaling benchmark · micarrsim + + + + +
+

192-channel reproducible benchmark

+

WebGPU DAS scaling

+

fixture를 준비하고 있습니다…

+
+ + + 전체 테스트 열기 +
+

+    
+ + + diff --git a/benchmarks/webgpu-scaling.js b/benchmarks/webgpu-scaling.js new file mode 100644 index 0000000..99ef8ac --- /dev/null +++ b/benchmarks/webgpu-scaling.js @@ -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(() => {}); diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md new file mode 100644 index 0000000..003193f --- /dev/null +++ b/docs/benchmarks/README.md @@ -0,0 +1,23 @@ +# WebGPU 기준 실측 + +2026-08-28에 Chrome 151 headless, Intel Gen9 adapter에서 synthetic 1 kHz fixture를 측정했다. 입력은 +48 kHz · 0.25 s, 192 microphones, FFT 512 / hop 256, 900–1100 Hz band이며 16×16 warm-up 뒤 각 +해상도를 3회 실행한 중앙값이다. + +| scan grid | complex MAC | GPU wall median | end-to-end median | working set | 50² 대비 GPU wall | +| --- | ---: | ---: | ---: | ---: | ---: | +| 50×50 | 44,160,000 | 79.2 ms | 1,818.1 ms | 9.33 MiB | 1.00× | +| 100×100 | 176,640,000 | 251.4 ms | 2,102.0 ms | 36.91 MiB | 3.17× | +| 200×200 | 706,560,000 | 891.9 ms | 4,206.7 ms | 80.47 MiB | 11.26× | + +`GPU wall`은 host packing/`writeBuffer()` 호출 직후부터 queue에 들어간 transfer 완료, steering pass, +DAS pass와 readback까지 기다린 시간이다. `end-to-end`에는 이 host 작업과 장치/pipeline 초기화, +distance 생성, CPU STFT가 포함된다. 200×200은 64 MiB steering buffer 목표를 지키면서 두 FFT bin을 +한 번에 유지하고 scan point를 21,845개씩 나눈다. 따라서 point chunk가 필요해도 CPU FFT를 다시 +계산하지 않는다. 절대 시간은 브라우저, driver와 GPU에 따라 달라지며 자동 성능 통과/실패 기준으로 +쓰지 않는다. + +같은 환경의 자동 검증에서 192채널·50×50 GPU raw map은 CPU raw map 대비 peak-normalized RMS +오차 `3.008e-5`를 기록했고, 모든 37개 테스트를 통과했다. 상세 원자료는 +`webgpu-intel-gen9-2026-08-28.json`에 있다. 사용자는 `/benchmarks/webgpu-scaling.html`에서 자신의 +장치 결과를 다시 측정해 JSON으로 저장할 수 있다. diff --git a/docs/benchmarks/webgpu-intel-gen9-2026-08-28.json b/docs/benchmarks/webgpu-intel-gen9-2026-08-28.json new file mode 100644 index 0000000..548e9ef --- /dev/null +++ b/docs/benchmarks/webgpu-intel-gen9-2026-08-28.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": "1.0.0", + "kind": "micarrsim.webgpu-benchmark", + "createdAt": "2026-08-28T02:24:00.354Z", + "fixture": { + "arrayId": "rectangular-16x12", + "microphoneCount": 192, + "sceneId": "single-tone", + "durationSeconds": 0.25, + "sampleRate": 48000, + "fftSize": 512, + "hopSize": 256, + "selectedBandHz": [[900, 1100]] + }, + "environment": { + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36", + "adapterInfo": { + "vendor": "intel", + "architecture": "gen-9", + "device": "", + "description": "" + }, + "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": 1200.5, + "warmupGrid": "16x16", + "resolutions": [ + { + "grid": "50x50", + "pointCount": 2500, + "runs": 3, + "medianTotalMs": 1818.1, + "medianStftMs": 1669.8, + "medianGpuWallMs": 79.2, + "medianBeamformingMs": 162.1, + "localizationErrorM": 0.008227, + "complexMacCount": 44160000, + "estimatedGpuWorkingMiB": 9.33, + "binBatchSize": 2, + "pointBatchSize": 2500, + "samples": [ + { "totalMs": 1907.7, "stftMs": 1727.7, "gpuWallMs": 82.5, "beamformingMs": 180 }, + { "totalMs": 1795.7, "stftMs": 1633.6, "gpuWallMs": 79.2, "beamformingMs": 162.1 }, + { "totalMs": 1818.1, "stftMs": 1669.8, "gpuWallMs": 79.1, "beamformingMs": 148.3 } + ], + "gpuWallScalingVs50": 1 + }, + { + "grid": "100x100", + "pointCount": 10000, + "runs": 3, + "medianTotalMs": 2102, + "medianStftMs": 1681.1, + "medianGpuWallMs": 251.4, + "medianBeamformingMs": 537.8, + "localizationErrorM": 0.001515, + "complexMacCount": 176640000, + "estimatedGpuWorkingMiB": 36.91, + "binBatchSize": 2, + "pointBatchSize": 10000, + "samples": [ + { "totalMs": 2102, "stftMs": 1564.2, "gpuWallMs": 251.4, "beamformingMs": 537.8 }, + { "totalMs": 2220.1, "stftMs": 1681.1, "gpuWallMs": 254.7, "beamformingMs": 539 }, + { "totalMs": 2098.8, "stftMs": 1685, "gpuWallMs": 220.4, "beamformingMs": 413.8 } + ], + "gpuWallScalingVs50": 3.17 + }, + { + "grid": "200x200", + "pointCount": 40000, + "runs": 3, + "medianTotalMs": 4206.7, + "medianStftMs": 2057.5, + "medianGpuWallMs": 891.9, + "medianBeamformingMs": 2039, + "localizationErrorM": 0.002099, + "complexMacCount": 706560000, + "estimatedGpuWorkingMiB": 80.468, + "binBatchSize": 2, + "pointBatchSize": 21845, + "samples": [ + { "totalMs": 4206.7, "stftMs": 2057.5, "gpuWallMs": 891.9, "beamformingMs": 2149.2 }, + { "totalMs": 3388.9, "stftMs": 1686.9, "gpuWallMs": 808.5, "beamformingMs": 1702 }, + { "totalMs": 6227.8, "stftMs": 4188.8, "gpuWallMs": 939.7, "beamformingMs": 2039 } + ], + "gpuWallScalingVs50": 11.26 + } + ] +} diff --git a/packages/acoustics-core/das.js b/packages/acoustics-core/das.js index 0b191af..81cd978 100644 --- a/packages/acoustics-core/das.js +++ b/packages/acoustics-core/das.js @@ -116,6 +116,37 @@ export function estimateDasWorkload(options) { }; } +export function finalizeDasBands(rawMaps, workload, experiment) { + return experiment.frequencyBands.map((band, bandIndex) => { + const rawMap = rawMaps[bandIndex]; + for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { + rawMap[pointIndex] /= workload.bandBins[bandIndex].length; + } + const rawPeak = findPeak(rawMap); + const displayMap = Float32Array.from(rawMap); + let displayScale = "power"; + if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) { + displayScale = "normalized"; + for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { + displayMap[pointIndex] /= rawPeak.value; + } + } + return { + id: band.id, + color: band.color, + bins: workload.bandBins[bandIndex], + rawMap, + displayMap, + displayScale, + peak: { + index: rawPeak.index, + position: scanIndexToPosition(rawPeak.index, experiment.scan), + rawPower: rawPeak.value, + }, + }; + }); +} + export async function computeDasMap(options) { const { channels, @@ -229,34 +260,7 @@ export async function computeDasMap(options) { } } - const bands = experiment.frequencyBands.map((band, bandIndex) => { - const rawMap = rawMaps[bandIndex]; - for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { - rawMap[pointIndex] /= workload.bandBins[bandIndex].length; - } - const rawPeak = findPeak(rawMap); - const displayMap = Float32Array.from(rawMap); - let displayScale = "power"; - if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) { - displayScale = "normalized"; - for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { - displayMap[pointIndex] /= rawPeak.value; - } - } - return { - id: band.id, - color: band.color, - bins: workload.bandBins[bandIndex], - rawMap, - displayMap, - displayScale, - peak: { - index: rawPeak.index, - position: scanIndexToPosition(rawPeak.index, experiment.scan), - rawPower: rawPeak.value, - }, - }; - }); + const bands = finalizeDasBands(rawMaps, workload, experiment); return { backend: "cpu", diff --git a/packages/webgpu-kernels/README.md b/packages/webgpu-kernels/README.md index 8b70d10..bbe7ba1 100644 --- a/packages/webgpu-kernels/README.md +++ b/packages/webgpu-kernels/README.md @@ -1,3 +1,41 @@ # WebGPU Kernels -WGSL steering vector 및 DAS 연산과 GPU buffer layout, benchmark 코드를 둡니다. +CPU STFT 결과를 받아 conventional delay-and-sum(DAS)을 WebGPU로 실행합니다. `backend: "auto"`는 +WebGPU unavailable, device limit, validation/OOM, device-loss 같은 운용 오류에서만 CPU 기준 구현으로 +한 번 fallback합니다. `backend: "webgpu"`는 오류를 숨기지 않으며, 취소와 잘못된 입력도 fallback하지 +않습니다. + +## 계산 구조 + +`das.wgsl.js`는 두 compute pass를 제공합니다. + +1. `compute_steering`: `(bin, point, microphone)`별 보정된 complex steering vector를 한 번 계산 +2. `compute_das`: 모든 STFT frame에서 steering을 재사용해 frame 평균 power 계산 + +평면 scan 순서, phase 부호, 감도/위상 보정, complex multiply, bin 평균과 peak normalization은 CPU +기준 구현과 동일합니다. GPU 결과를 읽은 뒤 band 조합과 정규화는 `finalizeDasBands()`를 함께 +사용하므로 raw map과 display map의 의미도 같습니다. + +## 장치 한도와 메모리 + +실행기는 하드코딩한 GPU 크기를 믿지 않고 `device.limits`를 조회합니다. steering buffer는 기본 +64 MiB, 전체 활성 GPU working set은 기본 256 MiB 이하가 되도록 분할합니다. CPU FFT 재계산을 +피하기 위해 가능한 bin batch를 유지하고 steering이 커지면 point를 먼저 chunk합니다. output storage와 +`MAP_READ` staging buffer는 WebGPU usage 규칙에 따라 분리합니다. + +장치 초기화, WGSL `getCompilationInfo()`, async pipeline 생성, validation/OOM/internal error scope, +`device.lost`, `uncapturederror`, readback 실패를 각각 처리합니다. 관련 기준은 최신 +[WebGPU specification](https://www.w3.org/TR/webgpu/)과 +[WGSL floating-point accuracy](https://www.w3.org/TR/WGSL/#floating-point-accuracy)를 따릅니다. + +## 검증과 benchmark + +- `/tests/`: CPU/GPU 교차검증 또는 WebGPU가 없는 환경의 실제 CPU fallback +- `/benchmarks/webgpu-scaling.html`: 192채널 fixture를 예열한 뒤 50²/100²/200² 각각 3회 측정하고 + JSON 저장 +- `docs/benchmarks/webgpu-intel-gen9-2026-08-28.json`: Intel Gen9에서 기록한 기준 실측 + +실측의 `gpuWallMs`는 이미 queue에 넣은 input transfer의 완료 대기 + 두 compute pass + readback +wall-clock입니다. host packing과 `writeBuffer()` 호출은 그 타이머 직전이며, `totalMs`에는 이 작업과 +장치/pipeline 초기화, CPU STFT도 포함됩니다. GPU timestamp query는 필수 기능이 아니므로 지원되지 +않는 장치도 동일한 계산 경로를 쓸 수 있습니다. diff --git a/packages/webgpu-kernels/compute.js b/packages/webgpu-kernels/compute.js new file mode 100644 index 0000000..a7f1080 --- /dev/null +++ b/packages/webgpu-kernels/compute.js @@ -0,0 +1,58 @@ +import { computeDasMap } from "../acoustics-core/das.js"; +import { computeDasWebGpu } from "./das.js"; +import { isRecoverableWebGpuError } from "./device.js"; + +function createAbortError(signal) { + if (signal?.reason instanceof Error && signal.reason.name === "AbortError") return signal.reason; + const error = new Error("DAS 계산이 취소되었습니다."); + error.name = "AbortError"; + return error; +} + +function throwIfAborted(signal) { + if (signal?.aborted) throw createAbortError(signal); +} + +/** + * Execute the requested DAS backend. `auto` falls back only for typed WebGPU + * operational errors; invalid inputs and AbortError remain visible to callers. + */ +export async function computeDasWithBackend(options, dependencies = {}) { + const computeGpu = dependencies.computeGpu ?? computeDasWebGpu; + const computeCpu = dependencies.computeCpu ?? computeDasMap; + const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto"; + + if (requestedBackend === "cpu") return computeCpu(options); + if (requestedBackend === "webgpu") return computeGpu(options); + if (requestedBackend !== "auto") { + throw new TypeError(`지원하지 않는 DAS backend입니다: ${requestedBackend}`); + } + + try { + return await computeGpu(options); + } catch (gpuError) { + if (!isRecoverableWebGpuError(gpuError)) throw gpuError; + throwIfAborted(options.signal); + options.onFallback?.({ from: "webgpu", to: "cpu", error: gpuError }); + throwIfAborted(options.signal); + try { + const cpuResult = await computeCpu(options); + return { + ...cpuResult, + requestedBackend: "auto", + fallback: { + from: "webgpu", + code: gpuError.code, + message: gpuError.message, + }, + }; + } catch (cpuError) { + if (cpuError?.name === "AbortError") throw cpuError; + throwIfAborted(options.signal); + throw new AggregateError( + [gpuError, cpuError], + `WebGPU 실행 실패 후 CPU fallback도 실패했습니다: ${cpuError.message}`, + ); + } + } +} diff --git a/packages/webgpu-kernels/das.js b/packages/webgpu-kernels/das.js new file mode 100644 index 0000000..74fea32 --- /dev/null +++ b/packages/webgpu-kernels/das.js @@ -0,0 +1,656 @@ +import { + createDistanceMatrix, + estimateDasWorkload, + finalizeDasBands, +} from "../acoustics-core/das.js"; +import { extractStftBins } from "../acoustics-core/stft.js"; +import { + DAS_PARAMS_BYTE_LENGTH, + DAS_WGSL, + WORKGROUP_SIZE, + packDasParams, + packMicrophoneCalibration, +} from "./das.wgsl.js"; +import { + WebGpuError, + WebGpuExecutionError, + WebGpuLimitError, + WebGpuUnavailableError, + assertWebGpuContextHealthy, + closeWebGpuContext, + createCheckedComputePipeline, + createWebGpuContext, + withWebGpuErrorScopes, +} from "./device.js"; + +export { + WebGpuError, + WebGpuExecutionError, + WebGpuLimitError, + WebGpuUnavailableError, +} from "./device.js"; + +const MEBIBYTE = 1024 ** 2; +const DEFAULT_MAXIMUM_GPU_WORKING_BYTES = 256 * MEBIBYTE; +const DEFAULT_MAXIMUM_HOST_WORKING_BYTES = 512 * MEBIBYTE; +const DEFAULT_MAXIMUM_STEERING_BUFFER_BYTES = 64 * MEBIBYTE; +export const DEFAULT_MAXIMUM_MACS_PER_INVOCATION = 250_000; + +function now() { + return globalThis.performance?.now?.() ?? Date.now(); +} + +function throwIfAborted(signal) { + if (!signal?.aborted) return; + const error = new Error("WebGPU DAS 계산이 취소되었습니다."); + error.name = "AbortError"; + throw error; +} + +function yieldToEventLoop() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function requirePositiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new WebGpuLimitError(`${label} 값이 유효하지 않습니다: ${value}`); + } + return value; +} + +function limitValue(limits, name, fallback) { + return requirePositiveInteger(limits?.[name] ?? fallback, `device.limits.${name}`); +} + +function minimumBufferSize(byteLength) { + return Math.max(4, Math.ceil(byteLength / 4) * 4); +} + +export function packStftBatch(stft, bins) { + if (!stft || !Number.isInteger(stft.frameCount) || !Number.isInteger(stft.microphoneCount)) { + throw new TypeError("유효한 STFT 결과가 필요합니다."); + } + if (!Array.isArray(bins) || bins.length === 0) throw new TypeError("하나 이상의 FFT bin이 필요합니다."); + const valuesPerBin = stft.frameCount * stft.microphoneCount; + const packed = new Float32Array(bins.length * valuesPerBin * 2); + bins.forEach((bin, binIndex) => { + const spectrum = stft.spectra.get(bin); + if (!spectrum || spectrum.real.length !== valuesPerBin || spectrum.imaginary.length !== valuesPerBin) { + throw new Error(`FFT bin ${bin}의 spectrum layout이 올바르지 않습니다.`); + } + const binOffset = binIndex * valuesPerBin * 2; + for (let index = 0; index < valuesPerBin; index += 1) { + packed[binOffset + index * 2] = spectrum.real[index]; + packed[binOffset + index * 2 + 1] = spectrum.imaginary[index]; + } + }); + return packed; +} + +function pointCapacityForBinBatch({ + workload, + binBatchSize, + storageBindingLimit, + bufferLimit, + maximumSteeringBufferBytes, + maximumGpuWorkingBytes, + maximumHostWorkingBytes, + maximumPointDispatch, +}) { + const microphoneCount = workload.microphoneCount; + const distanceBytesPerPoint = microphoneCount * Float32Array.BYTES_PER_ELEMENT; + const steeringBytesPerPoint = + binBatchSize * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; + const outputBytesPerPoint = binBatchSize * Float32Array.BYTES_PER_ELEMENT; + const spectrumBytes = binBatchSize * workload.spectrumBytesPerBin; + const binIndexBytes = binBatchSize * Uint32Array.BYTES_PER_ELEMENT; + const calibrationBytes = microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; + const storageLimit = Math.min(storageBindingLimit, bufferLimit); + const steeringLimit = Math.min(storageLimit, maximumSteeringBufferBytes); + + const gpuFixedBytes = DAS_PARAMS_BYTE_LENGTH + calibrationBytes + spectrumBytes + binIndexBytes; + const gpuBytesPerPoint = distanceBytesPerPoint + steeringBytesPerPoint + outputBytesPerPoint * 2; + const hostFixedBytes = workload.baseWorkingBytes + spectrumBytes * 2 + binIndexBytes; + const hostBytesPerPoint = distanceBytesPerPoint + outputBytesPerPoint; + + return Math.max( + 0, + Math.min( + workload.pointCount, + maximumPointDispatch, + Math.floor(storageLimit / distanceBytesPerPoint), + Math.floor(steeringLimit / steeringBytesPerPoint), + Math.floor(storageLimit / outputBytesPerPoint), + Math.floor(bufferLimit / outputBytesPerPoint), + Math.floor((maximumGpuWorkingBytes - gpuFixedBytes) / gpuBytesPerPoint), + Math.floor((maximumHostWorkingBytes - hostFixedBytes) / hostBytesPerPoint), + ), + ); +} + +/** + * Plan bin and point chunks against the limits of the device that will execute + * them. The largest safe spectrum/bin batch is retained when point chunking can + * satisfy steering memory; this avoids repeating the CPU FFT for every GPU bin + * chunk on wide bands. + */ +export function estimateWebGpuBatchPlan(options) { + const { + workload, + limits, + maximumGpuWorkingBytes = DEFAULT_MAXIMUM_GPU_WORKING_BYTES, + maximumHostWorkingBytes = DEFAULT_MAXIMUM_HOST_WORKING_BYTES, + maximumSteeringBufferBytes = DEFAULT_MAXIMUM_STEERING_BUFFER_BYTES, + maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER, + maximumMacsPerInvocation = DEFAULT_MAXIMUM_MACS_PER_INVOCATION, + } = options; + if (!workload || !Array.isArray(workload.selectedBins) || workload.selectedBins.length === 0) { + throw new WebGpuLimitError("WebGPU DAS workload에 FFT bin이 없습니다."); + } + requirePositiveInteger(workload.microphoneCount, "microphoneCount"); + requirePositiveInteger(workload.pointCount, "pointCount"); + requirePositiveInteger(workload.frameCount, "frameCount"); + requirePositiveInteger(workload.spectrumBytesPerBin, "spectrumBytesPerBin"); + requirePositiveInteger(maximumGpuWorkingBytes, "maximumGpuWorkingBytes"); + requirePositiveInteger(maximumHostWorkingBytes, "maximumHostWorkingBytes"); + requirePositiveInteger(maximumSteeringBufferBytes, "maximumSteeringBufferBytes"); + requirePositiveInteger(maximumMacsPerInvocation, "maximumMacsPerInvocation"); + + const macsPerInvocation = workload.frameCount * workload.microphoneCount; + if (!Number.isSafeInteger(macsPerInvocation) || macsPerInvocation > maximumMacsPerInvocation) { + throw new WebGpuLimitError( + `DAS invocation당 직렬 누산 ${macsPerInvocation.toLocaleString()}회가 안전 한도 ` + + `${maximumMacsPerInvocation.toLocaleString()}회를 넘습니다. duration을 줄이세요.`, + ); + } + + const storageBindingLimit = limitValue(limits, "maxStorageBufferBindingSize", 128 * MEBIBYTE); + const bufferLimit = limitValue(limits, "maxBufferSize", 256 * MEBIBYTE); + const maximumDispatch = limitValue(limits, "maxComputeWorkgroupsPerDimension", 65_535); + const maximumInvocations = limitValue(limits, "maxComputeInvocationsPerWorkgroup", 256); + const maximumWorkgroupX = limitValue(limits, "maxComputeWorkgroupSizeX", 256); + const maximumStorageBuffers = limitValue(limits, "maxStorageBuffersPerShaderStage", 8); + const maximumUniformBytes = limitValue(limits, "maxUniformBufferBindingSize", 65_536); + const storageLimit = Math.min(storageBindingLimit, bufferLimit); + + if (maximumInvocations < WORKGROUP_SIZE || maximumWorkgroupX < WORKGROUP_SIZE) { + throw new WebGpuLimitError( + `DAS workgroup ${WORKGROUP_SIZE}개 invocation을 지원하지 않는 WebGPU 장치입니다.`, + ); + } + if (maximumStorageBuffers < 6) { + throw new WebGpuLimitError(`DAS에 storage buffer 6개가 필요하지만 장치 한도는 ${maximumStorageBuffers}개입니다.`); + } + if (maximumUniformBytes < DAS_PARAMS_BYTE_LENGTH) { + throw new WebGpuLimitError(`DAS uniform ${DAS_PARAMS_BYTE_LENGTH} bytes가 장치 한도를 넘습니다.`); + } + const calibrationBytes = workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; + if (calibrationBytes > storageLimit) { + throw new WebGpuLimitError( + `마이크 calibration buffer ${calibrationBytes.toLocaleString()} bytes가 storage binding 한도를 넘습니다.`, + ); + } + const distanceBytesPerPoint = workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT; + if (distanceBytesPerPoint > storageLimit) { + throw new WebGpuLimitError("scan point 한 개의 distance data도 storage binding에 넣을 수 없습니다."); + } + if (workload.spectrumBytesPerBin > storageLimit) { + throw new WebGpuLimitError( + `FFT bin 한 개의 spectrum ${workload.spectrumBytesPerBin.toLocaleString()} bytes가 storage binding 한도를 넘습니다.`, + ); + } + + const maximumBinsBySpectrum = Math.floor(storageLimit / workload.spectrumBytesPerBin); + const maximumBinsByDispatch = maximumDispatch; + const requestedBinLimit = Math.max(1, Math.floor(maximumSpectrumBatchBins)); + const largestCandidate = Math.min( + workload.selectedBins.length, + maximumBinsBySpectrum, + maximumBinsByDispatch, + requestedBinLimit, + ); + if (largestCandidate < 1) throw new WebGpuLimitError("FFT bin 한 개도 WebGPU batch에 넣을 수 없습니다."); + + let binBatchSize = largestCandidate; + let pointBatchSize = 0; + for (; binBatchSize >= 1; binBatchSize -= 1) { + pointBatchSize = pointCapacityForBinBatch({ + workload, + binBatchSize, + storageBindingLimit, + bufferLimit, + maximumSteeringBufferBytes, + maximumGpuWorkingBytes, + maximumHostWorkingBytes, + maximumPointDispatch: maximumDispatch, + }); + if (pointBatchSize >= 1) break; + } + if (pointBatchSize < 1) { + throw new WebGpuLimitError( + "현재 장치 및 애플리케이션 메모리 한도에서는 scan point 한 개도 처리할 수 없습니다.", + ); + } + + const spectrumBytes = binBatchSize * workload.spectrumBytesPerBin; + const distanceBytes = pointBatchSize * distanceBytesPerPoint; + const steeringBytes = + binBatchSize * pointBatchSize * workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; + const outputBytes = binBatchSize * pointBatchSize * Float32Array.BYTES_PER_ELEMENT; + const calibrationAndParamsBytes = calibrationBytes + DAS_PARAMS_BYTE_LENGTH; + const estimatedGpuWorkingBytes = + calibrationAndParamsBytes + spectrumBytes + binBatchSize * 4 + distanceBytes + steeringBytes + outputBytes * 2; + + return { + binBatchSize, + pointBatchSize, + binBatchCount: Math.ceil(workload.selectedBins.length / binBatchSize), + pointBatchCount: Math.ceil(workload.pointCount / pointBatchSize), + macsPerInvocation, + steeringBytes, + estimatedGpuWorkingBytes, + maximumGpuWorkingBytes, + maximumHostWorkingBytes, + }; +} + +export function estimateWebGpuBatchSize(options) { + return estimateWebGpuBatchPlan(options).binBatchSize; +} + +function gpuGlobals() { + const usage = globalThis.GPUBufferUsage; + const mapMode = globalThis.GPUMapMode; + if (!usage || !mapMode) { + throw new WebGpuUnavailableError("WebGPU buffer 상수를 사용할 수 없습니다."); + } + return { usage, mapMode }; +} + +function createBuffer(device, { label, byteLength, usage, data }) { + const buffer = device.createBuffer({ + label, + size: minimumBufferSize(byteLength), + usage, + }); + if (data && data.byteLength > 0) { + device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, data.byteLength); + } + return buffer; +} + +function destroyBuffers(buffers) { + for (const buffer of buffers) { + try { + buffer?.destroy(); + } catch { + // A lost device can invalidate resources before deterministic cleanup. + } + } +} + +async function readOutputBuffer(context, readbackBuffer, byteLength, mapMode) { + const mappedOutcome = readbackBuffer.mapAsync(mapMode.READ).then( + () => ({ kind: "mapped" }), + (error) => ({ kind: "error", error }), + ); + const lostOutcome = context.lostPromise.then((info) => ({ kind: "lost", info })); + const outcome = await Promise.race([mappedOutcome, lostOutcome]); + if (outcome.kind === "lost") { + throw new WebGpuExecutionError(`WebGPU 장치가 계산 중 손실되었습니다: ${outcome.info.message || "원인 미상"}`); + } + if (outcome.kind === "error") { + throw new WebGpuExecutionError("WebGPU 결과 buffer를 읽지 못했습니다.", { cause: outcome.error }); + } + const copy = readbackBuffer.getMappedRange(0, byteLength).slice(0); + readbackBuffer.unmap(); + return new Float32Array(copy); +} + +async function executePointBatch(options) { + const { + context, + pipelines, + buffers, + pointCount, + binCount, + microphoneCount, + outputByteLength, + signal, + usage, + mapMode, + } = options; + throwIfAborted(signal); + assertWebGpuContextHealthy(context); + return withWebGpuErrorScopes(context.device, async () => { + const { device } = context; + const steeringBindGroup = device.createBindGroup({ + label: "DAS steering bind group", + layout: pipelines.steering.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: buffers.params } }, + { binding: 1, resource: { buffer: buffers.bins } }, + { binding: 3, resource: { buffer: buffers.distances } }, + { binding: 4, resource: { buffer: buffers.calibration } }, + { binding: 5, resource: { buffer: buffers.steering } }, + ], + }); + const dasBindGroup = device.createBindGroup({ + label: "DAS beamforming bind group", + layout: pipelines.das.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: buffers.params } }, + { binding: 2, resource: { buffer: buffers.spectrum } }, + { binding: 5, resource: { buffer: buffers.steering } }, + { binding: 6, resource: { buffer: buffers.output } }, + ], + }); + const encoder = device.createCommandEncoder({ label: "DAS command encoder" }); + const pass = encoder.beginComputePass({ label: "DAS steering and beamforming pass" }); + pass.setPipeline(pipelines.steering); + pass.setBindGroup(0, steeringBindGroup); + pass.dispatchWorkgroups(Math.ceil(microphoneCount / WORKGROUP_SIZE), pointCount, binCount); + pass.setPipeline(pipelines.das); + pass.setBindGroup(0, dasBindGroup); + pass.dispatchWorkgroups(Math.ceil(pointCount / WORKGROUP_SIZE), binCount, 1); + pass.end(); + encoder.copyBufferToBuffer(buffers.output, 0, buffers.readback, 0, outputByteLength); + device.queue.submit([encoder.finish()]); + const output = await readOutputBuffer(context, buffers.readback, outputByteLength, mapMode); + throwIfAborted(signal); + assertWebGpuContextHealthy(context); + return output; + }); +} + +function createBandIndexLookup(workload) { + const lookup = new Map(); + workload.bandBins.forEach((bins, bandIndex) => { + bins.forEach((bin) => { + if (!lookup.has(bin)) lookup.set(bin, []); + lookup.get(bin).push(bandIndex); + }); + }); + return lookup; +} + +export async function computeDasWebGpu(options) { + const { + channels, + sampleRate, + arrayDefinition, + scene, + experiment, + signal, + onProgress, + maximumGpuWorkingBytes = DEFAULT_MAXIMUM_GPU_WORKING_BYTES, + maximumHostWorkingBytes = DEFAULT_MAXIMUM_HOST_WORKING_BYTES, + maximumSteeringBufferBytes = DEFAULT_MAXIMUM_STEERING_BUFFER_BYTES, + maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER, + maximumMacsPerInvocation = DEFAULT_MAXIMUM_MACS_PER_INVOCATION, + maximumGpuComplexMacs = 20_000_000_000, + maximumFftButterflies = 1_000_000_000, + stftTransformBatchSize = 32, + gpu, + powerPreference, + } = options; + if (!Array.isArray(channels) || channels.length !== arrayDefinition?.microphones?.length) { + throw new Error(`채널 수 ${channels?.length ?? 0}와 마이크 수 ${arrayDefinition?.microphones?.length ?? 0}가 다릅니다.`); + } + const totalStartedAt = now(); + const workload = estimateDasWorkload({ channels, sampleRate, arrayDefinition, experiment }); + if (workload.complexMacCount > maximumGpuComplexMacs) { + throw new WebGpuLimitError( + `WebGPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 안전 한도 ` + + `${maximumGpuComplexMacs.toLocaleString()}회를 넘습니다.`, + ); + } + + const context = await createWebGpuContext({ gpu, powerPreference }); + const resources = []; + let stftMs = 0; + let gpuWallMs = 0; + try { + const { usage, mapMode } = gpuGlobals(); + assertWebGpuContextHealthy(context); + const plan = estimateWebGpuBatchPlan({ + workload, + limits: context.device.limits, + maximumGpuWorkingBytes, + maximumHostWorkingBytes, + maximumSteeringBufferBytes, + maximumSpectrumBatchBins, + maximumMacsPerInvocation, + }); + const fftButterflyCount = + plan.binBatchCount * + workload.frameCount * + workload.microphoneCount * + (experiment.stft.fftSize / 2) * + Math.log2(experiment.stft.fftSize); + if (fftButterflyCount > maximumFftButterflies) { + throw new WebGpuLimitError( + `CPU STFT 예상 butterfly ${Math.ceil(fftButterflyCount).toLocaleString()}회가 안전 한도 ` + + `${maximumFftButterflies.toLocaleString()}회를 넘습니다.`, + ); + } + + const pipelines = await withWebGpuErrorScopes(context.device, async () => ({ + steering: await createCheckedComputePipeline(context.device, { + code: DAS_WGSL, + entryPoint: "compute_steering", + label: "DAS steering pipeline", + }), + das: await createCheckedComputePipeline(context.device, { + code: DAS_WGSL, + entryPoint: "compute_das", + label: "DAS beamforming pipeline", + }), + })); + const calibration = packMicrophoneCalibration(arrayDefinition.microphones); + const calibrationBuffer = await withWebGpuErrorScopes(context.device, async () => + createBuffer(context.device, { + label: "DAS microphone calibration", + byteLength: calibration.byteLength, + usage: usage.STORAGE | usage.COPY_DST, + data: calibration, + }), + ); + resources.push(calibrationBuffer); + + const distances = createDistanceMatrix(arrayDefinition, experiment.scan); + const rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount)); + const bandIndexesByBin = createBandIndexLookup(workload); + const totalDispatchUnits = plan.binBatchCount * plan.pointBatchCount; + let completedDispatchUnits = 0; + let completedBins = 0; + + for (let binStart = 0; binStart < workload.selectedBins.length; binStart += plan.binBatchSize) { + throwIfAborted(signal); + await yieldToEventLoop(); + const batchBins = workload.selectedBins.slice(binStart, binStart + plan.binBatchSize); + const stftStartedAt = now(); + const stft = await extractStftBins(channels, { + fftSize: experiment.stft.fftSize, + hopSize: experiment.stft.hopSize, + bins: batchBins, + signal, + transformBatchSize: stftTransformBatchSize, + onProgress: ({ completedTransforms, totalTransforms }) => + onProgress?.({ + stage: "stft", + backend: "webgpu", + completedBins, + totalBins: workload.selectedBins.length, + completedTransforms, + totalTransforms, + spectrumBatch: Math.floor(binStart / plan.binBatchSize) + 1, + spectrumBatchCount: plan.binBatchCount, + }), + }); + const packedSpectrum = packStftBatch(stft, batchBins); + stftMs += now() - stftStartedAt; + const binIndices = Uint32Array.from(batchBins); + const binResources = []; + try { + const { binsBuffer, spectrumBuffer } = await withWebGpuErrorScopes(context.device, async () => ({ + binsBuffer: createBuffer(context.device, { + label: "DAS FFT bin indices", + byteLength: binIndices.byteLength, + usage: usage.STORAGE | usage.COPY_DST, + data: binIndices, + }), + spectrumBuffer: createBuffer(context.device, { + label: "DAS packed STFT spectrum", + byteLength: packedSpectrum.byteLength, + usage: usage.STORAGE | usage.COPY_DST, + data: packedSpectrum, + }), + })); + binResources.push(binsBuffer, spectrumBuffer); + + for (let pointStart = 0; pointStart < workload.pointCount; pointStart += plan.pointBatchSize) { + throwIfAborted(signal); + const pointCount = Math.min(plan.pointBatchSize, workload.pointCount - pointStart); + const distanceStart = pointStart * workload.microphoneCount; + const distanceEnd = distanceStart + pointCount * workload.microphoneCount; + const pointDistances = distances.subarray(distanceStart, distanceEnd); + const params = packDasParams({ + pointCount, + microphoneCount: workload.microphoneCount, + frameCount: workload.frameCount, + binCount: batchBins.length, + sampleRate, + fftSize: experiment.stft.fftSize, + speedOfSound: scene.speedOfSound, + }); + const steeringByteLength = + batchBins.length * pointCount * workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; + const outputByteLength = batchBins.length * pointCount * Float32Array.BYTES_PER_ELEMENT; + const pointResources = []; + try { + const { + paramsBuffer, + distancesBuffer, + steeringBuffer, + outputBuffer, + readbackBuffer, + } = await withWebGpuErrorScopes(context.device, async () => ({ + paramsBuffer: createBuffer(context.device, { + label: "DAS parameters", + byteLength: params.byteLength, + usage: usage.UNIFORM | usage.COPY_DST, + data: new Uint8Array(params), + }), + distancesBuffer: createBuffer(context.device, { + label: "DAS point-to-microphone distances", + byteLength: pointDistances.byteLength, + usage: usage.STORAGE | usage.COPY_DST, + data: pointDistances, + }), + steeringBuffer: createBuffer(context.device, { + label: "DAS steering vectors", + byteLength: steeringByteLength, + usage: usage.STORAGE, + }), + outputBuffer: createBuffer(context.device, { + label: "DAS output maps", + byteLength: outputByteLength, + usage: usage.STORAGE | usage.COPY_SRC, + }), + readbackBuffer: createBuffer(context.device, { + label: "DAS readback staging", + byteLength: outputByteLength, + usage: usage.MAP_READ | usage.COPY_DST, + }), + })); + pointResources.push(paramsBuffer, distancesBuffer, steeringBuffer, outputBuffer, readbackBuffer); + const gpuStartedAt = now(); + const output = await executePointBatch({ + context, + pipelines, + buffers: { + params: paramsBuffer, + bins: binsBuffer, + spectrum: spectrumBuffer, + distances: distancesBuffer, + calibration: calibrationBuffer, + steering: steeringBuffer, + output: outputBuffer, + readback: readbackBuffer, + }, + pointCount, + binCount: batchBins.length, + microphoneCount: workload.microphoneCount, + outputByteLength, + signal, + usage, + mapMode, + }); + gpuWallMs += now() - gpuStartedAt; + for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) { + if (!Number.isFinite(output[outputIndex]) || output[outputIndex] < 0) { + throw new WebGpuExecutionError( + `WebGPU DAS output[${outputIndex}]이 유효한 power 값이 아닙니다: ${output[outputIndex]}`, + ); + } + } + batchBins.forEach((bin, localBinIndex) => { + const localMapOffset = localBinIndex * pointCount; + for (const bandIndex of bandIndexesByBin.get(bin)) { + const rawMap = rawMaps[bandIndex]; + for (let localPointIndex = 0; localPointIndex < pointCount; localPointIndex += 1) { + rawMap[pointStart + localPointIndex] += output[localMapOffset + localPointIndex]; + } + } + }); + } finally { + destroyBuffers(pointResources); + } + completedDispatchUnits += 1; + onProgress?.({ + stage: "beamforming", + backend: "webgpu", + completedBins: + pointStart + pointCount === workload.pointCount + ? completedBins + batchBins.length + : completedBins, + totalBins: workload.selectedBins.length, + completedUnits: completedDispatchUnits, + totalUnits: totalDispatchUnits, + }); + await yieldToEventLoop(); + } + } finally { + destroyBuffers(binResources); + } + completedBins += batchBins.length; + } + + const bands = finalizeDasBands(rawMaps, workload, experiment); + const totalMs = now() - totalStartedAt; + return { + backend: "webgpu", + frameCount: workload.frameCount, + microphoneCount: workload.microphoneCount, + pointCount: workload.pointCount, + workload, + fftButterflyCount, + bands, + timingMs: { + total: totalMs, + stft: stftMs, + gpuWall: gpuWallMs, + beamforming: totalMs - stftMs, + }, + gpu: { + adapterInfo: context.adapterInfo, + timingKind: "wall-clock", + batchPlan: plan, + }, + }; + } finally { + destroyBuffers(resources); + closeWebGpuContext(context); + } +} diff --git a/packages/webgpu-kernels/das.wgsl.js b/packages/webgpu-kernels/das.wgsl.js new file mode 100644 index 0000000..aa6ce87 --- /dev/null +++ b/packages/webgpu-kernels/das.wgsl.js @@ -0,0 +1,414 @@ +/** + * Two-pass frequency-domain delay-and-sum beamforming kernel. + * + * Dispatch shapes: + * compute_steering = (ceil(microphoneCount / WORKGROUP_SIZE), pointCount, binCount) + * compute_das = (ceil(pointCount / WORKGROUP_SIZE), binCount, 1) + * + * The first pass computes trigonometry once per (bin, point, microphone). + * The second pass reuses those steering vectors for every STFT frame. Callers + * can bound the intermediate buffer by submitting point/bin batches. + * + * Flat buffer layouts: + * spectrum: [bin][frame][microphone][real, imaginary] + * distances: [point][microphone] + * calibration: [microphone][inverse sensitivity gain, phase offset radians] + * steering: [bin][point][microphone][real, imaginary] + * output: [bin][point] + */ + +export const WORKGROUP_SIZE = 64; +export const DAS_WORKGROUP_SIZE = WORKGROUP_SIZE; +export const MAX_MICROPHONES = 1024; + +// WGSL uniform structs are rounded to a 16-byte boundary. The reserved word +// keeps the JavaScript and shader representations explicitly 32 bytes. +export const DAS_PARAMS_BYTE_LENGTH = 32; + +export const DAS_PARAMS_LAYOUT = Object.freeze({ + byteLength: DAS_PARAMS_BYTE_LENGTH, + pointCount: Object.freeze({ offset: 0, type: "u32" }), + microphoneCount: Object.freeze({ offset: 4, type: "u32" }), + frameCount: Object.freeze({ offset: 8, type: "u32" }), + binCount: Object.freeze({ offset: 12, type: "u32" }), + sampleRate: Object.freeze({ offset: 16, type: "f32" }), + fftSize: Object.freeze({ offset: 20, type: "u32" }), + speedOfSound: Object.freeze({ offset: 24, type: "f32" }), + reserved: Object.freeze({ offset: 28, type: "u32" }), +}); + +export const DAS_BUFFER_LAYOUT = Object.freeze({ + params: Object.freeze({ group: 0, binding: 0, access: "uniform", layout: DAS_PARAMS_LAYOUT }), + binIndices: Object.freeze({ group: 0, binding: 1, access: "read", element: "u32" }), + spectrum: Object.freeze({ + group: 0, + binding: 2, + access: "read", + element: "f32", + order: "bin-frame-microphone-complex", + }), + distances: Object.freeze({ + group: 0, + binding: 3, + access: "read", + element: "f32", + order: "point-microphone", + }), + calibration: Object.freeze({ + group: 0, + binding: 4, + access: "read", + element: "vec2", + order: "microphone-(inverseGain-phaseRadians)", + }), + steering: Object.freeze({ + group: 0, + binding: 5, + access: "read_write", + element: "vec2", + order: "bin-point-microphone-complex", + }), + output: Object.freeze({ + group: 0, + binding: 6, + access: "read_write", + element: "f32", + order: "bin-point", + }), +}); + +export const DAS_ENTRY_POINTS = Object.freeze({ + steering: "compute_steering", + beamforming: "compute_das", +}); + +function requireUint32(name, value) { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`${name} must be an unsigned 32-bit integer.`); + } + return value; +} + +function requireUint32Product(name, values) { + let product = 1; + for (const value of values) { + if (value !== 0 && product > 0xffff_ffff / value) { + throw new RangeError(`${name} exceeds the shader's u32 address space.`); + } + product *= value; + } + return product; +} + +function requirePositiveF32(name, value) { + const rounded = Math.fround(value); + if (!(rounded > 0) || !Number.isFinite(rounded)) { + throw new RangeError(`${name} must be a finite positive f32 value.`); + } + return rounded; +} + +function requireFiniteF32(name, value) { + const rounded = Math.fround(value); + if (!Number.isFinite(rounded)) { + throw new RangeError(`${name} must be a finite f32 value.`); + } + return rounded; +} + +function requireFiniteNumber(name, value) { + if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite.`); + return value; +} + +/** Pack the binding-0 uniform using the offsets in DAS_PARAMS_LAYOUT. */ +export function packDasParams(options) { + const { + pointCount, + microphoneCount, + frameCount, + binCount, + sampleRate, + fftSize, + speedOfSound, + } = options; + const counts = { + pointCount: requireUint32("pointCount", pointCount), + microphoneCount: requireUint32("microphoneCount", microphoneCount), + frameCount: requireUint32("frameCount", frameCount), + binCount: requireUint32("binCount", binCount), + fftSize: requireUint32("fftSize", fftSize), + }; + if (counts.microphoneCount > MAX_MICROPHONES) { + throw new RangeError(`microphoneCount must not exceed ${MAX_MICROPHONES}.`); + } + if (counts.fftSize === 0) throw new RangeError("fftSize must be greater than zero."); + + requireUint32Product("distance buffer element count", [ + counts.pointCount, + counts.microphoneCount, + ]); + requireUint32Product("output buffer element count", [counts.binCount, counts.pointCount]); + requireUint32Product("steering buffer element count", [ + counts.binCount, + counts.pointCount, + counts.microphoneCount, + ]); + requireUint32Product("spectrum buffer element count", [ + counts.binCount, + counts.frameCount, + counts.microphoneCount, + 2, + ]); + + const packedSampleRate = requirePositiveF32("sampleRate", sampleRate); + const packedSpeedOfSound = requirePositiveF32("speedOfSound", speedOfSound); + const buffer = new ArrayBuffer(DAS_PARAMS_BYTE_LENGTH); + const view = new DataView(buffer); + view.setUint32(0, counts.pointCount, true); + view.setUint32(4, counts.microphoneCount, true); + view.setUint32(8, counts.frameCount, true); + view.setUint32(12, counts.binCount, true); + view.setFloat32(16, packedSampleRate, true); + view.setUint32(20, counts.fftSize, true); + view.setFloat32(24, packedSpeedOfSound, true); + view.setUint32(28, 0, true); + return buffer; +} + +/** + * Pack already-linear calibration as tightly packed vec2 entries. + * phase is the microphone phase offset in radians. The shader subtracts it + * from the propagation phase, exactly as the CPU reference does. + */ +export function packDasCalibration(calibration) { + if (!Array.isArray(calibration)) throw new TypeError("calibration must be an array."); + const packed = new Float32Array(calibration.length * 2); + for (let index = 0; index < calibration.length; index += 1) { + const entry = calibration[index]; + const gain = requireFiniteF32(`calibration[${index}].gain`, entry?.gain); + if (gain < 0) throw new RangeError(`calibration[${index}].gain must not be negative.`); + packed[index * 2] = gain; + packed[index * 2 + 1] = requireFiniteF32( + `calibration[${index}].phase`, + entry?.phase, + ); + } + return packed; +} + +/** Convert array-definition microphone metadata to the shader calibration layout. */ +export function packMicrophoneCalibration(microphones) { + if (!Array.isArray(microphones)) throw new TypeError("microphones must be an array."); + const packed = new Float32Array(microphones.length * 2); + for (let index = 0; index < microphones.length; index += 1) { + const microphone = microphones[index]; + const sensitivityDb = requireFiniteNumber( + `microphones[${index}].sensitivityDb`, + microphone?.sensitivityDb ?? 0, + ); + const phaseOffsetDeg = requireFiniteNumber( + `microphones[${index}].phaseOffsetDeg`, + microphone?.phaseOffsetDeg ?? 0, + ); + packed[index * 2] = requireFiniteF32( + `microphones[${index}] inverse sensitivity gain`, + 10 ** (-sensitivityDb / 20), + ); + packed[index * 2 + 1] = requireFiniteF32( + `microphones[${index}] phase offset`, + (phaseOffsetDeg * Math.PI) / 180, + ); + } + return packed; +} + +export const DAS_WGSL = /* wgsl */ ` +struct DasParams { + point_count: u32, + microphone_count: u32, + frame_count: u32, + bin_count: u32, + sample_rate: f32, + fft_size: u32, + speed_of_sound: f32, + reserved: u32, +} + +struct U32Buffer { + values: array, +} + +struct F32Buffer { + values: array, +} + +struct Vec2Buffer { + values: array>, +} + +@group(0) @binding(0) var params: DasParams; +@group(0) @binding(1) var bin_indices: U32Buffer; +@group(0) @binding(2) var spectrum: F32Buffer; +@group(0) @binding(3) var distances: F32Buffer; +@group(0) @binding(4) var calibration: Vec2Buffer; +@group(0) @binding(5) var steering_vectors: Vec2Buffer; +@group(0) @binding(6) var output_maps: F32Buffer; + +const TAU: f32 = 6.283185307179586; +const MAX_MICROPHONES: u32 = ${MAX_MICROPHONES}u; +const MAX_U32: u32 = 0xffffffffu; + +fn multiplication_overflows(left: u32, right: u32) -> bool { + return left != 0u && right > MAX_U32 / left; +} + +fn wrap_phase(phase: f32) -> f32 { + return phase - round(phase / TAU) * TAU; +} + +@compute @workgroup_size(${WORKGROUP_SIZE}, 1, 1) +fn compute_steering(@builtin(global_invocation_id) invocation: vec3) { + let microphone_index = invocation.x; + let point_index = invocation.y; + let bin_index = invocation.z; + + if ( + microphone_index >= params.microphone_count || + point_index >= params.point_count || + bin_index >= params.bin_count + ) { + return; + } + + if ( + params.microphone_count == 0u || + params.microphone_count > MAX_MICROPHONES || + params.fft_size == 0u || + params.sample_rate <= 0.0 || + params.speed_of_sound <= 0.0 || + multiplication_overflows(params.point_count, params.microphone_count) || + multiplication_overflows(params.bin_count, params.point_count) + ) { + return; + } + + let bin_point_count = params.bin_count * params.point_count; + if (multiplication_overflows(bin_point_count, params.microphone_count)) { + return; + } + + let required_distance_values = params.point_count * params.microphone_count; + let required_steering_values = bin_point_count * params.microphone_count; + if ( + arrayLength(&bin_indices.values) < params.bin_count || + arrayLength(&calibration.values) < params.microphone_count || + arrayLength(&distances.values) < required_distance_values || + arrayLength(&steering_vectors.values) < required_steering_values + ) { + return; + } + + let bin = f32(bin_indices.values[bin_index]); + let angular_wave_number = + (TAU * bin * params.sample_rate) / + (f32(params.fft_size) * params.speed_of_sound); + let distance_index = point_index * params.microphone_count + microphone_index; + let steering_index = + (bin_index * params.point_count + point_index) * params.microphone_count + + microphone_index; + let microphone_calibration = calibration.values[microphone_index]; + let phase = wrap_phase( + angular_wave_number * distances.values[distance_index] - + microphone_calibration.y + ); + steering_vectors.values[steering_index] = + vec2(cos(phase), sin(phase)) * microphone_calibration.x; +} + +@compute @workgroup_size(${WORKGROUP_SIZE}, 1, 1) +fn compute_das(@builtin(global_invocation_id) invocation: vec3) { + let point_index = invocation.x; + let bin_index = invocation.y; + + if (point_index >= params.point_count || bin_index >= params.bin_count) { + return; + } + + if ( + params.microphone_count == 0u || + params.microphone_count > MAX_MICROPHONES || + params.frame_count == 0u || + multiplication_overflows(params.bin_count, params.point_count) || + multiplication_overflows(params.bin_count, params.frame_count) + ) { + return; + } + + let bin_point_count = params.bin_count * params.point_count; + let bin_frame_count = params.bin_count * params.frame_count; + if ( + multiplication_overflows(bin_point_count, params.microphone_count) || + multiplication_overflows(bin_frame_count, params.microphone_count) + ) { + return; + } + + let spectrum_complex_count = bin_frame_count * params.microphone_count; + if (multiplication_overflows(spectrum_complex_count, 2u)) { + return; + } + + let required_output_values = bin_point_count; + let required_steering_values = bin_point_count * params.microphone_count; + let required_spectrum_values = spectrum_complex_count * 2u; + if ( + arrayLength(&output_maps.values) < required_output_values || + arrayLength(&steering_vectors.values) < required_steering_values || + arrayLength(&spectrum.values) < required_spectrum_values + ) { + return; + } + + let microphone_scale = 1.0 / f32(params.microphone_count); + let steering_base = + (bin_index * params.point_count + point_index) * params.microphone_count; + var accumulated_power = 0.0; + + for (var frame_index = 0u; frame_index < params.frame_count; frame_index += 1u) { + var sum = vec2(0.0, 0.0); + for ( + var microphone_index = 0u; + microphone_index < params.microphone_count; + microphone_index += 1u + ) { + let complex_index = ( + ( + (bin_index * params.frame_count + frame_index) * params.microphone_count + + microphone_index + ) * 2u + ); + let input = vec2( + spectrum.values[complex_index], + spectrum.values[complex_index + 1u] + ); + let steering = steering_vectors.values[steering_base + microphone_index]; + + // (input.real + i*input.imaginary) * + // (steering.real + i*steering.imaginary) + sum += vec2( + input.x * steering.x - input.y * steering.y, + input.x * steering.y + input.y * steering.x + ); + } + + accumulated_power += + dot(sum, sum) * microphone_scale * microphone_scale; + } + + let output_index = bin_index * params.point_count + point_index; + output_maps.values[output_index] = accumulated_power / f32(params.frame_count); +} +`; + +export const DAS_SHADER_SOURCE = DAS_WGSL; diff --git a/packages/webgpu-kernels/device.js b/packages/webgpu-kernels/device.js new file mode 100644 index 0000000..3fd9fe7 --- /dev/null +++ b/packages/webgpu-kernels/device.js @@ -0,0 +1,170 @@ +/** Errors in this module are deliberately typed so `auto` can fall back to CPU + * without hiding invalid experiment input or an explicit user cancellation. */ +export class WebGpuError extends Error { + constructor(message, options = {}) { + super(message, options.cause ? { cause: options.cause } : undefined); + this.name = new.target.name; + this.code = options.code ?? "WEBGPU_ERROR"; + } +} + +export class WebGpuUnavailableError extends WebGpuError { + constructor(message = "이 브라우저에서 WebGPU를 사용할 수 없습니다.", options = {}) { + super(message, { ...options, code: "WEBGPU_UNAVAILABLE" }); + } +} + +export class WebGpuLimitError extends WebGpuError { + constructor(message, options = {}) { + super(message, { ...options, code: "WEBGPU_LIMIT" }); + } +} + +export class WebGpuExecutionError extends WebGpuError { + constructor(message, options = {}) { + super(message, { ...options, code: "WEBGPU_EXECUTION" }); + } +} + +export function isRecoverableWebGpuError(error) { + return error instanceof WebGpuError; +} + +function describeAdapter(adapter) { + const info = adapter?.info; + if (!info) return null; + return { + vendor: info.vendor ?? "", + architecture: info.architecture ?? "", + device: info.device ?? "", + description: info.description ?? "", + }; +} + +function normalizeRequestError(error, message) { + if (error instanceof WebGpuError) return error; + return new WebGpuUnavailableError(message, { cause: error }); +} + +/** + * Request one short-lived WebGPU context. A new call always requests a new + * adapter so a device-loss retry never reuses an invalid adapter/device pair. + */ +export async function createWebGpuContext(options = {}) { + const gpu = options.gpu ?? globalThis.navigator?.gpu; + if (!gpu) throw new WebGpuUnavailableError(); + + let adapter; + try { + adapter = await gpu.requestAdapter({ powerPreference: options.powerPreference ?? "high-performance" }); + } catch (error) { + throw normalizeRequestError(error, "WebGPU 어댑터 요청에 실패했습니다."); + } + if (!adapter) throw new WebGpuUnavailableError("사용 가능한 WebGPU 어댑터가 없습니다."); + + let device; + try { + device = await adapter.requestDevice(); + } catch (error) { + throw normalizeRequestError(error, "WebGPU 장치 요청에 실패했습니다."); + } + + const state = { + lostInfo: null, + uncapturedError: null, + closedByApplication: false, + }; + const onUncapturedError = (event) => { + state.uncapturedError = event.error ?? new Error("알 수 없는 WebGPU 오류"); + }; + device.addEventListener?.("uncapturederror", onUncapturedError); + const lostPromise = device.lost.then((info) => { + state.lostInfo = info; + return info; + }); + + return { + adapter, + device, + adapterInfo: describeAdapter(adapter), + state, + lostPromise, + onUncapturedError, + }; +} + +export function assertWebGpuContextHealthy(context) { + const { state } = context; + if (state.uncapturedError) { + throw new WebGpuExecutionError(`처리되지 않은 WebGPU 오류: ${state.uncapturedError.message}`, { + cause: state.uncapturedError, + }); + } + if (state.lostInfo && !(state.closedByApplication && state.lostInfo.reason === "destroyed")) { + throw new WebGpuExecutionError(`WebGPU 장치가 손실되었습니다: ${state.lostInfo.message || "원인 미상"}`); + } +} + +export function closeWebGpuContext(context) { + if (!context?.device) return; + context.state.closedByApplication = true; + context.device.removeEventListener?.("uncapturederror", context.onUncapturedError); + context.device.destroy(); +} + +export async function createCheckedComputePipeline(device, { code, entryPoint, label }) { + let shaderModule; + try { + shaderModule = device.createShaderModule({ code, label: `${label} shader` }); + const compilationInfo = await shaderModule.getCompilationInfo(); + const compilationErrors = compilationInfo.messages.filter((message) => message.type === "error"); + if (compilationErrors.length > 0) { + const details = compilationErrors + .slice(0, 4) + .map((message) => `${message.lineNum}:${message.linePos} ${message.message}`) + .join("\n"); + throw new WebGpuExecutionError(`WGSL 컴파일에 실패했습니다.\n${details}`); + } + return await device.createComputePipelineAsync({ + label, + layout: "auto", + compute: { module: shaderModule, entryPoint }, + }); + } catch (error) { + if (error instanceof WebGpuError) throw error; + throw new WebGpuExecutionError("WebGPU compute pipeline 생성에 실패했습니다.", { cause: error }); + } +} + +/** Capture validation/OOM/internal errors around predictable GPU work. */ +export async function withWebGpuErrorScopes(device, callback) { + const filters = ["internal", "out-of-memory", "validation"]; + for (const filter of filters) device.pushErrorScope(filter); + let result; + let callbackError; + try { + result = await callback(); + } catch (error) { + callbackError = error; + } + + const captured = []; + for (let index = filters.length - 1; index >= 0; index -= 1) { + try { + const error = await device.popErrorScope(); + if (error) captured.push(error); + } catch (error) { + captured.push(error); + } + } + if (callbackError) { + if (callbackError instanceof WebGpuError) throw callbackError; + throw new WebGpuExecutionError("WebGPU 명령 실행에 실패했습니다.", { cause: callbackError }); + } + if (captured.length > 0) { + throw new WebGpuExecutionError(`WebGPU 검증 오류: ${captured.map((error) => error.message).join(" / ")}`, { + cause: captured[0], + }); + } + return result; +} diff --git a/packages/webgpu-kernels/index.js b/packages/webgpu-kernels/index.js new file mode 100644 index 0000000..d21c1d7 --- /dev/null +++ b/packages/webgpu-kernels/index.js @@ -0,0 +1,3 @@ +export * from "./compute.js"; +export * from "./das.js"; +export * from "./das.wgsl.js"; diff --git a/tests/integration/cpu-baseline.test.js b/tests/integration/cpu-baseline.test.js index 74e1bc0..b1e40ff 100644 --- a/tests/integration/cpu-baseline.test.js +++ b/tests/integration/cpu-baseline.test.js @@ -30,5 +30,13 @@ test("192채널 · 50×50 CPU baseline이 ground truth를 찾는다", async () = assertEqual(result.pointCount, 2500, "scan point count"); assert(localizationError < 0.03, `localization error=${localizationError} m`); assert(Number.isFinite(result.bands[0].peak.rawPower) && result.bands[0].peak.rawPower > 0, "finite peak power"); + globalThis.__micarrsimCpuBaseline = { + channels: synthesis.channels, + sampleRate: synthesis.sampleRate, + arrayDefinition, + scene, + experiment, + result, + }; document.documentElement.dataset.cpuBaselineMs = elapsedMs.toFixed(1); }); diff --git a/tests/integration/webgpu.test.js b/tests/integration/webgpu.test.js new file mode 100644 index 0000000..ebdc42e --- /dev/null +++ b/tests/integration/webgpu.test.js @@ -0,0 +1,176 @@ +import { computeDasMap } from "/packages/acoustics-core/das.js"; +import { distance3 } from "/packages/acoustics-core/math.js"; +import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; +import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js"; +import { computeDasWebGpu } from "/packages/webgpu-kernels/das.js"; + +function createArray(columns, rows, width, height) { + const microphones = []; + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const index = row * columns + column; + microphones.push({ + id: `gpu-mic-${row}-${column}`, + position: [ + -width / 2 + (column * width) / (columns - 1), + -height / 2 + (row * height) / (rows - 1), + 0, + ], + sensitivityDb: (index % 3) * 2 - 2, + phaseOffsetDeg: (index % 5) * 8 - 16, + }); + } + } + return { id: "gpu-test-array", aperture: { width, height }, microphones }; +} + +function createFixture(backend = "webgpu") { + const arrayDefinition = createArray(4, 4, 0.3, 0.3); + const scene = { + id: "gpu-das-scene", + sampleRate: 8_000, + duration: 0.064, + speedOfSound: 343, + seed: 11, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [{ + id: "source", + name: "source", + position: [0.1, 0.1, 1.2], + gainDb: -12, + startTime: 0, + duration: 0.064, + signal: { type: "tone", frequency: 1_000, phaseDeg: 0 }, + }], + ambient: [], + }; + const experiment = { + stft: { fftSize: 128, hopSize: 64, window: "hann" }, + scan: { z: 1.2, xMin: -0.4, xMax: 0.4, yMin: -0.4, yMax: 0.4, columns: 9, rows: 9 }, + frequencyBands: [{ id: "1k", minHz: 990, maxHz: 1_010, color: "#65f2bf" }], + beamforming: { backend, algorithm: "das", normalization: "peak" }, + }; + const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition }); + return { + channels: synthesis.channels, + sampleRate: synthesis.sampleRate, + arrayDefinition, + scene, + experiment, + }; +} + +function normalizedMapError(reference, actual) { + const referenceScale = Math.max(...reference); + let squaredError = 0; + let maximumAbsoluteError = 0; + for (let index = 0; index < reference.length; index += 1) { + const error = Math.abs(actual[index] - reference[index]) / referenceScale; + squaredError += error * error; + maximumAbsoluteError = Math.max(maximumAbsoluteError, error); + } + return { + rms: Math.sqrt(squaredError / reference.length), + maximumAbsoluteError, + }; +} + +test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다", async () => { + const adapter = await globalThis.navigator?.gpu?.requestAdapter(); + if (!adapter) { + const fixture = createFixture("auto"); + const result = await computeDasWithBackend(fixture); + assertEqual(result.backend, "cpu", "auto fallback backend without adapter"); + assert(result.bands[0].peak.rawPower > 0, "fallback produced a usable map"); + document.documentElement.dataset.webgpuIntegration = "cpu-fallback"; + return; + } + + const fixture = createFixture("webgpu"); + const cpuResult = await computeDasMap(fixture); + const gpuResult = await computeDasWebGpu(fixture); + const cpuBand = cpuResult.bands[0]; + const gpuBand = gpuResult.bands[0]; + const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap); + + assertEqual(gpuResult.backend, "webgpu", "actual WebGPU backend"); + assertEqual(gpuBand.rawMap.length, cpuBand.rawMap.length, "map size"); + assert(error.rms <= 2e-3, `CPU/GPU normalized RMS error=${error.rms}`); + assert( + error.maximumAbsoluteError <= 1e-2, + `CPU/GPU normalized maximum absolute error=${error.maximumAbsoluteError}`, + ); + const gridDiagonal = Math.hypot( + (fixture.experiment.scan.xMax - fixture.experiment.scan.xMin) / + (fixture.experiment.scan.columns - 1), + (fixture.experiment.scan.yMax - fixture.experiment.scan.yMin) / + (fixture.experiment.scan.rows - 1), + ); + assert( + distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9, + "CPU/GPU peak positions are within one grid diagonal", + ); + assert( + distance3(gpuBand.peak.position, fixture.scene.sources[0].position) <= 0.071, + `GPU localization error=${distance3(gpuBand.peak.position, fixture.scene.sources[0].position)} m`, + ); + document.documentElement.dataset.webgpuIntegration = "gpu"; + document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3); +}); + +test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => { + const adapter = await globalThis.navigator?.gpu?.requestAdapter(); + if (!adapter) { + document.documentElement.dataset.webgpuBaseline = "unavailable"; + return; + } + const baseline = globalThis.__micarrsimCpuBaseline; + assert(baseline, "CPU baseline fixture is available"); + const experiment = { + ...baseline.experiment, + beamforming: { ...baseline.experiment.beamforming, backend: "webgpu" }, + }; + const result = await computeDasWebGpu({ ...baseline, experiment }); + const cpuBand = baseline.result.bands[0]; + const gpuBand = result.bands[0]; + const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap); + const peakPowerRelativeError = + Math.abs(gpuBand.peak.rawPower - cpuBand.peak.rawPower) / cpuBand.peak.rawPower; + const gridDiagonal = Math.hypot( + (experiment.scan.xMax - experiment.scan.xMin) / (experiment.scan.columns - 1), + (experiment.scan.yMax - experiment.scan.yMin) / (experiment.scan.rows - 1), + ); + + assert(gpuBand.rawMap.every((value) => Number.isFinite(value) && value >= 0), "finite nonnegative GPU map"); + assert(error.rms <= 0.005, `192ch CPU/GPU normalized RMS error=${error.rms}`); + assert(error.maximumAbsoluteError <= 0.02, `192ch CPU/GPU normalized max error=${error.maximumAbsoluteError}`); + assert(peakPowerRelativeError <= 0.02, `192ch CPU/GPU peak power relative error=${peakPowerRelativeError}`); + assert( + distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9, + "192ch CPU/GPU peaks are within one grid diagonal", + ); + document.documentElement.dataset.webgpuBaseline = "gpu"; + document.documentElement.dataset.webgpuBaselineMs = result.timingMs.total.toFixed(1); + document.documentElement.dataset.webgpuBaselineRmsError = error.rms.toExponential(3); +}); + +test("WebGPU bin·point 강제 batching 결과가 CPU map과 일치한다", async () => { + const adapter = await globalThis.navigator?.gpu?.requestAdapter(); + if (!adapter) return; + const fixture = createFixture("webgpu"); + fixture.experiment.frequencyBands = [ + { id: "wide-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" }, + ]; + const cpuResult = await computeDasMap(fixture); + const gpuResult = await computeDasWebGpu({ + ...fixture, + maximumSpectrumBatchBins: 1, + maximumSteeringBufferBytes: 512, + }); + const error = normalizedMapError(cpuResult.bands[0].rawMap, gpuResult.bands[0].rawMap); + + assertEqual(gpuResult.gpu.batchPlan.binBatchSize, 1, "forced bin batch size"); + assertEqual(gpuResult.gpu.batchPlan.pointBatchSize, 4, "forced point batch size"); + assert(error.rms <= 0.005, `chunked CPU/GPU normalized RMS error=${error.rms}`); + assert(error.maximumAbsoluteError <= 0.02, `chunked CPU/GPU max error=${error.maximumAbsoluteError}`); +}); diff --git a/tests/test-runner.js b/tests/test-runner.js index 622ac0e..17d109e 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -12,7 +12,9 @@ try { await import("/tests/unit/contracts.test.js"); await import("/tests/unit/acoustics.test.js"); await import("/tests/unit/builder.test.js"); + await import("/tests/unit/webgpu.test.js"); await import("/tests/integration/cpu-baseline.test.js"); + await import("/tests/integration/webgpu.test.js"); await import("/tests/integration/builder-ui.test.js"); } catch (error) { registeredTests.push({ diff --git a/tests/unit/webgpu.test.js b/tests/unit/webgpu.test.js new file mode 100644 index 0000000..d409e3a --- /dev/null +++ b/tests/unit/webgpu.test.js @@ -0,0 +1,252 @@ +import { + DAS_PARAMS_BYTE_LENGTH, + packDasParams, + packMicrophoneCalibration, +} from "/packages/webgpu-kernels/das.wgsl.js"; +import { + WebGpuLimitError, + WebGpuUnavailableError, + estimateWebGpuBatchPlan, + estimateWebGpuBatchSize, + packStftBatch, +} from "/packages/webgpu-kernels/das.js"; +import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js"; + +function assertNear(actual, expected, tolerance, label) { + assert( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ± ${tolerance}, received ${actual}`, + ); +} + +test("WebGPU DAS uniform과 microphone calibration을 WGSL layout으로 packing한다", () => { + const params = packDasParams({ + pointCount: 81, + microphoneCount: 16, + frameCount: 7, + binCount: 3, + sampleRate: 8_000, + fftSize: 256, + speedOfSound: 343.25, + }); + const view = new DataView(params); + + assertEqual(params.byteLength, DAS_PARAMS_BYTE_LENGTH, "uniform byte length"); + assertEqual(view.getUint32(0, true), 81, "point count offset"); + assertEqual(view.getUint32(4, true), 16, "microphone count offset"); + assertEqual(view.getUint32(8, true), 7, "frame count offset"); + assertEqual(view.getUint32(12, true), 3, "bin count offset"); + assertEqual(view.getFloat32(16, true), Math.fround(8_000), "sample rate offset"); + assertEqual(view.getUint32(20, true), 256, "FFT size offset"); + assertEqual(view.getFloat32(24, true), Math.fround(343.25), "speed of sound offset"); + assertEqual(view.getUint32(28, true), 0, "reserved word"); + + const calibration = packMicrophoneCalibration([ + { sensitivityDb: 0, phaseOffsetDeg: 0 }, + { sensitivityDb: 6.020599913, phaseOffsetDeg: 90 }, + { sensitivityDb: -6.020599913, phaseOffsetDeg: -45 }, + ]); + assertEqual(calibration.length, 6, "two f32 values per microphone"); + assertNear(calibration[0], 1, 1e-7, "unity inverse gain"); + assertNear(calibration[1], 0, 1e-7, "zero phase"); + assertNear(calibration[2], 0.5, 1e-6, "+6 dB inverse gain"); + assertNear(calibration[3], Math.PI / 2, 1e-6, "+90 degree phase"); + assertNear(calibration[4], 2, 1e-6, "-6 dB inverse gain"); + assertNear(calibration[5], -Math.PI / 4, 1e-6, "-45 degree phase"); +}); + +test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () => { + const stft = { + frameCount: 2, + microphoneCount: 2, + spectra: new Map([ + [3, { + real: Float32Array.of(1, 2, 3, 4), + imaginary: Float32Array.of(11, 12, 13, 14), + }], + [7, { + real: Float32Array.of(5, 6, 7, 8), + imaginary: Float32Array.of(15, 16, 17, 18), + }], + ]), + }; + + const packed = packStftBatch(stft, [7, 3]); + assert(packed instanceof Float32Array, "packed STFT type"); + const expected = [ + 5, 15, 6, 16, 7, 17, 8, 18, + 1, 11, 2, 12, 3, 13, 4, 14, + ]; + assertEqual(packed.length, expected.length, "packed STFT length"); + expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`)); +}); + +test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => { + const workload = { + selectedBins: [4, 5, 6, 7, 8], + bandBins: [[4, 5, 6, 7, 8]], + frameCount: 2, + microphoneCount: 4, + pointCount: 8, + distanceBytes: 128, + outputBytes: 64, + spectrumBytesPerBin: 64, + baseWorkingBytes: 256, + }; + const limits = { + maxStorageBufferBindingSize: 192, + maxBufferSize: 192, + maxComputeWorkgroupsPerDimension: 2, + maxComputeInvocationsPerWorkgroup: 64, + maxComputeWorkgroupSizeX: 64, + }; + + const batchSize = estimateWebGpuBatchSize({ + workload, + limits, + maximumGpuWorkingBytes: 4_096, + maximumHostWorkingBytes: 4_096, + }); + assertEqual(batchSize, 2, "dispatch y dimension is the tightest batch limit"); + const plan = estimateWebGpuBatchPlan({ + workload, + limits, + maximumGpuWorkingBytes: 4_096, + maximumHostWorkingBytes: 4_096, + }); + assertEqual(plan.pointBatchSize, 2, "steering point dispatch batch"); + assertEqual(plan.steeringBytes, 128, "two-pass steering buffer bytes"); + + let limitError; + try { + estimateWebGpuBatchSize({ + workload, + limits: { ...limits, maxStorageBufferBindingSize: 31 }, + maximumGpuWorkingBytes: 4_096, + maximumHostWorkingBytes: 4_096, + }); + } catch (error) { + limitError = error; + } + assert(limitError instanceof WebGpuLimitError, "static calibration buffer limit error type"); + + let invocationLimitError; + try { + estimateWebGpuBatchSize({ + workload, + limits, + maximumGpuWorkingBytes: 4_096, + maximumHostWorkingBytes: 4_096, + maximumMacsPerInvocation: 7, + }); + } catch (error) { + invocationLimitError = error; + } + assert(invocationLimitError instanceof WebGpuLimitError, "per-invocation watchdog guard error type"); + + const scalingWorkload = { + selectedBins: [10, 11], + bandBins: [[10, 11]], + frameCount: 46, + microphoneCount: 192, + pointCount: 40_000, + distanceBytes: 40_000 * 192 * 4, + outputBytes: 40_000 * 4 * 2, + spectrumBytesPerBin: 46 * 192 * 8, + baseWorkingBytes: 40_000 * 192 * 4 + 40_000 * 4 * 3 + 512 * 8 * 2, + }; + const scalingPlan = estimateWebGpuBatchPlan({ + workload: scalingWorkload, + limits: { + maxStorageBufferBindingSize: 128 * 1024 ** 2, + maxBufferSize: 256 * 1024 ** 2, + maxComputeWorkgroupsPerDimension: 65_535, + maxComputeInvocationsPerWorkgroup: 256, + maxComputeWorkgroupSizeX: 256, + maxStorageBuffersPerShaderStage: 8, + maxUniformBufferBindingSize: 65_536, + }, + }); + assertEqual(scalingPlan.binBatchSize, 2, "200x200 preserves one CPU STFT bin batch"); + assert(scalingPlan.pointBatchSize < 40_000, "200x200 steering uses point chunks"); + assert(scalingPlan.steeringBytes <= 64 * 1024 ** 2, "steering target is respected"); +}); + +test("backend auto는 typed WebGPU 운용 오류만 CPU로 fallback한다", async () => { + const calls = []; + const options = { experiment: { beamforming: { backend: "auto" } } }; + const cpuResult = { backend: "cpu", bands: [], marker: "fallback-result" }; + const result = await computeDasWithBackend(options, { + computeGpu: async () => { + calls.push("gpu"); + throw new WebGpuUnavailableError("adapter unavailable"); + }, + computeCpu: async () => { + calls.push("cpu"); + return cpuResult; + }, + }); + + assertEqual(calls.join(","), "gpu,cpu", "auto backend order"); + assertEqual(result.backend, "cpu", "fallback backend"); + assertEqual(result.marker, "fallback-result", "CPU result is preserved"); + + const limitFallback = await computeDasWithBackend(options, { + computeGpu: async () => { + throw new WebGpuLimitError("device limit"); + }, + computeCpu: async () => cpuResult, + }); + assertEqual(limitFallback.backend, "cpu", "device limit also uses CPU fallback in auto mode"); + + let programmingError; + try { + await computeDasWithBackend(options, { + computeGpu: async () => { + throw new TypeError("programming error"); + }, + computeCpu: async () => { + throw new Error("CPU must not run"); + }, + }); + } catch (error) { + programmingError = error; + } + assert(programmingError instanceof TypeError, "unexpected GPU errors are not hidden by fallback"); + + const controller = new AbortController(); + controller.abort(); + let abortError; + try { + await computeDasWithBackend( + { ...options, signal: controller.signal }, + { + computeGpu: async () => { + throw new WebGpuUnavailableError("adapter unavailable after cancellation"); + }, + computeCpu: async () => { + throw new Error("cancelled auto mode must not start CPU fallback"); + }, + }, + ); + } catch (error) { + abortError = error; + } + assertEqual(abortError?.name, "AbortError", "cancellation wins over GPU fallback"); + + const explicitOptions = { experiment: { beamforming: { backend: "webgpu" } } }; + let explicitError; + try { + await computeDasWithBackend(explicitOptions, { + computeGpu: async () => { + throw new WebGpuLimitError("forced device limit"); + }, + computeCpu: async () => { + throw new Error("explicit webgpu must not run CPU fallback"); + }, + }); + } catch (error) { + explicitError = error; + } + assert(explicitError instanceof WebGpuLimitError, "explicit WebGPU preserves operational error"); +});