[js/web] fix bundle for multi-thread, add e2e test and support nodejs (#7688)

* fix bundle for multi-thread, add e2e test and support nodejs

* add copyright banner

* resolve comments

* add comments for isMultiThreadSupported()
This commit is contained in:
Yulong Wang 2021-05-14 18:15:38 -07:00 committed by GitHub
parent a74e41e47d
commit 97d9bcd644
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 612 additions and 145 deletions

View file

@ -127,6 +127,7 @@ Node.js v12+ (recommended v14+)
2. ~~Follow [instructions](https://www.onnxruntime.ai/docs/how-to/build.html#apis-and-language-bindings) for building ONNX Runtime WebAssembly. (TODO: document is not ready. we are working on it.)~~
in `<ORT_ROOT>/`, run either of the following commands to build WebAssembly:
```sh
# In windows, use 'build' to replace './build.sh'
@ -136,16 +137,19 @@ Node.js v12+ (recommended v14+)
# The following command build release.
./build.sh --config Release --build_wasm --skip_tests --disable_wasm_exception_catching --disable_rtti
```
To build with multi-thread support, append flag ` --enable_wasm_threads` to the command.
3. Copy following files from build output folder to `<ORT_ROOT>/js/web/dist/`:
- ort-wasm.wasm
- ort-wasm-threaded.wasm (if appliable)
- ort-wasm-threaded.worker.js (if appliable)
- ort-wasm-threaded.wasm (build with flag '--enable_wasm_threads')
4. Copy following files from build output folder to `<ORT_ROOT>/js/web/lib/wasm/binding/`:
- ort-wasm.js
- ort-wasm-threaded.js (if appliable)
- ort-wasm-threaded.js (build with flag '--enable_wasm_threads')
- ort-wasm-threaded.worker.js (build with flag '--enable_wasm_threads')
5. Use following command in folder `<ORT_ROOT>/js/web` to build:
```

1
js/web/.gitignore vendored
View file

@ -16,4 +16,5 @@ script/**/*.js.map
lib/wasm/binding/**/*.wasm
!lib/wasm/binding/**/*.d.ts
test/testdata-config.json
test/data/node/

View file

@ -45,10 +45,8 @@ module.exports = function (config) {
frameworks: ['mocha'],
files: [
{ pattern: commonFile },
{ pattern: 'test/testdata-config.js' },
{ pattern: mainFile },
{ pattern: 'test/testdata-file-cache-*.json', included: false },
//{ pattern: 'test/onnx-worker.js', included: false },
{ pattern: 'test/data/**/*', included: false, nocache: true },
{ pattern: 'dist/ort-wasm.wasm', included: false },
{ pattern: 'dist/ort-wasm-threaded.wasm', included: false },

View file

@ -1,7 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import {readFile} from 'fs';
import {Backend, env, InferenceSession, SessionHandler} from 'onnxruntime-common';
import {cpus} from 'os';
import {promisify} from 'util';
import {OnnxruntimeWebAssemblySessionHandler} from './wasm/session-handler';
import {initializeWebAssembly} from './wasm/wasm-factory';
@ -18,7 +21,8 @@ export const initializeFlags = (): void => {
}
if (typeof env.wasm.numThreads !== 'number' || !Number.isInteger(env.wasm.numThreads) || env.wasm.numThreads < 0) {
env.wasm.numThreads = Math.ceil((navigator.hardwareConcurrency || 1) / 2);
const numCpuLogicalCores = typeof navigator === 'undefined' ? cpus().length : navigator.hardwareConcurrency;
env.wasm.numThreads = Math.ceil((numCpuLogicalCores || 1) / 2);
}
env.wasm.numThreads = Math.min(4, env.wasm.numThreads);
@ -42,9 +46,15 @@ class OnnxruntimeWebAssemblyBackend implements Backend {
Promise<SessionHandler> {
let buffer: Uint8Array;
if (typeof pathOrBuffer === 'string') {
const response = await fetch(pathOrBuffer);
const arrayBuffer = await response.arrayBuffer();
buffer = new Uint8Array(arrayBuffer);
if (typeof fetch === 'undefined') {
// node
buffer = await promisify(readFile)(pathOrBuffer);
} else {
// browser
const response = await fetch(pathOrBuffer);
const arrayBuffer = await response.arrayBuffer();
buffer = new Uint8Array(arrayBuffer);
}
} else {
buffer = pathOrBuffer;
}

View file

@ -3,5 +3,9 @@
import {OrtWasmModule} from './ort-wasm';
declare const moduleFactory: EmscriptenModuleFactory<OrtWasmModule>;
export interface OrtWasmThreadedModule extends OrtWasmModule {
PThread?: {terminateAllThreads(): void};
}
declare const moduleFactory: EmscriptenModuleFactory<OrtWasmThreadedModule>;
export default moduleFactory;

View file

@ -2,7 +2,10 @@
// Licensed under the MIT License.
import {env} from 'onnxruntime-common';
import * as path from 'path';
import {OrtWasmModule} from './binding/ort-wasm';
import {OrtWasmThreadedModule} from './binding/ort-wasm-threaded';
import ortWasmFactoryThreaded from './binding/ort-wasm-threaded.js';
import ortWasmFactory from './binding/ort-wasm.js';
@ -13,11 +16,14 @@ let aborted = false;
const isMultiThreadSupported = (): boolean => {
try {
// Test for transferability of SABs (needed for Firefox)
// Test for transferability of SABs (for browsers. needed for Firefox)
// https://groups.google.com/forum/#!msg/mozilla.dev.platform/IHkBZlHETpA/dwsMNchWEQAJ
new MessageChannel().port1.postMessage(new SharedArrayBuffer(1));
// This typed array is a WebAssembly program containing threaded
// instructions.
if (typeof MessageChannel !== 'undefined') {
new MessageChannel().port1.postMessage(new SharedArrayBuffer(1));
}
// Test for WebAssembly threads capability (for both browsers and Node.js)
// This typed array is a WebAssembly program containing threaded instructions.
return WebAssembly.validate(new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 5,
4, 1, 3, 1, 1, 10, 11, 1, 9, 0, 65, 0, 254, 16, 2, 0, 26, 11
@ -65,9 +71,25 @@ export const initializeWebAssembly = async(): Promise<void> => {
const config: Partial<OrtWasmModule> = {};
if (useThreads) {
config.mainScriptUrlOrBlob = new Blob(
[`var ortWasmThreaded=(function(){var _scriptDir;return ${ortWasmFactoryThreaded.toString()}})();`],
{type: 'text/javascript'});
if (typeof Blob === 'undefined') {
config.mainScriptUrlOrBlob = path.join(__dirname, 'ort-wasm-threaded.js');
} else {
const scriptSourceCode =
`var ortWasmThreaded=(function(){var _scriptDir;return ${ortWasmFactoryThreaded.toString()}})();`;
config.mainScriptUrlOrBlob = new Blob([scriptSourceCode], {type: 'text/javascript'});
config.locateFile = (fileName: string, scriptDirectory: string) => {
if (fileName.endsWith('.worker.js')) {
return URL.createObjectURL(new Blob(
[
// This require() function is handled by webpack to load file content of the corresponding .worker.js
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('./binding/ort-wasm-threaded.worker.js')
],
{type: 'text/javascript'}));
}
return scriptDirectory + fileName;
};
}
}
factory(config).then(
@ -100,3 +122,15 @@ export const getInstance = (): OrtWasmModule => {
throw new Error('WebAssembly is not initialized yet.');
};
export const dispose = (): void => {
if (initialized && !initializing && !aborted) {
initializing = true;
(wasm as OrtWasmThreadedModule).PThread?.terminateAllThreads();
initializing = false;
initialized = false;
aborted = true;
}
};

View file

@ -17,6 +17,7 @@
"prepare": "tsc",
"build": "node ./script/build",
"test": "node ./script/prepare-test-data && node ./script/test-runner-cli",
"test:e2e": "node ./test/e2e/run",
"prepack": "node ./script/prepack"
},
"dependencies": {

View file

@ -13,8 +13,9 @@ const args = minimist(process.argv);
// --bundle-mode=prod (default)
// --bundle-mode=dev
// --bundle-mode=perf
// --bundle-mode=node
const MODE = args['bundle-mode'] || 'prod';
if (['prod', 'dev', 'perf'].indexOf(MODE) === -1) {
if (['prod', 'dev', 'perf', 'node'].indexOf(MODE) === -1) {
throw new Error(`unknown build mode: ${MODE}`);
}
@ -24,12 +25,16 @@ const WASM = typeof args.wasm === 'undefined' ? true : !!args.wasm;
// Path variables
const WASM_BINDING_FOLDER = path.join(__dirname, '..', 'lib', 'wasm', 'binding');
const WASM_JS_PATH = path.join(WASM_BINDING_FOLDER, 'ort-wasm.js');
const WASM_THREADED_JS_PATH = path.join(WASM_BINDING_FOLDER, 'ort-wasm-threaded.js');
const WASM_BINDING_JS_PATH = path.join(WASM_BINDING_FOLDER, 'ort-wasm.js');
const WASM_BINDING_THREADED_JS_PATH = path.join(WASM_BINDING_FOLDER, 'ort-wasm-threaded.js');
const WASM_BINDING_THREADED_WORKER_JS_PATH = path.join(WASM_BINDING_FOLDER, 'ort-wasm-threaded.worker.js');
const WASM_BINDING_THREADED_MIN_JS_PATH = path.join(WASM_BINDING_FOLDER, 'ort-wasm-threaded.min.js');
const WASM_BINDING_THREADED_MIN_WORKER_JS_PATH = path.join(WASM_BINDING_FOLDER, 'ort-wasm-threaded.min.worker.js');
const WASM_DIST_FOLDER = path.join(__dirname, '..', 'dist');
const WASM_WASM_PATH = path.join(WASM_DIST_FOLDER, 'ort-wasm.wasm');
const WASM_THREADED_WASM_PATH = path.join(WASM_DIST_FOLDER, 'ort-wasm-threaded.wasm');
const WASM_THREADED_WORKER_JS_PATH = path.join(WASM_DIST_FOLDER, 'ort-wasm-threaded.worker.js');
const WASM_THREADED_JS_PATH = path.join(WASM_DIST_FOLDER, 'ort-wasm-threaded.js');
function validateFile(path: string): void {
npmlog.info('Build', `Ensure file: ${path}`);
@ -41,28 +46,87 @@ function validateFile(path: string): void {
}
}
npmlog.info('Build.Bundle', 'Retrieving npm bin folder...');
const npmBin = execSync('npm bin', {encoding: 'utf8'}).trimRight();
npmlog.info('Build.Bundle', `Retrieving npm bin folder... DONE, folder: ${npmBin}`);
if (WASM) {
npmlog.info('Build', 'Validating WebAssembly artifacts...');
try {
validateFile(WASM_JS_PATH);
validateFile(WASM_THREADED_JS_PATH);
validateFile(WASM_BINDING_JS_PATH);
validateFile(WASM_BINDING_THREADED_JS_PATH);
validateFile(WASM_BINDING_THREADED_WORKER_JS_PATH);
validateFile(WASM_WASM_PATH);
validateFile(WASM_THREADED_WASM_PATH);
validateFile(WASM_THREADED_WORKER_JS_PATH);
} catch (e) {
npmlog.error('Build', `WebAssembly files are not ready. build WASM first. ERR: ${e}`);
throw e;
}
npmlog.info('Build', 'Validating WebAssembly artifacts... DONE');
const VERSION = require(path.join(__dirname, '../package.json')).version;
const COPYRIGHT_BANNER = `/*!
* ONNX Runtime Web v${VERSION}
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
`;
const terserCommand = path.join(npmBin, 'terser');
npmlog.info('Build', 'Minimizing file "ort-wasm-threaded.js"...');
try {
const terser = spawnSync(
terserCommand,
[
WASM_BINDING_THREADED_JS_PATH, '--compress', 'passes=2', '--format', 'comments=false', '--mangle',
'reserved=[_scriptDir]', '--module'
],
{shell: true, encoding: 'utf-8'});
if (terser.status !== 0) {
console.error(terser.error);
process.exit(terser.status === null ? undefined : terser.status);
}
fs.writeFileSync(WASM_BINDING_THREADED_MIN_JS_PATH, terser.stdout);
fs.writeFileSync(WASM_THREADED_JS_PATH, COPYRIGHT_BANNER + terser.stdout);
validateFile(WASM_BINDING_THREADED_MIN_JS_PATH);
validateFile(WASM_THREADED_JS_PATH);
} catch (e) {
npmlog.error('Build', `Failed to run terser on ort-wasm-threaded.js. ERR: ${e}`);
throw e;
}
npmlog.info('Build', 'Minimizing file "ort-wasm-threaded.js"... DONE');
npmlog.info('Build', 'Minimizing file "ort-wasm-threaded.worker.js"...');
try {
const terser = spawnSync(
terserCommand,
[
WASM_BINDING_THREADED_WORKER_JS_PATH, '--compress', 'passes=2', '--format', 'comments=false', '--mangle',
'reserved=[_scriptDir]', '--toplevel'
],
{shell: true, encoding: 'utf-8'});
if (terser.status !== 0) {
console.error(terser.error);
process.exit(terser.status === null ? undefined : terser.status);
}
fs.writeFileSync(WASM_BINDING_THREADED_MIN_WORKER_JS_PATH, terser.stdout);
fs.writeFileSync(WASM_THREADED_WORKER_JS_PATH, COPYRIGHT_BANNER + terser.stdout);
validateFile(WASM_BINDING_THREADED_MIN_WORKER_JS_PATH);
validateFile(WASM_THREADED_WORKER_JS_PATH);
} catch (e) {
npmlog.error('Build', `Failed to run terser on ort-wasm-threaded.worker.js. ERR: ${e}`);
throw e;
}
npmlog.info('Build', 'Minimizing file "ort-wasm-threaded.worker.js"... DONE');
}
npmlog.info('Build', 'Building bundle...');
{
npmlog.info('Build.Bundle', '(1/2) Retrieving npm bin folder...');
const npmBin = execSync('npm bin', {encoding: 'utf8'}).trimRight();
npmlog.info('Build.Bundle', `(1/2) Retrieving npm bin folder... DONE, folder: ${npmBin}`);
npmlog.info('Build.Bundle', '(2/2) Running webpack to generate bundles...');
npmlog.info('Build.Bundle', 'Running webpack to generate bundles...');
const webpackCommand = path.join(npmBin, 'webpack');
const webpackArgs = ['--env', `--bundle-mode=${MODE}`];
npmlog.info('Build.Bundle', `CMD: ${webpackCommand} ${webpackArgs.join(' ')}`);
@ -71,6 +135,6 @@ npmlog.info('Build', 'Building bundle...');
console.error(webpack.error);
process.exit(webpack.status === null ? undefined : webpack.status);
}
npmlog.info('Build.Bundle', '(2/2) Running webpack to generate bundles... DONE');
npmlog.info('Build.Bundle', 'Running webpack to generate bundles... DONE');
}
npmlog.info('Build', 'Building bundle... DONE');

View file

@ -119,11 +119,12 @@ export interface TestRunnerCliArgs {
*
* For running tests, the default mode is 'dev'. If flag '--perf' is set, the mode will be set to 'perf'.
*
* Mode | Output File | Main | Source Map | Webpack Config
* ------ | ------------------ | -------------------- | ------------------ | --------------
* prod | /dist/ort.min.js | /lib/index.ts | source-map | production
* dev | /test/ort.dev.js | /test/test-main.ts | inline-source-map | development
* perf | /test/ort.perf.js | /test/test-main.ts | (none) | production
* Mode | Output File | Main | Source Map | Webpack Config
* ------ | --------------------- | -------------------- | ------------------ | --------------
* prod | /dist/ort.min.js | /lib/index.ts | source-map | production
* node | /dist/ort-web.node.js | /lib/index.ts | source-map | production
* dev | /test/ort.dev.js | /test/test-main.ts | inline-source-map | development
* perf | /test/ort.perf.js | /test/test-main.ts | (none) | production
*/
bundleMode: TestRunnerCliArgs.BundleMode;
@ -298,24 +299,23 @@ export function parseTestRunnerCliArgs(cmdlineArgs: string[]): TestRunnerCliArgs
const mode = args._.length === 0 ? 'suite0' : args._[0];
// Option: -b=<...>, --backend=<...>
const backendArgs = args.backend || args.b;
const backend = (typeof backendArgs !== 'string') ? ['webgl', 'wasm'] : backendArgs.split(',');
for (const b of backend) {
if (b !== 'webgl' && b !== 'wasm') {
throw new Error(`not supported backend ${b}`);
}
}
// Option: -e=<...>, --env=<...>
const envArg = args.env || args.e;
const env = (typeof envArg !== 'string') ? 'chrome' : envArg;
if (['chrome', 'edge', 'firefox', 'electron', 'safari', 'node', 'bs'].indexOf(env) === -1) {
throw new Error(`not supported env ${env}`);
}
if (env === 'node') {
// TODO: support node
throw new Error('node is currently not supported.');
// Option: -b=<...>, --backend=<...>
const browserBackends = ['webgl', 'wasm'];
const nodejsBackends = ['cpu', 'wasm'];
const backendArgs = args.backend || args.b;
const backend =
(typeof backendArgs !== 'string') ? (env === 'node' ? nodejsBackends : browserBackends) : backendArgs.split(',');
for (const b of backend) {
if ((env !== 'node' && browserBackends.indexOf(b) === -1) || (env === 'node' && nodejsBackends.indexOf(b) === -1)) {
throw new Error(`backend ${b} is not supported in env ${env}`);
}
}
// Options:

View file

@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
import {execSync, spawnSync} from 'child_process';
import * as fs from 'fs';
import * as fs from 'fs-extra';
import * as globby from 'globby';
import {default as minimatch} from 'minimatch';
import npmlog from 'npmlog';
@ -399,31 +399,45 @@ function run(config: Test.Config) {
`(1/5) Writing file cache to file: testdata-file-cache-*.json ... ${
fileCacheUrls.length > 0 ? `DONE, ${fileCacheUrls.length} file(s) generated` : 'SKIPPED'}`);
// STEP 2. write the config to testdata-config.js
npmlog.info('TestRunnerCli.Run', '(2/5) Writing config to file: testdata-config.js ...');
// STEP 2. write the config to testdata-config.json
npmlog.info('TestRunnerCli.Run', '(2/5) Writing config to file: testdata-config.json ...');
saveConfig(config);
npmlog.info('TestRunnerCli.Run', '(2/5) Writing config to file: testdata-config.js ... DONE');
npmlog.info('TestRunnerCli.Run', '(2/5) Writing config to file: testdata-config.json ... DONE');
// STEP 3. get npm bin folder
npmlog.info('TestRunnerCli.Run', '(3/5) Retrieving npm bin folder...');
const npmBin = execSync('npm bin', {encoding: 'utf8'}).trimRight();
npmlog.info('TestRunnerCli.Run', `(3/5) Retrieving npm bin folder... DONE, folder: ${npmBin}`);
// STEP 4. generate bundle
npmlog.info('TestRunnerCli.Run', '(4/5) Running build to generate bundle...');
const buildCommand = `node ${path.join(__dirname, 'build')}`;
const buildArgs = [`--bundle-mode=${args.env === 'node' ? 'node' : args.bundleMode}`];
if (args.backends.indexOf('wasm') === -1) {
buildArgs.push('--no-wasm');
}
npmlog.info('TestRunnerCli.Run', `CMD: ${buildCommand} ${buildArgs.join(' ')}`);
const build = spawnSync(buildCommand, buildArgs, {shell: true, stdio: 'inherit'});
if (build.status !== 0) {
console.error(build.error);
process.exit(build.status === null ? undefined : build.status);
}
npmlog.info('TestRunnerCli.Run', '(4/5) Running build to generate bundle... DONE');
if (args.env === 'node') {
// STEP 4. use tsc to build ONNX Runtime Web
npmlog.info('TestRunnerCli.Run', '(4/5) Running tsc...');
// STEP 5. run tsc and run mocha
npmlog.info('TestRunnerCli.Run', '(5/5) Running tsc...');
const tscCommand = path.join(npmBin, 'tsc');
const tsc = spawnSync(tscCommand, {shell: true, stdio: 'inherit'});
if (tsc.status !== 0) {
console.error(tsc.error);
process.exit(tsc.status === null ? undefined : tsc.status);
}
npmlog.info('TestRunnerCli.Run', '(4/5) Running tsc... DONE');
npmlog.info('TestRunnerCli.Run', '(5/5) Running tsc... DONE');
// STEP 5. run mocha
npmlog.info('TestRunnerCli.Run', '(5/5) Running mocha...');
const mochaCommand = path.join(npmBin, 'mocha');
const mochaArgs = [path.join(TEST_ROOT, 'test-main'), '--timeout 60000'];
const mochaArgs = [path.join(TEST_ROOT, 'test-main'), `--timeout ${args.debug ? 9999999 : 60000}`];
npmlog.info('TestRunnerCli.Run', `CMD: ${mochaCommand} ${mochaArgs.join(' ')}`);
const mocha = spawnSync(mochaCommand, mochaArgs, {shell: true, stdio: 'inherit'});
if (mocha.status !== 0) {
@ -433,21 +447,6 @@ function run(config: Test.Config) {
npmlog.info('TestRunnerCli.Run', '(5/5) Running mocha... DONE');
} else {
// STEP 4. generate bundle
npmlog.info('TestRunnerCli.Run', '(4/5) Running build to generate bundle...');
const buildCommand = `node ${path.join(__dirname, 'build')}`;
const buildArgs = [`--bundle-mode=${args.bundleMode}`];
if (args.backends.indexOf('wasm') === -1) {
buildArgs.push('--no-wasm');
}
npmlog.info('TestRunnerCli.Run', `CMD: ${buildCommand} ${buildArgs.join(' ')}`);
const build = spawnSync(buildCommand, buildArgs, {shell: true, stdio: 'inherit'});
if (build.status !== 0) {
console.error(build.error);
process.exit(build.status === null ? undefined : build.status);
}
npmlog.info('TestRunnerCli.Run', '(4/5) Running build to generate bundle... DONE');
// STEP 5. use Karma to run test
npmlog.info('TestRunnerCli.Run', '(5/5) Running karma to start test runner...');
const karmaCommand = path.join(npmBin, 'karma');
@ -549,39 +548,7 @@ function saveOneFileCache(index: number, fileCache: Test.FileCache) {
}
function saveConfig(config: Test.Config) {
let setOptions = '';
if (config.options.debug !== undefined) {
setOptions += `ort.env.debug = ${config.options.debug};`;
}
if (config.options.webglFlags && config.options.webglFlags.contextId !== undefined) {
setOptions += `ort.env.webgl.contextId = ${JSON.stringify(config.options.webglFlags.contextId)};`;
}
if (config.options.webglFlags && config.options.webglFlags.matmulMaxBatchSize !== undefined) {
setOptions += `ort.env.webgl.matmulMaxBatchSize = ${config.options.webglFlags.matmulMaxBatchSize};`;
}
if (config.options.webglFlags && config.options.webglFlags.textureCacheMode !== undefined) {
setOptions += `ort.env.webgl.textureCacheMode = ${JSON.stringify(config.options.webglFlags.textureCacheMode)};`;
}
if (config.options.webglFlags && config.options.webglFlags.pack !== undefined) {
setOptions += `ort.env.webgl.pack = ${JSON.stringify(config.options.webglFlags.pack)};`;
}
if (config.options.wasmFlags && config.options.wasmFlags.numThreads !== undefined) {
setOptions += `ort.env.wasm.numThreads = ${JSON.stringify(config.options.wasmFlags.numThreads)};`;
}
if (config.options.wasmFlags && config.options.wasmFlags.loggingLevel !== undefined) {
setOptions += `ort.env.wasm.loggingLevel = ${JSON.stringify(config.options.wasmFlags.loggingLevel)};`;
}
if (config.options.wasmFlags && config.options.wasmFlags.initTimeout !== undefined) {
setOptions += `ort.env.wasm.initTimeout = ${JSON.stringify(config.options.wasmFlags.initTimeout)};`;
}
// TODO: support onnxruntime nodejs binding
// if (config.model.some(testGroup => testGroup.tests.some(test => test.backend === 'cpu'))) {
// setOptions += 'require(\'onnxruntime-node\');';
// }
fs.writeFileSync(path.join(TEST_ROOT, './testdata-config.js'), `${setOptions}
ort.env.ORT_WEB_TEST_DATA=${JSON.stringify(config)};`);
fs.writeJSONSync(path.join(TEST_ROOT, './testdata-config.json'), config);
}
function getBrowserNameFromEnv(env: TestRunnerCliArgs['env'], debug?: boolean) {

1
js/web/test/e2e/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
!**/*.js

View file

@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
it('Browser E2E testing - WebAssembly backend (no threads)', async function () {
ort.env.wasm.numThreads = 1;
await testFunction(ort, { executionProviders: ['wasm'] });
});

View file

@ -0,0 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
it('Browser E2E testing - WebAssembly backend', async function () {
await testFunction(ort, { executionProviders: ['wasm'] });
});

View file

@ -0,0 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
it('Browser E2E testing - WebGL backend', async function () {
await testFunction(ort, { executionProviders: ['webgl'] });
});

36
js/web/test/e2e/common.js Normal file
View file

@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
function assert(cond) {
if (!cond) throw new Error();
}
var testFunction = async function (ort, options) {
const session = await ort.InferenceSession.create('./model.onnx', options || {});
const dataA = Float32Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
const dataB = Float32Array.from([10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]);
const fetches = await session.run({
a: new ort.Tensor('float32', dataA, [3, 4]),
b: new ort.Tensor('float32', dataB, [4, 3])
});
const c = fetches.c;
assert(c instanceof ort.Tensor);
assert(c.dims.length === 2 && c.dims[0] === 3 && c.dims[1] === 3);
assert(c.data[0] === 700);
assert(c.data[1] === 800);
assert(c.data[2] === 900);
assert(c.data[3] === 1580);
assert(c.data[4] === 1840);
assert(c.data[5] === 2100);
assert(c.data[6] === 2460);
assert(c.data[7] === 2880);
assert(c.data[8] === 3300);
};
if (typeof module === 'object') {
module.exports = testFunction;
}

View file

@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
const args = require('minimist')(process.argv.slice(2));
const SELF_HOST = !!args['self-host'];
const TEST_MAIN = args['test-main'];
if (typeof TEST_MAIN !== 'string') {
throw new Error('flag --test-main=<TEST_MAIN_JS_FILE> is required');
}
const USER_DATA = args['user-data'];
if (typeof USER_DATA !== 'string') {
throw new Error('flag --user-data=<CHROME_USER_DATA_FOLDER> is required');
}
module.exports = function (config) {
const distPrefix = SELF_HOST ? './node_modules/onnxruntime-web/dist/' : 'http://localhost:8081/dist/';
config.set({
frameworks: ['mocha'],
files: [
{ pattern: distPrefix + 'ort.js' },
{ pattern: './common.js' },
{ pattern: TEST_MAIN },
{ pattern: './node_modules/onnxruntime-web/dist/**/*', included: false, nocache: true },
{ pattern: './model.onnx', included: false }
],
proxies: {
'/model.onnx': '/base/model.onnx',
},
client: { captureConsole: true, mocha: { expose: ['body'], timeout: 60000 } },
reporters: ['mocha'],
captureTimeout: 120000,
reportSlowerThan: 100,
browserDisconnectTimeout: 600000,
browserNoActivityTimeout: 300000,
browserDisconnectTolerance: 0,
browserSocketTimeout: 60000,
hostname: 'localhost',
browsers: [],
customLaunchers: {
Chrome_default: {
base: 'Chrome',
chromeDataDir: USER_DATA
},
Chrome_no_threads: {
base: 'Chrome',
chromeDataDir: USER_DATA,
// TODO: no-thread flags
}
}
});
};

View file

@ -0,0 +1,16 @@
 backend-test:b

a
bc"MatMultest_matmul_2dZ
a


Z
b


b
c


B

View file

@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
const ort = require('onnxruntime-web');
const testFunction = require('./common');
it('Browser E2E testing - WebAssembly backend', async function () {
ort.env.wasm.numThreads = 1;
await testFunction(ort, { executionProviders: ['wasm'] });
});

View file

@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
const ort = require('onnxruntime-web');
const testFunction = require('./common');
it('Browser E2E testing - WebAssembly backend', async function () {
await testFunction(ort, { executionProviders: ['wasm'] });
process.exit();
});

View file

@ -0,0 +1,13 @@
{
"devDependencies": {
"fs-extra": "^9.1.0",
"globby": "^11.0.3",
"karma": "^6.3.2",
"karma-chrome-launcher": "^3.1.0",
"karma-mocha": "^2.0.1",
"karma-mocha-reporter": "^2.2.5",
"light-server": "^2.9.1",
"minimist": "^1.2.5",
"mocha": "^8.3.2"
}
}

109
js/web/test/e2e/run.js Normal file
View file

@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
const path = require('path');
const fs = require('fs-extra');
const globby = require('globby');
const { spawn } = require('child_process');
const startServer = require('./simple-http-server');
// copy whole folder to out-side of <ORT_ROOT>/js/ because we need to test in a folder that no `package.json` file
// exists in its parent folder.
// here we use <ORT_ROOT>/build/js/e2e/ for the test
const TEST_E2E_SRC_FOLDER = __dirname;
const JS_ROOT_FOLDER = path.resolve(__dirname, '../../..');
const TEST_E2E_RUN_FOLDER = path.resolve(JS_ROOT_FOLDER, '../build/js/e2e');
const NPM_CACHE_FOLDER = path.resolve(TEST_E2E_RUN_FOLDER, '../npm_cache');
const CHROME_USER_DATA_FOLDER = path.resolve(TEST_E2E_RUN_FOLDER, '../user_data');
fs.emptyDirSync(TEST_E2E_RUN_FOLDER);
fs.emptyDirSync(NPM_CACHE_FOLDER);
fs.emptyDirSync(CHROME_USER_DATA_FOLDER);
fs.copySync(TEST_E2E_SRC_FOLDER, TEST_E2E_RUN_FOLDER);
// find packed package
const ORT_COMMON_FOLDER = path.resolve(JS_ROOT_FOLDER, 'common');
const ORT_COMMON_PACKED_FILEPATH_CANDIDATES = globby.sync('onnxruntime-common-*.tgz', { cwd: ORT_COMMON_FOLDER });
if (ORT_COMMON_PACKED_FILEPATH_CANDIDATES.length !== 1) {
throw new Error('cannot find exactly single package for onnxruntime-common.');
}
const ORT_COMMON_PACKED_FILEPATH = path.resolve(ORT_COMMON_FOLDER, ORT_COMMON_PACKED_FILEPATH_CANDIDATES[0]);
const ORT_WEB_FOLDER = path.resolve(JS_ROOT_FOLDER, 'web');
const ORT_WEB_PACKED_FILEPATH_CANDIDATES = globby.sync('onnxruntime-web-*.tgz', { cwd: ORT_WEB_FOLDER });
if (ORT_WEB_PACKED_FILEPATH_CANDIDATES.length !== 1) {
throw new Error('cannot find exactly single package for onnxruntime-web.');
}
const ORT_WEB_PACKED_FILEPATH = path.resolve(ORT_WEB_FOLDER, ORT_WEB_PACKED_FILEPATH_CANDIDATES[0]);
// we start here:
async function main() {
// install dev dependencies
await runInShell(`npm install"`);
// npm install with "--cache" to install packed packages with an empty cache folder
await runInShell(`npm install --cache "${NPM_CACHE_FOLDER}" "${ORT_COMMON_PACKED_FILEPATH}" "${ORT_WEB_PACKED_FILEPATH}"`);
// test case run in Node.js
await testAllNodejsCases();
// test cases with self-host (ort hosted in same origin)
await testAllBrowserCases({ hostInKarma: true });
// test cases without self-host (ort hosted in same origin)
startServer(path.resolve(TEST_E2E_RUN_FOLDER, 'node_modules', 'onnxruntime-web'));
await testAllBrowserCases({ hostInKarma: false });
// no error occurs, exit with code 0
process.exit(0);
}
async function testAllNodejsCases() {
await runInShell('node ./node_modules/mocha/bin/mocha ./node-test-main-no-threads.js');
await runInShell('node ./node_modules/mocha/bin/mocha ./node-test-main.js');
await runInShell('node --experimental-wasm-threads --experimental-wasm-bulk-memory ./node_modules/mocha/bin/mocha ./node-test-main-no-threads.js');
await runInShell('node --experimental-wasm-threads --experimental-wasm-bulk-memory ./node_modules/mocha/bin/mocha ./node-test-main.js');
}
async function testAllBrowserCases({ hostInKarma }) {
await runKarma({ hostInKarma, main: './browser-test-webgl.js', browser: 'Chrome_default' });
await runKarma({ hostInKarma, main: './browser-test-wasm.js', browser: 'Chrome_default' });
await runKarma({ hostInKarma, main: './browser-test-wasm-no-threads.js', browser: 'Chrome_default' });
}
async function runKarma({ hostInKarma, main, browser }) {
const selfHostFlag = hostInKarma ? '--self-host' : '';
await runInShell(
`npx karma start --single-run --browsers ${browser} ${selfHostFlag} --test-main=${main} --user-data=${CHROME_USER_DATA_FOLDER}`);
}
async function runInShell(cmd) {
console.log('===============================================================');
console.log(' Running command in shell:');
console.log(' > ' + cmd);
console.log('===============================================================');
let complete = false;
const childProcess = spawn(cmd, { shell: true, stdio: 'inherit', cwd: TEST_E2E_RUN_FOLDER });
childProcess.on('close', function (code) {
if (code !== 0) {
process.exit(code);
} else {
complete = true;
}
});
while (!complete) {
await delay(100);
}
}
async function delay(ms) {
return new Promise(function (resolve) {
setTimeout(function () {
resolve();
}, ms);
});
}
main();

View file

@ -0,0 +1,58 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
// this is a simple HTTP server that enables CORS.
// following code is based on https://developer.mozilla.org/en-US/docs/Learn/Server-side/Node_server_without_framework
var http = require('http');
var fs = require('fs');
var path = require('path');
module.exports = function (dir) {
http.createServer(function (request, response) {
console.log('request ', request.url);
var filePath = '.' + request.url;
var extname = String(path.extname(filePath)).toLowerCase();
var mimeTypes = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.woff': 'application/font-woff',
'.ttf': 'application/font-ttf',
'.eot': 'application/vnd.ms-fontobject',
'.otf': 'application/font-otf',
'.wasm': 'application/wasm'
};
var contentType = mimeTypes[extname] || 'application/octet-stream';
fs.readFile(path.resolve(dir, filePath), function (error, content) {
if (error) {
if (error.code == 'ENOENT') {
response.writeHead(404);
response.end('404');
}
else {
response.writeHead(500);
response.end('500');
}
}
else {
response.setHeader('access-control-allow-origin', '*');
response.writeHead(200, { 'Content-Type': contentType });
response.end(content, 'utf-8');
}
});
}).listen(8081);
console.log('Server running at http://127.0.0.1:8081/');
};

View file

@ -1,16 +1,48 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import '../lib/index'; // this need to be the first line
// Load onnxruntime-web and testdata-config.
// NOTE: this need to be called before import any other library.
const ort = require('..');
const ORT_WEB_TEST_CONFIG = require('./testdata-config.json') as Test.Config;
import * as ort from 'onnxruntime-common';
import * as platform from 'platform';
import {Logger} from '../lib/onnxjs/instrument';
import {Test} from './test-types';
const ORT_WEB_TEST_CONFIG = (ort.env as any).ORT_WEB_TEST_DATA as Test.Config;
if (ORT_WEB_TEST_CONFIG.model.some(testGroup => testGroup.tests.some(test => test.backend === 'cpu'))) {
// require onnxruntime-node
require('../../node');
}
// set flags
const options = ORT_WEB_TEST_CONFIG.options;
if (options.debug !== undefined) {
ort.env.debug = options.debug;
}
if (ort.env.webgl && options.webglFlags && options.webglFlags.contextId !== undefined) {
ort.env.webgl.contextId = options.webglFlags.contextId;
}
if (ort.env.webgl && options.webglFlags && options.webglFlags.matmulMaxBatchSize !== undefined) {
ort.env.webgl.matmulMaxBatchSize = options.webglFlags.matmulMaxBatchSize;
}
if (ort.env.webgl && options.webglFlags && options.webglFlags.textureCacheMode !== undefined) {
ort.env.webgl.textureCacheMode = options.webglFlags.textureCacheMode;
}
if (ort.env.webgl && options.webglFlags && options.webglFlags.pack !== undefined) {
ort.env.webgl.pack = options.webglFlags.pack;
}
if (ort.env.wasm && options.wasmFlags && options.wasmFlags.numThreads !== undefined) {
ort.env.wasm.numThreads = options.wasmFlags.numThreads;
}
if (ort.env.wasm && options.wasmFlags && options.wasmFlags.loggingLevel !== undefined) {
ort.env.wasm.loggingLevel = options.wasmFlags.loggingLevel;
}
if (ort.env.wasm && options.wasmFlags && options.wasmFlags.initTimeout !== undefined) {
ort.env.wasm.initTimeout = options.wasmFlags.initTimeout;
}
// Set logging configuration
for (const logConfig of ORT_WEB_TEST_CONFIG.log) {

View file

@ -1,4 +1,9 @@
{
"cpu": {
"onnx": [],
"node": [],
"ops": []
},
"webgl": {
"onnx": ["resnet50", "squeezenet", "tiny_yolov2", "emotion_ferplus"],
"node": [

View file

@ -7,38 +7,33 @@ const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
const TerserPlugin = require("terser-webpack-plugin");
const minimist = require('minimist');
function addCopyrightBannerPlugin(mode) {
const VERSION = require(path.join(__dirname, 'package.json')).version;
const COPYRIGHT_BANNER = `/*!
* ONNX Runtime Web v${VERSION}
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/`;
const VERSION = require(path.join(__dirname, 'package.json')).version;
const COPYRIGHT_BANNER = `/*!
* ONNX Runtime Web v${VERSION}
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/`;
if (mode === 'production') {
return new TerserPlugin({
extractComments: false,
terserOptions: {
format: {
preamble: COPYRIGHT_BANNER,
comments: false,
},
compress: {
passes: 2
},
mangle: {
reserved: ["_scriptDir"]
}
function defaultTerserPluginOptions() {
return {
extractComments: false,
terserOptions: {
format: {
comments: false,
},
compress: {
passes: 2
},
mangle: {
reserved: ["_scriptDir"]
}
});
} else {
return new webpack.BannerPlugin({ banner: COPYRIGHT_BANNER, raw: true });
}
}
};
}
// common config for release bundle
function buildConfig({ filename, format, target, mode, devtool }) {
return {
const config = {
target: [format === 'commonjs' ? 'node' : 'web', target],
entry: path.resolve(__dirname, 'lib/index.ts'),
output: {
@ -49,10 +44,7 @@ function buildConfig({ filename, format, target, mode, devtool }) {
}
},
resolve: { extensions: ['.ts', '.js'] },
plugins: [
new webpack.WatchIgnorePlugin({ paths: [/\.js$/, /\.d\.ts$/] }),
addCopyrightBannerPlugin(mode),
],
plugins: [new webpack.WatchIgnorePlugin({ paths: [/\.js$/, /\.d\.ts$/] })],
module: {
rules: [{
test: /\.ts$/,
@ -64,11 +56,28 @@ function buildConfig({ filename, format, target, mode, devtool }) {
}
}
]
}, {
test: /\.worker.js$/,
type: 'asset/source'
}]
},
mode,
devtool
};
if (mode === 'production') {
config.resolve.alias = {
'./binding/ort-wasm-threaded.js': './binding/ort-wasm-threaded.min.js',
'./binding/ort-wasm-threaded.worker.js': './binding/ort-wasm-threaded.min.worker.js'
};
const options = defaultTerserPluginOptions();
options.terserOptions.format.preamble = COPYRIGHT_BANNER;
config.plugins.push(new TerserPlugin(options));
} else {
config.plugins.push(new webpack.BannerPlugin({ banner: COPYRIGHT_BANNER, raw: true }));
}
return config;
}
// "ort{.min}.js" config
@ -108,10 +117,9 @@ function buildOrtWebConfig({
config.externals.path = 'path';
config.externals.fs = 'fs';
config.externals.util = 'util';
}
// in browser, do not use those node builtin modules
if (format === 'umd') {
config.resolve.fallback = { path: false, fs: false, util: false };
config.externals.worker_threads = 'worker_threads';
config.externals.perf_hooks = 'perf_hooks';
config.externals.os = 'os';
}
return config;
}
@ -123,7 +131,7 @@ function buildTestRunnerConfig({
mode = 'production',
devtool = 'source-map'
}) {
return {
const config = {
target: ['web', target],
entry: path.resolve(__dirname, 'test/test-main.ts'),
output: {
@ -139,6 +147,7 @@ function buildTestRunnerConfig({
'fs': 'fs',
'perf_hooks': 'perf_hooks',
'worker_threads': 'worker_threads',
'../../node': '../../node'
},
resolve: {
extensions: ['.ts', '.js'],
@ -148,7 +157,6 @@ function buildTestRunnerConfig({
plugins: [
new webpack.WatchIgnorePlugin({ paths: [/\.js$/, /\.d\.ts$/] }),
new NodePolyfillPlugin(),
addCopyrightBannerPlugin(mode),
],
module: {
rules: [{
@ -161,16 +169,25 @@ function buildTestRunnerConfig({
}
}
]
}, {
test: /\.worker\.js$/,
type: 'asset/source'
}]
},
mode: mode,
devtool: devtool,
};
if (mode === 'production') {
config.plugins.push(new TerserPlugin(defaultTerserPluginOptions()));
}
return config;
}
module.exports = () => {
const args = minimist(process.argv);
const bundleMode = args['bundle-mode'] || 'prod'; // 'prod'|'dev'|'perf'|undefined;
const bundleMode = args['bundle-mode'] || 'prod'; // 'prod'|'dev'|'perf'|'node'|undefined;
const builds = [];
switch (bundleMode) {
@ -193,7 +210,10 @@ module.exports = () => {
buildOrtWebConfig({ suffix: '.es6.min', target: 'es6' }),
// ort-web.es6.js
buildOrtWebConfig({ suffix: '.es6', mode: 'development', devtool: 'inline-source-map', target: 'es6' }),
);
case 'node':
builds.push(
// ort-web.node.js
buildOrtWebConfig({ suffix: '.node', format: 'commonjs' }),
);

View file

@ -157,7 +157,6 @@ jobs:
sourceFolder: $(Pipeline.Workspace)\artifacts
contents: |
**\*.wasm
**\*.worker.js
targetFolder: $(Build.SourcesDirectory)\js\web\dist
flattenFolders: true
displayName: 'Binplace dist files'
@ -166,7 +165,6 @@ jobs:
sourceFolder: $(Pipeline.Workspace)\artifacts
contents: |
**\*.js
!**\*.worker.js
targetFolder: $(Build.SourcesDirectory)\js\web\lib\wasm\binding
flattenFolders: true
displayName: 'Binplace js files'
@ -212,6 +210,11 @@ jobs:
workingDirectory: '$(Build.SourcesDirectory)\js\web'
displayName: 'Generate NPM package (onnxruntime-web)'
condition: and(succeeded(), eq(variables['BuildConfig'], 'Release'))
- script: |
npm run test:e2e
workingDirectory: '$(Build.SourcesDirectory)\js\web'
displayName: 'E2E package consuming test'
condition: and(succeeded(), eq(variables['BuildConfig'], 'Release'))
- task: CopyFiles@2
inputs:
sourceFolder: $(Build.SourcesDirectory)\js\common