From 888bf61a150c84e499d6b819c43d97953a6f8b13 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Sun, 23 Aug 2026 22:39:23 +0900 Subject: [PATCH 1/3] WASM: Defer Numba gufunc compilation to first call `import quantecon` compiled four `guvectorize` functions eagerly because of their explicit type signatures. In the in-browser (Emscripten) Numba build, where `cache=True` is disabled for gufuncs, that cost was paid on every session on the import path. Defer all four to first call: * `_ints_arr_to_bits` and `_sample_without_replacement` become dynamic (signature-less) gufuncs; the callers now supply the output array. * `_probvec_parallel` and `_probvec_cpu` are constructed on first call by `functools.cache`d factories, keeping their explicit signatures. The parallel target requires one, and the cpu variant keeps one too: an explicit-signature gufunc and a dynamic gufunc of the same kernel share an incompatible cache entry when `cache=True`, which crashes the process. Public API is unchanged. `functools.cache` requires Python 3.9, so `requires-python` is raised from 3.7 to 3.9. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- quantecon/game_theory/random.py | 2 +- quantecon/game_theory/vertex_enumeration.py | 38 +++++++------ quantecon/random/utilities.py | 59 ++++++++++++++++----- 4 files changed, 70 insertions(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 13db6f237..a47d8ae70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ keywords = [ 'economics' ] dynamic = ["description", "version"] -requires-python = ">=3.7" +requires-python = ">=3.9" dependencies = [ 'numba>=0.56.0', 'numpy>=1.17.0', diff --git a/quantecon/game_theory/random.py b/quantecon/game_theory/random.py index c3260beb6..d8cbdfdf7 100644 --- a/quantecon/game_theory/random.py +++ b/quantecon/game_theory/random.py @@ -202,7 +202,7 @@ def _random_mixed_actions(out, random_state): x[0] = 1 else: r = random_state.random(size=n-1) - _probvec_cpu(r, x) + _probvec_cpu()(r, x) return out diff --git a/quantecon/game_theory/vertex_enumeration.py b/quantecon/game_theory/vertex_enumeration.py index 236feea55..b04f5c584 100644 --- a/quantecon/game_theory/vertex_enumeration.py +++ b/quantecon/game_theory/vertex_enumeration.py @@ -105,8 +105,12 @@ def vertex_enumeration_gen(g, qhull_options=None): g.players[1-i], idx=i, qhull_options=qhull_options ) for i in range(N)] - labelings_bits_tup = \ - tuple(_ints_arr_to_bits(brps[i].labelings) for i in range(N)) + labelings_bits_tup = tuple( + _ints_arr_to_bits( + brps[i].labelings, + np.empty(brps[i].labelings.shape[0], dtype=np.uint64) + ) for i in range(N) + ) equations_tup = tuple(brps[i].equations for i in range(N)) trans_recips = tuple(brps[i].trans_recip for i in range(N)) @@ -296,33 +300,37 @@ def __init__(self, opponent_player, idx=0, qhull_options=None): self.trans_recip = trans_recip -@guvectorize(['(i4[:], u8[:])'], '(m)->()', nopython=True, cache=True) +@guvectorize('(m)->()', nopython=True, cache=True) def _ints_arr_to_bits(ints_arr, out): """ Convert an array of integers representing the set bits into the corresponding integer. - Compiled as a ufunc by Numba's `@guvectorize`: if the input is a - 2-dim array with shape[0]=K, the function returns a 1-dim array of - K converted integers. + Compiled as a dynamic (signature-less) gufunc by Numba's + `@guvectorize` on first call: if the input is a 2-dim array with + shape[0]=K, the function fills the 1-dim output array `out` with + K converted integers. The output array must be supplied by the + caller, with dtype np.uint64. Parameters ---------- - ints_arr : ndarray(int32, ndim=1) - Array of distinct integers from 0, ..., 63. + ints_arr : ndarray(int, ndim=2) + Array of distinct integers from 0, ..., 63 in each row. + + out : ndarray(np.uint64, ndim=1) + Output array, of length ints_arr.shape[0]. Returns ------- - np.uint64 - Integer with set bits represented by the input integers. + out : ndarray(np.uint64, ndim=1) + Array of integers with set bits represented by the rows of + the input. Examples -------- - >>> ints_arr = np.array([0, 1, 2], dtype=np.int32) - >>> _ints_arr_to_bits(ints_arr) - 7 - >>> ints_arr2d = np.array([[0, 1, 2], [3, 0, 1]], dtype=np.int32) - >>> _ints_arr_to_bits(ints_arr2d) + >>> ints_arr = np.array([[0, 1, 2], [3, 0, 1]], dtype=np.int32) + >>> out = np.empty(ints_arr.shape[0], dtype=np.uint64) + >>> _ints_arr_to_bits(ints_arr, out) array([ 7, 11], dtype=uint64) """ diff --git a/quantecon/random/utilities.py b/quantecon/random/utilities.py index 9ca73ecef..b8645de8d 100644 --- a/quantecon/random/utilities.py +++ b/quantecon/random/utilities.py @@ -3,6 +3,8 @@ """ +import functools + import numpy as np from numba import guvectorize, types from numba import TypingError @@ -57,9 +59,9 @@ def probvec(m, k, random_state=None, parallel=True): # Parse Parallel Option # if parallel: - _probvec_parallel(r, x) + _probvec_parallel()(r, x) else: - _probvec_cpu(r, x) + _probvec_cpu()(r, x) return x @@ -89,14 +91,41 @@ def _probvec(r, out): # pragma: no cover out[i] = r[i] - r[i-1] out[n] = 1 - r[n-1] -_probvec_parallel = guvectorize( - ['(f8[:], f8[:])'], '(n), (k)', nopython=True, target='parallel', - cache=True - )(_probvec) -_probvec_cpu = guvectorize( - ['(f8[:], f8[:])'], '(n), (k)', nopython=True, target='cpu', - cache=True - )(_probvec) +# The two gufuncs of `_probvec` are constructed on first call rather than +# at import, since an explicit signature makes `guvectorize` compile +# eagerly. The explicit signature is required by the parallel target, and +# is retained for the cpu target as well: an explicit-signature gufunc and +# a dynamic (signature-less) gufunc of the same kernel must not be mixed +# with `cache=True`, as they share a cache entry that is not compatible. + +_PROBVEC_SIGNATURE = ['(f8[:], f8[:])'] +_PROBVEC_LAYOUT = '(n), (k)' + + +@functools.cache +def _probvec_parallel(): + """ + Return the parallel-target gufunc of `_probvec`, compiling it on + first call. + + """ + return guvectorize( + _PROBVEC_SIGNATURE, _PROBVEC_LAYOUT, nopython=True, + target='parallel', cache=True + )(_probvec) + + +@functools.cache +def _probvec_cpu(): + """ + Return the cpu-target gufunc of `_probvec`, compiling it on first + call. + + """ + return guvectorize( + _PROBVEC_SIGNATURE, _PROBVEC_LAYOUT, nopython=True, + target='cpu', cache=True + )(_probvec) def sample_without_replacement(n, k, num_trials=None, random_state=None): @@ -148,16 +177,18 @@ def sample_without_replacement(n, k, num_trials=None, random_state=None): random_state = check_random_state(random_state) r = random_state.random(size=size) - result = _sample_without_replacement(n, r) + result = np.empty(size, dtype=np.int64) + _sample_without_replacement(n, r, result) return result -@guvectorize(['(i8, f8[:], i8[:])'], '(),(k)->(k)', nopython=True, cache=True) +@guvectorize('(),(k)->(k)', nopython=True, cache=True) def _sample_without_replacement(n, r, out): """ - Main body of `sample_without_replacement`. To be complied as a ufunc - by guvectorize of Numba. + Main body of `sample_without_replacement`. Compiled as a dynamic + (signature-less) gufunc by Numba's `@guvectorize` on first call; + the output array `out` must be supplied by the caller. """ k = r.shape[0] From 85c1b8be322311cd202bd6b5819f0fb7a491bc85 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Wed, 9 Sep 2026 19:29:47 +1000 Subject: [PATCH 2/3] MAINT: use lru_cache and leave requires-python to #864 Two adjustments to the cherry-picked implementation, neither changing its behaviour. `functools.cache` is an alias for `functools.lru_cache(maxsize=None)` added in 3.9 purely for convenience, so using `lru_cache` directly gives identical memoisation without imposing a version floor. That removes the need for the `requires-python` bump to 3.9, which is reverted here: the floor is stale at ">=3.7" while CI tests 3.12-3.14, but correcting it is #864's business and does not belong in a WASM change. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- quantecon/random/utilities.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a47d8ae70..13db6f237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ keywords = [ 'economics' ] dynamic = ["description", "version"] -requires-python = ">=3.9" +requires-python = ">=3.7" dependencies = [ 'numba>=0.56.0', 'numpy>=1.17.0', diff --git a/quantecon/random/utilities.py b/quantecon/random/utilities.py index b8645de8d..f1b6674e8 100644 --- a/quantecon/random/utilities.py +++ b/quantecon/random/utilities.py @@ -102,7 +102,7 @@ def _probvec(r, out): # pragma: no cover _PROBVEC_LAYOUT = '(n), (k)' -@functools.cache +@functools.lru_cache(maxsize=None) def _probvec_parallel(): """ Return the parallel-target gufunc of `_probvec`, compiling it on @@ -115,7 +115,7 @@ def _probvec_parallel(): )(_probvec) -@functools.cache +@functools.lru_cache(maxsize=None) def _probvec_cpu(): """ Return the cpu-target gufunc of `_probvec`, compiling it on first From d96c4d445eee95f7d13c398db8b17d0500cb1d84 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Thu, 10 Sep 2026 11:10:47 +0900 Subject: [PATCH 3/3] Use `functools.cache` for `functools.lru_cache(maxsize=None)` --- quantecon/random/utilities.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quantecon/random/utilities.py b/quantecon/random/utilities.py index 9ce327a42..863775cb4 100644 --- a/quantecon/random/utilities.py +++ b/quantecon/random/utilities.py @@ -105,7 +105,7 @@ def _probvec(r, out): # pragma: no cover _PROBVEC_LAYOUT = '(n), (k)' -@functools.lru_cache(maxsize=None) +@functools.cache def _probvec_parallel(): """ Return the parallel-target gufunc of `_probvec`, compiling it on @@ -118,7 +118,7 @@ def _probvec_parallel(): )(_probvec) -@functools.lru_cache(maxsize=None) +@functools.cache def _probvec_cpu(): """ Return the cpu-target gufunc of `_probvec`, compiling it on first