tabctx 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tabctx/__init__.py +41 -0
- tabctx/backends/__init__.py +3 -0
- tabctx/backends/base.py +58 -0
- tabctx/backends/fake.py +99 -0
- tabctx/backends/tabicl.py +263 -0
- tabctx/backends/tabpfn.py +147 -0
- tabctx/batching.py +206 -0
- tabctx/cache/__init__.py +9 -0
- tabctx/cache/manager.py +158 -0
- tabctx/cache/policies.py +29 -0
- tabctx/cache/spill.py +173 -0
- tabctx/chunking.py +54 -0
- tabctx/client.py +285 -0
- tabctx/engine.py +321 -0
- tabctx/errors.py +56 -0
- tabctx/memory/__init__.py +16 -0
- tabctx/memory/adaptive.py +164 -0
- tabctx/memory/calibration_data.py +47 -0
- tabctx/memory/calibration_tabicl_a100.py +121 -0
- tabctx/memory/estimator.py +198 -0
- tabctx/serve/__init__.py +0 -0
- tabctx/serve/affinity.py +91 -0
- tabctx/serve/app.py +588 -0
- tabctx/serve/csv_io.py +117 -0
- tabctx/serve/factory.py +291 -0
- tabctx/serve/tenancy.py +148 -0
- tabctx/serve/uploads.py +203 -0
- tabctx/types.py +35 -0
- tabctx-0.9.0.dist-info/METADATA +344 -0
- tabctx-0.9.0.dist-info/RECORD +32 -0
- tabctx-0.9.0.dist-info/WHEEL +4 -0
- tabctx-0.9.0.dist-info/licenses/LICENSE.md +201 -0
tabctx/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""tabctx: multi-tenant context caching and serving for tabular
|
|
2
|
+
in-context-learning foundation models (TabICL, TabPFN, and similar).
|
|
3
|
+
|
|
4
|
+
The core idea: these models already cache their encoded training context
|
|
5
|
+
internally to speed up repeated predict() calls against the same training
|
|
6
|
+
set (both TabICL and TabPFN call this a "KV cache"), but only as a
|
|
7
|
+
single-process, single-estimator-object feature. tabctx makes that a real
|
|
8
|
+
multi-tenant, evictable, memory-governed resource -- the analog of what
|
|
9
|
+
PagedAttention did for per-request LLM KV caches.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from tabctx.cache.manager import CachedContext, ContextCacheManager
|
|
13
|
+
from tabctx.engine import TabctxEngine
|
|
14
|
+
from tabctx.errors import (
|
|
15
|
+
AdmissionRejected,
|
|
16
|
+
BackendComputeError,
|
|
17
|
+
CacheCapacityError,
|
|
18
|
+
DatasetNotFoundError,
|
|
19
|
+
InvalidInputError,
|
|
20
|
+
TabctxError,
|
|
21
|
+
UploadNotFoundError,
|
|
22
|
+
UploadTooLargeError,
|
|
23
|
+
)
|
|
24
|
+
from tabctx.types import EngineStats, PredictOutcome, Task
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AdmissionRejected",
|
|
28
|
+
"BackendComputeError",
|
|
29
|
+
"CacheCapacityError",
|
|
30
|
+
"CachedContext",
|
|
31
|
+
"ContextCacheManager",
|
|
32
|
+
"DatasetNotFoundError",
|
|
33
|
+
"EngineStats",
|
|
34
|
+
"InvalidInputError",
|
|
35
|
+
"PredictOutcome",
|
|
36
|
+
"TabctxEngine",
|
|
37
|
+
"TabctxError",
|
|
38
|
+
"Task",
|
|
39
|
+
"UploadNotFoundError",
|
|
40
|
+
"UploadTooLargeError",
|
|
41
|
+
]
|
tabctx/backends/base.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Backend protocol: what tabctx needs from a tabular ICL model implementation.
|
|
2
|
+
|
|
3
|
+
This is the seam that lets tabctx add a second backend (e.g. TabPFN) later
|
|
4
|
+
without touching the cache manager, memory estimator, or engine -- none of
|
|
5
|
+
those import a specific backend, they only depend on this protocol.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from tabctx.types import ArrayLike, PredictOutcome, Task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TabularICLBackend(Protocol):
|
|
16
|
+
"""A tabular in-context-learning model, wrapped for tabctx.
|
|
17
|
+
|
|
18
|
+
Implementations MUST construct a fresh underlying estimator/model
|
|
19
|
+
instance inside every fit() call. fit() mutates instance state (that's
|
|
20
|
+
the whole point -- it's where the training context gets encoded), so
|
|
21
|
+
reusing one shared instance across dataset_ids would silently corrupt
|
|
22
|
+
every cached context that isn't the most recent one.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
|
|
28
|
+
"""Encode the training context. Returns an opaque, backend-owned
|
|
29
|
+
payload that the cache manager retains standalone (the engine never
|
|
30
|
+
inspects it) and later passes back into predict()."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def predict(
|
|
34
|
+
self, payload: Any, X_test: ArrayLike, return_proba: bool = False
|
|
35
|
+
) -> PredictOutcome:
|
|
36
|
+
"""Run inference against a cached context. Must be a SINGLE
|
|
37
|
+
inference pass regardless of return_proba -- derive predictions from
|
|
38
|
+
probabilities (e.g. argmax) rather than calling predict() and
|
|
39
|
+
predict_proba() separately, since each call is a full forward pass
|
|
40
|
+
over the cached context."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
|
|
44
|
+
"""RESIDENT cost of the context most recently returned by fit(),
|
|
45
|
+
for cache capacity/eviction accounting -- called by the engine
|
|
46
|
+
AFTER fit(), not before, so an implementation can report a real
|
|
47
|
+
measurement (e.g. actual device memory delta) instead of a guess.
|
|
48
|
+
Return None if the backend has no opinion; the engine falls back
|
|
49
|
+
to its own (much more conservative, pre-fit-only) MemoryEstimator.
|
|
50
|
+
|
|
51
|
+
Implementations MAY also expose ``fit_peak_bytes_hint() -> int |
|
|
52
|
+
None`` (optional; the engine probes with getattr): the TRANSIENT
|
|
53
|
+
high-water memory of the most recent fit(). That is the
|
|
54
|
+
admission-relevant quantity -- peak is what actually OOMs, and it
|
|
55
|
+
can exceed the resident context by orders of magnitude -- and the
|
|
56
|
+
engine feeds it (not the resident size) back into the estimator's
|
|
57
|
+
admission learning."""
|
|
58
|
+
...
|
tabctx/backends/fake.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Deterministic backend with no GPU/torch/tabicl dependency, for unit tests.
|
|
2
|
+
|
|
3
|
+
Not a serious model: classification predicts the training majority class for
|
|
4
|
+
every test row, regression predicts the training mean. What matters for
|
|
5
|
+
tabctx's own tests is the *shape* of the contract (fit returns an opaque
|
|
6
|
+
payload, predict does one pass, context_bytes_hint is controllable), not
|
|
7
|
+
prediction quality.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from tabctx.types import ArrayLike, PredictOutcome, Task
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class _FakePayload:
|
|
22
|
+
task: Task
|
|
23
|
+
n_train: int
|
|
24
|
+
n_features: int
|
|
25
|
+
majority_class: str | None = None
|
|
26
|
+
class_fractions: dict[str, float] | None = None
|
|
27
|
+
mean_y: float | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeBackend:
|
|
31
|
+
"""Test double. `fit_delay_s`/`predict_delay_s` let tests simulate work
|
|
32
|
+
without a real GPU, e.g. to assert cache reuse actually skips fit()."""
|
|
33
|
+
|
|
34
|
+
name = "fake"
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
bytes_hint: int | None = None,
|
|
39
|
+
fit_delay_s: float = 0.0,
|
|
40
|
+
predict_delay_s: float = 0.0,
|
|
41
|
+
peak_bytes_hint: int | None = None,
|
|
42
|
+
name: str = "fake",
|
|
43
|
+
) -> None:
|
|
44
|
+
self.name = name # instance override (tests deploy several fakes)
|
|
45
|
+
self._bytes_hint = bytes_hint
|
|
46
|
+
self._peak_bytes_hint = peak_bytes_hint
|
|
47
|
+
self._fit_delay_s = fit_delay_s
|
|
48
|
+
self._predict_delay_s = predict_delay_s
|
|
49
|
+
self.fit_calls = 0
|
|
50
|
+
self.predict_calls = 0
|
|
51
|
+
|
|
52
|
+
def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
|
|
53
|
+
self.fit_calls += 1
|
|
54
|
+
if self._fit_delay_s:
|
|
55
|
+
time.sleep(self._fit_delay_s)
|
|
56
|
+
n_train = len(X)
|
|
57
|
+
n_features = len(X[0]) if n_train else 0
|
|
58
|
+
if task == "classification":
|
|
59
|
+
counts = Counter(str(v) for v in y)
|
|
60
|
+
total = sum(counts.values()) or 1
|
|
61
|
+
return _FakePayload(
|
|
62
|
+
task=task,
|
|
63
|
+
n_train=n_train,
|
|
64
|
+
n_features=n_features,
|
|
65
|
+
majority_class=counts.most_common(1)[0][0] if counts else None,
|
|
66
|
+
class_fractions={k: v / total for k, v in counts.items()},
|
|
67
|
+
)
|
|
68
|
+
mean_y = sum(float(v) for v in y) / len(y) if len(y) else 0.0
|
|
69
|
+
return _FakePayload(
|
|
70
|
+
task=task, n_train=n_train, n_features=n_features, mean_y=mean_y
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def predict(
|
|
74
|
+
self, payload: _FakePayload, X_test: ArrayLike, return_proba: bool = False
|
|
75
|
+
) -> PredictOutcome:
|
|
76
|
+
self.predict_calls += 1
|
|
77
|
+
if self._predict_delay_s:
|
|
78
|
+
time.sleep(self._predict_delay_s)
|
|
79
|
+
n_test = len(X_test)
|
|
80
|
+
if payload.task == "classification":
|
|
81
|
+
predictions = [payload.majority_class] * n_test
|
|
82
|
+
probabilities = None
|
|
83
|
+
classes = None
|
|
84
|
+
if return_proba:
|
|
85
|
+
fractions = payload.class_fractions or {}
|
|
86
|
+
classes = sorted(fractions)
|
|
87
|
+
row = [fractions.get(c, 0.0) for c in classes]
|
|
88
|
+
probabilities = [row for _ in range(n_test)]
|
|
89
|
+
return PredictOutcome(
|
|
90
|
+
predictions=predictions, probabilities=probabilities, classes=classes
|
|
91
|
+
)
|
|
92
|
+
return PredictOutcome(predictions=[payload.mean_y] * n_test)
|
|
93
|
+
|
|
94
|
+
def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
|
|
95
|
+
del n_train, n_features # unused: this backend's hint is fixed at construction
|
|
96
|
+
return self._bytes_hint
|
|
97
|
+
|
|
98
|
+
def fit_peak_bytes_hint(self) -> int | None:
|
|
99
|
+
return self._peak_bytes_hint
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""TabICL/TabICLv2 backend (github.com/soda-inria/tabicl).
|
|
2
|
+
|
|
3
|
+
Fixes two bugs found in the hand-rolled wrapper this library replaces
|
|
4
|
+
(inference-platform's tests/gke-tabicl-test/app.py):
|
|
5
|
+
|
|
6
|
+
1. That wrapper called model.predict() and then model.predict_proba()
|
|
7
|
+
separately when return_proba was requested -- two full inference passes
|
|
8
|
+
for one request. Here, predict_proba() is called once and predictions are
|
|
9
|
+
derived from it via argmax, so return_proba never costs an extra pass.
|
|
10
|
+
2. That wrapper built a fresh estimator per request and never reused
|
|
11
|
+
anything across requests, discarding the entire benefit of TabICL's own
|
|
12
|
+
fit-time context caching. Here, fit() still builds a fresh estimator per
|
|
13
|
+
call (required -- see backends/base.py), but the returned estimator is
|
|
14
|
+
retained by tabctx's ContextCacheManager and reused across many predict()
|
|
15
|
+
calls, which is the actual point of this library.
|
|
16
|
+
|
|
17
|
+
torch/tabicl are imported lazily inside this module (not at package import
|
|
18
|
+
time) so the rest of tabctx has no hard dependency on either.
|
|
19
|
+
|
|
20
|
+
EMPIRICAL CHECK (per the tabctx build plan, run locally on CPU before
|
|
21
|
+
finalizing this file): does constructing a fresh TabICLClassifier/Regressor
|
|
22
|
+
reload the checkpoint every time, or is it memoized in-process? Confirmed by
|
|
23
|
+
reading tabicl/_sklearn/classifier.py's _load_model(): huggingface_hub only
|
|
24
|
+
avoids re-downloading (the file is cached on disk after the first fetch),
|
|
25
|
+
but torch.load() + load_state_dict() run fresh on every instantiation --
|
|
26
|
+
there is no in-process memoization. Measured cost of that reload alone (CPU,
|
|
27
|
+
checkpoint already on disk): ~0.1s per fit() call, separate from and in
|
|
28
|
+
addition to the actual context-encoding cost. Fine for v1 (a fresh instance
|
|
29
|
+
per fit() is required regardless -- see backends/base.py), but a real
|
|
30
|
+
optimization opportunity for later: load the backbone weights once per
|
|
31
|
+
backend instance and share them across fit() calls, keeping only the
|
|
32
|
+
per-fit training-encoding state instance-specific.
|
|
33
|
+
|
|
34
|
+
REAL GPU MEMORY MEASUREMENT (found via extensive multi-tenant load testing
|
|
35
|
+
on a real A100-40GB, 2026-08-28): the formula-based MemoryEstimator, when
|
|
36
|
+
used to size a context for cache-capacity accounting, reported ~14x more
|
|
37
|
+
bytes than what was actually resident on the device for a realistic shape
|
|
38
|
+
(cache accounting said ~21.85GB used; torch.cuda.memory_allocated() said
|
|
39
|
+
~1.56GB) -- because that estimator is calibrated on active train+test
|
|
40
|
+
forward-pass memory, not resting post-fit context size, and (separately)
|
|
41
|
+
already over-estimates small inputs (see memory/estimator.py). This
|
|
42
|
+
needlessly throttled effective multi-tenant capacity to a fraction of what
|
|
43
|
+
the hardware actually supports. Fixed here by measuring the real
|
|
44
|
+
torch.cuda.memory_allocated() delta across fit() and reporting it via
|
|
45
|
+
context_bytes_hint() -- safe because the engine holds its cache lock across
|
|
46
|
+
the whole fit() call (see engine.py), so no concurrent GPU work can pollute
|
|
47
|
+
the before/after delta.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
|
|
56
|
+
from tabctx.errors import BackendComputeError
|
|
57
|
+
from tabctx.types import ArrayLike, PredictOutcome, Task
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TabICLBackend:
|
|
61
|
+
# The exact model id callers select via the API's `model` field --
|
|
62
|
+
# named for the checkpoint actually loaded (tabicl-classifier-v2-*).
|
|
63
|
+
name = "tabicl-v2"
|
|
64
|
+
|
|
65
|
+
def __init__(self, device: str | None = None, kv_cache: bool | str = "kv") -> None:
|
|
66
|
+
"""kv_cache: passed through to TabICL ("kv", "repr", True, or
|
|
67
|
+
False). tabicl's own default is False, which makes every
|
|
68
|
+
predict() re-encode the ENTIRE training set through all three
|
|
69
|
+
transformer stages -- i.e. the exact cost tabctx exists to avoid
|
|
70
|
+
paying twice (confirmed by tracing predict_proba: with no cache,
|
|
71
|
+
transform(mode="both") re-concatenates and re-encodes train+test
|
|
72
|
+
on every call). tabctx defaults it ON ("kv": fastest, most
|
|
73
|
+
memory-hungry -- the memory cost lands inside fit()'s measured
|
|
74
|
+
delta, so cache accounting and the adaptive admission gate see it
|
|
75
|
+
automatically; "repr" trades ~24x less ICL-cache memory for
|
|
76
|
+
re-running the ICL stack per predict)."""
|
|
77
|
+
import torch
|
|
78
|
+
|
|
79
|
+
self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
80
|
+
self._kv_cache = kv_cache
|
|
81
|
+
self._last_context_bytes: int | None = None
|
|
82
|
+
self._last_fit_peak_bytes: int | None = None
|
|
83
|
+
# task -> attrs of an already-loaded backbone, shared across
|
|
84
|
+
# fits. Loading is ~hundreds of MB of torch.load + H2D transfer
|
|
85
|
+
# PER FIT without this. Sharing one nn.Module across estimators
|
|
86
|
+
# is tabicl's own sanctioned pattern (see
|
|
87
|
+
# _unsupervised/unsupervised.py and _finetune/base.py, both of
|
|
88
|
+
# which do exactly this with the comment "prevents redundant
|
|
89
|
+
# torch.load() calls"). Safe here because the engine serializes
|
|
90
|
+
# every backend call behind its cache lock -- the shared module's
|
|
91
|
+
# per-call mutable state (its _cache pointer, InferenceManager
|
|
92
|
+
# config) is never touched concurrently.
|
|
93
|
+
self._shared_backbones: dict[str, dict[str, Any]] = {}
|
|
94
|
+
|
|
95
|
+
def _make_estimator(self, task: Task) -> Any:
|
|
96
|
+
if task == "classification":
|
|
97
|
+
from tabicl import TabICLClassifier
|
|
98
|
+
|
|
99
|
+
est = TabICLClassifier(device=self._device, kv_cache=self._kv_cache)
|
|
100
|
+
else:
|
|
101
|
+
from tabicl import TabICLRegressor
|
|
102
|
+
|
|
103
|
+
est = TabICLRegressor(device=self._device, kv_cache=self._kv_cache)
|
|
104
|
+
|
|
105
|
+
shared = self._shared_backbones.get(task)
|
|
106
|
+
if shared is not None:
|
|
107
|
+
for attr, value in shared.items():
|
|
108
|
+
setattr(est, attr, value)
|
|
109
|
+
# Instance attribute shadows the method: fit() calls
|
|
110
|
+
# self._load_model() and gets this no-op instead of a fresh
|
|
111
|
+
# torch.load + load_state_dict.
|
|
112
|
+
est._load_model = lambda: None
|
|
113
|
+
return est
|
|
114
|
+
|
|
115
|
+
def _stash_backbone(self, task: Task, model: Any) -> None:
|
|
116
|
+
if task in self._shared_backbones:
|
|
117
|
+
return
|
|
118
|
+
shared = {"model_": model.model_}
|
|
119
|
+
# Keep save()/pickling of estimators working: __setstate__ only
|
|
120
|
+
# rebuilds from weights when model_config_ exists (see tabicl
|
|
121
|
+
# _sklearn/base.py); copying these mirrors _finetune/base.py.
|
|
122
|
+
for attr in ("model_config_", "model_path_"):
|
|
123
|
+
if hasattr(model, attr):
|
|
124
|
+
shared[attr] = getattr(model, attr)
|
|
125
|
+
self._shared_backbones[task] = shared
|
|
126
|
+
|
|
127
|
+
def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
|
|
128
|
+
import torch
|
|
129
|
+
|
|
130
|
+
# Fresh estimator instance every call (load-bearing -- see
|
|
131
|
+
# backends/base.py), but the underlying pretrained nn.Module is
|
|
132
|
+
# shared across fits after the first (see __init__).
|
|
133
|
+
model = self._make_estimator(task)
|
|
134
|
+
|
|
135
|
+
measuring = self._device == "cuda"
|
|
136
|
+
before_bytes = torch.cuda.memory_allocated() if measuring else 0
|
|
137
|
+
if measuring:
|
|
138
|
+
torch.cuda.reset_peak_memory_stats()
|
|
139
|
+
try:
|
|
140
|
+
model.fit(X, y)
|
|
141
|
+
except torch.cuda.OutOfMemoryError as e:
|
|
142
|
+
self._last_context_bytes = None
|
|
143
|
+
self._last_fit_peak_bytes = None
|
|
144
|
+
raise BackendComputeError(f"CUDA OOM during fit(): {e}") from e
|
|
145
|
+
# Two DIFFERENT real measurements, for two different consumers --
|
|
146
|
+
# conflating them was the v0.4.0 design flaw fixed in v0.9.0:
|
|
147
|
+
# - resident delta (context_bytes_hint): what the fitted context
|
|
148
|
+
# occupies afterward -> cache capacity/eviction accounting.
|
|
149
|
+
# - peak delta (fit_peak_bytes_hint): the transient high-water
|
|
150
|
+
# during fit -> what ADMISSION must bound, since this is what
|
|
151
|
+
# actually OOMs. Peak can exceed resident by orders of
|
|
152
|
+
# magnitude (activations, ensemble intermediates).
|
|
153
|
+
# None on CPU; the engine falls back to the formula estimator.
|
|
154
|
+
if measuring:
|
|
155
|
+
after_bytes = torch.cuda.memory_allocated()
|
|
156
|
+
self._last_context_bytes = max(0, after_bytes - before_bytes)
|
|
157
|
+
self._last_fit_peak_bytes = max(
|
|
158
|
+
0, torch.cuda.max_memory_allocated() - before_bytes
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
self._last_context_bytes = None
|
|
162
|
+
self._last_fit_peak_bytes = None
|
|
163
|
+
self._stash_backbone(task, model)
|
|
164
|
+
# Explicit even though tabicl's fit path clears internally today:
|
|
165
|
+
# a future tabicl change must not silently re-pin (see
|
|
166
|
+
# _unpin_shared_module for the OOM this prevents).
|
|
167
|
+
self._unpin_shared_module(model)
|
|
168
|
+
return model
|
|
169
|
+
|
|
170
|
+
def _unpin_shared_module(self, model: Any) -> None:
|
|
171
|
+
"""Drop the shared backbone's pointer to the last-used context.
|
|
172
|
+
|
|
173
|
+
tabicl's `forward_with_cache` stores the active context's cache
|
|
174
|
+
on the MODULE (`model_._cache = cache`) and leaves it there.
|
|
175
|
+
With the backbone shared across all contexts (see __init__),
|
|
176
|
+
that pointer pins the most recently used context's multi-GB kv
|
|
177
|
+
cache even after tabctx evicts it -- found on a real A100 as
|
|
178
|
+
~15GB that eviction "freed" in the accounting but never left the
|
|
179
|
+
device, eventually OOMing a fit the books said was safe. The
|
|
180
|
+
context's own cache lives on its estimator (model_kv_cache_), so
|
|
181
|
+
clearing the module pointer costs nothing and unpins eviction."""
|
|
182
|
+
module = getattr(model, "model_", None)
|
|
183
|
+
if module is not None and hasattr(module, "clear_cache"):
|
|
184
|
+
module.clear_cache()
|
|
185
|
+
|
|
186
|
+
def predict(
|
|
187
|
+
self, payload: Any, X_test: ArrayLike, return_proba: bool = False
|
|
188
|
+
) -> PredictOutcome:
|
|
189
|
+
import torch
|
|
190
|
+
|
|
191
|
+
model = payload
|
|
192
|
+
try:
|
|
193
|
+
if hasattr(model, "predict_proba"):
|
|
194
|
+
if return_proba:
|
|
195
|
+
proba = model.predict_proba(X_test)
|
|
196
|
+
class_idx = np.argmax(proba, axis=1)
|
|
197
|
+
classes = [str(c) for c in model.classes_]
|
|
198
|
+
predictions = [classes[i] for i in class_idx]
|
|
199
|
+
return PredictOutcome(
|
|
200
|
+
predictions=predictions,
|
|
201
|
+
probabilities=proba.tolist(),
|
|
202
|
+
classes=classes,
|
|
203
|
+
)
|
|
204
|
+
predictions = model.predict(X_test).tolist()
|
|
205
|
+
return PredictOutcome(predictions=predictions)
|
|
206
|
+
# Regressor: no predict_proba to derive from.
|
|
207
|
+
predictions = model.predict(X_test).tolist()
|
|
208
|
+
return PredictOutcome(predictions=predictions)
|
|
209
|
+
except torch.cuda.OutOfMemoryError as e:
|
|
210
|
+
raise BackendComputeError(f"CUDA OOM during predict(): {e}") from e
|
|
211
|
+
finally:
|
|
212
|
+
self._unpin_shared_module(model)
|
|
213
|
+
|
|
214
|
+
def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
|
|
215
|
+
del n_train, n_features
|
|
216
|
+
# Real measurement from the fit() call this is queried after (see
|
|
217
|
+
# module docstring); None on CPU or if fit() OOM'd, in which case
|
|
218
|
+
# the engine falls back to the formula-based MemoryEstimator.
|
|
219
|
+
return self._last_context_bytes
|
|
220
|
+
|
|
221
|
+
def fit_peak_bytes_hint(self) -> int | None:
|
|
222
|
+
"""Transient high-water memory of the most recent fit() -- the
|
|
223
|
+
admission-relevant quantity (see fit()); None on CPU/after OOM."""
|
|
224
|
+
return self._last_fit_peak_bytes
|
|
225
|
+
|
|
226
|
+
# ---- spillover serialization (see cache/spill.py) ----------------
|
|
227
|
+
|
|
228
|
+
def dumps_payload(self, payload: Any) -> bytes:
|
|
229
|
+
"""Pickle a fitted estimator for the disk spill tier WITHOUT the
|
|
230
|
+
shared pretrained backbone (hundreds of MB, identical across all
|
|
231
|
+
contexts, unpicklable anyway once _load_model is shadowed with a
|
|
232
|
+
lambda): strip the shared attributes, pickle, restore."""
|
|
233
|
+
import pickle
|
|
234
|
+
|
|
235
|
+
stripped = {}
|
|
236
|
+
for attr in ("model_", "_load_model"):
|
|
237
|
+
if attr in payload.__dict__:
|
|
238
|
+
stripped[attr] = payload.__dict__.pop(attr)
|
|
239
|
+
try:
|
|
240
|
+
return pickle.dumps(payload)
|
|
241
|
+
finally:
|
|
242
|
+
payload.__dict__.update(stripped)
|
|
243
|
+
|
|
244
|
+
def loads_payload(self, data: bytes) -> Any:
|
|
245
|
+
"""Inverse of dumps_payload: unpickle and re-attach this
|
|
246
|
+
process's shared backbone (loading it first if this replica has
|
|
247
|
+
never fit this task type -- e.g. right after a restart)."""
|
|
248
|
+
import pickle
|
|
249
|
+
|
|
250
|
+
model = pickle.loads(data)
|
|
251
|
+
task: Task = (
|
|
252
|
+
"classification" if hasattr(model, "predict_proba") else "regression"
|
|
253
|
+
)
|
|
254
|
+
if task not in self._shared_backbones:
|
|
255
|
+
# Cold restore on a fresh replica: borrow a throwaway
|
|
256
|
+
# estimator's load path to populate the shared backbone.
|
|
257
|
+
probe = self._make_estimator(task)
|
|
258
|
+
probe._load_model()
|
|
259
|
+
self._stash_backbone(task, probe)
|
|
260
|
+
for attr, value in self._shared_backbones[task].items():
|
|
261
|
+
setattr(model, attr, value)
|
|
262
|
+
model._load_model = lambda: None
|
|
263
|
+
return model
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""TabPFN backend (github.com/PriorLabs/TabPFN).
|
|
2
|
+
|
|
3
|
+
The second backend behind the `TabularICLBackend` protocol -- proving
|
|
4
|
+
the engine/cache/estimator stack really is backend-agnostic (the design
|
|
5
|
+
claim in backends/base.py; nothing outside this module imports tabpfn).
|
|
6
|
+
|
|
7
|
+
The kv-cache lesson from TabICL (see backends/tabicl.py and CHANGELOG
|
|
8
|
+
0.7.0) applies verbatim here: TabPFN's default `fit_mode` is
|
|
9
|
+
"fit_preprocessors", which caches training-data PREPROCESSING but still
|
|
10
|
+
re-runs the transformer's training-context forward pass on every
|
|
11
|
+
predict. Its equivalent of the kv cache is `fit_mode="fit_with_cache"`
|
|
12
|
+
("the transformer key-value cache is also initialized, allowing for much
|
|
13
|
+
faster inference on the same data at a large cost of memory" -- exactly
|
|
14
|
+
the cost tabctx's memory accounting exists to price). tabctx maps its
|
|
15
|
+
cache modes accordingly:
|
|
16
|
+
|
|
17
|
+
tabctx mode -> TabPFN fit_mode
|
|
18
|
+
"kv" "fit_with_cache" (fastest predicts, memory-heavy)
|
|
19
|
+
"repr" "fit_preprocessors" (cheap contexts, slower predicts)
|
|
20
|
+
"off" "low_memory" (escape hatch; re-preprocesses
|
|
21
|
+
per predict)
|
|
22
|
+
|
|
23
|
+
TabPFN enforces pretraining limits (~10k train rows, ~500 features on
|
|
24
|
+
v2-series checkpoints) by raising before any meaningful GPU work; those
|
|
25
|
+
surface as InvalidInputError so a multi-tenant caller gets a clean 422,
|
|
26
|
+
not a 500. Unlike TabICL, TabPFN memoizes model loading internally per
|
|
27
|
+
(model_path, device) via its model-cache mechanism, so there is no
|
|
28
|
+
per-fit torch.load to elide here; a fresh estimator per fit() (required
|
|
29
|
+
by the protocol -- fit() mutates estimator state) is already cheap.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from tabctx.errors import BackendComputeError, InvalidInputError
|
|
39
|
+
from tabctx.types import ArrayLike, PredictOutcome, Task
|
|
40
|
+
|
|
41
|
+
_FIT_MODES = {
|
|
42
|
+
"kv": "fit_with_cache",
|
|
43
|
+
"repr": "fit_preprocessors",
|
|
44
|
+
"off": "low_memory",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TabPFNBackend:
|
|
49
|
+
# The exact model id callers select via the API's `model` field --
|
|
50
|
+
# named for the checkpoint the OSS package loads locally
|
|
51
|
+
# (tabpfn-v3-*-v3_default.ckpt, i.e. PriorLabs' "TabPFN-3").
|
|
52
|
+
name = "tabpfn-3"
|
|
53
|
+
|
|
54
|
+
def __init__(self, device: str | None = None, cache_mode: str = "kv") -> None:
|
|
55
|
+
import torch
|
|
56
|
+
|
|
57
|
+
if cache_mode not in _FIT_MODES:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"cache_mode must be one of {sorted(_FIT_MODES)}, got {cache_mode!r}"
|
|
60
|
+
)
|
|
61
|
+
self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
62
|
+
self._fit_mode = _FIT_MODES[cache_mode]
|
|
63
|
+
self._last_context_bytes: int | None = None
|
|
64
|
+
self._last_fit_peak_bytes: int | None = None
|
|
65
|
+
|
|
66
|
+
def _make_estimator(self, task: Task) -> Any:
|
|
67
|
+
if task == "classification":
|
|
68
|
+
from tabpfn import TabPFNClassifier
|
|
69
|
+
|
|
70
|
+
return TabPFNClassifier(device=self._device, fit_mode=self._fit_mode)
|
|
71
|
+
from tabpfn import TabPFNRegressor
|
|
72
|
+
|
|
73
|
+
return TabPFNRegressor(device=self._device, fit_mode=self._fit_mode)
|
|
74
|
+
|
|
75
|
+
def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
|
|
76
|
+
import torch
|
|
77
|
+
|
|
78
|
+
model = self._make_estimator(task)
|
|
79
|
+
measuring = self._device == "cuda"
|
|
80
|
+
before_bytes = torch.cuda.memory_allocated() if measuring else 0
|
|
81
|
+
if measuring:
|
|
82
|
+
torch.cuda.reset_peak_memory_stats()
|
|
83
|
+
try:
|
|
84
|
+
model.fit(np.asarray(X, dtype=float), np.asarray(y))
|
|
85
|
+
except torch.cuda.OutOfMemoryError as e:
|
|
86
|
+
self._last_context_bytes = None
|
|
87
|
+
self._last_fit_peak_bytes = None
|
|
88
|
+
raise BackendComputeError(f"CUDA OOM during fit(): {e}") from e
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
# TabPFN's own input gate (pretraining limits: too many rows,
|
|
91
|
+
# features, or classes) raises before meaningful GPU work; a
|
|
92
|
+
# caller's oversized table is bad input, not a server fault.
|
|
93
|
+
self._last_context_bytes = None
|
|
94
|
+
raise InvalidInputError(f"tabpfn rejected the training table: {e}") from e
|
|
95
|
+
# Resident vs peak: two different consumers -- see
|
|
96
|
+
# backends/tabicl.py fit() for the full rationale.
|
|
97
|
+
if measuring:
|
|
98
|
+
self._last_context_bytes = max(
|
|
99
|
+
0, torch.cuda.memory_allocated() - before_bytes
|
|
100
|
+
)
|
|
101
|
+
self._last_fit_peak_bytes = max(
|
|
102
|
+
0, torch.cuda.max_memory_allocated() - before_bytes
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
self._last_context_bytes = None
|
|
106
|
+
self._last_fit_peak_bytes = None
|
|
107
|
+
return model
|
|
108
|
+
|
|
109
|
+
def predict(
|
|
110
|
+
self, payload: Any, X_test: ArrayLike, return_proba: bool = False
|
|
111
|
+
) -> PredictOutcome:
|
|
112
|
+
import torch
|
|
113
|
+
|
|
114
|
+
model = payload
|
|
115
|
+
X_arr = np.asarray(X_test, dtype=float)
|
|
116
|
+
try:
|
|
117
|
+
if hasattr(model, "predict_proba"):
|
|
118
|
+
# Single pass regardless of return_proba (protocol
|
|
119
|
+
# requirement): derive labels from probabilities.
|
|
120
|
+
proba = model.predict_proba(X_arr)
|
|
121
|
+
class_idx = np.argmax(proba, axis=1)
|
|
122
|
+
classes = [str(c) for c in model.classes_]
|
|
123
|
+
predictions = [classes[i] for i in class_idx]
|
|
124
|
+
if return_proba:
|
|
125
|
+
return PredictOutcome(
|
|
126
|
+
predictions=predictions,
|
|
127
|
+
probabilities=proba.tolist(),
|
|
128
|
+
classes=classes,
|
|
129
|
+
)
|
|
130
|
+
return PredictOutcome(predictions=predictions)
|
|
131
|
+
return PredictOutcome(predictions=model.predict(X_arr).tolist())
|
|
132
|
+
except torch.cuda.OutOfMemoryError as e:
|
|
133
|
+
raise BackendComputeError(f"CUDA OOM during predict(): {e}") from e
|
|
134
|
+
|
|
135
|
+
def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
|
|
136
|
+
del n_train, n_features
|
|
137
|
+
# Real measurement from the fit() this is queried after; None on
|
|
138
|
+
# CPU, where the engine falls back to its MemoryEstimator. NOTE:
|
|
139
|
+
# that estimator's static calibration is TabICL-on-A100 data --
|
|
140
|
+
# treat capacity numbers for TabPFN deployments as provisional
|
|
141
|
+
# until real fits accumulate in the AdaptiveMemoryEstimator.
|
|
142
|
+
return self._last_context_bytes
|
|
143
|
+
|
|
144
|
+
def fit_peak_bytes_hint(self) -> int | None:
|
|
145
|
+
"""Transient high-water memory of the most recent fit() -- the
|
|
146
|
+
admission-relevant quantity; None on CPU/after OOM."""
|
|
147
|
+
return self._last_fit_peak_bytes
|