Skip to content

Commit 010a8f0

Browse files
authored
[web] Support Blob-backed external data for on-demand loading in JSPI builds (#29477)
### Description This PR allows `externalData` to be supplied as a `Blob`/`File` in JSPI builds. Instead of materializing the whole external data file in the JS heap and holding it through `_OrtCreateSession`, the WASM external data loader reads each initializer's byte range from the Blob on demand and releases it right after the CPU copy / GPU upload. The load-time CPU memory peak drops from the full model size to roughly the size of the largest single initializer. This follows the Blob/range-backed source approach suggested by the maintainers in #29455. `ExternalDataFileDescription.data` already accepts `Blob` in its type union, so there is no public API change ### Motivation and Context Fixes #29455. ### Measurements Phi-4-mini-instruct ONNX (q4f16, WebGPU EP, external data 1896 MB + 492 MB), Chrome / Windows, PTL | | whole-file (current) | Blob-backed (this PR) | |---|---|---| | JS heap peak during load | 2476 MB (held until session created) | 434 MB | | Tab process peak during load | 2893 MB | ~845 MB | | Model load time | 10.7 s | 3.9 s | load time benefits mainly from skipping the 2.4 GB in-heap materialization. Signed-off-by: Zhenwei Jin <zhenwei.jin@intel.com>
1 parent 361184e commit 010a8f0

7 files changed

Lines changed: 211 additions & 6 deletions

File tree

cmake/adjust_global_compile_flags.cmake

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ if (CMAKE_SYSTEM_NAME STREQUAL "Emscripten")
4545
string(APPEND CMAKE_CXX_FLAGS " -msimd128")
4646
endif()
4747

48-
# Enable WebAssembly exception catching.
4948
if (onnxruntime_ENABLE_WEBASSEMBLY_JSPI)
49+
add_compile_definitions(ORT_WASM_JSPI)
50+
# Enable WebAssembly exception catching.
5051
string(APPEND CMAKE_C_FLAGS " -fwasm-exceptions -s WASM_LEGACY_EXCEPTIONS=0")
5152
string(APPEND CMAKE_CXX_FLAGS " -fwasm-exceptions -s WASM_LEGACY_EXCEPTIONS=0")
5253
elseif (onnxruntime_ENABLE_WEBASSEMBLY_EXCEPTION_CATCHING)

js/web/lib/wasm/wasm-core-impl.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,14 @@ export const createSession = async (
358358
const loadingPromises = [];
359359
for (const file of options.externalData) {
360360
const path = typeof file === 'string' ? file : file.path;
361+
const data = typeof file === 'string' ? file : file.data;
362+
if (BUILD_DEFS.ENABLE_JSPI && data instanceof Blob) {
363+
wasm.mountExternalData(path, data);
364+
continue;
365+
}
361366
loadingPromises.push(
362-
loadFile(typeof file === 'string' ? file : file.data).then((data) => {
363-
wasm.mountExternalData(path, data);
367+
loadFile(data).then((fileData) => {
368+
wasm.mountExternalData(path, fileData);
364369
}),
365370
);
366371
}

js/web/lib/wasm/wasm-types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,9 +468,11 @@ export interface OrtWasmModule
468468
* Mount the external data file to an internal map, which will be used during session initialization.
469469
*
470470
* @param externalDataFilePath - specify the relative path of the external data file.
471-
* @param externalDataFileData - specify the content data.
471+
* @param externalDataFileData - specify the content data, either as the whole file content (Uint8Array) or as a
472+
* reference to the file (Blob, including File). In JSPI builds a Blob is streamed on demand during session
473+
* initialization; in other builds it is fully materialized into memory first.
472474
*/
473-
mountExternalData(externalDataFilePath: string, externalDataFileData: Uint8Array): void;
475+
mountExternalData(externalDataFilePath: string, externalDataFileData: Uint8Array | Blob): void;
474476
/**
475477
* Unmount all external data files from the internal map.
476478
*/
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
it('Browser E2E testing - WebGPU backend with external data as Blob', async function () {
7+
// supplying external data as a Blob. In JSPI builds, the Blob is read on demand during session initialization
8+
// instead of being materialized in memory at once; in other builds it falls back to loading the whole content.
9+
const blob = await (await fetch('./model_with_orig_ext_data.bin')).blob();
10+
const session = await ort.InferenceSession.create('./model_with_orig_ext_data.onnx', {
11+
executionProviders: ['webgpu'],
12+
externalData: [{ data: blob, path: 'model_with_orig_ext_data.bin' }],
13+
});
14+
15+
const fetches = await session.run({ X: new ort.Tensor('float32', [1, 1], [1, 2]) });
16+
17+
const Y = fetches.Y;
18+
19+
assert(Y instanceof ort.Tensor);
20+
assert(Y.dims.length === 2 && Y.dims[0] === 2 && Y.dims[1] === 3);
21+
assert(Y.data[0] === 1);
22+
assert(Y.data[1] === 1);
23+
assert(Y.data[2] === 0);
24+
assert(Y.data[3] === 0);
25+
assert(Y.data[4] === 0);
26+
assert(Y.data[5] === 0);
27+
});

js/web/test/e2e/run-data.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const BROWSER_TEST_CASES = [
6767

6868
[true, true, './browser-test-wasm-image-tensor-image.js', 'ort.min.js'], // pre-post-process
6969
[true, true, './browser-test-webgpu-external-data.js', 'ort.webgpu.min.js'], // external data
70+
[true, true, './browser-test-webgpu-external-data-blob.js', 'ort.webgpu.min.js'], // external data as Blob (fallback)
71+
[true, true, './browser-test-webgpu-external-data-blob.js', 'ort.jspi.min.js'], // external data as Blob (on-demand)
7072
];
7173

7274
// [bundle_path, format]

onnxruntime/core/framework/external_data_loader.cc

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,170 @@ common::Status IExternalDataLoader::LoadTensor([[maybe_unused]] const Env& env,
2121

2222
#if defined(__wasm__)
2323

24+
// Error codes returned by the JS loader bodies below. The async and sync
25+
// loaders share this contract - keep them in sync:
26+
// 0 - success
27+
// 1 - Module.MountedFiles is not available
28+
// 2 - file not found in preloaded files
29+
// 3 - out-of-bounds
30+
// 4 - unknown error during memory copy / GPU upload
31+
// 5 - Blob stream returned an unexpected number of bytes
32+
33+
#if defined(ORT_WASM_JSPI)
34+
35+
// Asynchronous external data loader, available in JSPI builds only.
36+
//
37+
// The offset/length parameters are doubles so the loader itself can address beyond
38+
// 4GB, but the current wasm caller still rejects offset + length >= 4GB before reaching here.
39+
40+
// clang-format off
41+
EM_ASYNC_JS(int, OrtLoadWebAssemblyExternalDataAsync,
42+
(const char* data_file_path, double data_offset, double data_length,
43+
void* tensor_data, int32_t load_type), {
44+
45+
if (typeof Module == 'undefined' || !Module.MountedFiles) {
46+
return 1; // "Module.MountedFiles" is not available.
47+
}
48+
let fileName = UTF8ToString(Number(data_file_path >>> 0));
49+
if (fileName.startsWith('./')) {
50+
fileName = fileName.substring(2);
51+
}
52+
const fileData = Module.MountedFiles.get(fileName);
53+
if (!fileData) {
54+
return 2; // file not found in preloaded files.
55+
}
56+
const offset = data_offset;
57+
const length = data_length;
58+
const dataIdOrBuffer = Number(tensor_data >>> 0);
59+
60+
if (offset < 0 || length < 0) {
61+
return 3;
62+
}
63+
64+
let ownScratchLock = false;
65+
try {
66+
let data;
67+
if (typeof Blob !== 'undefined' && fileData instanceof Blob) {
68+
if (offset + length > fileData.size) {
69+
return 3; // Out of bounds.
70+
}
71+
if (length === 0) {
72+
data = new Uint8Array(0);
73+
} else {
74+
// Stream into a reused scratch buffer rather than slice().arrayBuffer(), which
75+
// creates a large short-lived buffer per initializer and inflates peak memory.
76+
let scratch;
77+
if (!Module.ortExtDataScratchBusy) {
78+
Module.ortExtDataScratchBusy = true;
79+
ownScratchLock = true;
80+
scratch = Module.ortExtDataScratch;
81+
if (!scratch || scratch.length < length) {
82+
scratch = Module.ortExtDataScratch = new Uint8Array(length);
83+
}
84+
} else {
85+
scratch = new Uint8Array(length);
86+
}
87+
const stream = fileData.slice(offset, offset + length).stream();
88+
// BYOB reader avoids per-chunk allocations; fall back if unavailable.
89+
let reader;
90+
let byob = false;
91+
try {
92+
reader = stream.getReader({ 'mode': 'byob' });
93+
byob = true;
94+
} catch (e) {
95+
reader = stream.getReader();
96+
}
97+
let pos = 0;
98+
if (byob) {
99+
let chunk = Module.ortExtDataChunk || new ArrayBuffer(1048576);
100+
Module.ortExtDataChunk = null; // BYOB read detaches; one owner per load.
101+
for (;;) {
102+
const r = await reader.read(new Uint8Array(chunk, 0, chunk.byteLength));
103+
if (r.value) {
104+
if (r.value.byteLength > 0) {
105+
if (pos + r.value.byteLength > length) {
106+
Module.ortExtDataChunk = r.value.buffer;
107+
return 5; // Stream yielded more bytes than requested.
108+
}
109+
scratch.set(r.value, pos);
110+
pos += r.value.byteLength;
111+
}
112+
chunk = r.value.buffer; // reclaim the (detached) chunk buffer
113+
}
114+
if (r.done) break;
115+
}
116+
Module.ortExtDataChunk = chunk;
117+
} else {
118+
for (;;) {
119+
const r = await reader.read();
120+
if (r.done) break;
121+
if (pos + r.value.byteLength > length) {
122+
return 5; // Stream yielded more bytes than requested.
123+
}
124+
scratch.set(r.value, pos);
125+
pos += r.value.byteLength;
126+
}
127+
}
128+
if (pos !== length) {
129+
return 5; // Reading from the Blob returned an unexpected number of bytes.
130+
}
131+
data = scratch.subarray(0, length);
132+
}
133+
} else {
134+
if (offset + length > fileData.byteLength) {
135+
return 3; // Out of bounds.
136+
}
137+
data = fileData.subarray(offset, offset + length);
138+
}
139+
140+
switch (load_type) {
141+
case 0:
142+
// Load external data to CPU memory.
143+
// Copy the file data (fileData,offset,length) into WebAssembly memory
144+
// (HEAPU8,buffer,length).
145+
HEAPU8.set(data, dataIdOrBuffer);
146+
break;
147+
case 1:
148+
// Load external data to GPU.
149+
// TODO: use a unified interface for upload external buffer.
150+
if (Module.webgpuUploadExternalBuffer) {
151+
Module.webgpuUploadExternalBuffer(dataIdOrBuffer, data);
152+
} else {
153+
Module.jsepUploadExternalBuffer(dataIdOrBuffer, data);
154+
}
155+
break;
156+
default:
157+
return 4; // Unknown error occurred in memory copy.
158+
}
159+
return 0;
160+
} catch (e) {
161+
console.error('Failed to load external data "' + fileName + '":', e);
162+
return 4;
163+
} finally {
164+
// `data` may alias the shared scratch; release it only after the copy/upload
165+
// above has consumed it.
166+
if (ownScratchLock) {
167+
Module.ortExtDataScratchBusy = false;
168+
}
169+
}
170+
});
171+
// clang-format on
172+
173+
#endif
174+
24175
common::Status LoadWebAssemblyExternalData(const Env& env,
25176
const std::filesystem::path& data_file_path,
26177
FileOffsetType data_offset,
27178
SafeInt<size_t> data_length,
28179
ExternalDataLoadType load_type,
29180
void* tensor_data) {
181+
#if defined(ORT_WASM_JSPI)
182+
auto err_code = OrtLoadWebAssemblyExternalDataAsync(data_file_path.c_str(),
183+
static_cast<double>(data_offset),
184+
static_cast<double>(static_cast<size_t>(data_length)),
185+
tensor_data,
186+
static_cast<int32_t>(load_type));
187+
#else
30188
auto err_code = EM_ASM_INT(({
31189
// If available, "Module.MountedFiles" is a Map for all preloaded files.
32190
if (typeof Module == 'undefined' || !Module.MountedFiles) {
@@ -80,6 +238,7 @@ common::Status LoadWebAssemblyExternalData(const Env& env,
80238
static_cast<int32_t>(data_length),
81239
tensor_data,
82240
static_cast<int32_t>(load_type));
241+
#endif
83242
const char* err_msg;
84243
switch (err_code) {
85244
case 0:
@@ -93,6 +252,11 @@ common::Status LoadWebAssemblyExternalData(const Env& env,
93252
case 3:
94253
err_msg = "Out of bounds.";
95254
break;
255+
#if defined(ORT_WASM_JSPI)
256+
case 5:
257+
err_msg = "Reading from the Blob returned an unexpected number of bytes.";
258+
break;
259+
#endif
96260
default:
97261
err_msg = "Unknown error occurred in memory copy.";
98262
}

onnxruntime/wasm/pre.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* Mount external data files of a model to an internal map, which will be used during session initialization.
1010
*
1111
* @param {string} externalDataFilesPath
12-
* @param {Uint8Array} externalDataFilesData
12+
* @param {Uint8Array|Blob} externalDataFilesData
1313
*/
1414
Module["mountExternalData"] = (externalDataFilePath, externalDataFileData) => {
1515
if (externalDataFilePath.startsWith("./")) {
@@ -24,6 +24,10 @@ Module["mountExternalData"] = (externalDataFilePath, externalDataFileData) => {
2424
*/
2525
Module["unmountExternalData"] = () => {
2626
delete Module.MountedFiles;
27+
// Release the buffers used by the Blob-backed external data loader
28+
delete Module.ortExtDataScratch;
29+
delete Module.ortExtDataChunk;
30+
delete Module.ortExtDataScratchBusy;
2731
};
2832

2933
/**

0 commit comments

Comments
 (0)