793 lines
31 KiB
JavaScript
793 lines
31 KiB
JavaScript
import {
|
|
createDistanceMatrix,
|
|
estimateDasWorkload,
|
|
finalizeDasBands,
|
|
} from "../acoustics-core/das.js";
|
|
import {
|
|
interpolateFrequencyResponse,
|
|
inverseResponseGainDb,
|
|
requireMaxCompensationDb,
|
|
} from "../acoustics-core/frequency-response.js";
|
|
import { dbToLinear } from "../acoustics-core/math.js";
|
|
import {
|
|
DEFAULT_PHAT_RELATIVE_FLOOR,
|
|
isDasAlgorithm,
|
|
requireLocalizationAlgorithmDefinition,
|
|
} from "../acoustics-core/localization-algorithms.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);
|
|
}
|
|
|
|
function requirePhatRelativeFloor(value) {
|
|
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
throw new RangeError("phatRelativeFloor must lie inside 0..1.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function phaseNormalizePackedStft(
|
|
packed,
|
|
binCount,
|
|
frameCount,
|
|
microphoneCount,
|
|
relativeFloor,
|
|
) {
|
|
requirePhatRelativeFloor(relativeFloor);
|
|
const valuesPerBin = frameCount * microphoneCount;
|
|
for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
|
|
const binOffset = binIndex * valuesPerBin * 2;
|
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
|
const frameOffset = binOffset + frameIndex * microphoneCount * 2;
|
|
let maximumMagnitude = 0;
|
|
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
|
const valueOffset = frameOffset + microphoneIndex * 2;
|
|
maximumMagnitude = Math.max(
|
|
maximumMagnitude,
|
|
Math.hypot(packed[valueOffset], packed[valueOffset + 1]),
|
|
);
|
|
}
|
|
const magnitudeFloor = maximumMagnitude * relativeFloor;
|
|
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
|
const valueOffset = frameOffset + microphoneIndex * 2;
|
|
const magnitude = Math.hypot(packed[valueOffset], packed[valueOffset + 1]);
|
|
if (!(magnitude > magnitudeFloor)) {
|
|
packed[valueOffset] = 0;
|
|
packed[valueOffset + 1] = 0;
|
|
continue;
|
|
}
|
|
packed[valueOffset] /= magnitude;
|
|
packed[valueOffset + 1] /= magnitude;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function packStftBatch(stft, bins, options = {}) {
|
|
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 algorithm = requireLocalizationAlgorithmDefinition(options.algorithm ?? "das");
|
|
if (!isDasAlgorithm(algorithm.id)) {
|
|
throw new RangeError(`packStftBatch does not implement ${algorithm.id}.`);
|
|
}
|
|
const phaseOnly = algorithm.spectralWeighting === "phat";
|
|
const valuesPerBin = stft.frameCount * stft.microphoneCount;
|
|
const packed = new Float32Array(bins.length * valuesPerBin * 2);
|
|
const microphones = options.microphones;
|
|
const compensateFrequencyResponse = options.compensateFrequencyResponse !== false;
|
|
const maxCompensationDb = requireMaxCompensationDb(options.maxCompensationDb);
|
|
const hasFrequencyResponse =
|
|
compensateFrequencyResponse &&
|
|
Array.isArray(microphones) &&
|
|
microphones.some((microphone) => Array.isArray(microphone.frequencyResponse));
|
|
if (hasFrequencyResponse) {
|
|
if (microphones.length !== stft.microphoneCount) {
|
|
throw new Error("STFT microphone 수와 주파수 응답 calibration 수가 다릅니다.");
|
|
}
|
|
if (!(options.sampleRate > 0) || !(options.fftSize > 0)) {
|
|
throw new Error("주파수 응답 보정에는 유효한 sampleRate와 fftSize가 필요합니다.");
|
|
}
|
|
}
|
|
const inverseResponses = hasFrequencyResponse
|
|
? new Float64Array(bins.length * stft.microphoneCount * 2)
|
|
: null;
|
|
if (hasFrequencyResponse) {
|
|
for (let index = 0; index < inverseResponses.length; index += 2) inverseResponses[index] = 1;
|
|
const response = {};
|
|
bins.forEach((bin, binIndex) => {
|
|
const frequencyHz = (bin * options.sampleRate) / options.fftSize;
|
|
microphones.forEach((microphone, microphoneIndex) => {
|
|
if (!Array.isArray(microphone.frequencyResponse)) return;
|
|
interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response);
|
|
const inverseGain = phaseOnly
|
|
? 1
|
|
: dbToLinear(inverseResponseGainDb(response.gainDb, maxCompensationDb));
|
|
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
|
|
inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians);
|
|
inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians);
|
|
});
|
|
});
|
|
}
|
|
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) {
|
|
if (!inverseResponses) {
|
|
packed[binOffset + index * 2] = spectrum.real[index];
|
|
packed[binOffset + index * 2 + 1] = spectrum.imaginary[index];
|
|
continue;
|
|
}
|
|
const microphoneIndex = index % stft.microphoneCount;
|
|
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
|
|
const inverseReal = inverseResponses[responseOffset];
|
|
const inverseImaginary = inverseResponses[responseOffset + 1];
|
|
const inputReal = spectrum.real[index];
|
|
const inputImaginary = spectrum.imaginary[index];
|
|
packed[binOffset + index * 2] =
|
|
inputReal * inverseReal - inputImaginary * inverseImaginary;
|
|
packed[binOffset + index * 2 + 1] =
|
|
inputReal * inverseImaginary + inputImaginary * inverseReal;
|
|
}
|
|
});
|
|
if (phaseOnly) {
|
|
phaseNormalizePackedStft(
|
|
packed,
|
|
bins.length,
|
|
stft.frameCount,
|
|
stft.microphoneCount,
|
|
options.phatRelativeFloor ?? DEFAULT_PHAT_RELATIVE_FLOOR,
|
|
);
|
|
}
|
|
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,
|
|
phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
|
|
gpu,
|
|
powerPreference,
|
|
} = options;
|
|
const algorithm = requireLocalizationAlgorithmDefinition(
|
|
experiment.beamforming?.algorithm ?? "das",
|
|
);
|
|
if (!isDasAlgorithm(algorithm.id)) {
|
|
throw new RangeError(`computeDasWebGpu does not implement ${algorithm.id}.`);
|
|
}
|
|
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, {
|
|
includeAmplitude: algorithm.spectralWeighting !== "phat",
|
|
});
|
|
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, {
|
|
algorithm: algorithm.id,
|
|
microphones: arrayDefinition.microphones,
|
|
sampleRate,
|
|
fftSize: experiment.stft.fftSize,
|
|
compensateFrequencyResponse:
|
|
experiment.beamforming.compensateFrequencyResponse !== false,
|
|
maxCompensationDb: experiment.beamforming.maxCompensationDb,
|
|
phatRelativeFloor,
|
|
});
|
|
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, algorithm.rawScale);
|
|
const totalMs = now() - totalStartedAt;
|
|
return {
|
|
algorithm: algorithm.id,
|
|
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);
|
|
}
|
|
}
|