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
+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) {
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",
+39 -1
View File
@@ -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는 필수 기능이 아니므로 지원되지
않는 장치도 동일한 계산 경로를 쓸 수 있습니다.
+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";