QuantEcon / QuantEcon/QuantEcon.py
WASM: reduce import-time eager Numba compilation
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 2.4k
- Forks
- 2.3k
- Avg merge
- 3d 3h
- Merged PRs (30d)
- 3
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 thatcomb_jitbenefits from the persistent cache — per #944 and emscripten-forge/recipes#6309 itscache=Trueis 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-gufuncsbranch (7c512ed) rebased ontomain. #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 #943 unless explicitly waived) - No public API change (
qe.random.probvec(..., parallel=...)keeps its signature)
Related
- #957 — implements Option 1 from @oyamad's
wasm-930-dynamic-gufuncsbranch; open, still needs the import timing above - #943 — the earlier implementation of Option 1 (@kp992); closed 2026-09-09 in favour of #957
- #942 —
int64widening ofcomb_jit; superseded by #929 and being closed unmerged; #943 currently carries its commit - #929 —
intpincomb_jitis intentional; documentation-only - #944 —
cache=Truecallers of cache-restored functions fail with "no compiled object yet" (reported upstream as emscripten-forge/recipes#6309); not addressed by #943 - #928 — Phase 0 results table; its cold/warm import-timing item feeds this issue and is still unchecked
- #938 (merged) and #933 — browser smoke suite and its parked Emscripten runner; the natural home for a recorded import timing
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the eager Numba definitions in quantecon/random/utilities.py, quantecon/game_theory/vertex_enumeration.py, and quantecon/util/numba.py, then review PR #957 and the browser harness referenced in #933. First run or add the before/after warm-cache JupyterLite import timing; done requires recording that result and preserving the public probvec signature.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- jupyter-notebook, python, wasm
- Domain
- performance, testing
- Issue type
- Refactor
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100