From d45888046d23d3989d226477047a4626f0b82755 Mon Sep 17 00:00:00 2001 From: Rusty Conover Date: Fri, 16 Oct 2026 17:22:40 +0000 Subject: [PATCH 1/3] Avoid copying all exports in applySignatureConversions When exports need wrapping for pointer conversions (`MEMORY64`, or `MAXIMUM_MEMORY` over 2GB) `applySignatureConversions` used to start by copying the entire exports object, and then replace the handful of exports that actually need wrappers. For programs with many exports (e.g. `MAIN_MODULE=1`, which exports everything) the copy dominates: with ~48k exports it takes ~11ms on an M3 (~39ms on an EC2 instance) in order to wrap ~350 functions. Instead, we now put just the wrapped exports on a new object, and set its prototype to the original exports object so that all other exports are inherited. The prototype has to be set after the wrappers are assigned: the original exports object is frozen, and assignment can't shadow an inherited read-only property. The original exports are read via a new `origExports` local, which the minify-import-export-names pass now also renames. The only consumer that enumerated the returned object was the `DECLARE_ASM_MODULE_EXPORTS=0` version of `assignWasmExports`, which used `Object.entries` (own properties only). It now uses `for...in`. When there is nothing to wrap the function now just returns its input. Co-Authored-By: Claude Opus 5.5 (1M context) --- ChangeLog.md | 7 ++++ src/lib/libcore.js | 6 ++- test/codesize/test_codesize_minimal_64.json | 8 ++-- test/test_other.py | 42 +++++++++++++++++++++ tools/acorn-optimizer.mjs | 7 ++-- tools/emscripten.py | 19 +++++++--- 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 436ab85c022ec..84475cb18373c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -26,6 +26,13 @@ See docs/process.md for more on how version tagging works. - The undocumented `FS.filesystems` object was removed. Replace things like `FS.filesystems['MEMFS']` with plain `MEMFS`. (#27940) - libpng port updated from 1.6.58 to 1.6.59. (#27956) +- When exports are wrapped for pointer conversions (`MEMORY64`, or + `MAXIMUM_MEMORY` over 2GB), the `wasmExports` object no longer contains a + copy of every export. Only the wrapped exports are its own properties and + the rest are inherited from the original `WebAssembly.Instance` exports. + This speeds up startup for programs with many exports (e.g. `MAIN_MODULE`). + Code that enumerates `wasmExports` should use `for...in` rather than + `Object.keys`/`Object.entries`. (#27996) 6.0.12 - 10/08/26 ----------------- diff --git a/src/lib/libcore.js b/src/lib/libcore.js index d280836d8a75b..ed8506a246d2c 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -1765,7 +1765,11 @@ addToLibrary({ #endif ], $assignWasmExports: (wasmExports) => { - for (var [name, exportedSymbol] of Object.entries(wasmExports)) { + // Use `for...in` rather than `Object.entries` here since, after + // `applySignatureConversions`, some exports are inherited rather than own + // properties. + for (var name in wasmExports) { + var exportedSymbol = wasmExports[name]; name = asmjsMangle(name); #if DYNCALLS || !WASM_BIGINT if (name.startsWith('dynCall_')) { diff --git a/test/codesize/test_codesize_minimal_64.json b/test/codesize/test_codesize_minimal_64.json index 77cbcc551baba..49e46883b1f10 100644 --- a/test/codesize/test_codesize_minimal_64.json +++ b/test/codesize/test_codesize_minimal_64.json @@ -1,10 +1,10 @@ { - "a.out.js": 2367, - "a.out.js.gz": 1157, + "a.out.js": 2379, + "a.out.js.gz": 1167, "a.out.nodebug.wasm": 75, "a.out.nodebug.wasm.gz": 88, - "total": 2442, - "total_gz": 1245, + "total": 2454, + "total_gz": 1255, "sent": [], "imports": [], "exports": [ diff --git a/test/test_other.py b/test/test_other.py index 951bc3a077587..eb8289acd0ad8 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13986,6 +13986,48 @@ def test_wasmfs_before_preload(self): def test_hello_world_above_2gb(self): self.do_runf_out_file('hello_world.c', cflags=['-sGLOBAL_BASE=2GB', '-sINITIAL_MEMORY=3GB']) + @parameterized({ + '': ([],), + # Minified export names + 'O3': (['-O3'],), + 'no_declare_asm_module_exports': (['-sDECLARE_ASM_MODULE_EXPORTS=0'],), + }) + def test_signature_conversions(self, args): + # Exports returning pointers get wrapped so that they return unsigned + # values when memory can be above 2GB. Check that the wrapped exports are + # used, and that all other exports are still present on `wasmExports` + # (including when enumerated). + create_file('main.c', r''' + #include + #include + + EMSCRIPTEN_KEEPALIVE void* get_high_ptr() { return (void*)(uintptr_t)0x90000000u; } + EMSCRIPTEN_KEEPALIVE int add_one(int x) { return x + 1; } + + int main() { + EM_ASM({ + out('_get_high_ptr: ' + _get_high_ptr()); + out('wasmExports.get_high_ptr: ' + wasmExports['get_high_ptr']()); + out('_add_one: ' + _add_one(41)); + out('wasmExports.add_one: ' + wasmExports['add_one'](41)); + var found = 0; + for (var name in wasmExports) { + if (wasmExports[name] === wasmExports['get_high_ptr']) found |= 1; + if (wasmExports[name] === wasmExports['add_one']) found |= 2; + } + out('enumerated: ' + found); + }); + } + ''') + expected = '''\ +_get_high_ptr: 2415919104 +wasmExports.get_high_ptr: 2415919104 +_add_one: 42 +wasmExports.add_one: 42 +enumerated: 3 +''' + self.do_runf('main.c', expected, cflags=['-sALLOW_MEMORY_GROWTH', '-sMAXIMUM_MEMORY=4GB', '-sSIGNATURE_CONVERSIONS=get_high_ptr:p'] + args) + def test_unistd_strerror(self): self.do_runf_out_file('unistd/strerror.c') diff --git a/tools/acorn-optimizer.mjs b/tools/acorn-optimizer.mjs index 0b759c0cd93e7..8c7f4dce8808f 100755 --- a/tools/acorn-optimizer.mjs +++ b/tools/acorn-optimizer.mjs @@ -461,14 +461,14 @@ function isExportSpecifierList(node) { ); } -function isExportUse(node) { +function isExportUse(node, objectName = 'wasmExports') { // Match usages of symbols on the `wasmExports` object. e.g: // wasmExports['X'] return ( node.type === 'MemberExpression' && node.object.type === 'Identifier' && isLiteralString(node.property) && - node.object.name === 'wasmExports' + node.object.name === objectName ); } @@ -511,7 +511,8 @@ function applyImportAndExportNameChanges(ast) { if (mapping[name]) { setLiteralValue(callee.property, mapping[name]); } - } else if (isExportUse(node)) { + } else if (isExportUse(node) || isExportUse(node, 'origExports')) { + // `origExports` holds the unwrapped exports in applySignatureConversions. const prop = node.property; const name = prop.value; if (mapping[name]) { diff --git a/tools/emscripten.py b/tools/emscripten.py index 013f7d0cb16f9..28e3eba9e05c4 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -1165,8 +1165,6 @@ def create_pointer_conversion_wrappers(metadata): // that it is recognised by metadce and minify-import-export-names // passes. function applySignatureConversions(wasmExports) { - // First, make a copy of the incoming exports object - wasmExports = Object.assign({}, wasmExports); ''' sigs_seen = set() @@ -1195,9 +1193,18 @@ def create_pointer_conversion_wrappers(metadata): sigs_seen.add(sig) wrap_functions.append(symbol) - for f in wrap_functions: - sig = native_sigs[f] - wrappers += f"\n wasmExports['{f}'] = makeWrapper_{sig}(wasmExports['{f}']);" - wrappers += '\n return wasmExports;\n}' + if wrap_functions: + # Rather than copying the entire exports object (which is slow when there + # are many exports) we put just the wrapped exports on a new object, which + # then inherits all the others from the original. The wrappers can't be + # assigned to an object that already inherits from the original exports + # object, since that is frozen, so we set the prototype afterwards. + wrappers += '\n var origExports = wasmExports;\n wasmExports = {};' + for f in wrap_functions: + sig = native_sigs[f] + wrappers += f"\n wasmExports['{f}'] = makeWrapper_{sig}(origExports['{f}']);" + wrappers += '\n return Object.setPrototypeOf(wasmExports, origExports);\n}' + else: + wrappers += '\n return wasmExports;\n}' return wrappers From 7d6ffc3a405587e6a40c37f605a9bed276b545bb Mon Sep 17 00:00:00 2001 From: Rusty Conover Date: Fri, 16 Oct 2026 15:14:40 -0400 Subject: [PATCH 2/3] Address review comments - `isExportUse` matches both `wasmExports` and `origExports`, so callers only check once. - Always end `applySignatureConversions` with `return wasmExports`, and set the prototype as a separate statement when wrapping. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/codesize/test_codesize_minimal_64.json | 8 ++++---- tools/acorn-optimizer.mjs | 8 ++++---- tools/emscripten.py | 5 ++--- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/test/codesize/test_codesize_minimal_64.json b/test/codesize/test_codesize_minimal_64.json index 49e46883b1f10..303adda359c96 100644 --- a/test/codesize/test_codesize_minimal_64.json +++ b/test/codesize/test_codesize_minimal_64.json @@ -1,10 +1,10 @@ { - "a.out.js": 2379, - "a.out.js.gz": 1167, + "a.out.js": 2381, + "a.out.js.gz": 1169, "a.out.nodebug.wasm": 75, "a.out.nodebug.wasm.gz": 88, - "total": 2454, - "total_gz": 1255, + "total": 2456, + "total_gz": 1257, "sent": [], "imports": [], "exports": [ diff --git a/tools/acorn-optimizer.mjs b/tools/acorn-optimizer.mjs index 8c7f4dce8808f..b084a54adb378 100755 --- a/tools/acorn-optimizer.mjs +++ b/tools/acorn-optimizer.mjs @@ -461,14 +461,15 @@ function isExportSpecifierList(node) { ); } -function isExportUse(node, objectName = 'wasmExports') { +function isExportUse(node) { // Match usages of symbols on the `wasmExports` object. e.g: // wasmExports['X'] + // `origExports` holds the unwrapped exports in applySignatureConversions. return ( node.type === 'MemberExpression' && node.object.type === 'Identifier' && isLiteralString(node.property) && - node.object.name === objectName + (node.object.name === 'wasmExports' || node.object.name === 'origExports') ); } @@ -511,8 +512,7 @@ function applyImportAndExportNameChanges(ast) { if (mapping[name]) { setLiteralValue(callee.property, mapping[name]); } - } else if (isExportUse(node) || isExportUse(node, 'origExports')) { - // `origExports` holds the unwrapped exports in applySignatureConversions. + } else if (isExportUse(node)) { const prop = node.property; const name = prop.value; if (mapping[name]) { diff --git a/tools/emscripten.py b/tools/emscripten.py index 28e3eba9e05c4..fbda905a0923f 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -1203,8 +1203,7 @@ def create_pointer_conversion_wrappers(metadata): for f in wrap_functions: sig = native_sigs[f] wrappers += f"\n wasmExports['{f}'] = makeWrapper_{sig}(origExports['{f}']);" - wrappers += '\n return Object.setPrototypeOf(wasmExports, origExports);\n}' - else: - wrappers += '\n return wasmExports;\n}' + wrappers += '\n Object.setPrototypeOf(wasmExports, origExports);' + wrappers += '\n return wasmExports;\n}' return wrappers From ffe122442b8675ec405478f7ec3d669022770b59 Mon Sep 17 00:00:00 2001 From: Rusty Conover Date: Fri, 16 Oct 2026 18:26:29 -0400 Subject: [PATCH 3/3] Remove redundant test variant test_no_declare_asm_module_exports already covers the libcore.js change under wasm64, wasm64_4gb and core_2gb. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/test_other.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_other.py b/test/test_other.py index eb8289acd0ad8..02e78bba36aec 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13990,7 +13990,6 @@ def test_hello_world_above_2gb(self): '': ([],), # Minified export names 'O3': (['-O3'],), - 'no_declare_asm_module_exports': (['-sDECLARE_ASM_MODULE_EXPORTS=0'],), }) def test_signature_conversions(self, args): # Exports returning pointers get wrapped so that they return unsigned