feat(image-pipeline): add wasm simd fallback backend scaffold
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
createWebglPreviewBackend,
|
createWebglPreviewBackend,
|
||||||
isWebglPreviewPipelineSupported,
|
isWebglPreviewPipelineSupported,
|
||||||
} from "@/lib/image-pipeline/backend/webgl/webgl-backend";
|
} 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";
|
type BackendFallbackReason = "unsupported_api" | "flag_disabled" | "runtime_error";
|
||||||
|
|
||||||
@@ -249,6 +250,8 @@ type RolloutRouterState = {
|
|||||||
router: BackendRouter;
|
router: BackendRouter;
|
||||||
webglAvailable: boolean;
|
webglAvailable: boolean;
|
||||||
webglEnabled: boolean;
|
webglEnabled: boolean;
|
||||||
|
wasmAvailable: boolean;
|
||||||
|
wasmEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedRolloutState: RolloutRouterState | null = null;
|
let cachedRolloutState: RolloutRouterState | null = null;
|
||||||
@@ -258,12 +261,15 @@ function getRolloutRouterState(): RolloutRouterState {
|
|||||||
const featureFlags = getBackendFeatureFlags();
|
const featureFlags = getBackendFeatureFlags();
|
||||||
const capabilities = detectBackendCapabilities();
|
const capabilities = detectBackendCapabilities();
|
||||||
const webglAvailable = capabilities.webgl;
|
const webglAvailable = capabilities.webgl;
|
||||||
|
const wasmAvailable = capabilities.wasmSimd;
|
||||||
const webglEnabled = featureFlags.webglEnabled && !featureFlags.forceCpu;
|
const webglEnabled = featureFlags.webglEnabled && !featureFlags.forceCpu;
|
||||||
|
const wasmEnabled = featureFlags.wasmEnabled && !featureFlags.forceCpu;
|
||||||
const rolloutKey = JSON.stringify({
|
const rolloutKey = JSON.stringify({
|
||||||
forceCpu: featureFlags.forceCpu,
|
forceCpu: featureFlags.forceCpu,
|
||||||
webglEnabled: featureFlags.webglEnabled,
|
webglEnabled: featureFlags.webglEnabled,
|
||||||
wasmEnabled: featureFlags.wasmEnabled,
|
wasmEnabled: featureFlags.wasmEnabled,
|
||||||
webglAvailable,
|
webglAvailable,
|
||||||
|
wasmAvailable,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cachedRolloutState && cachedRolloutKey === rolloutKey) {
|
if (cachedRolloutState && cachedRolloutKey === rolloutKey) {
|
||||||
@@ -272,18 +278,25 @@ function getRolloutRouterState(): RolloutRouterState {
|
|||||||
|
|
||||||
cachedRolloutState = {
|
cachedRolloutState = {
|
||||||
router: createBackendRouter({
|
router: createBackendRouter({
|
||||||
backends: [cpuBackend, createWebglPreviewBackend()],
|
backends: [cpuBackend, createWasmSimdBackend(), createWebglPreviewBackend()],
|
||||||
defaultBackendId: "webgl",
|
defaultBackendId:
|
||||||
|
webglEnabled && webglAvailable ? "webgl" : wasmEnabled && wasmAvailable ? "wasm" : CPU_BACKEND_ID,
|
||||||
backendAvailability: {
|
backendAvailability: {
|
||||||
webgl: {
|
webgl: {
|
||||||
supported: webglAvailable,
|
supported: webglAvailable,
|
||||||
enabled: webglEnabled,
|
enabled: webglEnabled,
|
||||||
},
|
},
|
||||||
|
wasm: {
|
||||||
|
supported: wasmAvailable,
|
||||||
|
enabled: wasmEnabled,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
featureFlags,
|
featureFlags,
|
||||||
}),
|
}),
|
||||||
webglAvailable,
|
webglAvailable,
|
||||||
webglEnabled,
|
webglEnabled,
|
||||||
|
wasmAvailable,
|
||||||
|
wasmEnabled,
|
||||||
};
|
};
|
||||||
cachedRolloutKey = rolloutKey;
|
cachedRolloutKey = rolloutKey;
|
||||||
|
|
||||||
@@ -293,11 +306,15 @@ function getRolloutRouterState(): RolloutRouterState {
|
|||||||
export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequest["step"][]): BackendHint {
|
export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequest["step"][]): BackendHint {
|
||||||
const rolloutState = getRolloutRouterState();
|
const rolloutState = getRolloutRouterState();
|
||||||
|
|
||||||
if (!rolloutState.webglEnabled || !rolloutState.webglAvailable) {
|
if (rolloutState.webglEnabled && rolloutState.webglAvailable) {
|
||||||
return CPU_BACKEND_ID;
|
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 {
|
export function runPreviewStepWithBackendRouter(request: PreviewBackendRequest): void {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type CapabilityProbes = {
|
|||||||
probeOffscreenCanvas: () => boolean;
|
probeOffscreenCanvas: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WASM_SIMD_PROBE_MODULE = new Uint8Array([
|
export const WASM_SIMD_PROBE_MODULE = new Uint8Array([
|
||||||
0x00,
|
0x00,
|
||||||
0x61,
|
0x61,
|
||||||
0x73,
|
0x73,
|
||||||
|
|||||||
37
lib/image-pipeline/backend/wasm/wasm-backend.ts
Normal file
37
lib/image-pipeline/backend/wasm/wasm-backend.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
65
lib/image-pipeline/backend/wasm/wasm-loader.ts
Normal file
65
lib/image-pipeline/backend/wasm/wasm-loader.ts
Normal 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;
|
||||||
|
}
|
||||||
154
tests/image-pipeline/wasm-backend.test.ts
Normal file
154
tests/image-pipeline/wasm-backend.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user