feat: add WebGPU DAS backend
This commit is contained in:
@@ -30,5 +30,13 @@ test("192채널 · 50×50 CPU baseline이 ground truth를 찾는다", async () =
|
||||
assertEqual(result.pointCount, 2500, "scan point count");
|
||||
assert(localizationError < 0.03, `localization error=${localizationError} m`);
|
||||
assert(Number.isFinite(result.bands[0].peak.rawPower) && result.bands[0].peak.rawPower > 0, "finite peak power");
|
||||
globalThis.__micarrsimCpuBaseline = {
|
||||
channels: synthesis.channels,
|
||||
sampleRate: synthesis.sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment,
|
||||
result,
|
||||
};
|
||||
document.documentElement.dataset.cpuBaselineMs = elapsedMs.toFixed(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { computeDasMap } from "/packages/acoustics-core/das.js";
|
||||
import { distance3 } from "/packages/acoustics-core/math.js";
|
||||
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
|
||||
import { computeDasWebGpu } from "/packages/webgpu-kernels/das.js";
|
||||
|
||||
function createArray(columns, rows, width, height) {
|
||||
const microphones = [];
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const index = row * columns + column;
|
||||
microphones.push({
|
||||
id: `gpu-mic-${row}-${column}`,
|
||||
position: [
|
||||
-width / 2 + (column * width) / (columns - 1),
|
||||
-height / 2 + (row * height) / (rows - 1),
|
||||
0,
|
||||
],
|
||||
sensitivityDb: (index % 3) * 2 - 2,
|
||||
phaseOffsetDeg: (index % 5) * 8 - 16,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { id: "gpu-test-array", aperture: { width, height }, microphones };
|
||||
}
|
||||
|
||||
function createFixture(backend = "webgpu") {
|
||||
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
|
||||
const scene = {
|
||||
id: "gpu-das-scene",
|
||||
sampleRate: 8_000,
|
||||
duration: 0.064,
|
||||
speedOfSound: 343,
|
||||
seed: 11,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "source",
|
||||
name: "source",
|
||||
position: [0.1, 0.1, 1.2],
|
||||
gainDb: -12,
|
||||
startTime: 0,
|
||||
duration: 0.064,
|
||||
signal: { type: "tone", frequency: 1_000, phaseDeg: 0 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const experiment = {
|
||||
stft: { fftSize: 128, hopSize: 64, window: "hann" },
|
||||
scan: { z: 1.2, xMin: -0.4, xMax: 0.4, yMin: -0.4, yMax: 0.4, columns: 9, rows: 9 },
|
||||
frequencyBands: [{ id: "1k", minHz: 990, maxHz: 1_010, color: "#65f2bf" }],
|
||||
beamforming: { backend, algorithm: "das", normalization: "peak" },
|
||||
};
|
||||
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
||||
return {
|
||||
channels: synthesis.channels,
|
||||
sampleRate: synthesis.sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedMapError(reference, actual) {
|
||||
const referenceScale = Math.max(...reference);
|
||||
let squaredError = 0;
|
||||
let maximumAbsoluteError = 0;
|
||||
for (let index = 0; index < reference.length; index += 1) {
|
||||
const error = Math.abs(actual[index] - reference[index]) / referenceScale;
|
||||
squaredError += error * error;
|
||||
maximumAbsoluteError = Math.max(maximumAbsoluteError, error);
|
||||
}
|
||||
return {
|
||||
rms: Math.sqrt(squaredError / reference.length),
|
||||
maximumAbsoluteError,
|
||||
};
|
||||
}
|
||||
|
||||
test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다", async () => {
|
||||
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
|
||||
if (!adapter) {
|
||||
const fixture = createFixture("auto");
|
||||
const result = await computeDasWithBackend(fixture);
|
||||
assertEqual(result.backend, "cpu", "auto fallback backend without adapter");
|
||||
assert(result.bands[0].peak.rawPower > 0, "fallback produced a usable map");
|
||||
document.documentElement.dataset.webgpuIntegration = "cpu-fallback";
|
||||
return;
|
||||
}
|
||||
|
||||
const fixture = createFixture("webgpu");
|
||||
const cpuResult = await computeDasMap(fixture);
|
||||
const gpuResult = await computeDasWebGpu(fixture);
|
||||
const cpuBand = cpuResult.bands[0];
|
||||
const gpuBand = gpuResult.bands[0];
|
||||
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
|
||||
|
||||
assertEqual(gpuResult.backend, "webgpu", "actual WebGPU backend");
|
||||
assertEqual(gpuBand.rawMap.length, cpuBand.rawMap.length, "map size");
|
||||
assert(error.rms <= 2e-3, `CPU/GPU normalized RMS error=${error.rms}`);
|
||||
assert(
|
||||
error.maximumAbsoluteError <= 1e-2,
|
||||
`CPU/GPU normalized maximum absolute error=${error.maximumAbsoluteError}`,
|
||||
);
|
||||
const gridDiagonal = Math.hypot(
|
||||
(fixture.experiment.scan.xMax - fixture.experiment.scan.xMin) /
|
||||
(fixture.experiment.scan.columns - 1),
|
||||
(fixture.experiment.scan.yMax - fixture.experiment.scan.yMin) /
|
||||
(fixture.experiment.scan.rows - 1),
|
||||
);
|
||||
assert(
|
||||
distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9,
|
||||
"CPU/GPU peak positions are within one grid diagonal",
|
||||
);
|
||||
assert(
|
||||
distance3(gpuBand.peak.position, fixture.scene.sources[0].position) <= 0.071,
|
||||
`GPU localization error=${distance3(gpuBand.peak.position, fixture.scene.sources[0].position)} m`,
|
||||
);
|
||||
document.documentElement.dataset.webgpuIntegration = "gpu";
|
||||
document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3);
|
||||
});
|
||||
|
||||
test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => {
|
||||
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
|
||||
if (!adapter) {
|
||||
document.documentElement.dataset.webgpuBaseline = "unavailable";
|
||||
return;
|
||||
}
|
||||
const baseline = globalThis.__micarrsimCpuBaseline;
|
||||
assert(baseline, "CPU baseline fixture is available");
|
||||
const experiment = {
|
||||
...baseline.experiment,
|
||||
beamforming: { ...baseline.experiment.beamforming, backend: "webgpu" },
|
||||
};
|
||||
const result = await computeDasWebGpu({ ...baseline, experiment });
|
||||
const cpuBand = baseline.result.bands[0];
|
||||
const gpuBand = result.bands[0];
|
||||
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
|
||||
const peakPowerRelativeError =
|
||||
Math.abs(gpuBand.peak.rawPower - cpuBand.peak.rawPower) / cpuBand.peak.rawPower;
|
||||
const gridDiagonal = Math.hypot(
|
||||
(experiment.scan.xMax - experiment.scan.xMin) / (experiment.scan.columns - 1),
|
||||
(experiment.scan.yMax - experiment.scan.yMin) / (experiment.scan.rows - 1),
|
||||
);
|
||||
|
||||
assert(gpuBand.rawMap.every((value) => Number.isFinite(value) && value >= 0), "finite nonnegative GPU map");
|
||||
assert(error.rms <= 0.005, `192ch CPU/GPU normalized RMS error=${error.rms}`);
|
||||
assert(error.maximumAbsoluteError <= 0.02, `192ch CPU/GPU normalized max error=${error.maximumAbsoluteError}`);
|
||||
assert(peakPowerRelativeError <= 0.02, `192ch CPU/GPU peak power relative error=${peakPowerRelativeError}`);
|
||||
assert(
|
||||
distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9,
|
||||
"192ch CPU/GPU peaks are within one grid diagonal",
|
||||
);
|
||||
document.documentElement.dataset.webgpuBaseline = "gpu";
|
||||
document.documentElement.dataset.webgpuBaselineMs = result.timingMs.total.toFixed(1);
|
||||
document.documentElement.dataset.webgpuBaselineRmsError = error.rms.toExponential(3);
|
||||
});
|
||||
|
||||
test("WebGPU bin·point 강제 batching 결과가 CPU map과 일치한다", async () => {
|
||||
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
|
||||
if (!adapter) return;
|
||||
const fixture = createFixture("webgpu");
|
||||
fixture.experiment.frequencyBands = [
|
||||
{ id: "wide-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" },
|
||||
];
|
||||
const cpuResult = await computeDasMap(fixture);
|
||||
const gpuResult = await computeDasWebGpu({
|
||||
...fixture,
|
||||
maximumSpectrumBatchBins: 1,
|
||||
maximumSteeringBufferBytes: 512,
|
||||
});
|
||||
const error = normalizedMapError(cpuResult.bands[0].rawMap, gpuResult.bands[0].rawMap);
|
||||
|
||||
assertEqual(gpuResult.gpu.batchPlan.binBatchSize, 1, "forced bin batch size");
|
||||
assertEqual(gpuResult.gpu.batchPlan.pointBatchSize, 4, "forced point batch size");
|
||||
assert(error.rms <= 0.005, `chunked CPU/GPU normalized RMS error=${error.rms}`);
|
||||
assert(error.maximumAbsoluteError <= 0.02, `chunked CPU/GPU max error=${error.maximumAbsoluteError}`);
|
||||
});
|
||||
@@ -12,7 +12,9 @@ try {
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
await import("/tests/unit/acoustics.test.js");
|
||||
await import("/tests/unit/builder.test.js");
|
||||
await import("/tests/unit/webgpu.test.js");
|
||||
await import("/tests/integration/cpu-baseline.test.js");
|
||||
await import("/tests/integration/webgpu.test.js");
|
||||
await import("/tests/integration/builder-ui.test.js");
|
||||
} catch (error) {
|
||||
registeredTests.push({
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
DAS_PARAMS_BYTE_LENGTH,
|
||||
packDasParams,
|
||||
packMicrophoneCalibration,
|
||||
} from "/packages/webgpu-kernels/das.wgsl.js";
|
||||
import {
|
||||
WebGpuLimitError,
|
||||
WebGpuUnavailableError,
|
||||
estimateWebGpuBatchPlan,
|
||||
estimateWebGpuBatchSize,
|
||||
packStftBatch,
|
||||
} from "/packages/webgpu-kernels/das.js";
|
||||
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
test("WebGPU DAS uniform과 microphone calibration을 WGSL layout으로 packing한다", () => {
|
||||
const params = packDasParams({
|
||||
pointCount: 81,
|
||||
microphoneCount: 16,
|
||||
frameCount: 7,
|
||||
binCount: 3,
|
||||
sampleRate: 8_000,
|
||||
fftSize: 256,
|
||||
speedOfSound: 343.25,
|
||||
});
|
||||
const view = new DataView(params);
|
||||
|
||||
assertEqual(params.byteLength, DAS_PARAMS_BYTE_LENGTH, "uniform byte length");
|
||||
assertEqual(view.getUint32(0, true), 81, "point count offset");
|
||||
assertEqual(view.getUint32(4, true), 16, "microphone count offset");
|
||||
assertEqual(view.getUint32(8, true), 7, "frame count offset");
|
||||
assertEqual(view.getUint32(12, true), 3, "bin count offset");
|
||||
assertEqual(view.getFloat32(16, true), Math.fround(8_000), "sample rate offset");
|
||||
assertEqual(view.getUint32(20, true), 256, "FFT size offset");
|
||||
assertEqual(view.getFloat32(24, true), Math.fround(343.25), "speed of sound offset");
|
||||
assertEqual(view.getUint32(28, true), 0, "reserved word");
|
||||
|
||||
const calibration = packMicrophoneCalibration([
|
||||
{ sensitivityDb: 0, phaseOffsetDeg: 0 },
|
||||
{ sensitivityDb: 6.020599913, phaseOffsetDeg: 90 },
|
||||
{ sensitivityDb: -6.020599913, phaseOffsetDeg: -45 },
|
||||
]);
|
||||
assertEqual(calibration.length, 6, "two f32 values per microphone");
|
||||
assertNear(calibration[0], 1, 1e-7, "unity inverse gain");
|
||||
assertNear(calibration[1], 0, 1e-7, "zero phase");
|
||||
assertNear(calibration[2], 0.5, 1e-6, "+6 dB inverse gain");
|
||||
assertNear(calibration[3], Math.PI / 2, 1e-6, "+90 degree phase");
|
||||
assertNear(calibration[4], 2, 1e-6, "-6 dB inverse gain");
|
||||
assertNear(calibration[5], -Math.PI / 4, 1e-6, "-45 degree phase");
|
||||
});
|
||||
|
||||
test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () => {
|
||||
const stft = {
|
||||
frameCount: 2,
|
||||
microphoneCount: 2,
|
||||
spectra: new Map([
|
||||
[3, {
|
||||
real: Float32Array.of(1, 2, 3, 4),
|
||||
imaginary: Float32Array.of(11, 12, 13, 14),
|
||||
}],
|
||||
[7, {
|
||||
real: Float32Array.of(5, 6, 7, 8),
|
||||
imaginary: Float32Array.of(15, 16, 17, 18),
|
||||
}],
|
||||
]),
|
||||
};
|
||||
|
||||
const packed = packStftBatch(stft, [7, 3]);
|
||||
assert(packed instanceof Float32Array, "packed STFT type");
|
||||
const expected = [
|
||||
5, 15, 6, 16, 7, 17, 8, 18,
|
||||
1, 11, 2, 12, 3, 13, 4, 14,
|
||||
];
|
||||
assertEqual(packed.length, expected.length, "packed STFT length");
|
||||
expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`));
|
||||
});
|
||||
|
||||
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
|
||||
const workload = {
|
||||
selectedBins: [4, 5, 6, 7, 8],
|
||||
bandBins: [[4, 5, 6, 7, 8]],
|
||||
frameCount: 2,
|
||||
microphoneCount: 4,
|
||||
pointCount: 8,
|
||||
distanceBytes: 128,
|
||||
outputBytes: 64,
|
||||
spectrumBytesPerBin: 64,
|
||||
baseWorkingBytes: 256,
|
||||
};
|
||||
const limits = {
|
||||
maxStorageBufferBindingSize: 192,
|
||||
maxBufferSize: 192,
|
||||
maxComputeWorkgroupsPerDimension: 2,
|
||||
maxComputeInvocationsPerWorkgroup: 64,
|
||||
maxComputeWorkgroupSizeX: 64,
|
||||
};
|
||||
|
||||
const batchSize = estimateWebGpuBatchSize({
|
||||
workload,
|
||||
limits,
|
||||
maximumGpuWorkingBytes: 4_096,
|
||||
maximumHostWorkingBytes: 4_096,
|
||||
});
|
||||
assertEqual(batchSize, 2, "dispatch y dimension is the tightest batch limit");
|
||||
const plan = estimateWebGpuBatchPlan({
|
||||
workload,
|
||||
limits,
|
||||
maximumGpuWorkingBytes: 4_096,
|
||||
maximumHostWorkingBytes: 4_096,
|
||||
});
|
||||
assertEqual(plan.pointBatchSize, 2, "steering point dispatch batch");
|
||||
assertEqual(plan.steeringBytes, 128, "two-pass steering buffer bytes");
|
||||
|
||||
let limitError;
|
||||
try {
|
||||
estimateWebGpuBatchSize({
|
||||
workload,
|
||||
limits: { ...limits, maxStorageBufferBindingSize: 31 },
|
||||
maximumGpuWorkingBytes: 4_096,
|
||||
maximumHostWorkingBytes: 4_096,
|
||||
});
|
||||
} catch (error) {
|
||||
limitError = error;
|
||||
}
|
||||
assert(limitError instanceof WebGpuLimitError, "static calibration buffer limit error type");
|
||||
|
||||
let invocationLimitError;
|
||||
try {
|
||||
estimateWebGpuBatchSize({
|
||||
workload,
|
||||
limits,
|
||||
maximumGpuWorkingBytes: 4_096,
|
||||
maximumHostWorkingBytes: 4_096,
|
||||
maximumMacsPerInvocation: 7,
|
||||
});
|
||||
} catch (error) {
|
||||
invocationLimitError = error;
|
||||
}
|
||||
assert(invocationLimitError instanceof WebGpuLimitError, "per-invocation watchdog guard error type");
|
||||
|
||||
const scalingWorkload = {
|
||||
selectedBins: [10, 11],
|
||||
bandBins: [[10, 11]],
|
||||
frameCount: 46,
|
||||
microphoneCount: 192,
|
||||
pointCount: 40_000,
|
||||
distanceBytes: 40_000 * 192 * 4,
|
||||
outputBytes: 40_000 * 4 * 2,
|
||||
spectrumBytesPerBin: 46 * 192 * 8,
|
||||
baseWorkingBytes: 40_000 * 192 * 4 + 40_000 * 4 * 3 + 512 * 8 * 2,
|
||||
};
|
||||
const scalingPlan = estimateWebGpuBatchPlan({
|
||||
workload: scalingWorkload,
|
||||
limits: {
|
||||
maxStorageBufferBindingSize: 128 * 1024 ** 2,
|
||||
maxBufferSize: 256 * 1024 ** 2,
|
||||
maxComputeWorkgroupsPerDimension: 65_535,
|
||||
maxComputeInvocationsPerWorkgroup: 256,
|
||||
maxComputeWorkgroupSizeX: 256,
|
||||
maxStorageBuffersPerShaderStage: 8,
|
||||
maxUniformBufferBindingSize: 65_536,
|
||||
},
|
||||
});
|
||||
assertEqual(scalingPlan.binBatchSize, 2, "200x200 preserves one CPU STFT bin batch");
|
||||
assert(scalingPlan.pointBatchSize < 40_000, "200x200 steering uses point chunks");
|
||||
assert(scalingPlan.steeringBytes <= 64 * 1024 ** 2, "steering target is respected");
|
||||
});
|
||||
|
||||
test("backend auto는 typed WebGPU 운용 오류만 CPU로 fallback한다", async () => {
|
||||
const calls = [];
|
||||
const options = { experiment: { beamforming: { backend: "auto" } } };
|
||||
const cpuResult = { backend: "cpu", bands: [], marker: "fallback-result" };
|
||||
const result = await computeDasWithBackend(options, {
|
||||
computeGpu: async () => {
|
||||
calls.push("gpu");
|
||||
throw new WebGpuUnavailableError("adapter unavailable");
|
||||
},
|
||||
computeCpu: async () => {
|
||||
calls.push("cpu");
|
||||
return cpuResult;
|
||||
},
|
||||
});
|
||||
|
||||
assertEqual(calls.join(","), "gpu,cpu", "auto backend order");
|
||||
assertEqual(result.backend, "cpu", "fallback backend");
|
||||
assertEqual(result.marker, "fallback-result", "CPU result is preserved");
|
||||
|
||||
const limitFallback = await computeDasWithBackend(options, {
|
||||
computeGpu: async () => {
|
||||
throw new WebGpuLimitError("device limit");
|
||||
},
|
||||
computeCpu: async () => cpuResult,
|
||||
});
|
||||
assertEqual(limitFallback.backend, "cpu", "device limit also uses CPU fallback in auto mode");
|
||||
|
||||
let programmingError;
|
||||
try {
|
||||
await computeDasWithBackend(options, {
|
||||
computeGpu: async () => {
|
||||
throw new TypeError("programming error");
|
||||
},
|
||||
computeCpu: async () => {
|
||||
throw new Error("CPU must not run");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
programmingError = error;
|
||||
}
|
||||
assert(programmingError instanceof TypeError, "unexpected GPU errors are not hidden by fallback");
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let abortError;
|
||||
try {
|
||||
await computeDasWithBackend(
|
||||
{ ...options, signal: controller.signal },
|
||||
{
|
||||
computeGpu: async () => {
|
||||
throw new WebGpuUnavailableError("adapter unavailable after cancellation");
|
||||
},
|
||||
computeCpu: async () => {
|
||||
throw new Error("cancelled auto mode must not start CPU fallback");
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
abortError = error;
|
||||
}
|
||||
assertEqual(abortError?.name, "AbortError", "cancellation wins over GPU fallback");
|
||||
|
||||
const explicitOptions = { experiment: { beamforming: { backend: "webgpu" } } };
|
||||
let explicitError;
|
||||
try {
|
||||
await computeDasWithBackend(explicitOptions, {
|
||||
computeGpu: async () => {
|
||||
throw new WebGpuLimitError("forced device limit");
|
||||
},
|
||||
computeCpu: async () => {
|
||||
throw new Error("explicit webgpu must not run CPU fallback");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
explicitError = error;
|
||||
}
|
||||
assert(explicitError instanceof WebGpuLimitError, "explicit WebGPU preserves operational error");
|
||||
});
|
||||
Reference in New Issue
Block a user