Repository navigation
Speed up MAIN_MODULE startup by up to 40% by creating GOT entries lazily - #27993
Open
rustyconover wants to merge 2 commits into
Open
rustyconover wants to merge 2 commits into
rustyconover wants to merge 2 commits into
Conversation
rustyconover
force-pushed
the
dylink-lazy-main-got
branch
from
October 16, 2026 17:07
52c46e3 to
5193fca
Compare
rustyconover
force-pushed
the
dylink-lazy-main-got
branch
from
October 17, 2026 15:17
5193fca to
6532c99
Compare
Contributor
Author
|
For a real-world data point: in DuckDB-WASM the main module exports about 48,000 C++ symbols, and before this change the GOT setup walked all of them in the main worker and again in every pthread worker. With this patch, the threaded build's time to its first query result dropped by 43%. Write-up with the full measurements: https://query.farm/blog/duckdb-wasm-startup-time/ |
rustyconover
force-pushed
the
dylink-lazy-main-got
branch
2 times, most recently
from
October 19, 2026 20:38
6e878e3 to
c73ee6f
Compare
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 emscripten-core#17891. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rustyconover
force-pushed
the
dylink-lazy-main-got
branch
from
October 24, 2026 17:41
d8028a6 to
8e46b6a
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This makes startup of
MAIN_MODULEprograms with many exports much faster. The main module's GOT entries and table slots are now created when a side module first needs them, instead of all at startup.What the GOT is. In emscripten's dynamic linking, a side module reaches functions and data defined in other modules through the Global Offset Table (GOT). The GOT is a table of addresses that the loader (
libdylink.js) fills in at run time.For each such symbol, the side module imports a mutable
WebAssembly.Global:GOT.func, holding the function's index in the shared function table (so the function needs a table slot), orGOT.mem, holding the data's address in linear memory.Today the loader creates a GOT entry for every export of the main module at startup. That means a table slot for every exported function, even though a side module will only ever ask for a small fraction of them.
Impact
DuckDB-wasm. DuckDB-wasm builds its engine with
MAIN_MODULE=1so that extensions can bedlopened against it, and the engine exports about 48k symbols. We ship the same change as a post-link patch in Haybarn, a DuckDB-wasm fork: Query-farm-haybarn/haybarn-wasm#26,scripts/patch_dylink_glue.cjs. It was released in@haybarn/haybarn-wasm@1.5.6-rc3. Measured with the same wasm, only the glue JS differs:On an M3, the dynamic-linking glue was the largest single part of DuckDB-wasm's cold start: ~90 ms of ~283 ms.
updateGOT(origExports)took 44 ms, because it creates ~48.7kWebAssembly.Globals and callsaddFunction~24.8k times, which grows the table one slot at a time (#17891).reportUndefinedSymbolsthen took another 17 ms to walk the resulting GOT. The pthreads build repeats all of this in every worker, which is why it gains the most.The Haybarn version was validated against all 163 published DuckDB extensions (a fresh-process
LOADof each, with identical results), real queries through icu/spatial/parquet/inet/fts, and browsers with pthreads. (The Haybarn numbers come from the full patch; this PR is its lazy-GOT part, which accounts for ~61 of those ~90 ms.)A standalone benchmark reproduces it without DuckDB: a main module with 40,000
EMSCRIPTEN_KEEPALIVEfunctions (-O2 -sMAIN_MODULE=2 -sNO_AUTOLOAD_DYLIBS -sNODERAWFS), plus a tiny side module loaded withdlopenthat callsf7and takes&f8. The wasm is identical between the builds. Node 24 on aarch64,taskset -c 28-35, 30 interleaved fresh processes:mainmain()(median, IQR)dlopen(median, IQR)The
dlopensaving comes fromreportUndefinedSymbolsandupdateGOTno longer walking a 40k-entry GOT.Change
initMainModuleGOT(exports)replacesupdateGOT(origExports)inpreamble.js. It keeps the main module's (unrelocated) exports inmainModuleExports. It only fills in the GOT entries that already exist at that point, which are the main module's ownGOT.mem/GOT.funcimports.addMainModuleGOTEntry(symName)creates or fills one entry from the main module's definition, by callingupdateGOT. The entry therefore gets exactly the value the eager path would have given it, includingisInternalSym, the!WASM_BIGINTorig$preference,to64/MEMORY64, and skipping non-pointer exports.GOTHandler.getcalls it before creating a new entry.updateGOT(exports, replace)withreplacefalse (side-module exports) first adds the main module's definition for any symbol that has no entry yet. The existing "keep the existing entry" logic then applies unchanged.Non-
MAIN_MODULEoutput is unaffected. Hello world at-O0,-O2and-Oz, with and without-pthread, is byte-identical.MAIN_MODULEJS grows by 150 bytes (71 gzipped), and the codesize expectations are rebaselined.Why it's safe
Precedence. The eager GOT only mattered because every main-module export already had an entry. So when a side module both defines a symbol and imports it through the GOT (ODR/linkonce duplicates such as vtables, typeinfo and inline functions),
updateGOT(moduleExports)kept the main module's definition. A naive lazy version lets the side module's copy win. This PR keeps the main module's definition in both cases:GOTHandler.getcreates the entry from the main module first.updateGOTadds the main module's definition before the side module's own.Threads. Tables aren't shared, so they stay identical across threads only if every thread performs the same table operations in the same order. Lazy main-module slots are only allocated in three places:
GOTHandler.get, during side-module instantiation;updateGOT/reportUndefinedSymbols, during side-module loading;dlsym, which already usesaddFunction.Side-module loads come from
loadDylibs, which uses the same list on every thread, or fromdlopen.dlopenevents, anddlsymevents that add a slot (viasymbolIndex→_dlsym_catchup_js), go into one ordereddleventlist indynlink.c, and every thread replays that list in order. So every lazy slot is created inside a replayed operation, in the same order, against the same GOT state.The existing
assert(wasmTable.length == tableBase)inloadModulewould catch any divergence. The main module's TLS GOT updates (registerTLSInit,replace=true) only touch data symbols, so they never allocate table slots.Testing
New test:
test_dylink_main_module_precedenceand_pthreads, intest_core.py.dup_func1/dup_data1/dup_func2/dup_data2and never takes their addresses.libside1.sodefines all four and takes the address ofdup_func1/dup_data1.libside2.sotakes the address ofdup_func2/dup_data2without defining them.dlsym(RTLD_DEFAULT, …).GOT.memimport still fails todlopen.The test passes on both
mainand this branch, as it checks behaviour that is preserved. It fails with a naive lazy GOT (libside1.so: func=2 data=2) and with aGOTHandler-only version (libside2.so: func=2 data=2).Dylink suites,
mainvs this branch (aarch64 Linux, node 24, emsdk tot). I rancore0|core2|wasm64|core_2gb×*dylink* *dlfcn* *dlopen* *dlsym* *main_module* *side_module*, plusother.with the same patterns and*MAIN_MODULE* *_so *shared_lib*:mainThe per-test status is identical between the two.
I also ran
core0|core2×*dylink* *dlfcn*pluscore0.*pthread_dl*with-sWASM_BIGINT=0and with--closure=1. The failures are identical onmainand this branch, and they come from those flags themselves. All*_i64*dylink tests pass withWASM_BIGINT=0.This addresses the startup cost discussed in #17891 (see this comment for the overall plan). For dylink startup it makes #23633 unnecessary, because the main module's exports no longer go through
addFunctionat startup. It doesn't replace #23633 for otheraddFunctionusers.🤖 Generated with Claude Code