Skip to content

WASM: defer eager Numba gufunc/jit compilation to first use (#930) - #943

Closed
kp992 wants to merge 1 commit into
QuantEcon:mainfrom
kp992:wasm-930-lazy-numba
Closed

kp992 wants to merge 1 commit into
QuantEcon:mainfrom
kp992:wasm-930-lazy-numba

Conversation

@kp992

@kp992 kp992 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #930, part of #925.

What

Four guvectorize callables compiled at import quantecon time are now compiled lazily on first call:

Callable File Change
_probvec_parallel random/utilities.py module-level gufunc → lazy Python wrapper
_probvec_cpu random/utilities.py same
_sample_without_replacement random/utilities.py @guvectorize decorator → lazy wrapper
_ints_arr_to_bits game_theory/vertex_enumeration.py same

How

Gufuncs are replaced by thin Python wrapper functions that create the underlying ufunc on first call and cache it in a module-level sentinel. _probvec_cpu keeps the same name so the game_theory/random.py import is unchanged.

Why this matters for WASM

On native CPython, eager compilation costs milliseconds. In a JupyterLite/WASM kernel, each compile is a full LLVM optimise + WASM object emit + in-process LLD link. The emscripten-forge Numba patches also force-disable cache=True for @guvectorize, so the four gufunc compiles were paid on every session on the critical import path of every notebook.

Public API

No change. qe.random.probvec(..., parallel=...), qe.random.sample_without_replacement(...), and qe.game_theory.vertex_enumeration(...) are unaffected.

@coveralls

coveralls commented Aug 21, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 90.786% (+0.006%) from 90.78% — kp992:wasm-930-lazy-numba into QuantEcon:main

@mmcky

mmcky commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Thanks @kp992. Now that #938 is merged, could you rebase this onto main and drop 97518e0 (the int64 widening from #942)? #929 concluded the intp typing in comb_jit is intentional since its result is an array size, so please keep intp and drop the np.int64 casts, INT64_MAX, the MAX_INT64/test_max_int64 renames and test_simplex_grid_comb_int64 — the lazy @jit(nopython=True, cache=True) over the unchanged body is all #930 needs. The "Depends on #942" line can go too.

Two more things. #930 asks for a before/after warm-cache import quantecon timing in JupyterLite before this merges — could you add it to the PR description, or say so if you can't and we'll decide whether to waive it. And this doesn't address #944 (the upstream reproducer in emscripten-forge/recipes#6309 uses lazy @njit(cache=True) functions only), so please don't reference it as such.

@kp992
kp992 force-pushed the wasm-930-lazy-numba branch from 7ae4be8 to 8a7da64 Compare August 22, 2026 00:07
Four guvectorize callables compiled at import time are now compiled
lazily on first call: _probvec_parallel, _probvec_cpu,
_sample_without_replacement (_ints_arr_to_bits).

Gufuncs are replaced by thin Python wrapper functions that create the
underlying ufunc on first invocation and cache it in a module-level
sentinel. _probvec_cpu keeps the same name so the game_theory/random.py
import is unchanged.

Fixes QuantEcon#930, part of QuantEcon#925.
@mmcky

mmcky commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Thanks @kp992 — closing this in favour of #957, which opens @oyamad's implementation of the same idea from his wasm-930-dynamic-gufuncs branch. That is not a reflection on the work here: your 21 Aug rebase did exactly what was asked, the int64 residue is gone, the change is confined to two files, and CI is green.

Two things decided it. His version builds the gufuncs through memoised factory functions rather than module-level sentinels mutated through global, which is less state to reason about; and it converts _sample_without_replacement and _ints_arr_to_bits to dynamic (signature-less) gufuncs rather than keeping explicit signatures behind wrappers. More importantly, it documents why the explicit signature is retained for the two _probvec targets — an explicit-signature gufunc and a dynamic one of the same kernel share an incompatible cache entry under cache=True (numba#10128). That constraint was written down nowhere in the codebase, and it is the kind of thing that gets silently re-broken later.

One correction that applied to both versions: this PR says "Closes #930", but #930's acceptance criterion 1 — warm-cache import quantecon timed in JupyterLite, before and after — is still unmet, so merging either implementation would have auto-closed the issue with that criterion outstanding. #957 says "Part of #930" instead. It matters more than bookkeeping: emscripten-forge patch 0007 forces cache=False for every @guvectorize, so in the browser these four compiles are still paid once per session either way. The change moves the cost from import to first call rather than removing it, and whether that is a win depends on how many notebooks reach probvec, sample_without_replacement or vertex_enumeration — which is what the measurement would tell us.

Thanks for the work on this. The analysis in your description was accurate and it is what got the approach agreed.

@mmcky mmcky closed this Sep 9, 2026
@kp992
kp992 deleted the wasm-930-lazy-numba branch September 10, 2026 01:38
@kp992

kp992 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @mmcky for the review and feedback. Sounds good to me.

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.

WASM: reduce import-time eager Numba compilation

3 participants