fix(image-pipeline): prefer wasm before cpu fallback

This commit is contained in:
Matthias
2026-04-04 22:56:01 +02:00
parent 198090b6c0
commit 92034e171e
2 changed files with 252 additions and 21 deletions

View File

@@ -196,31 +196,60 @@ export function createBackendRouter(options?: {
}); });
} }
try { const shouldAbort = args.executionOptions?.shouldAbort;
args.runBackend(selection.backend); let backend = selection.backend;
return;
} catch (error: unknown) {
const shouldAbort = args.executionOptions?.shouldAbort;
if (shouldAbort?.()) {
throw error;
}
if (selection.backend.id.toLowerCase() === cpuFallbackBackend.id.toLowerCase()) { while (true) {
throw error; try {
} args.runBackend(backend);
return;
} catch (error: unknown) {
if (shouldAbort?.()) {
throw error;
}
const normalizedError = if (backend.id.toLowerCase() === cpuFallbackBackend.id.toLowerCase()) {
error instanceof Error ? error : new Error("Image pipeline backend execution failed."); throw error;
emitFallback({ }
reason: "runtime_error",
requestedBackend: selection.backend.id.toLowerCase(), const fallbackBackend = resolveRuntimeFallbackBackend(backend.id);
fallbackBackend: cpuFallbackBackend.id, if (!fallbackBackend || fallbackBackend.id.toLowerCase() === backend.id.toLowerCase()) {
error: normalizedError, throw error;
}); }
args.runBackend(cpuFallbackBackend);
const normalizedError =
error instanceof Error ? error : new Error("Image pipeline backend execution failed.");
emitFallback({
reason: "runtime_error",
requestedBackend: backend.id.toLowerCase(),
fallbackBackend: fallbackBackend.id,
error: normalizedError,
});
backend = fallbackBackend;
}
} }
} }
function resolveRuntimeFallbackBackend(failedBackendId: string): ImagePipelineBackend | null {
const normalizedFailedBackendId = failedBackendId.toLowerCase();
if (normalizedFailedBackendId === "webgl") {
const wasmBackend = byId.get("wasm");
const wasmAvailability = readAvailability("wasm");
if (
wasmBackend &&
isBackendEnabledByFlags("wasm") &&
wasmAvailability?.enabled !== false &&
wasmAvailability?.supported !== false
) {
return wasmBackend;
}
}
return cpuFallbackBackend;
}
return { return {
resolveBackend(backendHint) { resolveBackend(backendHint) {
return resolveBackendWithFallbackReason(backendHint).backend; return resolveBackendWithFallbackReason(backendHint).backend;
@@ -307,7 +336,15 @@ export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequ
const rolloutState = getRolloutRouterState(); const rolloutState = getRolloutRouterState();
if (rolloutState.webglEnabled && rolloutState.webglAvailable) { if (rolloutState.webglEnabled && rolloutState.webglAvailable) {
return isWebglPreviewPipelineSupported(steps) ? "webgl" : CPU_BACKEND_ID; if (isWebglPreviewPipelineSupported(steps)) {
return "webgl";
}
if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) {
return "wasm";
}
return CPU_BACKEND_ID;
} }
if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) { if (rolloutState.wasmEnabled && rolloutState.wasmAvailable) {

View File

@@ -70,9 +70,203 @@ describe("wasm backend rollout selection", () => {
expect(backendRouter.getPreviewBackendHintForSteps([createStep()])).toBe("wasm"); expect(backendRouter.getPreviewBackendHintForSteps([createStep()])).toBe("wasm");
}); });
it("prefers wasm when webgl is enabled+available but unsupported for the step set", 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: true,
wasmSimd: true,
offscreenCanvas: true,
}),
};
});
vi.doMock("@/lib/image-pipeline/backend/webgl/webgl-backend", async () => {
const actual = await vi.importActual("@/lib/image-pipeline/backend/webgl/webgl-backend");
return {
...actual,
createWebglPreviewBackend: () => ({
id: "webgl",
runPreviewStep: vi.fn(),
runFullPipeline: vi.fn(),
}),
isWebglPreviewPipelineSupported: () => false,
};
});
const backendRouter = await import("@/lib/image-pipeline/backend/backend-router");
expect(backendRouter.getPreviewBackendHintForSteps([createStep()])).toBe("wasm");
});
}); });
describe("wasm backend fallback behavior", () => { describe("wasm backend fallback behavior", () => {
it("uses wasm as runtime fallback before cpu when webgl fails", () => {
const fallbackEvents: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const webglPreview = vi.fn(() => {
throw new Error("webgl failed");
});
const wasmPreview = vi.fn();
const cpuPreview = vi.fn();
const router = createBackendRouter({
backends: [
{
id: "cpu",
runPreviewStep: cpuPreview,
runFullPipeline: vi.fn(),
},
{
id: "wasm",
runPreviewStep: wasmPreview,
runFullPipeline: vi.fn(),
},
{
id: "webgl",
runPreviewStep: webglPreview,
runFullPipeline: vi.fn(),
},
],
defaultBackendId: "webgl",
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: {
forceCpu: false,
webglEnabled: true,
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: "webgl",
});
expect(webglPreview).toHaveBeenCalledTimes(1);
expect(wasmPreview).toHaveBeenCalledTimes(1);
expect(cpuPreview).not.toHaveBeenCalled();
expect(fallbackEvents).toEqual([
{
reason: "runtime_error",
requestedBackend: "webgl",
fallbackBackend: "wasm",
},
]);
});
it("falls through to cpu when both webgl and wasm fail at runtime", () => {
const fallbackEvents: Array<{
reason: string;
requestedBackend: string;
fallbackBackend: string;
}> = [];
const cpuPreview = vi.fn();
const router = createBackendRouter({
backends: [
{
id: "cpu",
runPreviewStep: cpuPreview,
runFullPipeline: vi.fn(),
},
{
id: "wasm",
runPreviewStep: () => {
throw new Error("wasm failed");
},
runFullPipeline: vi.fn(),
},
{
id: "webgl",
runPreviewStep: () => {
throw new Error("webgl failed");
},
runFullPipeline: vi.fn(),
},
],
defaultBackendId: "webgl",
backendAvailability: {
webgl: {
supported: true,
enabled: true,
},
wasm: {
supported: true,
enabled: true,
},
},
featureFlags: {
forceCpu: false,
webglEnabled: true,
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: "webgl",
});
expect(cpuPreview).toHaveBeenCalledTimes(1);
expect(fallbackEvents).toEqual([
{
reason: "runtime_error",
requestedBackend: "webgl",
fallbackBackend: "wasm",
},
{
reason: "runtime_error",
requestedBackend: "wasm",
fallbackBackend: "cpu",
},
]);
});
it("downgrades to cpu with runtime_error when wasm initialization fails", () => { it("downgrades to cpu with runtime_error when wasm initialization fails", () => {
const fallbackEvents: Array<{ const fallbackEvents: Array<{
reason: string; reason: string;