fix: key the built-model cache on device and inference dtype - #1219
fix: key the built-model cache on device and inference dtype#1219jmkuebler wants to merge 4 commits into
Conversation
|
@cursor review |
The built-model cache hands one instance to every estimator with the same placement, so anything that mutates a model in place reaches every other holder. Two such mutations were reachable now that the cache is on by default: - `.to()` on a fitted estimator moved the shared instance, so another estimator holding it failed its next predict with "Placeholder storage has not been allocated on MPS device!". Clearing the cache did not help: it stops future mis-placed hits but cannot un-share what was already handed out. - Fine-tuning optimises `model_`'s weights in place. A `FinetunedTabPFN*` fit followed by an ordinary fit in the same process silently returned the fine-tuned weights — no error, just wrong predictions. Both are the same rule: a caller about to mutate a model takes a private copy first. `detach_models_from_cache` does that for the models and the regressor's bar distribution, which comes from the same cached tuple, and is called from the four places that mutate: `TabPFNClassifier.to`, `TabPFNRegressor.to`, `fit_from_preprocessed` on both, and the finetuner before its optimizer binds to `model_`. Reported by Cursor Bugbot on #1219. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`fit_from_preprocessed` only detached inside its re-initialisation branch, so any path reaching it with `models_` already populated — an earlier `fit()`, with the default `no_refit=True` — trained the shared cached instance. Moving the call out of the branch means it runs once per batch, which a plain deep copy could not survive: it would copy every step and hand back a different object than the optimizer is holding. So `detach_models_from_cache` now copies only while `is_cached_model` reports the cache still holds the instance, making it a no-op once the models are private and safe to call unconditionally. Reported by Cursor Bugbot on #1219. Its stated route was `get_preprocessed_datasets`, which no longer exists on the estimators (only stale docstring references remain), but the branch it identified was reachable by other means. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@cursor review |
86adfcc to
a977596
Compare
|
@cursor review |
oscarkey
left a comment
There was a problem hiding this comment.
hey! One thing re .to(), otherwise just comments on claude on comments.
I'm wondering if another option might be to replace the current cache with an extension of tabpfn.inference._PerDeviceModelCache, which might allow us to neatly have .to() work and properly support multi-device inference (atm the model is only cached on one device?). But, the current approach seems pragmatic as it's not part of the public api.
| # Sharing is what a caller who enables it takes on: two estimators served one | ||
| # entry hold the same module, so moving or training either reaches the other. | ||
| # | ||
| # `cache_trainset_representation` is not part of the key: every architecture's |
There was a problem hiding this comment.
Maybe: We don't include the cache_trainset_representation kwarg of ArchitectureModule.get_architecture() in the key, because it is no longer used since InferenceEngineCacheKV was removed.
There was a problem hiding this comment.
I now went for the real fix and opened a PR to entirely wipe cache_trainset_representation #1250
I rebased the current PR on top of it.
| # holds a classifier and a regressor. Entries are shared by reference and left in | ||
| # ``eval()`` mode, for repeated sequential fit/predict. | ||
| # | ||
| # Sharing is what a caller who enables it takes on: two estimators served one |
There was a problem hiding this comment.
What do you think about disabling .to() when the cache is enabled? Because the key is based on the initialisation options, not the actual device, you can get some confusing behaviour e.g.
a = TabPFNClassifier(device="cpu").to("cuda")
b = TabPFNClassifier(device="cpu") # actually on cudaThere was a problem hiding this comment.
oh no wait I'm wrong! Actually both a and b will end up on cpu. So that's mostly covered by this comment already.
There was a problem hiding this comment.
so do you think we are fine as is?
5bb9201 to
6aebfda
Compare
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6aebfda. Configure here.
The cache (opt-in via `TABPFN_MODEL_CACHE_SIZE`) hands one built model to
every estimator that asks for it. Estimators then place what they are given:
the inference engine moves the module with an in-place `model.to(device)`
and casts it with `model.type(dtype)`. Neither was part of the cache key, so
two estimators wanting different placements were served the same instance:
Placeholder storage has not been allocated on MPS device!
mat1 and mat2 must have the same dtype, but got Float and Half
Both are now in the key. The dtype cannot be repaired instead: casting to
float16 is lossy, so a model that has been cast can never serve a
full-precision caller. `has_equal_borders` compares values rather than
requiring a shared device, since a cached criterion carries the device of
the fit that placed it.
`fit_mode="fit_with_cache"` is covered too. It was excluded on the grounds
that `cache_trainset_representation` makes the module accumulate the
train-set representation, but every architecture's `get_architecture`
deletes that flag and the KV cache is owned by the inference engine.
Setting `TABPFN_MODEL_CACHE_SIZE` over tests/test_classifier_interface.py
and tests/test_regressor_interface.py fails 38 tests without this and
passes with it, taking those two files from 146s to 117s; over the whole
suite, 398s to 368s.
The default stays off, and two estimators served one entry still share the
module, so moving or training either reaches the other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Oscar <oscar@priorlabs.ai>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d by #1250 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6aebfda to
e17adc0
Compare
Issue
RES-2743. Stacked on #1250, which removes the dead
cache_trainset_representationflag and letsfit_mode="fit_with_cache"use the cache.Motivation and Context
The built-model cache is opt-in via
TABPFN_MODEL_CACHE_SIZEand hands one built model to every estimator that asks for it, so a warm fit costs ~0.003 s instead of ~0.12 s.It could not be shared across placements. An estimator applies the placement to the module it is handed, in place — the inference engine moves it with
model.to(device)and casts it withmodel.type(dtype). Neither was part of the key, so two estimators wanting different placements were served the same instance:The dtype cannot be repaired instead of keyed:
model.type(torch.float16)is destructive, so a model cast to fp16 can never serve a full-precision caller.The suite is the clearest evidence
Setting
TABPFN_MODEL_CACHE_SIZEovertests/test_classifier_interface.pyandtests/test_regressor_interface.py:Every one of the 38 was a placement collision —
Float and Double,Float and Half,Cannot compare two tensors on different devices,Cannot convert a MPS Tensor to float64. The suite spans cpu/mps and three forced precisions, so it could not opt in at all before. On top of #1250, wherefit_with_cachealso hits the cache, the same suites show 64 collisions before this change and none after.Those two files are unusually fit-dense. Over the whole suite the effect is smaller — 1861 passed either way, 398 s off versus 368 s on, so ~30 s or 7.5%. Enabling it in
conftest.pyis a follow-up, not part of this PR.What a caller still owns
The default is unchanged, so nothing happens unless the env var is set. Mixing devices or precisions now yields separate entries rather than corruption. Two estimators served the same entry still share the module, so moving one with
.to()after fit or training its weights reaches the other — deliberate, and left to the caller. Calling.to()before fit is safe: the key is built from the device the estimator has at fit time.Public API Changes
No Public API changes
Yes, Public API changes (Details below)
tabpfn.model_loading.load_modelandload_model_criterion_configtake optionaldevicesandforce_inference_dtypekeyword arguments (both defaultNone, so existing calls are unaffected — a caller that places the returned model should pass them).A non-integer
TABPFN_MODEL_CACHE_SIZEwarns instead of being silently ignored.The cache default is unchanged (off).
How Has This Been Tested?
TABPFN_MODEL_CACHE_SIZE=8: the same 1861 passed, 176 skipped, 8 xfailed, so the cache is exercised across every device and precision the suite covers.tests/test_classifier_interface.py,tests/test_regressor_interface.pyandtests/test_model_cache.pywithTABPFN_MODEL_CACHE_SIZE=8on cpu and cuda (A100): all pass.tests/test_model_cache.py: separate entries per device and per dtype; unspecified devices kept apart from device-specific ones; disabled by default; two entries holding a classifier and a regressor; invalid env value; LRU eviction; cache-clear forcing a rebuild; and a tripwire onload_model's parameters so a future parameter cannot quietly bypass the key.Checklist
changelog/README.md), or "no changelog needed" label requested.🤖 Generated with Claude Code