import { createDistanceMatrix, estimateDasWorkload, finalizeDasBands, } from "../acoustics-core/das.js"; import { interpolateFrequencyResponse } from "../acoustics-core/frequency-response.js"; import { dbToLinear } from "../acoustics-core/math.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, 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 valuesPerBin = stft.frameCount * stft.microphoneCount; const packed = new Float32Array(bins.length * valuesPerBin * 2); const microphones = options.microphones; const compensateFrequencyResponse = options.compensateFrequencyResponse !== false; 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 = dbToLinear(-response.gainDb); 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; } }); 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, { microphones: arrayDefinition.microphones, sampleRate, fftSize: experiment.stft.fftSize, compensateFrequencyResponse: experiment.beamforming.compensateFrequencyResponse !== false, }); 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); } }