ENH: locked_spaces, export models for free-threaded Python - #274
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
locked_spacesparameter toexport_model,Model.exportandExporter, 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 forBasicTerm_S, but inTradLife_AeveryProjection[i]sharesInputData,Assumptions,PolicyAttrs,EconomicandCommTable, whose cache methods have no synchronisation: two threads missing at once compute the formula twice, and insideInputDatathey race in the same openpyxl workbook.How it works
threading.RLockper 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).__call__of a locked parameterized Space does the same, with a singledict.geton the hit path so that a concurrentdelcannot surface as aKeyError._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._mx_lockis rejected for bothuse_slotssettings; listing a Space below an unlocked parameterized Space warns, because exactly-once then holds per ItemSpace instance.TradLife_A.InputDataare 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, andtest_annuallife_threadedpasses on both.golden_exports.jsonrecords the output of the sample models frommain(f63ffc8) for bothuse_slotssettings; the parameter-absent,Noneand[]exports match it._mx_sys.pyis untouched and still copied verbatim.TradLife_A on 3.14.7t, shared caches warm, model points per second of
pv_net_cf(0):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
enh/free-threadingto compile a locked export (acquire/release atomics on the_has_flags,_mx_lockonBaseParent,freethreading_compatiblein the generatedsetup.py); v0.0.9 and earlier fail at import of the compiled model withAttributeError: _mx_lock, which the release notes say.actions/setup-python@v5with3.14t; it has not run on GitHub yet.devnotes/FreeThreadingExportTask.md: design decisions, code map, traps met on the way, verification and the benchmark.CommTableof lifelib, a threadedTradLife_Adriver script in lifelib.🤖 Generated with Claude Code