Skip to content

WASM: reduce import-time eager Numba compilation #930

Description

@mmcky

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.

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_jitsimplex_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

  1. 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.
  2. Replace the small gufuncs with @njit(cache=True) loop implementations exposed through @overload — converts them from never-cached to persistently cached on Emscripten.
  3. Defer heavy submodule imports in quantecon/__init__.py via module __getattr__ (PEP 562) — also improves native import time.

Status (2026-08-21)

Implementation. PR #943 (@kp992, opened 2026-08-21, not yet reviewed) implements 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

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions