Skip to content

perf: resolve native symbols inside lean.wasm instead of exporting all of Lean - #16149

Draft
basvandijk wants to merge 18 commits into
leanprover:masterfrom
basvandijk:wasm-symbols
Draft

basvandijk wants to merge 18 commits into
leanprover:masterfrom
basvandijk:wasm-symbols

Conversation

@basvandijk

@basvandijk basvandijk commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

This PR makes lean.js, the WebAssembly build of lean, start about 50 times faster (5 s instead of 236 s on an empty file on a workstation), shrinks its JavaScript file from 113 MB to 201 KB and cuts the memory of a lean process by about two thirds.

Background

The interpreter switches to native code by looking up a declaration's mangled symbol at run time, which is why lean.wasm exported every symbol of Lean (247,834 of them) and why lean.js was linked with -sMAIN_MODULE=1 -sEXPORT_ALL=1. Emscripten's dlsym enumerates all exports of the module on every call (Object.keys(lib.exports).indexOf(symbol), emscripten-core/emscripten#28124), about 75 ms per call with that many exports, and lean performs one lookup per imported module and two per imported parser constant at startup, 2,630 for Init alone; that was most of the time a test took on the Web Assembly CI job.

Changes

  • The new script script/wasm_symbols.py now reads the global symbols of the Lean archives that lean.js links (libInit.a, libStd.a, libLean.a) from their wasm object files, together with each function's type, and writes a sorted name/address table with a binary-search lookup, lean_lookup_native_symbol.
  • The table is about 28 MB of static data, so the link's initial memory is raised from Emscripten's default 16 MB to 64 MB.
  • lookup_symbol_in_cur_exe consults that new function on Emscripten instead of dlsym;
    only the symbols of the C and C++ runtime archives, which the script writes as the export list for the link (600 names), are still resolved through dlsym.
  • With that table embedded in the module, lean.js is linked with -sMAIN_MODULE=2, so unused code is removed and the JavaScript glue no longer declares a variable per export, which was three quarters of the 113 MB file;
  • doc/make/emscripten.md describes the symbol table and the export list.

Results

  • Threads start in 0.5 s instead of 5 s.
  • tests/elab/Format.lean peaks at 1.5 GB instead of 5.3 GB.

This PR is stacked on #16142 and carries its commits until that PR merges; it will be rebased onto master afterwards. Until then, the diff of this PR alone is basvandijk:wasm-emsdk-6...basvandijk:wasm-symbols.

Claude Code, an AI coding assistant, helped with the investigation, the changes, the testing and this description. I reviewed and checked them.

🤖 Generated with Claude Code

@basvandijk

Copy link
Copy Markdown
Contributor Author

How this was tested:

  • The Web Assembly job runs only at check level 2 or higher, so the CI evidence is a run of this branch's job on my fork: https://github.com/basvandijk/lean4/actions/runs/41286288809 (emsdk 6.0.13, Node.js 26.13.0, ccache warm from an earlier run): green, all steps done 31 min after the start; Setup emsdk 9 s, Build Stage 0 37 s, Build Target Stage 22.6 min, Install 5 s, Pack 5.8 min, Test 17 s (1037 2.30 s, Format 2.69 s, ac_rfl 3.24 s, libuv 2.54 s, tempfile 3.03 s, 1007 2.83 s; 6/6), source tree clean; lean.js 196 KB and lean.wasm 125 MB in the install tree; warnings: 0 linker setting ignored during compilation, 0 function signature mismatch, the same three Emscripten notes as before, 6 undefined symbols (the five lean_uv_* and dl_iterate_phdr; the eight uv__* internals of before are eliminated with the unused libuv code). An earlier run of the branch with the untyped table declarations described below, https://github.com/basvandijk/lean4/actions/runs/41256450142, was green in 54 min with the same timings (Test 28 s) but logged the 58 function signature mismatch warnings. Baseline for comparison: run 41233382394 of chore: update the Web Assembly CI job to Emscripten 6.0.13 #16142 (Build Target Stage 40.1 min, Install 7 s, Pack 8.4 min, Test 13.3 min, 6/6).
  • Local build in a Docker container mirroring the runner (ubuntu:24.04, clang 18.1.3, cmake 3.31.6, emsdk 6.0.13, Node.js 26.13.0) with the job's CMAKE_OPTIONS, on an AMD EPYC 7302 (slower per thread than the runner). Before, with the first commit (table, exports unchanged), and with all commits: lean -j4 on an empty file 236 s → 21 s → 5.0 s (three runs each, spread under 1 s); tests/elab/1037.lean 234 s → 20 s → 5.4 s; tests/elab/Format.lean 193 s and 5.3 GB → 27 s and 5.5 GB → 6.3 s and 1.5 GB; the six CI tests about 20 min → 2 min 42 s → 41 s, all passing, source tree clean, no default.profraw; lean --version 6.7 s → 7.1 s → 1.7 s; peak RSS of the empty-file run 2.5 GB → 2.6 GB → 1.0 GB. lean.js 113,180,992 B → 113,181,131 B → 200,852 B; lean.wasm 134,824,759 B → 163,278,667 B (the table is 28 MB of static data, hence -sINITIAL_MEMORY=64MB) → 130,973,589 B (dead-code elimination and the dropped export-name section outweigh the table); exports 247,834 → 247,835 → 629 (589 lean_*, 29 Emscripten runtime, calloc, pthread_self, segv_handler, three llvm_*, initialize_libuv, initialize_cadical); imports 410 → 410 → 74; the undefined symbols the link reports go from 14 to 6 (the eight uv__* internals are eliminated with the unused libuv code; the five lean_uv_* and dl_iterate_phdr remain, as before). Lean's --profile on the empty file: import 57.1 s → 2.1 s, interpretation 161 s → 10 ms, initialization 351 ms unchanged.
  • Where the time went, measured by wrapping _dlsym_js in a copy of the generated lean.js: the baseline empty-file run makes 2,630 dlsym calls, 678 for the module initializers (initialize_Init_*), 502 for l_*___boxed symbols of imported parser constants and 725 pairs of a ___boxed miss followed by the unboxed hit, each 65-130 ms (median 75 ms), 210 s in total out of 236 s; with the table the same run makes 0 dlsym calls. A standalone main module with N exported functions shows the per-call cost growing from 0.5 ms at 1,000 exports to 80 ms at 250,000 (0.016 ms natively); reported at dlsym enumerates every export of the library on each call (Object.keys(lib.exports).indexOf) emscripten-core/emscripten#28124, fix proposed in Make dlsym O(1) in the number of exports. NFC emscripten-core/emscripten#28125. Loader phases per thread, before → after: parsing the glue and compiling the wasm 1.4 s → 0.8 s on the main thread, mergeLibSymbols 0.7 s → 0.02 s, updateGOT 2.5 s → 0.4 s, main entered at 5.1 s → 1.2 s, each pthread worker ready about 5 s → 0.5 s after it is spawned; Lean's first symbol lookup happened at 18.5 s, now its whole work runs from 2.7 s to 4.8 s.
  • Interpreter probes on the final build: #eval Nat.gcd 12 18 (native ___boxed symbol reached from interpreted code) → 6; functions with scalar parameters and a constant (Nat.add 40 2, UInt8.ofNat 300, 2.5 + 0.25) → (42, 44, 2.750000); a macro defined in the file → (21, 21); an interpreted closure calling native code on a task thread (IO.asTask) → task 6; import Lean and Lean.Meta.whnf from #eval → whnf: 1 (12 s, 2.7 GB, 2,437 modules; 32 s and 6.6 GB with the table alone). Exit codes of IO.Process.exit 3, a failing file, a missing file, a kernel exception and panic! as before; --version, --run and --print-prefix still return the 32-bit garbage code of lean --version, --run and other options exit with a garbage code on 32-bit targets #16136; the LEAN_PATH and cwd controls of chore: update the Web Assembly CI job to Emscripten 6.0.13 #16142 behave as before, which also shows that the JavaScript helpers Lean's EM_ASM blocks use (ENV, FS, NODEFS, UTF8ToString, stringToUTF8, _malloc) are still present in the dead-code-eliminated link; lean -o still fails on dl_iterate_phdr (pre-existing), so an initialize in an imported uncompiled module could not be probed on wasm.
  • Generator checks: the table holds every global symbol of libInit.a, libStd.a and libLean.a (239,781 entries: 231,036 functions, 8,745 data), the export list every global symbol of the four runtime archives that is a C identifier and not C++-mangled or Emscripten metadata (600 names, none of them l_*); the table object's undefined symbols are exactly the Lean archives' symbols plus strcmp. The declarations carry the functions' wasm types read from the archives' object files (type, import, function and linking sections), so they match the definitions exactly and the link reports no signature mismatch; the script cross-checks the symbols it reads against llvm-nm's listing and fails on any difference. An earlier version declared every function as void f(void) and relied on wasm-ld ignoring prototypes for address-only references; the table it produced was correct (every one of the 231,036 entries, resolved through lean_lookup_native_symbol in the running module, had its real type, checked with WebAssembly.Function.type()), but the link warned function signature mismatch for the 58 functions that the C++ runtime calls directly, which is why the types are now exact. The generator takes 6 s and the table C file compiles in 18 s with 1.2 GB.
  • Controls: the first commit alone (table, EXPORT_ALL kept) attributes the startup gain to the lookup (236 s → 21 s, lean.js and per-thread loader time unchanged) and the second commit the size, per-thread and memory gains to the export list (21 s → 5 s, 113 MB → 201 KB, 2.6 GB → 1.0 GB); the baseline numbers above come from the same toolchain, host and day. A third variant, the unchanged baseline lean.js with only Emscripten's _dlsym_js patched to the O(1) form proposed upstream, starts in 20 s with its 2,630 dlsym calls taking 17 ms in total, so the table matches what a fixed Emscripten would give and the export list is the part a toolchain fix cannot provide. The generated export list of the first attempt contained __main_argc_argv, which emcc rejects as an export (it synthesizes main from it), hence its exclusion in the script.
  • Not tested: browser embedding; Emscripten versions other than 6.0.13; a module-based importer (two initializer lookups per module, both table hits).

@basvandijk

Copy link
Copy Markdown
Contributor Author

changelog-no

@github-actions github-actions Bot added the changelog-no Do not include this PR in the release changelog label Nov 1, 2026
@github-actions github-actions Bot added the toolchain-available A toolchain is available for this PR, at leanprover/lean4-pr-releases:pr-release-NNNN label Nov 1, 2026
basvandijk and others added 17 commits November 2, 2026 11:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t `lean.js` links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Emscripten the shared libraries are dummies: `lean.js` links the static archives (`LEAN_EXE_LINKER_FLAGS`), and `src/CMakeLists.txt` only touches `libInit_shared.so`, `libleanshared_1.so`, `libleanshared_2.so` and `libleanshared.so`. The `lean` rule in `stdlib.make` still depended on them, so once per build, after `lib/temp/empty.c` had been created, `make` ran their recipes, that is `emcc -shared`, which Emscripten before 6.0 emulated with a static object file and a warning and which Emscripten 6 turns into a real side-module link of all of Lean. And because the dummies are touched on every build, `make install` relinked `lean.js`, nine of the Web Assembly job's sixty minutes.

`lean.js` now depends on `libInit.a`, `libStd.a`, `libLean.a`, `libleancpp.a`, `libleanrt.a`, `libleanshell.a` and `libleanmain.a`, with the Lake `.trace` files of the first three like the other binaries, and the dummy `leanshared` target depends on `leanrt`, which nothing ordered before `lean.js` so far. The shared-library recipes are no longer reachable on Emscripten; the native rule is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Since Emscripten 6.0.6, `emcc` links libc++ and libc++abi only when run as `em++` or with `-sDEFAULT_TO_CXX=1`, and it has always ignored `-lstdc++` for compatibility with GNU toolchains. `lean.js` is linked by `leanc.sh`, that is by `emcc`, with `ERROR_ON_UNDEFINED_SYMBOLS=0` and `MAIN_MODULE=1`, so on Emscripten 6 the link succeeds and `lean.js` fails when Node.js starts it. On Emscripten the C++ standard library flag is therefore `-sDEFAULT_TO_CXX=1`; before 6.0.6 the setting is on by default, so this changes nothing there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`-sALLOW_MEMORY_GROWTH=1` takes effect only when linking, but `EMSCRIPTEN_SETTINGS` passed it to every compilation as well, through `LEAN_EXTRA_CXX_FLAGS` and libuv's `CMAKE_C_FLAGS`, which emcc reports 147 times per build as `linker setting ignored during compilation`. The flags for compiling and linking, `-fwasm-exceptions -pthread -flto`, are now `EMSCRIPTEN_FLAGS` and the settings `EMSCRIPTEN_LINK_SETTINGS`; both go into `LEAN_EXTRA_LINKER_FLAGS`, which `leanc.sh` adds to every link, so `LEAN_EXE_LINKER_FLAGS` does not repeat them. Settings use the `-sKEY=VALUE` spelling that Emscripten documents. `LINKABLE=1` is dropped: `MAIN_MODULE=1` implies it in every Emscripten version from 3.1.44 to 6.0.13, and Emscripten 4.0.15 deprecated it, so newer versions warn about setting it explicitly. The comment quoting Emscripten's old advice to pass all `-s` flags at compile time went with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Since Emscripten 4.0.11 the `libclang_rt.builtins` system library bundles LLVM's profiling runtime, and a main module (`-sMAIN_MODULE=1`) links its system libraries as whole archives, so the runtime's static initializer runs in every `lean.js` process and its `atexit` hook writes `default.profraw` into the current directory: 128 bytes after `lean --version`, 640 bytes after a test. That fails CI's check that the test suite leaves the source tree clean and would litter every user's working directory. `lean_main` now points the runtime at `/dev/null`. The symbol is weak, so Emscripten versions without the runtime, such as 3.1.44, link and behave as before. Reported upstream as emscripten-core/emscripten#28123.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Emscripten 3.1.44 is from July 2023; 6.0.13 is the current release. `setup-emsdk` installs it directly and keys its cache on the version. The previous commits cover the two changes in Emscripten 6 that affect this build: `-shared` builds real side modules, and `emcc` no longer links the C++ runtime by default. `lean.js` still runs on Node.js 26; emcc uses the Node.js bundled with the SDK for its own passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The page described the Lean 3 build (`emconfigure`, the `lean_js_js` and `lean_js_wasm` targets, lean-web-editor) and was linked from nowhere. It now describes the build as the Web Assembly CI job runs it: a native 32-bit stage 0 compiles the `.olean` files, Emscripten cross-compiles stage 1 into `lean.js` and `lean.wasm`, Node.js 24 or newer runs them through the `bin/lean` launcher, and the tests run one at a time. `ci.yml` and `build-template.yml` remain the authoritative source of the flags and the SDK version. `doc/make/index.md` links the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The interpreter switches to native code by looking up a declaration's mangled symbol at run time. In `lean.js` that lookup went through Emscripten's `dlsym`, which enumerates every export of the module on each call (`Object.keys(lib.exports).indexOf(symbol)` in `_dlsym_js`, Emscripten 6.0.13); with about 250,000 exports one lookup costs 65 to 130 ms, and `lean` performs one per imported module and two per imported parser constant at startup, 2,630 of them for `Init`, which is most of the time a test takes on the Web Assembly CI job.

`lean.js` now carries its own symbol table. `script/wasm_symbols.py` reads the global symbols of the Lean archives that `lean.js` links (`libInit.a`, `libStd.a`, `libLean.a`) from their wasm object files, which also give each function's type, and writes a sorted name/address table with a binary-search lookup, `lean_lookup_native_symbol`, which `lookup_symbol_in_cur_exe` consults on Emscripten; since the table holds every symbol of those archives, only the runtime's symbols are still looked up with `dlsym`. `stdlib.make` builds the table and links it into `lean.js`. It is about 28 MB of static data (the names alone are 25 MB), so the link's initial memory is raised from Emscripten's default 16 MB to 64 MB. The script also writes the list of the runtime archives' global symbols, which the next commit uses as the export list.

On an AMD EPYC 7302 with Emscripten 6.0.13 and Node.js 26, `lean` on an empty file takes 21 s instead of 236 s (210 s of which were the 2,630 `dlsym` calls; now there are none), `tests/elab/Format.lean` 27 s instead of 193 s, and the six tests of the CI job 2 m 42 s instead of about 20 m. Lean's own profiler reports import 2.1 s instead of 57 s and interpretation 10 ms instead of 161 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With the interpreter's symbol table inside the module, `lean.wasm` no longer has to export all of Lean for `dlsym`. `lean.js` is now linked with `-sMAIN_MODULE=2` and the export list that `script/wasm_symbols.py` writes, the global symbols of the C and C++ runtime archives (600 names instead of 247,834 exports), so Emscripten removes unused code and the JavaScript glue no longer declares a variable per export, which was 76% of the 113 MB `lean.js`. Every pthread is a Node.js worker that loads that file, so each thread now starts in 0.5 s instead of 5 s and a `lean` process needs a third of the memory. Such a module includes only the JavaScript library functions the linked code references, so the helpers that Lean's `EM_ASM` blocks use are requested with `EXPORTED_RUNTIME_METHODS`. The Node.js heap override for emcc's JavaScript passes and the two `TODO(WN)` items in `src/CMakeLists.txt` about dead-code elimination and unused system libraries are obsolete and removed, and the comment on the CI job's Node.js step no longer cites the export count.

On an AMD EPYC 7302 with Emscripten 6.0.13 and Node.js 26: `lean.js` is 201 KB instead of 113 MB, `lean.wasm` 131 MB instead of 163 MB with the table (135 MB before it), `lean` on an empty file takes 5.0 s instead of 21 s with the table alone (236 s before), `tests/elab/Format.lean` 6.3 s and 1.5 GB instead of 27 s and 5.5 GB (193 s and 5.3 GB before), and the six tests of the CI job 42 s instead of 2 m 42 s (about 20 m before). The eight `uv__*` symbols the link reported undefined disappear with the unused libuv code; the five `lean_uv_*` ones and `dl_iterate_phdr` remain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the sentences in `doc/make/emscripten.md` about `lean.wasm` exporting about 250,000 symbols, the Node.js version that requires, `-sMAIN_MODULE=1 -sEXPORT_ALL=1` and the Node.js heap limit of the link with how the interpreter finds compiled code in `lean.js`: the table that `script/wasm_symbols.py` generates from the Lean archives and the export list of the runtime archives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread script/wasm_symbols.py Resolved

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

changelog-no Do not include this PR in the release changelog toolchain-available A toolchain is available for this PR, at leanprover/lean4-pr-releases:pr-release-NNNN

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant