Make the numba wrapper functions disk-cacheable (and faster to call) - #334
Make the numba wrapper functions disk-cacheable (and faster to call)#334velochy wants to merge 1 commit into
Conversation
4f99130 to
f784b5a
Compare
|
Real Margus here: This is AI generated, but I did hold it's hand, forcing it to find a simplest possible solution, testing with the standard model library etc. I'm not that well versed in nutpie internals (hence the AI use), but as you can see, this has a significant impact on both speed and memory for the larger models I am running, so I am invested in getting this in. And I'm hoping I'm not the only one for whom this would be a real and significant benefit. |
|
This certainly looks very interesting! |
|
Also probably worth explaining the weird use case with PyMC. We are sampling multiple pymc models that depend on eachother. Numba has war-crime level atrocious memory handling when it comes to compile cache, so during compilation it creates gigabytes of meta-info and it keeps it around with no way to free it. (Supposedly not fully their fault, comes with the LLVM compiler they use) So the way to run memory-efficiently is the following:
Ridiculous, but it works. The only way to actually properly fix it seems deep in the numba internals with an issue from 10 years ago flagging it already so I'm guessing unlikely to ever be handled, so I'm commiting to the workaround. The approach works without this PR, but the patch here makes the compiled cache use a lot more fast and efficient, so if it makes sense in the nutpie context as well, I'd be very happy to get it included :) |
The logp/expand wrappers close over the inner pytensor dispatcher and over the shared-variable arrays. Numba refuses to cache functions referencing such dynamic globals, so the whole wrapper chain was retyped, relowered and re-optimised by LLVM in every process - and because the wrapper inlines everything up to the dispatcher call, that compile traverses the entire model graph: minutes of work and a GB-scale retained arena per process for large models. Three changes make the chain cacheable while keeping its structure: * the inner function is compiled up front for the wrapper's argument types and called through its C-wrapper address, stored in user_data next to the shared-array pointers and rebuilt into a first-class function value by an intrinsic (the same pattern PyTensor uses for cached LAPACK calls); * the prototype tuple in the extraction function uses zero-size stand-in arrays: only their numba types matter, every slot is overwritten before use, and small constants are embeddable where the real (large) shared arrays would be dynamic globals; * shared-variable keys are positional instead of uuid4, so the record field names - and with them the cache keys - are stable across processes. cfuncs are now built with cache=True by default (pass cache=False to opt out) and the 'cannot cache' warning suppression is removed: if caching regresses, we want to hear about it. Draws are bit-identical to the previous wrappers. Measured on an 8.5k-parameter hierarchical model (~3000 graph nodes): first-ever compile 1113s -> 440s with 3.5GB less retained memory, warm process compile 425s -> 262s. Separating the wrapper from the inner function also turns out to optimise better: logp calls 29.0 -> 21.6 ms, expand 18.1 -> 13.0. Small-model compile: 32s every process before; 42s once, 10.5s warm after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f784b5a to
1e74645
Compare
|
|
||
| @pytest.mark.pymc | ||
| def test_wrapper_functions_cacheable(): | ||
| """The wrapper cfuncs must stay free of dynamic globals, or numba silently |
There was a problem hiding this comment.
your bot may be over-reading into the warning message. Usually I see cache disabled because we have large constant arrays, not "dynamic globals" (same warning message iirc). I don't know what dynamic globals are, because AFAICT everything but constants are explicit inputs the way we define numba functions. And I don't see what you're doing here to handle constants differently
There was a problem hiding this comment.
or your "fix" was exactly that, to make constants explicit inputs? My understanding is a bit vague
|
Closing this — you were right to push back, and re-testing against a fair baseline shows the premise is wrong. Two errors in what I posted: 1. The baseline was unfair. nutpie's numba backend never asks for caching (
Run-to-run noise. The restructuring buys nothing. 2. Wrong culprit, exactly as you suspected. The "dynamic globals" I chased were in the PyTensor-generated function, not nutpie's wrapper, and the cause was large constant arrays — To your second question: for the shared arrays the answer is "they already were explicit inputs" — they arrive as pointers in The only defensible remnant is that |
|
@velochy we've talked about promoting large constants to explicit inputs (pytensor C codegen aready works like that), and it makes sense since numba isn't interning these anyway and I suppose llvmlite is not looking at them either. So no loss, and still cacheability. It's just more backend work in pytensor/NumbaLinker. A quick band-aid is to explicitly promote large constants to shared variables and then compiling that function instead. |
Which |
Exactly what I did, actually, and that created a big win in both warm compile time and memory use that was reported initially. So yes, the potential impact of doing that automagically is pretty significant. Since
Not pytensor's — |
|
I don't know how actionable that is for the average person who'd see the warning. I leaned negative E[utility] and suppressed. |
|
The fact you wasted a lot of time on misreading the message confirms my guess, although I didn't think of changing/expanding the message. |
What / why
This addresses the slow-start problem reported in #115, which was closed at the time with
"I don't think we can do much about the numba compilation times in nutpie" — it turns out we
can, for every process after the first.
The numba-backend logp and expand wrappers close over the inner pytensor dispatcher and over
the shared-variable arrays. Numba refuses to disk-cache functions that reference such "dynamic
globals" (nutpie currently suppresses exactly that warning), so the whole wrapper chain is
retyped, relowered and re-optimised by LLVM in every process that compiles a model — and
because the wrapper inlines everything up to the dispatcher call, that compilation traverses
the entire model graph. For a large hierarchical model this costs minutes of compile time and
a GB-scale arena that numba never releases, in every worker process, on every run, even when
the inner functions themselves come straight from PyTensor's numba disk cache.
Three changes make the chain cacheable while keeping its structure as-is:
types; its C-wrapper address is stored in
user_datanext to the shared-array pointers andrebuilt into a first-class function value by a small intrinsic. (Same pattern PyTensor uses
for its cached LAPACK calls.)
shared_tupleis only ever used for its numba types — every slot is overwritten via
tuple_setitembefore use. Zero-size arrays have identical types and are small enough for numba to embed
as constants, where the real shared arrays are dynamic globals.
s0000, …) instead ofuuid4().hex, so the recordfield names — and with them the cache keys — are identical across processes.
The cfuncs are then built with
cache=Trueby default (cache=Falseincompile_kwargsopts out), and the warning suppression is removed: if cacheability regresses, the warning is
the only way to notice.
Results
8.5k-parameter hierarchical model, ~3000 apply nodes (numba backend, same machine, RSS deltas
of the compiling process):
Small model (toy hierarchical logistic): 32 s compile in every process before; 42 s once,
10.5 s in later processes after.
Per-call cost on small standard models: the indirect call adds a fixed ~1-1.5 us to the logp
path, visible only where a gradient evaluation itself is a few microseconds (radon-style
hierarchical regression: 7.9 -> 9.4 us). An LKJ/MvNormal model (22 us evals) is at parity,
and the large model above is 26% faster. So: cheap-eval models pay a small per-call premium,
compute-dense models gain.
The call-time improvement was unexpected but is reproducible: compiling the model function as
its own unit (instead of inlining ~3000 nodes into one giant cfunc) appears to give LLVM a
better optimisation unit, and the shared arguments are now typed readonly.
Draws are bit-identical to the current wrappers (same seeds, checksums compared on both the
toy and the large model).
with_dataworks unchanged — shapes still come fromuser_dataat call time, nothing shape-specific is compiled in.
Notes for review
numba.experimental.function_type._get_wrapper_addressis a private API (it is themechanism behind first-class function support). If that's a concern, the address could be
obtained from the dispatcher's compile result instead — happy to change it.
Tuplewhere the dispatcher's*argssignaturesays
StarArgTuple; the element types and ABI are identical. The function type is declaredwith the plain form for that reason.
elements (constants) are typed readonly; the
carrayviews passed at runtime convertsafely.
compile_pymc.py(__pycache__/*.nbi/.nbc) via numba'snormal locator, plus PyTensor's own cache for the inner functions. Nothing new to manage.
Test plan
tests/test_pymc.py::test_wrapper_functions_cacheable— no dynamic globals in eitherwrapper (the property the disk cache depends on), plus a sampling smoke test.
tests/test_pymc.py::test_wrapper_with_data—with_datacorrectness against arecomputed deterministic.
test_order_sharedand friends pass unchanged.deliberately identical n_dim, record layout, wrapper source and C signature), a radon-style
hierarchical model and an LKJ/MvNormal model compiled in one process and re-compiled in a
fresh process in reverse order against the same caches: all posteriors bit-identical to
the uncached reference. With equal layouts the wrapper is genuinely model-agnostic (the
inner function's address arrives via
user_dataat call time), so cache sharing betweensuch models is correct by construction; differing layouts change the record dtype and the
dim guards reject loudly.
🤖 Generated with Claude Code