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
+30
View File
@@ -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>
+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(() => {});
+23
View File
@@ -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으로 저장할 수 있다.
@@ -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
}
]
}
+32 -28
View File
@@ -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) { export async function computeDasMap(options) {
const { const {
channels, channels,
@@ -229,34 +260,7 @@ export async function computeDasMap(options) {
} }
} }
const bands = experiment.frequencyBands.map((band, bandIndex) => { const bands = finalizeDasBands(rawMaps, workload, experiment);
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,
},
};
});
return { return {
backend: "cpu", backend: "cpu",
+39 -1
View File
@@ -1,3 +1,41 @@
# WebGPU Kernels # 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는 필수 기능이 아니므로 지원되지
않는 장치도 동일한 계산 경로를 쓸 수 있습니다.
+58
View File
@@ -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}`,
);
}
}
}
+656
View File
@@ -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);
}
}
+414
View File
@@ -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<f32>",
order: "microphone-(inverseGain-phaseRadians)",
}),
steering: Object.freeze({
group: 0,
binding: 5,
access: "read_write",
element: "vec2<f32>",
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<f32> 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<u32>,
}
struct F32Buffer {
values: array<f32>,
}
struct Vec2Buffer {
values: array<vec2<f32>>,
}
@group(0) @binding(0) var<uniform> params: DasParams;
@group(0) @binding(1) var<storage, read> bin_indices: U32Buffer;
@group(0) @binding(2) var<storage, read> spectrum: F32Buffer;
@group(0) @binding(3) var<storage, read> distances: F32Buffer;
@group(0) @binding(4) var<storage, read> calibration: Vec2Buffer;
@group(0) @binding(5) var<storage, read_write> steering_vectors: Vec2Buffer;
@group(0) @binding(6) var<storage, read_write> 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<u32>) {
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<f32>(cos(phase), sin(phase)) * microphone_calibration.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, 1, 1)
fn compute_das(@builtin(global_invocation_id) invocation: vec3<u32>) {
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<f32>(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<f32>(
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<f32>(
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;
+170
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./compute.js";
export * from "./das.js";
export * from "./das.wgsl.js";
+8
View File
@@ -30,5 +30,13 @@ test("192채널 · 50×50 CPU baseline이 ground truth를 찾는다", async () =
assertEqual(result.pointCount, 2500, "scan point count"); assertEqual(result.pointCount, 2500, "scan point count");
assert(localizationError < 0.03, `localization error=${localizationError} m`); 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"); 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); document.documentElement.dataset.cpuBaselineMs = elapsedMs.toFixed(1);
}); });
+176
View File
@@ -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}`);
});
+2
View File
@@ -12,7 +12,9 @@ try {
await import("/tests/unit/contracts.test.js"); await import("/tests/unit/contracts.test.js");
await import("/tests/unit/acoustics.test.js"); await import("/tests/unit/acoustics.test.js");
await import("/tests/unit/builder.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/cpu-baseline.test.js");
await import("/tests/integration/webgpu.test.js");
await import("/tests/integration/builder-ui.test.js"); await import("/tests/integration/builder-ui.test.js");
} catch (error) { } catch (error) {
registeredTests.push({ registeredTests.push({
+252
View File
@@ -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");
});