feat(image-pipeline): add wasm simd fallback backend scaffold

This commit is contained in:
Matthias
2026-04-04 22:51:26 +02:00
parent 46b7aeb26e
commit 198090b6c0
5 changed files with 279 additions and 6 deletions

View File

@@ -18,6 +18,7 @@ import {
createWebglPreviewBackend,
isWebglPreviewPipelineSupported,
} from "@/lib/image-pipeline/backend/webgl/webgl-backend";
import { createWasmSimdBackend } from "@/lib/image-pipeline/backend/wasm/wasm-backend";
type BackendFallbackReason = "unsupported_api" | "flag_disabled" | "runtime_error";
@@ -249,6 +250,8 @@ type RolloutRouterState = {
router: BackendRouter;
webglAvailable: boolean;
webglEnabled: boolean;
wasmAvailable: boolean;
wasmEnabled: boolean;
};
let cachedRolloutState: RolloutRouterState | null = null;
@@ -258,12 +261,15 @@ function getRolloutRouterState(): RolloutRouterState {
const featureFlags = getBackendFeatureFlags();
const capabilities = detectBackendCapabilities();
const webglAvailable = capabilities.webgl;
const wasmAvailable = capabilities.wasmSimd;
const webglEnabled = featureFlags.webglEnabled && !featureFlags.forceCpu;
const wasmEnabled = featureFlags.wasmEnabled && !featureFlags.forceCpu;
const rolloutKey = JSON.stringify({
forceCpu: featureFlags.forceCpu,
webglEnabled: featureFlags.webglEnabled,
wasmEnabled: featureFlags.wasmEnabled,
webglAvailable,
wasmAvailable,
});
if (cachedRolloutState && cachedRolloutKey === rolloutKey) {
@@ -272,18 +278,25 @@ function getRolloutRouterState(): RolloutRouterState {
cachedRolloutState = {
router: createBackendRouter({
backends: [cpuBackend, createWebglPreviewBackend()],
defaultBackendId: "webgl",
backends: [cpuBackend, createWasmSimdBackend(), createWebglPreviewBackend()],
defaultBackendId:
webglEnabled && webglAvailable ? "webgl" : wasmEnabled && wasmAvailable ? "wasm" : CPU_BACKEND_ID,
backendAvailability: {
webgl: {
supported: webglAvailable,
enabled: webglEnabled,
},
wasm: {
supported: wasmAvailable,
enabled: wasmEnabled,
},
},
featureFlags,
}),
webglAvailable,
webglEnabled,
wasmAvailable,
wasmEnabled,
};
cachedRolloutKey = rolloutKey;
@@ -293,11 +306,15 @@ function getRolloutRouterState(): RolloutRouterState {
export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequest["step"][]): BackendHint {
const rolloutState = getRolloutRouterState();
if (!rolloutState.webglEnabled || !rolloutState.webglAvailable) {
return CPU_BACKEND_ID;
if (rolloutState.webglEnabled && rolloutState.webglAvailable) {
return isWebglPreviewPipelineSupported(steps) ? "webgl" : CPU_BACKEND_ID;
}
return isWebglPreviewPipelineSupported(steps) ? "webgl" : CPU_BACKEND_ID;
if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) {
return "wasm";
}
return CPU_BACKEND_ID;
}
export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void {

View File

@@ -10,7 +10,7 @@ type CapabilityProbes = {
probeOffscreenCanvas: () => boolean;
};
const WASM_SIMD_PROBE_MODULE = new Uint8Array([
export const WASM_SIMD_PROBE_MODULE = new Uint8Array([
0x00,
0x61,
0x73,

View File

@@ -0,0 +1,37 @@
import type {
BackendPipelineRequest,
BackendStepRequest,
ImagePipelineBackend,
} from "@/lib/image-pipeline/backend/backend-types";
import {
loadWasmKernelModule,
type WasmKernelModule,
} from "@/lib/image-pipeline/backend/wasm/wasm-loader";
type WasmBackendOptions = {
loadModule?: () => WasmKernelModule;
};
export function createWasmSimdBackend(options?: WasmBackendOptions): ImagePipelineBackend {
const loadModule = options?.loadModule ?? loadWasmKernelModule;
let kernelModule: WasmKernelModule | null = null;
function ensureModule(): WasmKernelModule {
if (kernelModule) {
return kernelModule;
}
kernelModule = loadModule();
return kernelModule;
}
return {
id: "wasm",
runPreviewStep(request: BackendStepRequest): void {
ensureModule().applyPreviewStep(request);
},
runFullPipeline(request: BackendPipelineRequest): void {
ensureModule().applyFullPipeline(request);
},
};
}

View File

@@ -0,0 +1,65 @@
import type {
BackendPipelineRequest,
BackendStepRequest,
} from "@/lib/image-pipeline/backend/backend-types";
import { WASM_SIMD_PROBE_MODULE } from "@/lib/image-pipeline/backend/capabilities";
import { applyPipelineStep, applyPipelineSteps } from "@/lib/image-pipeline/render-core";
export type WasmKernelModule = {
applyPreviewStep: (request: BackendStepRequest) => void;
applyFullPipeline: (request: BackendPipelineRequest) => void;
};
let cachedModule: WasmKernelModule | null = null;
function assertWasmSimdRuntimeSupport(): void {
if (typeof WebAssembly === "undefined") {
throw new Error("WebAssembly runtime is unavailable.");
}
if (typeof WebAssembly.validate !== "function") {
throw new Error("WebAssembly validation API is unavailable.");
}
if (!WebAssembly.validate(WASM_SIMD_PROBE_MODULE)) {
throw new Error("WebAssembly SIMD is unavailable.");
}
const module = new WebAssembly.Module(WASM_SIMD_PROBE_MODULE);
void new WebAssembly.Instance(module);
}
export function loadWasmKernelModule(): WasmKernelModule {
if (cachedModule) {
return cachedModule;
}
assertWasmSimdRuntimeSupport();
cachedModule = {
applyPreviewStep(request): void {
applyPipelineStep(
request.pixels,
request.step,
request.width,
request.height,
request.executionOptions,
);
},
applyFullPipeline(request): void {
applyPipelineSteps(
request.pixels,
request.steps,
request.width,
request.height,
request.executionOptions,
);
},
};
return cachedModule;
}
export function resetWasmKernelModuleCache(): void {
cachedModule = null;
}

View File

@@ -0,0 +1,154 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ImagePipelineBackend } from "@/lib/image-pipeline/backend/backend-types";
import { createBackendRouter } from "@/lib/image-pipeline/backend/backend-router";
import type { WasmKernelModule } from "@/lib/image-pipeline/backend/wasm/wasm-loader";
import { createWasmSimdBackend } from "@/lib/image-pipeline/backend/wasm/wasm-backend";
function createStep() {
return {
nodeId: "n1",
type: "color-adjust",
params: {
hsl: {
hue: 0,
saturation: 0,
luminance: 0,
},
temperature: 0,
tint: 0,
vibrance: 0,
},
} as const;
}
function createCpuBackend(overrides?: {
preview?: ImagePipelineBackend["runPreviewStep"];
full?: ImagePipelineBackend["runFullPipeline"];
}): ImagePipelineBackend {
return {
id: "cpu",
runPreviewStep: overrides?.preview ?? vi.fn(),
runFullPipeline: overrides?.full ?? vi.fn(),
};
}
describe("wasm backend rollout selection", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});
it("selects wasm when webgl is unavailable and wasm simd is enabled + available", async () => {
vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {
const actual = await vi.importActual("@/lib/image-pipeline/backend/feature-flags");
return {
...actual,
getBackendFeatureFlags: () => ({
forceCpu: false,
webglEnabled: true,
wasmEnabled: true,
}),
};
});
vi.doMock("@/lib/image-pipeline/backend/capabilities", async () => {
const actual = await vi.importActual("@/lib/image-pipeline/backend/capabilities");
return {
...actual,
detectBackendCapabilities: () => ({
webgl: false,
wasmSimd: true,
offscreenCanvas: false,
}),
};
});
const backendRouter = await import("@/lib/image-pipeline/backend/backend-router");
expect(backendRouter.getPreviewBackendHintForSteps([createStep()])).toBe("wasm");
});
});
describe("wasm backend fallback behavior", () => {
it("downgrades to cpu with runtime_error when wasm initialization fails", () => {
const fallbackEvents: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const cpuPreview = vi.fn();
const cpuBackend = createCpuBackend({
preview: cpuPreview,
});
const wasmBackend = createWasmSimdBackend({
loadModule: () => {
throw new Error("wasm init failed");
},
});
const router = createBackendRouter({
backends: [cpuBackend, wasmBackend],
backendAvailability: {
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: {
forceCpu: false,
webglEnabled: false,
wasmEnabled: true,
},
onFallback: (event) => {
fallbackEvents.push({
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
});
},
});
router.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
backendHint: "wasm",
});
expect(cpuPreview).toHaveBeenCalledTimes(1);
expect(fallbackEvents).toEqual([
{
reason: "runtime_error",
requestedBackend: "wasm",
fallbackBackend: "cpu",
},
]);
});
it("does not require SharedArrayBuffer or threads", () => {
vi.stubGlobal("SharedArrayBuffer", undefined);
vi.stubGlobal("Worker", undefined);
const module: WasmKernelModule = {
applyPreviewStep: vi.fn(),
applyFullPipeline: vi.fn(),
};
const backend = createWasmSimdBackend({
loadModule: () => module,
});
expect(() => {
backend.runPreviewStep({
pixels: new Uint8ClampedArray(4),
step: createStep(),
width: 1,
height: 1,
});
}).not.toThrow();
expect(module.applyPreviewStep).toHaveBeenCalledTimes(1);
});
});