From 223ea6f5b7da44c060e13968ff236cf923704376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Mon, 29 Jan 2024 15:41:56 +0100 Subject: [PATCH] Add shared worker support --- docs/intro/developing.md | 5 +- src/common/internal/query/query.ts | 2 +- src/common/runtime/helper/options.ts | 15 ++- .../runtime/helper/test_worker-worker.ts | 18 +++- src/common/runtime/helper/test_worker.ts | 98 ++++++++++++++++++- src/common/runtime/standalone.ts | 11 ++- src/common/tools/dev_server.ts | 2 + src/common/tools/gen_wpt_cts_html.ts | 12 +-- src/webgpu/web_platform/worker/worker.spec.ts | 46 ++++++++- src/webgpu/web_platform/worker/worker.ts | 20 +++- .../web_platform/worker/worker_launcher.ts | 35 +++++++ 11 files changed, 239 insertions(+), 25 deletions(-) diff --git a/docs/intro/developing.md b/docs/intro/developing.md index a942a6842d75..890e64d7d224 100644 --- a/docs/intro/developing.md +++ b/docs/intro/developing.md @@ -48,7 +48,7 @@ You can use this to preview how your test plan will appear. You can view different suites (webgpu, unittests, stress, etc.) or different subtrees of the test suite. -- `http://localhost:8080/standalone/` (defaults to `?runnow=0&worker=0&debug=0&q=webgpu:*`) +- `http://localhost:8080/standalone/` (defaults to `?runnow=0&debug=0&debug=0&q=webgpu:*`) - `http://localhost:8080/standalone/?q=unittests:*` - `http://localhost:8080/standalone/?q=unittests:basic:*` @@ -56,7 +56,8 @@ The following url parameters change how the harness runs: - `runnow=1` runs all matching tests on page load. - `debug=1` enables verbose debug logging from tests. -- `worker=1` runs the tests on a Web Worker instead of the main thread. +- `worker=dedicated` runs the tests on a Web Worker instead of the main thread. +- `worker=shared` runs the tests on a Shared Worker instead of the main thread. - `power_preference=low-power` runs most tests passing `powerPreference: low-power` to `requestAdapter` - `power_preference=high-performance` runs most tests passing `powerPreference: high-performance` to `requestAdapter` diff --git a/src/common/internal/query/query.ts b/src/common/internal/query/query.ts index 7c72a62f885a..59fb3caa1391 100644 --- a/src/common/internal/query/query.ts +++ b/src/common/internal/query/query.ts @@ -188,7 +188,7 @@ export function parseExpectationsForTestQuery( assert( expectationURL.pathname === wptURL.pathname, `Invalid expectation path ${expectationURL.pathname} -Expectation should be of the form path/to/cts.https.html?worker=0&q=suite:test_path:test_name:foo=1;bar=2;... +Expectation should be of the form path/to/cts.https.html?debug=0&q=suite:test_path:test_name:foo=1;bar=2;... ` ); diff --git a/src/common/runtime/helper/options.ts b/src/common/runtime/helper/options.ts index 38974b803fac..b8812baa26c8 100644 --- a/src/common/runtime/helper/options.ts +++ b/src/common/runtime/helper/options.ts @@ -25,7 +25,7 @@ export function optionString( * The possible options for the tests. */ export interface CTSOptions { - worker: boolean; + worker?: 'dedicated' | 'shared' | 'service' | ''; debug: boolean; compatibility: boolean; unrollConstEvalLoops: boolean; @@ -33,7 +33,7 @@ export interface CTSOptions { } export const kDefaultCTSOptions: CTSOptions = { - worker: false, + worker: '', debug: true, compatibility: false, unrollConstEvalLoops: false, @@ -59,7 +59,16 @@ export type OptionsInfos = Record; * Options to the CTS. */ export const kCTSOptionsInfo: OptionsInfos = { - worker: { description: 'run in a worker' }, + worker: { + description: 'run in a worker', + parser: optionString, + selectValueDescriptions: [ + { value: '', description: 'no worker' }, + { value: 'dedicated', description: 'dedicated worker' }, + { value: 'shared', description: 'shared worker' }, + { value: 'service', description: 'service worker' }, + ], + }, debug: { description: 'show more info' }, compatibility: { description: 'run in compatibility mode' }, unrollConstEvalLoops: { description: 'unroll const eval loops in WGSL' }, diff --git a/src/common/runtime/helper/test_worker-worker.ts b/src/common/runtime/helper/test_worker-worker.ts index e8d187ea7e3d..5ded86bacd15 100644 --- a/src/common/runtime/helper/test_worker-worker.ts +++ b/src/common/runtime/helper/test_worker-worker.ts @@ -9,7 +9,7 @@ import { assert } from '../../util/util.js'; import { CTSOptions } from './options.js'; -// Should be DedicatedWorkerGlobalScope, but importing lib "webworker" conflicts with lib "dom". +// Should be WorkerGlobalScope, but importing lib "webworker" conflicts with lib "dom". /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ declare const self: any; @@ -17,7 +17,7 @@ const loader = new DefaultTestFileLoader(); setBaseResourcePath('../../../resources'); -self.onmessage = async (ev: MessageEvent) => { +async function reportTestResults(this: MessagePort | Worker, ev: MessageEvent) { const query: string = ev.data.query; const expectations: TestQueryWithExpectation[] = ev.data.expectations; const ctsOptions: CTSOptions = ev.data.ctsOptions; @@ -44,5 +44,17 @@ self.onmessage = async (ev: MessageEvent) => { const [rec, result] = log.record(testcase.query.toString()); await testcase.run(rec, expectations); - self.postMessage({ query, result }); + this.postMessage({ query, result }); +} + +self.onmessage = (ev: MessageEvent) => { + reportTestResults.call(ev.source || self, ev); +}; + +self.onconnect = (event : MessageEvent) => { + const port = event.ports[0]; + + port.onmessage = (ev: MessageEvent) => { + reportTestResults.call(port, ev); + }; }; diff --git a/src/common/runtime/helper/test_worker.ts b/src/common/runtime/helper/test_worker.ts index 9bbcab094671..236172e5308e 100644 --- a/src/common/runtime/helper/test_worker.ts +++ b/src/common/runtime/helper/test_worker.ts @@ -11,7 +11,7 @@ export class TestWorker { private readonly resolvers = new Map void>(); constructor(ctsOptions?: CTSOptions) { - this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker: true } }; + this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker: 'dedicated' } }; const selfPath = import.meta.url; const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); const workerPath = selfPathDir + '/test_worker-worker.js'; @@ -47,3 +47,99 @@ export class TestWorker { rec.injectResult(workerResult); } } + +export class TestSharedWorker { + private readonly ctsOptions: CTSOptions; + private readonly sharedWorker: SharedWorker; + private readonly resolvers = new Map void>(); + + constructor(ctsOptions?: CTSOptions) { + this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker: 'shared' } }; + const selfPath = import.meta.url; + const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); + const sharedWorkerPath = selfPathDir + '/test_worker-worker.js'; + this.sharedWorker = new SharedWorker(sharedWorkerPath, { type: 'module' }); + this.sharedWorker.port.start(); + this.sharedWorker.port.onmessage = ev => { + const query: string = ev.data.query; + const result: TransferredTestCaseResult = ev.data.result; + if (result.logs) { + for (const l of result.logs) { + Object.setPrototypeOf(l, LogMessageWithStack.prototype); + } + } + this.resolvers.get(query)!(result as LiveTestCaseResult); + + // MAINTENANCE_TODO(kainino0x): update the Logger with this result (or don't have a logger and + // update the entire results JSON somehow at some point). + }; + } + + async run( + rec: TestCaseRecorder, + query: string, + expectations: TestQueryWithExpectation[] = [] + ): Promise { + this.sharedWorker.port.postMessage({ + query, + expectations, + ctsOptions: this.ctsOptions, + }); + const sharedWorkerResult = await new Promise(resolve => { + this.resolvers.set(query, resolve); + }); + rec.injectResult(sharedWorkerResult); + } +} + +export class TestServiceWorker { + private readonly ctsOptions: CTSOptions; + private readonly serviceWorker: ServiceWorker; + private readonly resolvers = new Map void>(); + + constructor(ctsOptions?: CTSOptions) { + this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker: 'shared' } }; + } + + async ready() { + const selfPath = import.meta.url; + const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); + const serviceWorkerPath = selfPathDir + '/test_worker-worker.js'; + const registration = await navigator.serviceWorker.register(serviceWorkerPath,{ + type: 'module', + scope: '/', + }); + await navigator.serviceWorker.ready; + this.serviceWorker = registration.active; + + navigator.serviceWorker.onmessage = ev => { + const query: string = ev.data.query; + const result: TransferredTestCaseResult = ev.data.result; + if (result.logs) { + for (const l of result.logs) { + Object.setPrototypeOf(l, LogMessageWithStack.prototype); + } + } + this.resolvers.get(query)!(result as LiveTestCaseResult); + + // MAINTENANCE_TODO(kainino0x): update the Logger with this result (or don't have a logger and + // update the entire results JSON somehow at some point). + }; + } + + async run( + rec: TestCaseRecorder, + query: string, + expectations: TestQueryWithExpectation[] = [] + ): Promise { + this.serviceWorker.postMessage({ + query, + expectations, + ctsOptions: this.ctsOptions, + }); + const serviceWorkerResult = await new Promise(resolve => { + this.resolvers.set(query, resolve); + }); + rec.injectResult(serviceWorkerResult); + } +} diff --git a/src/common/runtime/standalone.ts b/src/common/runtime/standalone.ts index 2c650b4544e5..4303ed092b5c 100644 --- a/src/common/runtime/standalone.ts +++ b/src/common/runtime/standalone.ts @@ -21,7 +21,7 @@ import { OptionsInfos, camelCaseToSnakeCase, } from './helper/options.js'; -import { TestWorker } from './helper/test_worker.js'; +import { TestWorker, TestServiceWorker, TestSharedWorker } from './helper/test_worker.js'; const rootQuerySpec = 'webgpu:*'; let promptBeforeReload = false; @@ -56,7 +56,9 @@ const logger = new Logger(); setBaseResourcePath('../out/resources'); -const worker = options.worker ? new TestWorker(options) : undefined; +const worker = options.worker === 'dedicated' ? new TestWorker(options) : undefined; +const sharedWorker = options.worker === 'shared' ? new TestSharedWorker(options) : undefined; +const serviceWorker = options.worker === 'service' ? new TestServiceWorker(options) : undefined; const autoCloseOnPass = document.getElementById('autoCloseOnPass') as HTMLInputElement; const resultsVis = document.getElementById('resultsVis')!; @@ -170,6 +172,11 @@ function makeCaseHTML(t: TestTreeLeaf): VisualizedSubtree { caseResult = res; if (worker) { await worker.run(rec, name); + } else if (sharedWorker) { + await sharedWorker.run(rec, name); + } else if (serviceWorker) { + await serviceWorker.ready(); + await serviceWorker.run(rec, name); } else { await t.run(rec); } diff --git a/src/common/tools/dev_server.ts b/src/common/tools/dev_server.ts index 57cb6a7ea4f6..71f58ae900fc 100644 --- a/src/common/tools/dev_server.ts +++ b/src/common/tools/dev_server.ts @@ -150,6 +150,7 @@ app.get('/out/**/*.js', async (req, res, next) => { const tsUrl = jsUrl.replace(/\.js$/, '.ts'); if (compileCache.has(tsUrl)) { res.setHeader('Content-Type', 'application/javascript'); + res.setHeader('Service-Worker-Allowed', '/'); res.send(compileCache.get(tsUrl)); return; } @@ -166,6 +167,7 @@ app.get('/out/**/*.js', async (req, res, next) => { compileCache.set(tsUrl, result.code); res.setHeader('Content-Type', 'application/javascript'); + res.setHeader('Service-Worker-Allowed', '/'); res.send(result.code); } else { throw new Error(`Failed compile ${tsUrl}.`); diff --git a/src/common/tools/gen_wpt_cts_html.ts b/src/common/tools/gen_wpt_cts_html.ts index 90c7cd4ef4c5..8293d6612671 100644 --- a/src/common/tools/gen_wpt_cts_html.ts +++ b/src/common/tools/gen_wpt_cts_html.ts @@ -35,15 +35,15 @@ where arguments.txt is a file containing a list of arguments prefixes to both ge in the expectations. The entire variant list generation runs *once per prefix*, so this multiplies the size of the variant list. - ?worker=0&q= - ?worker=1&q= + ?debug=0&q= + ?debug=1&q= and myexpectations.txt is a file containing a list of WPT paths to suppress, e.g.: - path/to/cts.https.html?worker=0&q=webgpu:a/foo:bar={"x":1} - path/to/cts.https.html?worker=1&q=webgpu:a/foo:bar={"x":1} + path/to/cts.https.html?debug=0&q=webgpu:a/foo:bar={"x":1} + path/to/cts.https.html?debug=1&q=webgpu:a/foo:bar={"x":1} - path/to/cts.https.html?worker=1&q=webgpu:a/foo:bar={"x":3} + path/to/cts.https.html?debug=1&q=webgpu:a/foo:bar={"x":3} `); process.exit(rc); } @@ -224,7 +224,7 @@ ${queryString}` } lines.push({ - urlQueryString: prefix + query.toString(), // "?worker=0&q=..." + urlQueryString: prefix + query.toString(), // "?debug=0&q=..." comment: useChunking ? `estimated: ${subtreeCounts?.totalTimeMS.toFixed(3)} ms` : undefined, }); diff --git a/src/webgpu/web_platform/worker/worker.spec.ts b/src/webgpu/web_platform/worker/worker.spec.ts index 67f9f693bee0..21665d600064 100644 --- a/src/webgpu/web_platform/worker/worker.spec.ts +++ b/src/webgpu/web_platform/worker/worker.spec.ts @@ -1,9 +1,9 @@ export const description = ` -Tests WebGPU is available in a worker. +Tests WebGPU is available in a worker and a shared worker. -Note: The CTS test can be run in a worker by passing in worker=1 as -a query parameter. This test is specifically to check that WebGPU -is available in a worker. +Note: The CTS test can be run respectively in a worker and a shared worker by +passing in worker=dedicated and worker=shared as a query parameter. These tests +are specifically to check that WebGPU is available in a worker and a shared worker. `; import { Fixture } from '../../../common/framework/fixture.js'; @@ -17,7 +17,7 @@ function isNode(): boolean { } g.test('worker') - .desc(`test WebGPU is available in DedicatedWorkers and check for basic functionality`) + .desc(`test WebGPU is available in dedicated workers and check for basic functionality`) .fn(async t => { if (isNode()) { t.skip('node does not support 100% compatible workers'); @@ -33,3 +33,39 @@ g.test('worker') const result = await launchWorker(); assert(result.error === undefined, `should be no error from worker but was: ${result.error}`); }); + + g.test('shared_worker') + .desc(`test WebGPU is available in shared workers and check for basic functionality`) + .fn(async t => { + if (isNode()) { + t.skip('node does not support 100% compatible shared workers'); + return; + } + // Note: we load worker_launcher dynamically because ts-node support + // is using commonjs which doesn't support import.meta. Further, + // we need to put the url in a string add pass the string to import + // otherwise typescript tries to parse the file which again, fails. + // worker_launcher.js is excluded in node.tsconfig.json. + const url = './worker_launcher.js'; + const { launchSharedWorker } = await import(url); + const result = await launchSharedWorker(); + assert(result.error === undefined, `should be no error from shared worker but was: ${result.error}`); + }); + + g.test('service_worker') + .desc(`test WebGPU is available in service workers and check for basic functionality`) + .fn(async t => { + if (isNode()) { + t.skip('node does not support 100% compatible service workers'); + return; + } + // Note: we load worker_launcher dynamically because ts-node support + // is using commonjs which doesn't support import.meta. Further, + // we need to put the url in a string add pass the string to import + // otherwise typescript tries to parse the file which again, fails. + // worker_launcher.js is excluded in node.tsconfig.json. + const url = './worker_launcher.js'; + const { launchServiceWorker } = await import(url); + const result = await launchServiceWorker(); + assert(result.error === undefined, `should be no error from service worker but was: ${result.error}`); + }); diff --git a/src/webgpu/web_platform/worker/worker.ts b/src/webgpu/web_platform/worker/worker.ts index a3cf8064e26a..30b37969efd2 100644 --- a/src/webgpu/web_platform/worker/worker.ts +++ b/src/webgpu/web_platform/worker/worker.ts @@ -1,6 +1,10 @@ import { getGPU, setDefaultRequestAdapterOptions } from '../../../common/util/navigator_gpu.js'; import { assert, objectEquals, iterRange } from '../../../common/util/util.js'; +// Should be WorkerGlobalScope, but importing lib "webworker" conflicts with lib "dom". +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +declare const self: any; + async function basicTest() { const adapter = await getGPU(null).requestAdapter(); assert(adapter !== null, 'Failed to get adapter.'); @@ -68,7 +72,7 @@ async function basicTest() { device.destroy(); } -self.onmessage = async (ev: MessageEvent) => { +async function reportTestResults(this: MessagePort | Worker, ev: MessageEvent) { const defaultRequestAdapterOptions: GPURequestAdapterOptions = ev.data.defaultRequestAdapterOptions; setDefaultRequestAdapterOptions(defaultRequestAdapterOptions); @@ -79,5 +83,17 @@ self.onmessage = async (ev: MessageEvent) => { } catch (err: unknown) { error = (err as Error).toString(); } - self.postMessage({ error }); + this.postMessage({ error }); +} + +self.onmessage = (ev: MessageEvent) => { + reportTestResults.call(ev.source || self, ev); +}; + +self.onconnect = (event: MessageEvent) => { + const port = event.ports[0]; + + port.onmessage = async (ev: MessageEvent) => { + reportTestResults.call(port, ev); + }; }; diff --git a/src/webgpu/web_platform/worker/worker_launcher.ts b/src/webgpu/web_platform/worker/worker_launcher.ts index 72059eb99fda..1d1f7e8839a8 100644 --- a/src/webgpu/web_platform/worker/worker_launcher.ts +++ b/src/webgpu/web_platform/worker/worker_launcher.ts @@ -16,3 +16,38 @@ export async function launchWorker() { worker.postMessage({ defaultRequestAdapterOptions: getDefaultRequestAdapterOptions() }); return await promise; } + +export async function launchSharedWorker() { + const selfPath = import.meta.url; + const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); + const sharedWorkerPath = selfPathDir + '/worker.js'; + const sharedWorker = new SharedWorker(sharedWorkerPath, { type: 'module' }); + + const promise = new Promise(resolve => { + sharedWorker.port.addEventListener('message', ev => { + resolve(ev.data as TestResult); + }, { once: true }); + }); + sharedWorker.port.start(); + sharedWorker.port.postMessage({ defaultRequestAdapterOptions: getDefaultRequestAdapterOptions() }); + return await promise; +} + +export async function launchServiceWorker() { + const selfPath = import.meta.url; + const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/')); + const serviceWorkerPath = selfPathDir + '/worker.js'; + const registration = await navigator.serviceWorker.register(serviceWorkerPath,{ + type: 'module', + scope: '/', + }); + await navigator.serviceWorker.ready; + + const promise = new Promise(resolve => { + navigator.serviceWorker.addEventListener('message', ev => { + resolve(ev.data as TestResult); + }, { once: true }); + }); + registration.active.postMessage({ defaultRequestAdapterOptions: getDefaultRequestAdapterOptions() }); + return await promise; +}