Skip to content

ENH: locked_spaces, export models for free-threaded Python - #274

Merged
fumitoh merged 4 commits into
mainfrom
enh/export-locked-spaces
Sep 3, 2026
Merged

fumitoh merged 4 commits into
mainfrom
enh/export-locked-spaces

Conversation

@fumitoh

@fumitoh fumitoh commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds a locked_spaces parameter to export_model, Model.export and Exporter, so that an exported model can be used from several threads on a free-threaded build of Python (3.13t, 3.14t). A listed Space, the Spaces below it and its ItemSpaces compute each cached Cells value and create each ItemSpace at most once when several threads ask for it; Spaces not listed, and models exported without the parameter, are generated byte for byte as before.

Motivation: discussion #192. One thread per range of Projection[i] already scales for BasicTerm_S, but in TradLife_A every Projection[i] shares InputData, Assumptions, PolicyAttrs, Economic and CommTable, whose cache methods have no synchronisation: two threads missing at once compute the formula twice, and inside InputData they race in the same openpyxl workbook.

m.export("TradLife_A_nomx", locked_spaces=[
    "InputData", "Economic", "Assumptions", "PolicyAttrs", "CommTable"])

How it works

  • One threading.RLock per exported model, created by the generated model class and copied into every locked Space (self._mx_lock = self._model._mx_lock, which is also the marker the modelx-cython branch reads).
  • The cache methods keep the current unlocked cache-hit path byte for byte and take the lock only on a miss, re-check, then compute (double-checked locking). __call__ of a locked parameterized Space does the same, with a single dict.get on the hit path so that a concurrent del cannot surface as a KeyError.
  • The value is stored before the _has_ flag. CPython stores a slot or instance-dict attribute with a release store and loads it atomically, and dict operations are synchronised, so a reader that sees the flag without the lock sees the value.
  • A single reentrant lock cannot deadlock through the call graph. The documented exception is a formula of a locked Space that waits for another thread which needs a locked Cells.
  • Validation runs before anything is written: bare strings, Cells and Reference names, ItemSpaces and Spaces of another model are rejected; a Space parameter named _mx_lock is rejected for both use_slots settings; listing a Space below an unlocked parameterized Space warns, because exactly-once then holds per ItemSpace instance.
  • Uncached Cells are unchanged and unlocked; they run under the lock only when called from a locked Cells, which is the only way the cacheless helpers of TradLife_A.InputData are reached.

Rejected alternatives, recorded in the docstring and release notes: per-Space locks (deadlock on cyclic space-level call graphs), always taking the lock (kills the scaling the feature exists for), a locked __delitem__ (protects nothing, since the hit path never takes the lock).

Verification

  • pytest modelx/tests/export: 134 passed on Python 3.13; test_locked_spaces.py (41 tests) passes on 3.14.7 free-threaded, including the control that shows duplicate formula runs on the unlocked export on both builds, and test_annuallife_threaded passes on both.
  • golden_exports.json records the output of the sample models from main (f63ffc8) for both use_slots settings; the parameter-absent, None and [] exports match it.
  • _mx_sys.py is untouched and still copied verbatim.

TradLife_A on 3.14.7t, shared caches warm, model points per second of pv_net_cf(0):

model 1 thread, unlocked 1 thread, locked 8 threads, locked
exported 1,785 1,853 4,097
compiled with modelx-cython 5,834 5,771 18,984

The single-thread cost of the lock is within noise. The ceiling is CommTable.AnnDuenx/Axn, whose keys change per model point, so their misses queue on the one lock; the docstring says which Spaces to lock and recommends warming the shared Spaces single-threaded before starting the pool.

Reviewer notes

  • modelx-cython needs the companion branch enh/free-threading to compile a locked export (acquire/release atomics on the _has_ flags, _mx_lock on BaseParent, freethreading_compatible in the generated setup.py); v0.0.9 and earlier fail at import of the compiled model with AttributeError: _mx_lock, which the release notes say.
  • The new CI job uses actions/setup-python@v5 with 3.14t; it has not run on GitHub yet.
  • The working notes are in devnotes/FreeThreadingExportTask.md: design decisions, code map, traps met on the way, verification and the benchmark.
  • Follow-ups not in this PR: per-Space lock granularity as an option (needs a static cycle check), fewer per-model-point misses in the CommTable of lifelib, a threaded TradLife_A driver script in lifelib.

🤖 Generated with Claude Code

fumitoh and others added 4 commits September 3, 2026 22:50
Add a locked_spaces parameter to export_model, Model.export and Exporter.
Every Space listed, together with the Spaces below it and its ItemSpaces,
computes each cached Cells value and creates each ItemSpace at most once
when several threads call it: the generated cache methods and __call__
keep today's unlocked cache-hit path and, on a miss, take a single
threading.RLock created by the generated model class, check the cache
again and compute. Spaces not listed, and models exported without the
parameter, are generated byte for byte as before.

Motivation: on a free-threaded build of Python one thread per range of
ItemSpaces already scales for a single-Space model, but in a model such
as lifelib's TradLife_A every Projection[i] shares InputData, Assumptions,
PolicyAttrs, Economic and CommTable, whose unsynchronised cache methods
compute formulas twice and race inside shared state such as an openpyxl
workbook (#192).

A reader that sees the _has_ flag without the lock also sees the value,
because CPython stores a slot or instance-dict attribute with a release
store and loads it atomically, and dict operations are synchronised;
the value is therefore assigned before the flag. One reentrant lock per
model cannot deadlock through the call graph. The parameter is validated
before anything is written; a Space parameter named _mx_lock is rejected,
and listing a Space below an unlocked parameterized Space warns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test_locked_spaces.py builds a model with shared Spaces, a per-thread
Space and a Space that is called back into, and checks the generated
code shape, the __slots__ coverage, the validation errors and warnings,
and that under eight threads every formula of a locked Space runs once
and every ItemSpace key gives one object; the same test on the unlocked
export shows the duplicate runs, on builds with and without the GIL.

golden_exports.json records the _mx_classes.py and _mx_model.py of the
sample models as main (f63ffc8) generated them, for both use_slots
settings; the exports with the parameter absent, None and empty must
match it, so a template edit that changes the default output is caught
against a reference outside the exporter itself.

test_lifelib.py gains a threaded TradLife_A case with its five shared
Spaces locked, checking the values and that the workbook is read once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A separate job on 3.14t with PYTHON_GIL=0 and a reduced requirements
file: QuantLib has no free-threaded wheel, so the assets test is left
out, and pytest-benchmark is not needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Design decisions, code map, traps met on the way, verification and the
TradLife_A benchmark on free-threaded Python 3.14.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@fumitoh
fumitoh merged commit 0fb7122 into main Sep 3, 2026
19 checks passed
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.

1 participant