feat: add WebGPU DAS backend
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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는 필수 기능이 아니므로 지원되지
|
||||
않는 장치도 동일한 계산 경로를 쓸 수 있습니다.
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./compute.js";
|
||||
export * from "./das.js";
|
||||
export * from "./das.wgsl.js";
|
||||
Reference in New Issue
Block a user