diff --git a/src/wasm-runtime-probe.spec.ts b/src/wasm-runtime-probe.spec.ts index 2f1aabc..248284f 100644 --- a/src/wasm-runtime-probe.spec.ts +++ b/src/wasm-runtime-probe.spec.ts @@ -1,44 +1,65 @@ -import { WasmRuntimeProbe, WasmRuntimeStatus } from './wasm-runtime-probe'; +import { + WasmRuntimeProbe, + WasmRuntimeStatus, + WasmRuntimeUncertainReason, + WasmRuntimeUnknownReason, +} from './wasm-runtime-probe'; import { CapabilityState } from './web-capabilities'; -interface FakeReply { +interface MockWorkerResponse { ok: boolean; - wasmMs?: number; - jsMs?: number; + ops?: number; + addMedianMs?: number; + divMedianMs?: number; + sqrtMedianMs?: number; } -// Shared state that controls how the mock Worker behaves in the current test. -let workerReply: FakeReply | undefined; -let workerConstructCount = 0; - -/** - * Fake Worker for jsdom, which has no real one. On postMessage it replies immediately with whatever - * {@link workerReply} the test set, or stays silent so we can test the timeout path. - */ +const TOTAL_OPERATIONS = 16_000_000; + +const FAST_BENCHMARK_RESPONSE: MockWorkerResponse = { + ok: true, + ops: TOTAL_OPERATIONS, + addMedianMs: 6.5, + divMedianMs: 35.6, + sqrtMedianMs: 80.6, +}; +const SLOW_BENCHMARK_RESPONSE: MockWorkerResponse = { + ok: true, + ops: TOTAL_OPERATIONS, + addMedianMs: 32.1, + divMedianMs: 60.6, + sqrtMedianMs: 111.5, +}; + +let workerResponse: MockWorkerResponse | undefined; +let shouldRaiseWorkerRuntimeError = false; +let shouldFailWorkerConstruction = false; +let workerConstructionCount = 0; + +/** Mock Worker controlled by test state. */ class MockWorker { - onmessage: ((event: { data: FakeReply }) => void) | null = null; + onmessage: ((event: { data: MockWorkerResponse }) => void) | null = null; onerror: (() => void) | null = null; - /** - * Counts how many workers were created, so the caching test can check it. - */ + /** Creates a worker or simulates a construction failure. */ constructor() { - workerConstructCount += 1; + workerConstructionCount += 1; + if (shouldFailWorkerConstruction) { + throw new Error('Worker construction failed'); + } } - /** - * Sends the configured reply back to the probe, or nothing if none is set. - */ + /** Delivers the configured worker outcome. */ postMessage(): void { - if (workerReply && this.onmessage) { - this.onmessage({ data: workerReply }); + if (shouldRaiseWorkerRuntimeError && this.onerror) { + this.onerror(); + } else if (workerResponse && this.onmessage) { + this.onmessage({ data: workerResponse }); } } - /** - * Does nothing; just matches the real Worker API. - */ + /** Matches the Worker API without test cleanup. */ // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-empty-function terminate(): void {} } @@ -47,36 +68,42 @@ describe('WasmRuntimeProbe', () => { const originalWebAssembly = globalThis.WebAssembly; beforeEach(() => { - // Clear the per-page cache so each test starts fresh (private, reached via a cast). (WasmRuntimeProbe as unknown as { cachedResult?: unknown }).cachedResult = undefined; - workerReply = undefined; - workerConstructCount = 0; + workerResponse = undefined; + shouldRaiseWorkerRuntimeError = false; + shouldFailWorkerConstruction = false; + workerConstructionCount = 0; }); afterEach(() => { (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly = originalWebAssembly; }); - it('should return DISABLED when WebAssembly is hard-disabled', async () => { - expect.assertions(5); + it('should return DISABLED when WebAssembly is unavailable', async () => { + expect.assertions(1); delete (globalThis as { WebAssembly?: typeof WebAssembly }).WebAssembly; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.DISABLED); - expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.ratio).toBeNull(); - expect(result.wasmMs).toBeNull(); - expect(result.jsMs).toBeNull(); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.DISABLED, + capability: CapabilityState.NOT_CAPABLE, + reason: null, + measurements: null, + }); }); - it('should return UNKNOWN when Web Workers are not available', async () => { - expect.assertions(2); + it('should return UNKNOWN when Web Workers are unavailable', async () => { + expect.assertions(1); const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); - expect(result.capability).toBe(CapabilityState.UNKNOWN); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + capability: CapabilityState.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_UNAVAILABLE, + measurements: null, + }); }); describe('worker benchmark', () => { @@ -104,73 +131,190 @@ describe('WasmRuntimeProbe', () => { delete (URL as { revokeObjectURL?: unknown }).revokeObjectURL; }); - it('should return SLOW when the wasm/js ratio is below the threshold', async () => { - expect.assertions(5); - workerReply = { ok: true, wasmMs: 25, jsMs: 100 }; + it('should revoke the Blob URL when Worker construction fails', async () => { + expect.assertions(2); + shouldFailWorkerConstruction = true; + + const result = await WasmRuntimeProbe.check(); + + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_START_FAILED, + measurements: null, + }); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock'); + }); + + it('should return OK when measurements indicate fast WASM', async () => { + expect.assertions(1); + workerResponse = FAST_BENCHMARK_RESPONSE; + + const result = await WasmRuntimeProbe.check(); + + expect(result).toMatchObject({ + status: WasmRuntimeStatus.OK, + capability: CapabilityState.CAPABLE, + reason: null, + measurements: { + divRatio: 5.477, + sqrtRatio: 12.4, + addNsPerOp: 0.406, + addMedianMs: 6.5, + divMedianMs: 35.6, + sqrtMedianMs: 80.6, + }, + }); + }); + + it('should return SLOW when measurements indicate slow WASM', async () => { + expect.assertions(1); + workerResponse = SLOW_BENCHMARK_RESPONSE; + + const result = await WasmRuntimeProbe.check(); + + expect(result).toMatchObject({ + status: WasmRuntimeStatus.SLOW, + capability: CapabilityState.NOT_CAPABLE, + reason: null, + measurements: { + divRatio: 1.888, + sqrtRatio: 3.474, + addNsPerOp: 2.006, + addMedianMs: 32.1, + divMedianMs: 60.6, + sqrtMedianMs: 111.5, + }, + }); + }); + + it('should return UNCERTAIN when fast ratios conflict with slow add timing', async () => { + expect.assertions(1); + workerResponse = { + ok: true, + ops: TOTAL_OPERATIONS, + addMedianMs: 40, + divMedianMs: 200, + sqrtMedianMs: 480, + }; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.SLOW); - expect(result.capability).toBe(CapabilityState.NOT_CAPABLE); - expect(result.ratio).toBe(0.25); - expect(result.wasmMs).toBe(25); - expect(result.jsMs).toBe(100); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNCERTAIN, + capability: CapabilityState.UNKNOWN, + reason: WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD, + measurements: { + divRatio: 5, + sqrtRatio: 12, + addNsPerOp: 2.5, + }, + }); }); - it('should return OK when the wasm/js ratio is at or above the threshold', async () => { - expect.assertions(5); - workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + it('should return UNCERTAIN when ratios are between thresholds', async () => { + expect.assertions(1); + workerResponse = { + ok: true, + ops: TOTAL_OPERATIONS, + addMedianMs: 10, + divMedianMs: 35, + sqrtMedianMs: 70, + }; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.OK); - expect(result.capability).toBe(CapabilityState.CAPABLE); - expect(result.ratio).toBe(1.1); - expect(result.wasmMs).toBe(110); - expect(result.jsMs).toBe(100); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNCERTAIN, + capability: CapabilityState.UNKNOWN, + reason: WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS, + }); }); - it('should return UNKNOWN when the worker reports a failure', async () => { + it('should return UNKNOWN when divide timing is too short', async () => { expect.assertions(1); - workerReply = { ok: false }; + workerResponse = { + ok: true, + ops: TOTAL_OPERATIONS, + addMedianMs: 1, + divMedianMs: 2, + sqrtMedianMs: 5, + }; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.DIV_TIMING_TOO_SHORT, + measurements: { divMedianMs: 2 }, + }); }); - it('should return UNKNOWN when jsMs is not a positive number', async () => { + it('should return UNKNOWN when the worker reports benchmark failure', async () => { expect.assertions(1); - workerReply = { ok: true, wasmMs: 10, jsMs: 0 }; + workerResponse = { ok: false }; const result = await WasmRuntimeProbe.check(); - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED, + measurements: null, + }); }); - it('should return UNKNOWN when the worker does not reply before the timeout', async () => { + it('should return UNKNOWN when the worker raises a runtime error', async () => { + expect.assertions(1); + shouldRaiseWorkerRuntimeError = true; + + const result = await WasmRuntimeProbe.check(); + + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR, + measurements: null, + }); + }); + + it('should reject an incomplete worker response', async () => { + expect.assertions(1); + workerResponse = { ok: true, ops: TOTAL_OPERATIONS, addMedianMs: 6.5 }; + + const result = await WasmRuntimeProbe.check(); + + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.INVALID_MEASUREMENT, + measurements: null, + }); + }); + + it('should return UNKNOWN when the worker times out', async () => { expect.assertions(1); jest.useFakeTimers(); - workerReply = undefined; // never replies + workerResponse = undefined; const promise = WasmRuntimeProbe.check(); - jest.advanceTimersByTime(3000); + jest.advanceTimersByTime(5000); const result = await promise; - expect(result.status).toBe(WasmRuntimeStatus.UNKNOWN); + expect(result).toMatchObject({ + status: WasmRuntimeStatus.UNKNOWN, + reason: WasmRuntimeUnknownReason.WORKER_TIMEOUT, + measurements: null, + }); jest.useRealTimers(); }); - it('should cache the result so repeated calls run the benchmark only once', async () => { + it('should run the benchmark once for repeated checks', async () => { expect.assertions(2); - workerReply = { ok: true, wasmMs: 110, jsMs: 100 }; + workerResponse = FAST_BENCHMARK_RESPONSE; const first = WasmRuntimeProbe.check(); const second = WasmRuntimeProbe.check(); expect(first).toBe(second); await first; - expect(workerConstructCount).toBe(1); + expect(workerConstructionCount).toBe(1); }); }); }); diff --git a/src/wasm-runtime-probe.ts b/src/wasm-runtime-probe.ts index bbbe4ed..bacbdc3 100644 --- a/src/wasm-runtime-probe.ts +++ b/src/wasm-runtime-probe.ts @@ -1,33 +1,93 @@ import { CapabilityState, WebCapabilities } from './web-capabilities'; import WORKER_SRC from './wasm-runtime-probe.worker'; -/** Possible results of the WASM runtime probe. */ +/** Outcome of {@link WasmRuntimeProbe.check}. */ export enum WasmRuntimeStatus { + /** WASM runs at full JIT speed. */ OK = 'ok', + /** WASM runs through a slow interpreter (too slow for real-time effects). */ SLOW = 'slow', + /** WASM missing or will not compile. */ DISABLED = 'disabled', + /** Measurements do not clearly classify WASM as fast or slow. */ + UNCERTAIN = 'uncertain', + /** Probe could not complete or validate a measurement. */ UNKNOWN = 'unknown', } +/** Reasons a probe returns {@link WasmRuntimeStatus.UNKNOWN}. */ +export enum WasmRuntimeUnknownReason { + WORKER_UNAVAILABLE = 'worker_unavailable', + WORKER_START_FAILED = 'worker_start_failed', + WORKER_TIMEOUT = 'worker_timeout', + WORKER_RUNTIME_ERROR = 'worker_runtime_error', + WORKER_BENCHMARK_FAILED = 'worker_benchmark_failed', + INVALID_MEASUREMENT = 'invalid_measurement', + /** Page was hidden during the benchmark, which can distort its timing. */ + BACKGROUND_TAB = 'background_tab', + /** Divide benchmark finished too quickly to classify the runtime. */ + DIV_TIMING_TOO_SHORT = 'div_timing_too_short', +} + +/** Reasons a probe returns {@link WasmRuntimeStatus.UNCERTAIN}. */ +export enum WasmRuntimeUncertainReason { + /** Divide or sqrt ratio indicates fast WASM, but add cost indicates slow WASM. */ + FAST_RATIO_SLOW_ADD = 'fast_ratio_slow_add', + /** Ratios are neither clearly fast nor clearly slow. */ + RATIOS_BETWEEN_THRESHOLDS = 'ratios_between_thresholds', +} + +/** Additional context for an unknown or uncertain result. */ +export type WasmRuntimeReason = WasmRuntimeUnknownReason | WasmRuntimeUncertainReason; + +/** Measurements produced by a completed benchmark. */ +export interface WasmRuntimeMeasurements { + /** Divide time relative to add time. */ + divRatio: number; + /** Square root time relative to add time. */ + sqrtRatio: number; + /** Time for one add operation in nanoseconds. */ + addNsPerOp: number; + /** Typical add benchmark time in milliseconds. */ + addMedianMs: number; + /** Typical divide benchmark time in milliseconds. */ + divMedianMs: number; + /** Typical square root benchmark time in milliseconds. */ + sqrtMedianMs: number; +} + /** * Result of the WASM runtime probe. Used to decide whether to allow real-time * WASM effects (BNR, VBG), which run poorly when the browser runs WASM through a * slow interpreter. */ export interface WasmRuntimeResult { + /** See {@link WasmRuntimeStatus}. */ status: WasmRuntimeStatus; + /** Product capability derived from {@link status}. */ capability: CapabilityState; - ratio: number | null; // wasmMs / jsMs, kept raw so the cutoff can be tuned later - wasmMs: number | null; - jsMs: number | null; + /** Additional context for an unknown or uncertain status. */ + reason: WasmRuntimeReason | null; + /** Benchmark measurements when useful data was produced. */ + measurements: WasmRuntimeMeasurements | null; } -// Calibrated cutoff for the wasm/js ratio. -const SLOW_RATIO_THRESHOLD = 0.6; -const WORKER_TIMEOUT_MS = 3000; +/** Divide ratio at or above this value indicates fast WASM. */ +const DIV_FAST_RATIO = 4.0; +/** Divide ratio at or below this value indicates slow WASM. */ +const DIV_SLOW_RATIO = 3.0; +/** Square root ratio at or above this value provides another fast WASM signal. */ +const SQRT_FAST_RATIO = 8.0; +/** Add time per operation above this value indicates slow WASM unless a fast ratio disagrees. */ +const SLOW_ADD_NS_PER_OP_THRESHOLD = 1.2; +/** Divide samples below this duration are too short to classify. */ +const MIN_DIV_MEDIAN_MS = 8; +/** Maximum time allowed for the worker benchmark to finish. */ +const WORKER_TIMEOUT_MS = 5000; /** - * Maps a probe status to a CAPABLE/NOT_CAPABLE verdict. + * Maps probe status to capability. Uncertain stays unknown capability so we do not + * false-block effects. * * @param status - The probe {@link WasmRuntimeStatus}. * @returns The corresponding {@link CapabilityState}. @@ -44,29 +104,46 @@ const statusToCapability = (status: WasmRuntimeStatus): CapabilityState => { } }; -interface WorkerReply { +interface WorkerResponse { ok: boolean; - wasmMs?: number; - jsMs?: number; + ops?: number; + addMedianMs?: number; + divMedianMs?: number; + sqrtMedianMs?: number; } /** - * Checks whether this browser runs WebAssembly at full (JIT) speed or through a - * slow interpreter, by timing the same loop in WASM vs JS. This catches the case - * where WASM is present but too slow for real-time effects (e.g. Edge with JIT - * turned off). The quick "disabled" check is instant; the timed benchmark runs - * off the main thread. The result is cached, so it runs at most once per page. + * Checks that a worker response contains complete, usable measurements. + * + * @param response - Worker response to validate. + * @returns Whether every measurement is finite and positive. + */ +const hasValidMeasurements = (response: WorkerResponse): response is Required => + [response.ops, response.addMedianMs, response.divMedianMs, response.sqrtMedianMs].every( + (value) => typeof value === 'number' && Number.isFinite(value) && value > 0 + ); + +type WorkerResponseOutcome = + | { type: 'response'; response: WorkerResponse } + | { + type: 'no_response'; + reason: + | WasmRuntimeUnknownReason.WORKER_TIMEOUT + | WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR; + }; + +/** + * Tells whether WASM is fast enough for real-time effects (for example BNR on Edge + * when JIT is off). Runs a quick WASM disabled check, then a Worker benchmark. + * Cached once per page load. */ export class WasmRuntimeProbe { private static cachedResult?: Promise; /** - * Runs the probe (cached per page) and resolves with the classified result. - * - * Times the same loop in WASM and JS off the main thread and compares them as a - * ratio (wasmMs / jsMs), which normalizes for the user's CPU. When the engine - * isn't running at full JIT speed the ratio drops below a calibrated threshold, - * and the probe reports {@link WasmRuntimeStatus.SLOW}. + * Runs the probe once per page (cached). The Worker times add, div, and sqrt in + * WASM and the main thread compares ratios. Slow interpreter engines collapse both + * ratios near 2 and status becomes {@link WasmRuntimeStatus.SLOW}. * * @returns A promise that resolves with the {@link WasmRuntimeResult}. */ @@ -78,86 +155,87 @@ export class WasmRuntimeProbe { } /** - * Builds a {@link WasmRuntimeResult} from a status and optional raw measurements. + * Derives capability while keeping result construction in one place. * - * @param status - The classified {@link WasmRuntimeStatus}. - * @param extra - Optional raw measurements to include. - * @param extra.ratio - The wasmMs / jsMs ratio. - * @param extra.wasmMs - The measured WASM time in milliseconds. - * @param extra.jsMs - The measured JS time in milliseconds. - * @returns The assembled {@link WasmRuntimeResult}. + * @param status - Classified status. + * @param reason - Additional result context. + * @param measurements - Useful benchmark measurements. + * @returns Assembled {@link WasmRuntimeResult}. */ private static buildResult( status: WasmRuntimeStatus, - extra?: { ratio?: number; wasmMs?: number; jsMs?: number } + reason: WasmRuntimeReason | null = null, + measurements: WasmRuntimeMeasurements | null = null ): WasmRuntimeResult { return { status, capability: statusToCapability(status), - ratio: extra?.ratio ?? null, - wasmMs: extra?.wasmMs ?? null, - jsMs: extra?.jsMs ?? null, + reason, + measurements, }; } /** - * Runs the checks in order: the instant "disabled" check, then the timed Worker benchmark. + * WASM support check, then Worker benchmark with timeout and cleanup. * - * @returns A promise that resolves with the {@link WasmRuntimeResult}. + * @returns Classified probe result. */ private static async run(): Promise { if (WebCapabilities.supportsWasm() === CapabilityState.NOT_CAPABLE) { return this.buildResult(WasmRuntimeStatus.DISABLED); } - // Can't run the benchmark without a Web Worker and a Blob URL. if ( WebCapabilities.supportsWorker() === CapabilityState.NOT_CAPABLE || typeof URL === 'undefined' || !URL.createObjectURL ) { - return this.buildResult(WasmRuntimeStatus.UNKNOWN); + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + WasmRuntimeUnknownReason.WORKER_UNAVAILABLE + ); } const started = this.startWorker(); if (!started) { - return this.buildResult(WasmRuntimeStatus.UNKNOWN); + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + WasmRuntimeUnknownReason.WORKER_START_FAILED + ); } const { worker, url } = started; try { - // eslint-disable-next-line jsdoc/require-jsdoc - const msg = await new Promise((resolve) => { - const timer = setTimeout(() => resolve({ ok: false }), WORKER_TIMEOUT_MS); + const outcome = await new Promise((resolve) => { + const timer = setTimeout( + () => + resolve({ + type: 'no_response', + reason: WasmRuntimeUnknownReason.WORKER_TIMEOUT, + }), + WORKER_TIMEOUT_MS + ); // eslint-disable-next-line jsdoc/require-jsdoc - worker.onmessage = (e: MessageEvent) => { + worker.onmessage = (event: MessageEvent) => { clearTimeout(timer); - resolve(e.data); + resolve({ type: 'response', response: event.data }); }; // eslint-disable-next-line jsdoc/require-jsdoc worker.onerror = () => { clearTimeout(timer); - resolve({ ok: false }); + resolve({ + type: 'no_response', + reason: WasmRuntimeUnknownReason.WORKER_RUNTIME_ERROR, + }); }; worker.postMessage('start'); }); - if ( - !msg.ok || - typeof msg.jsMs !== 'number' || - msg.jsMs <= 0 || - typeof msg.wasmMs !== 'number' - ) { - return this.buildResult(WasmRuntimeStatus.UNKNOWN); + if (outcome.type === 'no_response') { + return this.buildResult(WasmRuntimeStatus.UNKNOWN, outcome.reason); } - const ratio = Number((msg.wasmMs / msg.jsMs).toFixed(2)); - const status = ratio < SLOW_RATIO_THRESHOLD ? WasmRuntimeStatus.SLOW : WasmRuntimeStatus.OK; - return this.buildResult(status, { - ratio, - wasmMs: Number(msg.wasmMs.toFixed(2)), - jsMs: Number(msg.jsMs.toFixed(2)), - }); + return this.classify(outcome.response); } finally { worker.terminate(); URL.revokeObjectURL(url); @@ -165,9 +243,76 @@ export class WasmRuntimeProbe { } /** - * Starts the benchmark worker from the inline source (via a Blob URL). + * Turns worker medians into status and metrics. High div or sqrt ratio means fast + * WASM unless absolute add cost disagrees ({@link WasmRuntimeStatus.UNCERTAIN}). + * + * @param response - Raw worker response. + * @returns Classified result with rounded metrics. + */ + private static classify(response: WorkerResponse): WasmRuntimeResult { + if (!response.ok) { + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + WasmRuntimeUnknownReason.WORKER_BENCHMARK_FAILED + ); + } + + if (!hasValidMeasurements(response)) { + return this.buildResult( + WasmRuntimeStatus.UNKNOWN, + WasmRuntimeUnknownReason.INVALID_MEASUREMENT + ); + } + + const { addMedianMs, divMedianMs, sqrtMedianMs } = response; + // eslint-disable-next-line jsdoc/require-jsdoc + const roundToThreeDecimals = (value: number): number => Number(value.toFixed(3)); + + const divRatio = divMedianMs / addMedianMs; + const sqrtRatio = sqrtMedianMs / addMedianMs; + const addNsPerOp = (addMedianMs * 1_000_000) / response.ops; + + // CPUs can handle divide and square root differently, so either fast ratio is enough. + const hasFastRatio = divRatio >= DIV_FAST_RATIO || sqrtRatio >= SQRT_FAST_RATIO; + const hasSlowDivRatio = divRatio <= DIV_SLOW_RATIO; + const isAddTimingSlow = addNsPerOp > SLOW_ADD_NS_PER_OP_THRESHOLD; + const hasSufficientDivTiming = divMedianMs >= MIN_DIV_MEDIAN_MS; + const isPageHidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; + + let status: WasmRuntimeStatus; + let reason: WasmRuntimeReason | null = null; + if (isPageHidden) { + status = WasmRuntimeStatus.UNKNOWN; + reason = WasmRuntimeUnknownReason.BACKGROUND_TAB; + } else if (!hasSufficientDivTiming) { + status = WasmRuntimeStatus.UNKNOWN; + reason = WasmRuntimeUnknownReason.DIV_TIMING_TOO_SHORT; + } else if (hasFastRatio && isAddTimingSlow) { + status = WasmRuntimeStatus.UNCERTAIN; + reason = WasmRuntimeUncertainReason.FAST_RATIO_SLOW_ADD; + } else if (hasFastRatio) { + status = WasmRuntimeStatus.OK; + } else if (hasSlowDivRatio || isAddTimingSlow) { + status = WasmRuntimeStatus.SLOW; + } else { + status = WasmRuntimeStatus.UNCERTAIN; + reason = WasmRuntimeUncertainReason.RATIOS_BETWEEN_THRESHOLDS; + } + + return this.buildResult(status, reason, { + divRatio: roundToThreeDecimals(divRatio), + sqrtRatio: roundToThreeDecimals(sqrtRatio), + addNsPerOp: roundToThreeDecimals(addNsPerOp), + addMedianMs: roundToThreeDecimals(addMedianMs), + divMedianMs: roundToThreeDecimals(divMedianMs), + sqrtMedianMs: roundToThreeDecimals(sqrtMedianMs), + }); + } + + /** + * Creates a Worker from the inlined benchmark source. * - * @returns The worker and its Blob URL, or undefined if creation fails. + * @returns Worker plus Blob URL, or undefined if creation fails. */ private static startWorker(): { worker: Worker; url: string } | undefined { let url: string | undefined; diff --git a/src/wasm-runtime-probe.worker.js b/src/wasm-runtime-probe.worker.js index d24ba9c..7aa53b7 100644 --- a/src/wasm-runtime-probe.worker.js +++ b/src/wasm-runtime-probe.worker.js @@ -1,24 +1,25 @@ /* - * wasm-runtime-probe.worker.js — measures how long the same loop takes in WASM vs JS, off the main thread. + * WASM runtime benchmark worker. Inlined into wasm-runtime-probe.ts via Blob URL. * - * Inlined as a string at build time and started from a Blob URL by wasm-runtime-probe.ts; - * kept as a real file so it stays readable. + * Times add, divide, and square root WASM loops. Each operation uses the previous + * result so the browser cannot run several operations at once. The main thread + * compares divide/add and square root/add ratios so CPU speed mostly cancels out. * - * Protocol: main thread posts 'start'; replies { ok: true, wasmMs, jsMs } or { ok: false }. + * Counterintuitive: HIGH ratio means JIT OK. LOW near 2 means interpreted WASM. + * Thresholds live in wasm-runtime-probe.ts. Do not invert ratios when classifying. + * + * postMessage('start') returns medians or { ok: false }. */ self.onmessage = function onProbeStart() { try { - // Standard LCG constants (Numerical Recipes): acc = acc * MULT + INC is a cheap - // arithmetic loop the JIT can't optimize away, so it's a fair CPU benchmark. The - // WASM and JS loops share them so both do identical work; changing them - // invalidates the calibrated threshold. - var LCG_MULT = 1664525; // multiplier - var LCG_INC = 1013904223; // increment - var ITERATIONS = 5000000; - var SAMPLE_RUNS = 7; // keep the median of this many runs + // Each function runs 1 million loops with 16 operations per loop. + var OPERATIONS_PER_LOOP = 16; + var LOOPS = 1000000; + var OPS = LOOPS * OPERATIONS_PER_LOOP; + var TRIALS = 5; + var WARMUP_LOOPS = 200000; - // Build a tiny WASM module in memory that exports bench(n) — nothing to fetch. - // Bytes use LEB128: encodeU32 for lengths/counts, encodeI32 for signed values. + // Build the WASM binary in memory so the probe does not need a separate file. var encodeU32 = function encodeU32(value) { var out = []; do { @@ -30,87 +31,113 @@ self.onmessage = function onProbeStart() { return out; }; - var encodeI32 = function encodeI32(value) { - var out = []; - var more = true; - while (more) { - var byte = value & 0x7f; - value >>= 7; - if ((value === 0 && !(byte & 0x40)) || (value === -1 && byte & 0x40)) { - more = false; - } else { - byte |= 0x80; - } - out.push(byte); - } - return out; - }; - - // A section is one labelled block of the file: [id, length, ...bytes]. var section = function section(id, bytes) { return [id].concat(encodeU32(bytes.length)).concat(bytes); }; - var I32 = 0x7f; // WASM's code for the 32-bit integer type. - - var buildWasmLoopModule = function buildWasmLoopModule() { - var typeSec = section(1, encodeU32(1).concat([0x60, 0x01, I32, 0x01, I32])); // bench's type: takes one i32, returns one i32 - var funcSec = section(3, encodeU32(1).concat([0x00])); // function 0 uses signature 0 - var name = 'bench'.split('').map(function toCharCode(c) { + var toCharCodes = function toCharCodes(str) { + return str.split('').map(function charCode(c) { return c.charCodeAt(0); }); - var exportSec = section( - 7, - encodeU32(1).concat(encodeU32(name.length)).concat(name).concat([0x00, 0x00]) - ); // export the function as "bench" - // Function body — 2 locals (i, acc), then the loop: - // acc = acc * LCG_MULT + LCG_INC; i += 1; if (i < n) loop; return acc - var body = encodeU32(1) - .concat(encodeU32(2)) - .concat([I32, 0x03, 0x40, 0x20, 0x02, 0x41]) - .concat(encodeI32(LCG_MULT)) - .concat([0x6c, 0x41]) - .concat(encodeI32(LCG_INC)) - .concat([ - 0x6a, 0x21, 0x02, 0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00, - 0x0b, 0x20, 0x02, 0x0b, - ]); - var codeSec = section(10, encodeU32(1).concat(encodeU32(body.length)).concat(body)); - // "\0asm" + version 1 + the four sections - return new Uint8Array( - [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0].concat(typeSec, funcSec, exportSec, codeSec) - ); }; - // Median of several runs, so one slow run (e.g. a background hiccup) is ignored. - var medianRuntimeMs = function medianRuntimeMs(loopFn) { - loopFn(ITERATIONS); // warm-up run (not timed) - var samples = []; - for (var k = 0; k < SAMPLE_RUNS; k++) { - var start = performance.now(); - loopFn(ITERATIONS); - samples.push(performance.now() - start); + var exportEntry = function exportEntry(name, funcIndex) { + var chars = toCharCodes(name); + return encodeU32(chars.length).concat(chars).concat([0x00, funcIndex]); + }; + + var I32 = 0x7f; + var F64 = 0x7c; + + // Define three exported functions. Add and divide return i32, while square root returns f64. + var typeSec = section( + 1, + encodeU32(2) + .concat([0x60, 0x01, I32, 0x01, I32]) + .concat([0x60, 0x01, I32, 0x01, F64]) + ); + var funcSec = section(3, encodeU32(3).concat([0x00, 0x00, 0x01])); + var exportSec = section( + 7, + encodeU32(3) + .concat(exportEntry('add', 0)) + .concat(exportEntry('div', 1)) + .concat(exportEntry('sqrt', 2)) + ); + + // Build an integer loop where each result becomes the input to the next operation. + var buildIntBody = function buildIntBody(opcode) { + var body = [1, 3, I32].concat([0x03, 0x40]); + body = body.concat([0x20, 0x01, 0x41, 0x01, 0x72, 0x21, 0x03]); + for (var k = 0; k < OPERATIONS_PER_LOOP; k++) { + body = body.concat([0x20, 0x02, 0x20, 0x03, opcode, 0x21, 0x02]); } - samples.sort(function ascending(a, b) { - return a - b; - }); - return samples[Math.floor(samples.length / 2)]; + body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); + body = body.concat([0x0b, 0x20, 0x02, 0x0b]); + return encodeU32(body.length).concat(body); }; + // These WASM opcodes select i32.add and unsigned i32.div. + var addCode = buildIntBody(0x6a); + var divCode = buildIntBody(0x6e); - var runWasmLoop = new WebAssembly.Instance( - new WebAssembly.Module(buildWasmLoopModule()) - ).exports.bench; - var runJsLoop = function runJsLoop(n) { - var acc = 0; - for (var k = 0; k < n; k++) { - acc = (Math.imul(acc, LCG_MULT) + LCG_INC) | 0; + // Build the same dependency pattern for floating-point square root. + var buildSqrtBody = function buildSqrtBody() { + var body = [2, 1, I32, 1, F64].concat([0x03, 0x40]); + for (var k = 0; k < OPERATIONS_PER_LOOP; k++) { + body = body.concat([0x20, 0x02, 0x20, 0x01, 0x41, 0x01, 0x72, 0xb8, 0xa0, 0x9f, 0x21, 0x02]); } - return acc; + body = body.concat([0x20, 0x01, 0x41, 0x01, 0x6a, 0x22, 0x01, 0x20, 0x00, 0x48, 0x0d, 0x00]); + body = body.concat([0x0b, 0x20, 0x02, 0x0b]); + return encodeU32(body.length).concat(body); }; + var sqrtCode = buildSqrtBody(); + + // Assemble and compile the module once before any timed trials. + var codeSec = section(10, encodeU32(3).concat(addCode).concat(divCode).concat(sqrtCode)); + var bytes = new Uint8Array( + [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0].concat(typeSec, funcSec, exportSec, codeSec) + ); + var exports = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; + + // Median reduces the effect of one unusually slow trial. + var median = function median(samples) { + var sorted = samples.slice().sort(function ascending(a, b) { + return a - b; + }); + return sorted[Math.floor(sorted.length / 2)]; + }; + + // Run each function before timing so the browser can compile it. + exports.add(WARMUP_LOOPS); + exports.div(WARMUP_LOOPS); + exports.sqrt(WARMUP_LOOPS); + exports.add(WARMUP_LOOPS); + exports.div(WARMUP_LOOPS); + exports.sqrt(WARMUP_LOOPS); + + // Alternate operations so temporary system load affects them similarly. + var addMs = []; + var divMs = []; + var sqrtMs = []; + for (var t = 0; t < TRIALS; t++) { + var a0 = performance.now(); + exports.add(LOOPS); + addMs.push(performance.now() - a0); + var d0 = performance.now(); + exports.div(LOOPS); + divMs.push(performance.now() - d0); + var s0 = performance.now(); + exports.sqrt(LOOPS); + sqrtMs.push(performance.now() - s0); + } - var wasmMs = medianRuntimeMs(runWasmLoop); - var jsMs = medianRuntimeMs(runJsLoop); - self.postMessage({ ok: true, wasmMs: wasmMs, jsMs: jsMs }); + self.postMessage({ + ok: true, + ops: OPS, + addMedianMs: median(addMs), + divMedianMs: median(divMs), + sqrtMedianMs: median(sqrtMs), + }); } catch (err) { self.postMessage({ ok: false }); }