Skip to content

Speed up MAIN_MODULE startup by up to 40% by creating GOT entries lazily - #27993

Open
rustyconover wants to merge 2 commits into
emscripten-core:mainfrom
rustyconover:dylink-lazy-main-got
Open

rustyconover wants to merge 2 commits into
emscripten-core:mainfrom
rustyconover:dylink-lazy-main-got

Conversation

@rustyconover

@rustyconover rustyconover commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

This makes startup of MAIN_MODULE programs 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:

  • from GOT.func, holding the function's index in the shared function table (so the function needs a table slot), or
  • from GOT.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=1 so that extensions can be dlopened 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:

DuckDB-wasm (Haybarn) before after
Node: cold start → first query result 1,055 ms 640 ms −39%
Chromium, pthreads build: page load → first result 1,882 ms 1,065 ms −43%
Node on an M3, against the published previous release 323 ms 211 ms −35%

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.7k WebAssembly.Globals and calls addFunction ~24.8k times, which grows the table one slot at a time (#17891). reportUndefinedSymbols then 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 LOAD of 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_KEEPALIVE functions (-O2 -sMAIN_MODULE=2 -sNO_AUTOLOAD_DYLIBS -sNODERAWFS), plus a tiny side module loaded with dlopen that calls f7 and takes &f8. The wasm is identical between the builds. Node 24 on aarch64, taskset -c 28-35, 30 interleaved fresh processes:

main this PR
process start → main() (median, IQR) 516.0 ms [511.5, 524.7] 327.4 ms [323.8, 333.7]
dlopen (median, IQR) 43.6 ms [42.9, 44.5] 2.9 ms [2.87, 2.93]

The dlopen saving comes from reportUndefinedSymbols and updateGOT no longer walking a 40k-entry GOT.

Change

  • initMainModuleGOT(exports) replaces updateGOT(origExports) in preamble.js. It keeps the main module's (unrelocated) exports in mainModuleExports. It only fills in the GOT entries that already exist at that point, which are the main module's own GOT.mem/GOT.func imports.
  • addMainModuleGOTEntry(symName) creates or fills one entry from the main module's definition, by calling updateGOT. The entry therefore gets exactly the value the eager path would have given it, including isInternalSym, the !WASM_BIGINT orig$ preference, to64/MEMORY64, and skipping non-pointer exports.
  • GOTHandler.get calls it before creating a new entry.
  • updateGOT(exports, replace) with replace false (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_MODULE output is unaffected. Hello world at -O0, -O2 and -Oz, with and without -pthread, is byte-identical. MAIN_MODULE JS 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:

  1. The side module imports the symbol. GOTHandler.get creates the entry from the main module first.
  2. The side module defines the symbol without importing it, and a later module imports it. updateGOT adds 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 uses addFunction.

Side-module loads come from loadDylibs, which uses the same list on every thread, or from dlopen. dlopen events, and dlsym events that add a slot (via symbolIndex → _dlsym_catchup_js), go into one ordered dlevent list in dynlink.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) in loadModule would 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_precedence and _pthreads, in test_core.py.

  • The main module exports dup_func1/dup_data1/dup_func2/dup_data2 and never takes their addresses.
  • libside1.so defines all four and takes the address of dup_func1/dup_data1.
  • libside2.so takes the address of dup_func2/dup_data2 without defining them.
  • The test checks that both side modules see the main module's definitions: the call result, the data value, and pointer equality with dlsym(RTLD_DEFAULT, …).
  • The pthreads variant also calls the function pointer on a new thread.
  • A side module with a missing non-weak GOT.mem import still fails to dlopen.

The test passes on both main and this branch, as it checks behaviour that is preserved. It fails with a naive lazy GOT (libside1.so: func=2 data=2) and with a GOTHandler-only version (libside2.so: func=2 data=2).

Dylink suites, main vs this branch (aarch64 Linux, node 24, emsdk tot). I ran core0|core2|wasm64|core_2gb × *dylink* *dlfcn* *dlopen* *dlsym* *main_module* *side_module*, plus other. with the same patterns and *MAIN_MODULE* *_so *shared_lib*:

tests result
main 750 OK (33 skipped)
this branch 758 (750 + 8 new) OK (33 skipped)

The per-test status is identical between the two.

I also ran core0|core2 × *dylink* *dlfcn* plus core0.*pthread_dl* with -sWASM_BIGINT=0 and with --closure=1. The failures are identical on main and this branch, and they come from those flags themselves. All *_i64* dylink tests pass with WASM_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 addFunction at startup. It doesn't replace #23633 for other addFunction users.

🤖 Generated with Claude Code

@rustyconover rustyconover changed the title Create GOT entries for main module exports lazily Speed up MAIN_MODULE startup by up to 40% by creating GOT entries lazily Oct 16, 2026
@rustyconover

Copy link
Copy Markdown
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
rustyconover force-pushed the dylink-lazy-main-got branch 2 times, most recently from 6e878e3 to c73ee6f Compare October 19, 2026 20:38
Rusty Conover and others added 2 commits October 24, 2026 13:38
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant