Repository navigation
WASM: reduce import-time eager Numba compilation #930
Description
Note
Updated 2026-08-21. Added a Status section: PR #943 implements Option 1 but is stacked on #938 (now merged) and on the superseded #942, and the "measure first" gate has not been performed. Corrected the claim that comb_jit benefits from the persistent cache — per #944 and emscripten-forge/recipes#6309 its cache=True is currently part of a failing cached→cached chain on warm sessions. The original text is preserved in the edit history.
Note
Updated 2026-09-09. PR #943 is closed; #957 replaces it, opening @oyamad's implementation from the
wasm-930-dynamic-gufuncs branch (7c512ed) rebased onto main. #957 is deliberately "Part of #930"
rather than "Closes", because acceptance criterion 1 below is still unmet. The Status section is
updated accordingly.
Part of #925 (Phase 1). Measure first: the Phase 0 deployment (#928) was meant to produce cold/warm import-time benchmarks — if warm import lands under a couple of seconds, deprioritise this issue. As of 2026-08-21 that measurement has not been made; see Status below.
Problem
import quantecon triggers eager Numba compilation of five callables:
| Callable | Where | Why eager |
|---|---|---|
_probvec_parallel |
random/utilities.py#L94 | module-level guvectorize with explicit signature |
_probvec_cpu |
random/utilities.py#L98 | same |
sample_without_replacement gufunc |
random/utilities.py#L162 | same |
_ints_arr_to_bits |
vertex_enumeration.py#L305 | same |
comb_jit |
util/numba.py#L80 | eager @jit signature intp(intp, intp) |
Natively this costs milliseconds and nobody notices. In the browser, each eager compile is a full LLVM optimisation + WASM object emission + in-process LLD link + side-module load, and the emscripten-forge Numba patch 0007 force-disables cache=True for @guvectorize/@vectorize — so the four gufunc compiles are not amortised by the persistent cache and are paid every session, on the critical path of every notebook.
comb_jit is an ordinary dispatcher with cache=True, but on the emscripten-forge build that is currently a liability rather than an asset. Per #944 and emscripten-forge/recipes#6309, a cache=True caller that is a cache miss fails with RuntimeError: no compiled object yet when it links a cache=True callee restored from the persistent cache in the same session, and comb_jit is exactly such a callee for num_compositions_jit → simplex_grid and for k_array_rank_jit. The eager signature only makes comb_jit a guaranteed cache hit at import in every warm session, which is why the failure is deterministic today.
Options, in increasing order of ambition
- Convert the eagerly-signed gufuncs to lazy compilation — drop the explicit signature lists (dynamic gufuncs), or construct the parallel/cpu variants on first use inside
probvec. - Replace the small gufuncs with
@njit(cache=True)loop implementations exposed through@overload— converts them from never-cached to persistently cached on Emscripten. - Defer heavy submodule imports in
quantecon/__init__.pyvia module__getattr__(PEP 562) — also improves native import time.
Status (2026-08-21)
Implementation (updated 2026-09-09). PR #957 implements Option 1, taking @oyamad's wasm-930-dynamic-gufuncs branch (7c512ed, 23 Aug) rebased onto main: the two _probvec gufuncs keep their explicit signatures but are built by memoised factories on first call, while _sample_without_replacement and _ints_arr_to_bits become dynamic (signature-less) gufuncs with the output array supplied by the caller. It records the numba#10128 constraint that forces the explicit signature on the _probvec pair. Native warm-cache import quantecon measures 0.70 s on main against 0.65 s on the branch, best of 7; that is not the browser measurement criterion 1 asks for. Options 2 and 3 are untouched. PR #943 (@kp992) previously implemented Option 1 in its "construct on first use" form: the explicit gufunc signatures are retained, the four gufuncs are built lazily behind module-level sentinels on first call, and comb_jit becomes a lazy @jit(nopython=True, cache=True). Options 2 and 3 are untouched.
Rebase required before review. #943 is stacked on #938 (merged 2026-08-21, now on main) and on #942's commit 97518e0, so its diff still carries #942's int64 widening of comb_jit — the np.int64() casts and INT64_MAX, the docstrings in util/numba.py and _gridtools.py, the MAX_INT64/test_max_int64 renames in test_numba.py, and the smoke test test_simplex_grid_comb_int64 — and its body cites "the int64 arithmetic guarantees from #929". The rewritten #929 concluded that intp is intentional (the result is an array size) and #942 is being closed unmerged. #943 therefore has to be rebased onto main, dropping 97518e0 and all int64 residue and keeping a lazy @jit(nopython=True, cache=True) over the unchanged intp body; its "Depends on #942" line should be retracted.
The "measure first" gate is unmet. No JupyterLite import quantecon timing exists anywhere in #928, #938 or #943: #928's timing item is unchecked, #938's native test_import_time only asserts elapsed < 30 s, the browser harness test_import_quantecon is untimed, and the Emscripten runner is parked in #933. import quantecon does work in the xeus-python kernel (#944), so a manual measurement is feasible today. The before/after warm-cache import timing (acceptance criterion 1) is a pre-merge requirement for #943 unless a maintainer explicitly waives it in a comment here; merging #943 as written would auto-close this issue with that criterion unmet. The number can come from a manual JupyterLite run now, or from the #933 runner once the browser harness records cold and warm import times.
What Option 1 does and does not buy. Patch 0007 forces cache=False for every @vectorize/@guvectorize on Emscripten, so after #943 the four gufuncs are still never cached in the browser: the compile cost moves from import to first call but is paid once per session. Only Option 2 (@njit(cache=True) loops behind @overload) would make them persistently cached. Whether that is worth doing is what the measurement above should tell us.
Relation to #944. #943 changes the comb_jit decorator that #944 is about, but it does not fix #944. The upstream reproducer (emscripten-forge/recipes#6309) fails with two plain lazy @njit(cache=True) functions whenever a cache-miss caller links a cache-hit callee, so a lazy comb_jit restored from the persistent cache still breaks the first compile of num_compositions_jit/simplex_grid or k_array_rank_jit in that session. #943 narrows the exposure — the literal qe.simplex_grid(3, 4) reproducer passes in cold sessions and in warm sessions where the caller is also cached — but the failure remains whenever comb_jit's cache entry is a hit and the caller's is a miss. #944's decision (wait for upstream) stands, and #943 should not be cited as resolving it.
Acceptance criteria
- Warm-cache
import quantecontime in JupyterLite measured before and after; result recorded here (pre-merge requirement for WASM: defer eager Numba gufunc/jit compilation to first use (#930) #943 unless explicitly waived) - No public API change (
qe.random.probvec(..., parallel=...)keeps its signature)
Related
- WASM: defer Numba gufunc compilation to first call (#930) #957 — implements Option 1 from @oyamad's
wasm-930-dynamic-gufuncsbranch; open, still needs the import timing above - WASM: defer eager Numba gufunc/jit compilation to first use (#930) #943 — the earlier implementation of Option 1 (@kp992); closed 2026-09-09 in favour of WASM: defer Numba gufunc compilation to first call (#930) #957
- WASM: use int64 in comb_jit for consistent overflow guards on wasm32 #942 —
int64widening ofcomb_jit; superseded by WASM: audit 32-bit intp behaviour on wasm32 (overflow guards, simplex_index wrapping, dtypes) #929 and being closed unmerged; WASM: defer eager Numba gufunc/jit compilation to first use (#930) #943 currently carries its commit - WASM: audit 32-bit intp behaviour on wasm32 (overflow guards, simplex_index wrapping, dtypes) #929 —
intpincomb_jitis intentional; documentation-only - WASM: cache=True functions that link a cache-restored callee fail with "no compiled object yet" (simplex_grid → num_compositions_jit → comb_jit) #944 —
cache=Truecallers of cache-restored functions fail with "no compiled object yet" (reported upstream as Numba:RuntimeError: no compiled object yetemscripten-forge/recipes#6309); not addressed by WASM: defer eager Numba gufunc/jit compilation to first use (#930) #943 - WASM: JupyterLite proof-of-concept deployment and browser smoke suite #928 — Phase 0 results table; its cold/warm import-timing item feeds this issue and is still unchecked
- WASM: JupyterLite environment config and browser smoke suite #938 (merged) and WASM: add a WebAssembly/JupyterLite smoke-test job to CI #933 — browser smoke suite and its parked Emscripten runner; the natural home for a recorded import timing
Activity
WASM: defer eager Numba gufunc and jit compilation to first use
WASM: defer eager Numba gufunc compilation to first use
oyamad commentedon Aug 30, 2026
Comment from Claude Code Fable 5 (with minor edits):
A constraint on Option 1 found while implementing it: an explicit-signature gufunc and a dynamic (signature-less) gufunc created from the same kernel function, both with
cache=True, segfault at the dynamic one's first call — the two paths share a wrapper-cache entry whose ABI tag can mismatch. Reproducer and analysis posted at numba/numba#10128 (comment: numba/numba#10128 (comment)).Consequence for this issue:
_probvec_cpucannot be made dynamic while_probvec_parallelkeeps its explicit signature (required by the parallel target) on the shared_probveckernel._ints_arr_to_bitsand_sample_without_replacementhave no such sharing and can be dynamic. Until a numba release ships the fix (numba/numba#10545), the safe form of Option 1 is: dynamic gufuncs for those two, and lazily constructed explicit-signature gufuncs for bothprobvecvariants.🤖 Generated with Claude Code (Claude Fable 5)
WASM: defer Numba gufunc compilation to first call (#930) (#957)