The model cache — no compilation overhead after the first run¶
The first call costs seconds because the backend compiles, and the price is per process. That suits a long Julia session and punishes the way Python is actually used: scripts re-run from scratch, so every run pays the startup and the kernels again.
Core(cache=True) removes the per-run price. The first run of a script
compiles the model ahead of time into a shared library and stores it, keyed
by the model’s content; every later run of the unchanged script loads the
library and solves — nothing compiles, nothing warms up. (The stored
library is self-contained, so those runs happen not to start Julia at all —
a mechanism, not the point: the point is that the overhead is gone.)
Measured on one machine (the same script, run repeatedly):
first run ever (1 000 variables): build + compile + store |
90 s |
every later run: load + solve |
0.35 s |
a second solve inside one process |
0.04 s |
Write it as a recipe¶
The natural form for a cached model is a recipe: the structure
is written against placeholders, so the data — sizes included — stays a
per-run input, and one compiled entry serves every instantiation. With
cache=, the core records the model in pure Python (expressions become
small trees, and Julia is not started):
import examodels as exa
core = exa.Core(nargs=2, cache=True)
n, x0 = core.args
x = core.add_var(n, start=x0)
core.add_obj(lambda i: 100 * (x[i-1]**2 - x[i])**2 + (x[i-1] - 1)**2,
over=exa.srange(1, n))
Model does the rest. On a miss it replays the record through the
ordinary eager path — Julia starts, exactly as without cache= — then
compiles the model with the compiler backend and stores the
library before returning, so that run is slower by the compile time, once.
Every later Model of the same structure is a hit: the library is
loaded through cnlpmodels,
instantiated with this run’s values, and solved with Ipopt through cyipopt:
model = exa.Model(core, 1000, [0.0] * 1000) # first ever: compiles once
sol = model.solve()
print(sol.status, sol.objective, sol[x])
model = exa.Model(core, 5000, [0.5] * 5000) # any later run, ANY size: a hit
The entry is keyed by the types of the instantiation values, never their
contents — an integer size and a float vector here — so new sizes and new
data keep hitting. An argument of a different type (1000.0 where an
integer was compiled, an integer array where floats were) is a different
entry. Both runs present the same surface: named blocks, sol[x],
multipliers, parameters / set_parameters.
What the key covers¶
core.fingerprint() returns the pair of digests behind all of this. The
fingerprint covers what determines the generated code — the expressions,
operators, block shapes and names, index-set kinds, placeholder-sized
dimensions. The data digest covers the values the library bakes in:
literal starts, bounds, index-set contents. Change either and the next
Model compiles a fresh entry; change neither and it loads the stored one.
Parameter values are deliberately outside both digests — the ABI keeps
them settable on a loaded library — so data routed through add_par blocks
changes freely without ever touching the key:
core = exa.Core(cache=True)
x = core.add_var(4)
p = core.add_par([1.0, 2.0, 3.0, 4.0]) # change these freely: still a hit
core.add_obj(lambda i: (x[i] - p[i]) ** 2, over=range(4))
Fixed models cache too¶
A core with no placeholders — plain Core(cache=True) — is the simpler
case: everything but parameter values is baked, so the entry answers to
exactly one model and any data change is one recompile. It is the right
form when the model genuinely is one instance; reach for the recipe form
the moment sizes or data vary between runs.
Two caveats that apply on any hit: solve(solver=) must be Ipopt (MadNLP
would need Julia, which a hit never starts — build without cache= to use
it), and starts and bounds are baked (they are data; their setters say so).
On a recipe hit there is one more: a dims-only constraint block
(add_con(2, ...)) cannot be addressed by handle, because the eager surface
cannot name it into the library’s layout — read the whole vector instead.
What it needs, and how it degrades¶
Loading entries needs the [cache] extra (cnlpmodels and cyipopt); storing
them needs the compiler backend, which needs Julia 1.12 and
therefore a Python linking OpenSSL ≥ 3.5:
pip install "examodels[cache] @ git+https://github.com/madsuite-org/examodels-py"
Nothing breaks where those are missing. Without the compiler, Model
warns once and returns the ordinary eager model, storing nothing. In a
process where Julia is already running — you built an eager model first, or
rebuilt the same model right after a miss — a matching entry is served
eagerly too, without recompiling: a compiled library cannot start its runtime
beside a live one, so the cache steps aside rather than crash.
Where entries live¶
cache=True stores under $EXAMODELS_CACHE (default ~/.cache/examodels),
content-addressed. cache="@name" installs the library on CNLPMODELS_PATH
under that name, where any cnlp consumer finds it; cache="/some/path" uses
that directory as the entry. Each entry is a shared library plus a JSON
sidecar carrying the digests it answers to; deleting an entry’s directory is
always safe and costs one recompile.