From a7ef28e4dbdf37c73284e60aef5383ff0220e2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 29 Oct 2026 23:04:09 +0100 Subject: [PATCH 1/5] Don't walk the main module's table in pthread workers The first call to getFunctionAddress walks the whole table, which creates a JS function object for every entry. With MAIN_MODULE and pthreads, every worker does this when it instantiates the main module, and keeps those objects for its lifetime: about 8 MiB per worker for a 50k-entry table. The dynamic linker only looks up the main module's exported functions, so the main thread now records their table slots and passes them to workers, which add just those to the map. Co-Authored-By: Claude Opus 5.5 --- ChangeLog.md | 4 ++++ src/lib/libaddfunction.js | 12 ++++++++++++ src/lib/libdylink.js | 18 ++++++++++++++++++ src/lib/libpthread.js | 2 ++ src/preamble.js | 5 +++++ src/runtime_pthread.js | 7 +++++++ test/core/pthread/test_pthread_dylink.c | 3 +++ test/core/pthread/test_pthread_dylink.h | 3 +++ test/core/pthread/test_pthread_dylink_side.c | 4 ++++ tools/link.py | 2 ++ 10 files changed, 60 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index 92dd03ef909bd..309e61a5cb805 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -71,6 +71,10 @@ See docs/process.md for more on how version tagging works. - `MIN_FIREFOX_VERSION`: 79 (Jul 2020) -> 92 (Sep 2021) - `MIN_SAFARI_VERSION`: 15.0 (Sep 2021) -> 15.4 (Mar 2022) (#28141) +- In pthreads builds with `MAIN_MODULE`, workers no longer walk the main + module's whole function table, which created a JS function object for every + table entry in every worker. The main thread now passes them the table slots + of the main module's exported functions. 6.0.13 - 10/19/26 ----------------- diff --git a/src/lib/libaddfunction.js b/src/lib/libaddfunction.js index 4f772bc6bfeff..d7838350d4331 100644 --- a/src/lib/libaddfunction.js +++ b/src/lib/libaddfunction.js @@ -124,6 +124,18 @@ addToLibrary({ // First, create the map if this is the first use. if (!functionsInTableMap) { functionsInTableMap = new WeakMap(); +#if MAIN_MODULE && PTHREADS + // Walking the main module's part of the table would create a JS wrapper + // for every function in it, in every worker. The dynamic linker only + // looks up the main module's exported functions, so workers add just the + // slots of those, as recorded by the main thread. + if (ENVIRONMENT_IS_PTHREAD && mainModuleExportSlots) { + for (var slot of mainModuleExportSlots) { + functionsInTableMap.set(getWasmTableEntry(slot), slot); + } + updateTableMap(mainModuleTableSize, {{{ from64Expr('wasmTable.length') }}} - mainModuleTableSize); + } else +#endif updateTableMap(0, {{{ from64Expr('wasmTable.length') }}}); } return functionsInTableMap.get(func) || 0; diff --git a/src/lib/libdylink.js b/src/lib/libdylink.js index 0972538cecb8a..2d661bff4ac68 100644 --- a/src/lib/libdylink.js +++ b/src/lib/libdylink.js @@ -201,6 +201,24 @@ var LibraryDylink = { ; }, +#if PTHREADS + // Called on the main thread once the main module is instantiated. Records + // which table slots hold the main module's exported functions, which new + // workers use in getFunctionAddress. + $recordMainModuleExportSlots__internal: true, + $recordMainModuleExportSlots: (exports) => { + mainModuleTableSize = {{{ from64Expr('wasmTable.length') }}}; + mainModuleExportSlots = []; + for (var symName in exports) { + var value = exports[symName]; + var slot = typeof value === 'function' && getFunctionAddress(value); + if (slot) { + mainModuleExportSlots.push(slot); + } + } + }, +#endif + $updateGOT__internal: true, $updateGOT__docs: '/** @param {boolean=} replace */', $updateGOT: (exports, replace) => { diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index 5cfd2180ff438..d002b32c3e266 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -434,6 +434,8 @@ var LibraryPThread = { // Share all modules that have been loaded so far. New workers // won't start running threads until these are all loaded. sharedModules, + mainModuleTableSize, + mainModuleExportSlots, #endif #if ASSERTIONS workerID: worker.workerID, diff --git a/src/preamble.js b/src/preamble.js index 7f5d17868b610..f72be700f0e9a 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -798,6 +798,11 @@ function getWasmImports() { assignWasmExports(wasmExports); #if MAIN_MODULE +#if PTHREADS + if (!ENVIRONMENT_IS_PTHREAD) { + recordMainModuleExportSlots(origExports); + } +#endif updateGOT(origExports); #endif diff --git a/src/runtime_pthread.js b/src/runtime_pthread.js index 4f0841c60582f..b8cf44fe905c6 100644 --- a/src/runtime_pthread.js +++ b/src/runtime_pthread.js @@ -12,6 +12,11 @@ // Map of modules to be shared with new threads. This gets populated by the // main thread and shared with all new workers via the initial `load` message. var sharedModules = {}; + +// The main module's initial table size, and the table slots of its exported +// functions. Recorded by the main thread and shared with all new workers via +// the initial `load` message (see getFunctionAddress). +var mainModuleTableSize, mainModuleExportSlots; #endif var startWorker; @@ -67,6 +72,8 @@ if (ENVIRONMENT_IS_PTHREAD) { #if MAIN_MODULE dynamicLibraries = msgData.dynamicLibraries; sharedModules = msgData.sharedModules; + mainModuleTableSize = msgData.mainModuleTableSize; + mainModuleExportSlots = msgData.mainModuleExportSlots; #if RUNTIME_DEBUG dbg(`worker: received ${Object.keys(msgData.sharedModules).length} shared modules: ${Object.keys(msgData.sharedModules)}`); #endif diff --git a/test/core/pthread/test_pthread_dylink.c b/test/core/pthread/test_pthread_dylink.c index 6ab09b24cb07d..49a25adb3ca37 100644 --- a/test/core/pthread/test_pthread_dylink.c +++ b/test/core/pthread/test_pthread_dylink.c @@ -1,3 +1,4 @@ +#include #include #include #include "test_pthread_dylink.h" @@ -13,6 +14,8 @@ void print_addresses() { printf("main_module: &foo_side %p -> %d\n", &foo_side, foo_side); printf("main_module: &bar_main %p\n", &bar_main); printf("main_module: &bar_side %p\n", &bar_side); + // Each function has one address in every module and every thread. + assert(bar_main_addr_side() == &bar_main); } void* thread_main(void* arg) { diff --git a/test/core/pthread/test_pthread_dylink.h b/test/core/pthread/test_pthread_dylink.h index 2367131225ddc..7f17ab3ec0e79 100644 --- a/test/core/pthread/test_pthread_dylink.h +++ b/test/core/pthread/test_pthread_dylink.h @@ -5,3 +5,6 @@ int bar_main(); // Function defined in main int bar_side(); // Function defined in side void print_addresses_side(); + +typedef int (*bar_func)(); +bar_func bar_main_addr_side(); // &bar_main, as seen by side diff --git a/test/core/pthread/test_pthread_dylink_side.c b/test/core/pthread/test_pthread_dylink_side.c index cb33cb6614c76..80ab9d8461ba6 100644 --- a/test/core/pthread/test_pthread_dylink_side.c +++ b/test/core/pthread/test_pthread_dylink_side.c @@ -13,3 +13,7 @@ void print_addresses_side() { printf("side_module: &bar_main %p\n", &bar_main); printf("side_module: &bar_side %p\n", &bar_side); } + +bar_func bar_main_addr_side() { + return &bar_main; +} diff --git a/tools/link.py b/tools/link.py index c33dcf453753e..2eefd72b24d09 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1283,6 +1283,8 @@ def limit_incoming_module_api(): settings.INCLUDE_FULL_LIBRARY = 1 # Called from preamble.js once the main module is instantiated. settings.DEFAULT_LIBRARY_FUNCS_TO_INCLUDE += ['$loadDylibs'] + if settings.PTHREADS: + settings.DEFAULT_LIBRARY_FUNCS_TO_INCLUDE += ['$recordMainModuleExportSlots'] # `__stack_pointer` is exported for PIC side modules. `__heap_base` is used # by `$getMemory` in `libdylink.js` (cannot be in `$getMemory__deps` because # `wasm-ld` processes stub library dependencies before synthesizing optional From accf384011b1e5cf5ab18ed44cd91352bd194152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 29 Oct 2026 23:08:28 +0100 Subject: [PATCH 2/5] Add PR number to ChangeLog Co-Authored-By: Claude Opus 5.5 --- ChangeLog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChangeLog.md b/ChangeLog.md index 309e61a5cb805..117a2a81c72b9 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -74,7 +74,7 @@ See docs/process.md for more on how version tagging works. - In pthreads builds with `MAIN_MODULE`, workers no longer walk the main module's whole function table, which created a JS function object for every table entry in every worker. The main thread now passes them the table slots - of the main module's exported functions. + of the main module's exported functions. (#28105) 6.0.13 - 10/19/26 ----------------- From dd6a0ba5a3d19322a6326f0d641706e7d380fa2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Tue, 3 Nov 2026 10:19:10 +0000 Subject: [PATCH 3/5] Document the worker addFunction limitation and test with ASSERTIONS=2 Note in getFunctionAddress and the ChangeLog that a non-exported main-module function passed to addFunction in a worker now gets a new slot. Add an ASSERTIONS=2 variant of test_pthread_dylink, whose addFunction check catches export slots missing from a worker's map. Co-Authored-By: Claude Opus 5.5 --- ChangeLog.md | 5 ++++- src/lib/libaddfunction.js | 4 ++++ test/test_core.py | 11 ++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 117a2a81c72b9..9b3e5b0498343 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -74,7 +74,10 @@ See docs/process.md for more on how version tagging works. - In pthreads builds with `MAIN_MODULE`, workers no longer walk the main module's whole function table, which created a JS function object for every table entry in every worker. The main thread now passes them the table slots - of the main module's exported functions. (#28105) + of the main module's exported functions. As a result, calling `addFunction` + in a worker on a main-module function that is not exported (for example one + obtained with `getWasmTableEntry`) now allocates a new table slot instead of + returning its existing address. (#28105) 6.0.13 - 10/19/26 ----------------- diff --git a/src/lib/libaddfunction.js b/src/lib/libaddfunction.js index d7838350d4331..9ebb049e890e9 100644 --- a/src/lib/libaddfunction.js +++ b/src/lib/libaddfunction.js @@ -129,6 +129,10 @@ addToLibrary({ // for every function in it, in every worker. The dynamic linker only // looks up the main module's exported functions, so workers add just the // slots of those, as recorded by the main thread. + // As a consequence, a main-module function that is in the table but not + // exported is not found here, so addFunction in a worker gives it a new + // slot rather than the address it has on the main thread (ASSERTIONS=2 + // reports this in addFunction). if (ENVIRONMENT_IS_PTHREAD && mainModuleExportSlots) { for (var slot of mainModuleExportSlots) { functionsInTableMap.set(getWasmTableEntry(slot), slot); diff --git a/test/test_core.py b/test/test_core.py index 94d51b5273b9e..c08475008ba8f 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9355,7 +9355,16 @@ def test_pthread_dylink_basics(self): @needs_dylink @requires_pthreads - def test_pthread_dylink(self): + @parameterized({ + '': (False,), + # ASSERTIONS=2 makes addFunction check that every function in the table + # is also in functionsInTableMap, which catches missing export slots in + # workers. + 'assertions2': (True,), + }) + def test_pthread_dylink(self, assertions2): + if assertions2: + self.set_setting('ASSERTIONS', 2) self.cflags += ['-Wno-experimental', '-pthread'] main = test_file('core/pthread/test_pthread_dylink.c') From 3bc2eab53df00c8311c24ffd133d53efb54f2d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Sun, 8 Nov 2026 11:23:37 +0100 Subject: [PATCH 4/5] Seed workers' functionsInTableMap at instantiation Instead of special-casing workers in getFunctionAddress, fill their functionsInTableMap with the main module's export slots right before updateGOT. At that point the table only holds the main module, and side modules add their own slots when they are loaded, so workers no longer need the main module's table size. The main thread's recording and the worker's seeding are now one function, initFunctionsInTableMap. Drop the ASSERTIONS=2 variant of test_pthread_dylink: the &bar_main assertion already fails if workers don't get the export slots. Co-Authored-By: Claude Opus 5.5 --- src/lib/libaddfunction.js | 16 ---------------- src/lib/libdylink.js | 35 +++++++++++++++++++++++------------ src/lib/libpthread.js | 1 - src/preamble.js | 4 +--- src/runtime_pthread.js | 9 ++++----- test/test_core.py | 11 +---------- tools/link.py | 2 +- 7 files changed, 30 insertions(+), 48 deletions(-) diff --git a/src/lib/libaddfunction.js b/src/lib/libaddfunction.js index 9ebb049e890e9..4f772bc6bfeff 100644 --- a/src/lib/libaddfunction.js +++ b/src/lib/libaddfunction.js @@ -124,22 +124,6 @@ addToLibrary({ // First, create the map if this is the first use. if (!functionsInTableMap) { functionsInTableMap = new WeakMap(); -#if MAIN_MODULE && PTHREADS - // Walking the main module's part of the table would create a JS wrapper - // for every function in it, in every worker. The dynamic linker only - // looks up the main module's exported functions, so workers add just the - // slots of those, as recorded by the main thread. - // As a consequence, a main-module function that is in the table but not - // exported is not found here, so addFunction in a worker gives it a new - // slot rather than the address it has on the main thread (ASSERTIONS=2 - // reports this in addFunction). - if (ENVIRONMENT_IS_PTHREAD && mainModuleExportSlots) { - for (var slot of mainModuleExportSlots) { - functionsInTableMap.set(getWasmTableEntry(slot), slot); - } - updateTableMap(mainModuleTableSize, {{{ from64Expr('wasmTable.length') }}} - mainModuleTableSize); - } else -#endif updateTableMap(0, {{{ from64Expr('wasmTable.length') }}}); } return functionsInTableMap.get(func) || 0; diff --git a/src/lib/libdylink.js b/src/lib/libdylink.js index 2d661bff4ac68..38871cf542592 100644 --- a/src/lib/libdylink.js +++ b/src/lib/libdylink.js @@ -202,18 +202,29 @@ var LibraryDylink = { }, #if PTHREADS - // Called on the main thread once the main module is instantiated. Records - // which table slots hold the main module's exported functions, which new - // workers use in getFunctionAddress. - $recordMainModuleExportSlots__internal: true, - $recordMainModuleExportSlots: (exports) => { - mainModuleTableSize = {{{ from64Expr('wasmTable.length') }}}; - mainModuleExportSlots = []; - for (var symName in exports) { - var value = exports[symName]; - var slot = typeof value === 'function' && getFunctionAddress(value); - if (slot) { - mainModuleExportSlots.push(slot); + // Called once the main module is instantiated, before updateGOT. The first + // getFunctionAddress walks the whole table, creating a JS function object for + // every entry, which every worker would keep for its lifetime. The dynamic + // linker only looks up the main module's exported functions, so the main + // thread records their table slots and workers start functionsInTableMap + // with just those. Side modules add their own slots when they are loaded. + // As a consequence, addFunction in a worker on a main-module function that + // is in the table but not exported gives it a new slot (ASSERTIONS=2 reports + // this). + $initFunctionsInTableMap__internal: true, + $initFunctionsInTableMap: (exports) => { + if (!ENVIRONMENT_IS_PTHREAD) { + mainModuleExportSlots = []; + for (var value of Object.values(exports)) { + var addr = typeof value == 'function' && getFunctionAddress(value); + if (addr) { + mainModuleExportSlots.push(addr); + } + } + } else if (mainModuleExportSlots) { + functionsInTableMap = new WeakMap(); + for (var slot of mainModuleExportSlots) { + functionsInTableMap.set(getWasmTableEntry(slot), slot); } } }, diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index d002b32c3e266..05cedb74d3c32 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -434,7 +434,6 @@ var LibraryPThread = { // Share all modules that have been loaded so far. New workers // won't start running threads until these are all loaded. sharedModules, - mainModuleTableSize, mainModuleExportSlots, #endif #if ASSERTIONS diff --git a/src/preamble.js b/src/preamble.js index f72be700f0e9a..3a996c8dc179c 100644 --- a/src/preamble.js +++ b/src/preamble.js @@ -799,9 +799,7 @@ function getWasmImports() { #if MAIN_MODULE #if PTHREADS - if (!ENVIRONMENT_IS_PTHREAD) { - recordMainModuleExportSlots(origExports); - } + initFunctionsInTableMap(origExports); #endif updateGOT(origExports); #endif diff --git a/src/runtime_pthread.js b/src/runtime_pthread.js index b8cf44fe905c6..61dfb80bd4384 100644 --- a/src/runtime_pthread.js +++ b/src/runtime_pthread.js @@ -13,10 +13,10 @@ // main thread and shared with all new workers via the initial `load` message. var sharedModules = {}; -// The main module's initial table size, and the table slots of its exported -// functions. Recorded by the main thread and shared with all new workers via -// the initial `load` message (see getFunctionAddress). -var mainModuleTableSize, mainModuleExportSlots; +// Table slots of the main module's exported functions. Recorded by the main +// thread and shared with all new workers via the initial `load` message (see +// initFunctionsInTableMap). +var mainModuleExportSlots; #endif var startWorker; @@ -72,7 +72,6 @@ if (ENVIRONMENT_IS_PTHREAD) { #if MAIN_MODULE dynamicLibraries = msgData.dynamicLibraries; sharedModules = msgData.sharedModules; - mainModuleTableSize = msgData.mainModuleTableSize; mainModuleExportSlots = msgData.mainModuleExportSlots; #if RUNTIME_DEBUG dbg(`worker: received ${Object.keys(msgData.sharedModules).length} shared modules: ${Object.keys(msgData.sharedModules)}`); diff --git a/test/test_core.py b/test/test_core.py index c08475008ba8f..94d51b5273b9e 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9355,16 +9355,7 @@ def test_pthread_dylink_basics(self): @needs_dylink @requires_pthreads - @parameterized({ - '': (False,), - # ASSERTIONS=2 makes addFunction check that every function in the table - # is also in functionsInTableMap, which catches missing export slots in - # workers. - 'assertions2': (True,), - }) - def test_pthread_dylink(self, assertions2): - if assertions2: - self.set_setting('ASSERTIONS', 2) + def test_pthread_dylink(self): self.cflags += ['-Wno-experimental', '-pthread'] main = test_file('core/pthread/test_pthread_dylink.c') diff --git a/tools/link.py b/tools/link.py index 2eefd72b24d09..8643fc413306f 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1284,7 +1284,7 @@ def limit_incoming_module_api(): # Called from preamble.js once the main module is instantiated. settings.DEFAULT_LIBRARY_FUNCS_TO_INCLUDE += ['$loadDylibs'] if settings.PTHREADS: - settings.DEFAULT_LIBRARY_FUNCS_TO_INCLUDE += ['$recordMainModuleExportSlots'] + settings.DEFAULT_LIBRARY_FUNCS_TO_INCLUDE += ['$initFunctionsInTableMap'] # `__stack_pointer` is exported for PIC side modules. `__heap_base` is used # by `$getMemory` in `libdylink.js` (cannot be in `$getMemory__deps` because # `wasm-ld` processes stub library dependencies before synthesizing optional From 0b491387c737824fe458f2a7d76b3f9e741c78df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Sun, 8 Nov 2026 11:24:58 +0100 Subject: [PATCH 5/5] Move ChangeLog entry to 7.0.1 Co-Authored-By: Claude Opus 5.5 --- ChangeLog.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 9b3e5b0498343..cb0db2c2048c3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -24,6 +24,13 @@ See docs/process.md for more on how version tagging works. `{{{ cDefs.FOO }}}`) are now exposed as `const` JavaScript library symbols (e.g., `ENOENT`, `O_CREAT`) and referenced directly in JS library code, making unminified output more readable. (#28144) +- In pthreads builds with `MAIN_MODULE`, workers no longer walk the main + module's whole function table, which created a JS function object for every + table entry in every worker. The main thread now passes them the table slots + of the main module's exported functions. As a result, calling `addFunction` + in a worker on a main-module function that is not exported (for example one + obtained with `getWasmTableEntry`) now allocates a new table slot instead of + returning its existing address. (#28105) 7.0.0 - 11/06/26 ---------------- @@ -71,13 +78,6 @@ See docs/process.md for more on how version tagging works. - `MIN_FIREFOX_VERSION`: 79 (Jul 2020) -> 92 (Sep 2021) - `MIN_SAFARI_VERSION`: 15.0 (Sep 2021) -> 15.4 (Mar 2022) (#28141) -- In pthreads builds with `MAIN_MODULE`, workers no longer walk the main - module's whole function table, which created a JS function object for every - table entry in every worker. The main thread now passes them the table slots - of the main module's exported functions. As a result, calling `addFunction` - in a worker on a main-module function that is not exported (for example one - obtained with `getWasmTableEntry`) now allocates a new table slot instead of - returning its existing address. (#28105) 6.0.13 - 10/19/26 -----------------