From 198090b6c09145f73c3d120b8126cb8feef48b3c Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 22:51:26 +0200 Subject: [PATCH] feat(image-pipeline): add wasm simd fallback backend scaffold --- lib/image-pipeline/backend/backend-router.ts | 27 ++- lib/image-pipeline/backend/capabilities.ts | 2 +- .../backend/wasm/wasm-backend.ts | 37 +++++ .../backend/wasm/wasm-loader.ts | 65 ++++++++ tests/image-pipeline/wasm-backend.test.ts | 154 ++++++++++++++++++ 5 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 lib/image-pipeline/backend/wasm/wasm-backend.ts create mode 100644 lib/image-pipeline/backend/wasm/wasm-loader.ts create mode 100644 tests/image-pipeline/wasm-backend.test.ts diff --git a/lib/image-pipeline/backend/backend-router.ts b/lib/image-pipeline/backend/backend-router.ts index 31f3efd..e249371 100644 --- a/lib/image-pipeline/backend/backend-router.ts +++ b/lib/image-pipeline/backend/backend-router.ts @@ -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 { diff --git a/lib/image-pipeline/backend/capabilities.ts b/lib/image-pipeline/backend/capabilities.ts index e620584..de0e865 100644 --- a/lib/image-pipeline/backend/capabilities.ts +++ b/lib/image-pipeline/backend/capabilities.ts @@ -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, diff --git a/lib/image-pipeline/backend/wasm/wasm-backend.ts b/lib/image-pipeline/backend/wasm/wasm-backend.ts new file mode 100644 index 0000000..a4b17bc --- /dev/null +++ b/lib/image-pipeline/backend/wasm/wasm-backend.ts @@ -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); + }, + }; +} diff --git a/lib/image-pipeline/backend/wasm/wasm-loader.ts b/lib/image-pipeline/backend/wasm/wasm-loader.ts new file mode 100644 index 0000000..5bf6a04 --- /dev/null +++ b/lib/image-pipeline/backend/wasm/wasm-loader.ts @@ -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; +} diff --git a/tests/image-pipeline/wasm-backend.test.ts b/tests/image-pipeline/wasm-backend.test.ts new file mode 100644 index 0000000..6fecb9a --- /dev/null +++ b/tests/image-pipeline/wasm-backend.test.ts @@ -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); + }); +});