From f26dcf279d16c8cb059568058c59228ff4e71158 Mon Sep 17 00:00:00 2001 From: Rusty Conover Date: Fri, 16 Oct 2026 16:58:39 +0000 Subject: [PATCH 1/2] Create GOT entries for main module exports lazily In `MAIN_MODULE` builds we used to call `updateGOT` on all of the main module's exports at startup. This creates a `WebAssembly.Global` for every export and calls `addFunction` (a one-slot table grow) for every exported function, which is slow for programs with many exports. For example, a program with ~48k exports spends ~44ms (on an M3) doing this, and then `reportUndefinedSymbols` spends another ~17ms iterating over the resulting GOT. In threaded builds this is repeated by every worker. Instead, we now keep a reference to the main module's exports and only fill in the GOT entries that the main module itself imports. All other entries are created on demand, using the main module's definition, the first time they are needed: - `GOTHandler.get` (i.e. when a side module imports a symbol via GOT.mem/GOT.func), and - `updateGOT` for a side module, before adding the side module's own definition of a symbol that has no GOT entry yet. The second case preserves the existing precedence rules: when the main module and a side module both define a symbol (e.g. ODR/linkonce duplicates such as vtables, typeinfo and inline functions), GOT entries continue to resolve to the main module's definition. Table slots for main module functions are now allocated in the middle of side module loading rather than all up front, but they are still only allocated during operations that are replayed in the same order on every thread (side module loads and `dlsym`), so tables stay in sync across threads. Addresses the startup cost discussed in #17891. Co-Authored-By: Claude Opus 5.5 (1M context) --- ChangeLog.md | 4 ++ src/lib/libdylink.js | 53 ++++++++++++++++++++++- src/preamble.js | 2 +- test/test_core.py | 101 +++++++++++++++++++++++++++++++++++++++++++ tools/link.py | 1 + 5 files changed, 158 insertions(+), 3 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index c2e9b8ac929fb..9eb327949642c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -20,6 +20,10 @@ See docs/process.md for more on how version tagging works. 6.0.14 (in development) ----------------------- +- In `MAIN_MODULE` builds the GOT entries (and table slots) for the main + module's exports are now created on demand, when first needed by a side + module, rather than all at startup. This can significantly reduce startup + time for programs with a large number of exports. (#27993) - When compiling and linking in a single command, auxiliary compiler output files (such as `.dwo` files from `-gsplit-dwarf` or `.json` files from `-ftime-trace`) are now named `-.`, matching the behavior diff --git a/src/lib/libdylink.js b/src/lib/libdylink.js index f4fe51f2fc3d9..59b5805afc2d8 100644 --- a/src/lib/libdylink.js +++ b/src/lib/libdylink.js @@ -159,9 +159,12 @@ var LibraryDylink = { // handler. We abuse the `target` of the Proxy in order to pass the set of // weak imports to the handler. $GOTHandler__internal: true, - $GOTHandler__deps: ['$GOT'], + $GOTHandler__deps: ['$GOT', '$addMainModuleGOTEntry'], $GOTHandler: { get(weakImports, symName) { + if (!GOT[symName]) { + addMainModuleGOTEntry(symName); + } var rtn = GOT[symName]; if (!rtn) { #if DYLINK_DEBUG == 2 @@ -210,8 +213,49 @@ var LibraryDylink = { ; }, + // The main module's (unrelocated) exports. Rather than creating GOT entries + // for all of these on startup, which is slow for programs with many exports + // since each exported function needs its own table slot, we create them on + // demand using `addMainModuleGOTEntry`. + $mainModuleExports: null, + + // Called once the main module has been instantiated. Only the GOT entries + // that the main module itself imports exist at this point, so these are the + // only ones we need to fill in. + $initMainModuleGOT__internal: true, + $initMainModuleGOT__deps: ['$GOT', '$mainModuleExports', '$addMainModuleGOTEntry'], + $initMainModuleGOT: (exports) => { + mainModuleExports = exports; + for (var [symName, entry] of Object.entries(GOT)) { + if (entry.value == {{{ UNDEFINED_ADDR }}}) { + addMainModuleGOTEntry(symName); + } + } + }, + + // Create (or fill in) the GOT entry for `symName` using the main module's + // definition, if it has one. This is called before any other definition of + // a symbol is added to the GOT, which gives the entry exactly the value it + // would have had if we had created GOT entries for all of the main module's + // exports on startup. In particular this means that the main module's + // definition of a symbol takes precedence over any side module definition. + $addMainModuleGOTEntry__internal: true, + $addMainModuleGOTEntry__deps: ['$mainModuleExports', '$updateGOT'], + $addMainModuleGOTEntry: (symName) => { +#if !WASM_BIGINT + // Prefer the version of the symbol without i64 legalization (see updateGOT). + if (mainModuleExports?.['orig$' + symName]) { + symName = 'orig$' + symName; + } +#endif + var value = mainModuleExports?.[symName]; + if (value) { + updateGOT({[symName]: value}, /*replace=*/true); + } + }, + $updateGOT__internal: true, - $updateGOT__deps: ['$GOT', '$isInternalSym', '$addFunction'], + $updateGOT__deps: ['$GOT', '$isInternalSym', '$addFunction', '$addMainModuleGOTEntry'], $updateGOT__docs: '/** @param {boolean=} replace */', $updateGOT: (exports, replace) => { #if DYLINK_DEBUG @@ -230,6 +274,11 @@ var LibraryDylink = { } #endif + if (!replace && !GOT[symName]) { + // The main module's definition, if any, takes precedence. + addMainModuleGOTEntry(symName); + } + var existingEntry = GOT[symName] && GOT[symName].value != {{{ UNDEFINED_ADDR }}}; if (replace || !existingEntry) { #if DYLINK_DEBUG == 2 diff --git a/src/preamble.js b/src/preamble.js index c6656a977b60f..170ff88550be2 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -798,7 +798,7 @@ function getWasmImports() { assignWasmExports(wasmExports); #if MAIN_MODULE - updateGOT(origExports); + initMainModuleGOT(origExports); #endif #if EXPORTED_RUNTIME_METHODS.has('wasmExports') diff --git a/test/test_core.py b/test/test_core.py index 7d7785b77b47c..c7b3f006c2e1f 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -5261,6 +5261,107 @@ def test_dylink_weak(self): def test_dylink_weak_undef(self): self.dylink_testf(test_file('core/test_dylink_weak_undef.c')) + @needs_dylink + @parameterized({ + '': ([],), + 'pthreads': (['-pthread', '-sEXIT_RUNTIME', '-sPROXY_TO_PTHREAD', '-Wno-experimental'],), + }) + def test_dylink_main_module_precedence(self, args): + # When the main module and a side module both define a symbol, the main + # module's definition takes precedence when the symbol is accessed via the + # GOT, both from the defining side module (libside1) and from a later side + # module (libside2). The main module never takes the address of these + # symbols itself, so it has no GOT entries for them on startup. + if args: + self.require_pthreads() + self.cflags += args + create_file('libside1.c', r''' + int dup_data1 = 2; + int dup_func1(void) { return 2; } + int dup_data2 = 2; + int dup_func2(void) { return 2; } + void* side1_get_func(void) { return (void*)&dup_func1; } + int* side1_get_data(void) { return &dup_data1; } + ''') + create_file('libside2.c', r''' + int dup_func2(void); + extern int dup_data2; + void* side2_get_func(void) { return (void*)&dup_func2; } + int* side2_get_data(void) { return &dup_data2; } + ''') + create_file('libmissing.c', r''' + extern int missing_data; + int* missing_get_data(void) { return &missing_data; } + ''') + create_file('main.c', r''' + #include + #include + #include + #include + #ifdef __EMSCRIPTEN_PTHREADS__ + #include + #endif + + typedef int (*func_t)(void); + + EMSCRIPTEN_KEEPALIVE int dup_data1 = 1; + EMSCRIPTEN_KEEPALIVE int dup_func1(void) { return 1; } + EMSCRIPTEN_KEEPALIVE int dup_data2 = 1; + EMSCRIPTEN_KEEPALIVE int dup_func2(void) { return 1; } + + void* thread_main(void* arg) { + printf("thread: %d\n", ((func_t)arg)()); + return NULL; + } + + void check(const char* lib_name, const char* get_func, const char* get_data, + const char* func_name, const char* data_name) { + void* lib = dlopen(lib_name, RTLD_NOW); + assert(lib); + func_t f = ((func_t(*)(void))dlsym(lib, get_func))(); + int* d = ((int*(*)(void))dlsym(lib, get_data))(); + printf("%s: func=%d data=%d\n", lib_name, f(), *d); + assert(f == (func_t)dlsym(RTLD_DEFAULT, func_name)); + assert(d == (int*)dlsym(RTLD_DEFAULT, data_name)); + #ifdef __EMSCRIPTEN_PTHREADS__ + // Function pointers must be the same on a newly started thread. + pthread_t t; + pthread_create(&t, NULL, thread_main, f); + pthread_join(t, NULL); + #endif + } + + int main() { + check("libside1.so", "side1_get_func", "side1_get_data", "dup_func1", "dup_data1"); + check("libside2.so", "side2_get_func", "side2_get_data", "dup_func2", "dup_data2"); + printf("done\n"); + return 0; + } + ''') + create_file('missing.c', r''' + #include + #include + int main() { + printf("libmissing: %s\n", dlopen("libmissing.so", RTLD_NOW) ? "loaded" : "failed"); + return 0; + } + ''') + self.build_dlfcn_lib('libside1.c', outfile='libside1.so') + self.build_dlfcn_lib('libside2.c', outfile='libside2.so') + self.build_dlfcn_lib('libmissing.c', outfile='libmissing.so') + self.prep_dlfcn_main(libs=['libside1.so', 'libside2.so']) + expected = 'libside1.so: func=1 data=1\n' + if args: + expected += 'thread: 1\n' + expected += 'libside2.so: func=1 data=1\n' + if args: + expected += 'thread: 1\n' + expected += 'done\n' + self.do_runf('main.c', expected) + + # A missing (non-weak) GOT import is still an error. + self.do_runf('missing.c', 'libmissing: failed\n') + @needs_dylink def test_dylink_weak_multilib(self): create_file('liba.c', ''' diff --git a/tools/link.py b/tools/link.py index 062816b97464c..4daf5befb66d6 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1304,6 +1304,7 @@ def limit_incoming_module_api(): '$reportUndefinedSymbols', '$relocateExports', '$GOTHandler', + '$initMainModuleGOT', ] # shared modules need memory utilities to allocate their memory settings.ALLOW_TABLE_GROWTH = 1 From 8e46b6a5606dd96f1eaf936ac553d1a2e4443394 Mon Sep 17 00:00:00 2001 From: Rusty Conover Date: Sat, 24 Oct 2026 13:41:47 -0400 Subject: [PATCH 2/2] Update codesize expectations after rebasing on main --- test/codesize/test_codesize_hello_dylink.json | 8 ++++---- test/codesize/test_codesize_hello_dylink_all.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/codesize/test_codesize_hello_dylink.json b/test/codesize/test_codesize_hello_dylink.json index 15ca5b6de0401..eac1e1fc570ee 100644 --- a/test/codesize/test_codesize_hello_dylink.json +++ b/test/codesize/test_codesize_hello_dylink.json @@ -1,10 +1,10 @@ { - "a.out.js": 26229, - "a.out.js.gz": 11137, + "a.out.js": 26379, + "a.out.js.gz": 11224, "a.out.nodebug.wasm": 16986, "a.out.nodebug.wasm.gz": 8667, - "total": 43215, - "total_gz": 19804, + "total": 43365, + "total_gz": 19891, "sent": [ "__syscall_stat64", "emscripten_resize_heap", diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 4d4e9e1e31f10..76f20d6118a92 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { - "a.out.js": 272704, + "a.out.js": 272855, "a.out.nodebug.wasm": 589443, - "total": 862147, + "total": 862298, "sent": [ "IMG_Init", "IMG_Load",