prism-engine 1.0.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.
- prism/__init__.py +156 -0
- prism/adapters/__init__.py +5 -0
- prism/adapters/data_pipeline.py +146 -0
- prism/adapters/jax_adapter.py +77 -0
- prism/adapters/pytorch_adapter.py +167 -0
- prism/adapters/sklearn_adapter.py +140 -0
- prism/cache.py +621 -0
- prism/classifier.py +103 -0
- prism/cli.py +619 -0
- prism/config.py +208 -0
- prism/core.py +875 -0
- prism/encryption.py +137 -0
- prism/hashing.py +404 -0
- prism/integrations/__init__.py +0 -0
- prism/integrations/fastapi.py +101 -0
- prism/integrations/langchain.py +102 -0
- prism/logging_util.py +104 -0
- prism/metrics.py +202 -0
- prism/observer.py +119 -0
- prism/predictor.py +73 -0
- prism/predictor_lstm.py +245 -0
- prism/redis_cache.py +287 -0
- prism/report.py +301 -0
- prism/semantic_cache.py +332 -0
- prism/serialization.py +229 -0
- prism/state.py +189 -0
- prism/telemetry.py +97 -0
- prism_engine-1.0.0.dist-info/METADATA +891 -0
- prism_engine-1.0.0.dist-info/RECORD +33 -0
- prism_engine-1.0.0.dist-info/WHEEL +5 -0
- prism_engine-1.0.0.dist-info/entry_points.txt +2 -0
- prism_engine-1.0.0.dist-info/licenses/LICENSE +21 -0
- prism_engine-1.0.0.dist-info/top_level.txt +1 -0
prism/__init__.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Prism Compute Engine — speculative-redundant-compute elimination for ML workloads.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
from prism import PrismEngine, WorkloadClassifier, WasteReport, PrismConfig
|
|
5
|
+
"""
|
|
6
|
+
import contextlib
|
|
7
|
+
|
|
8
|
+
from prism.hashing import hash_object, hash_call, hash_numpy, fingerprint_array
|
|
9
|
+
from prism.config import PrismConfig, CacheConfig, HashingConfig, PredictorConfig, AdapterConfig, LoggingConfig, load_config
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ─── Default engine + decorator API ──────────────────────────────────────────
|
|
15
|
+
_default_engine = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_default_engine():
|
|
19
|
+
"""Return (creating if needed) the module-global default PrismEngine.
|
|
20
|
+
|
|
21
|
+
The default engine is in ACTIVE mode with the default config. Use
|
|
22
|
+
``set_default_engine`` to override it (e.g. with a disk-backed or observe
|
|
23
|
+
engine).
|
|
24
|
+
"""
|
|
25
|
+
global _default_engine
|
|
26
|
+
if _default_engine is None:
|
|
27
|
+
from prism.core import PrismEngine, EngineMode
|
|
28
|
+
_default_engine = PrismEngine(mode=EngineMode.ACTIVE)
|
|
29
|
+
return _default_engine
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_default_engine(engine) -> None:
|
|
33
|
+
"""Override the module-global default engine used by ``@prism.cache``."""
|
|
34
|
+
global _default_engine
|
|
35
|
+
_default_engine = engine
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cached(arg=None):
|
|
39
|
+
"""Decorator that routes a function's calls through a Prism engine.
|
|
40
|
+
|
|
41
|
+
Two usage forms::
|
|
42
|
+
|
|
43
|
+
@prism.cached # uses the default active engine
|
|
44
|
+
def train(X, y):
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
@prism.cached(my_engine) # use an explicit engine
|
|
48
|
+
def train(X, y):
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
In ACTIVE mode the wrapped function only runs on a cache miss; in OBSERVE
|
|
52
|
+
mode it always runs and the would-have-been-hit is recorded.
|
|
53
|
+
|
|
54
|
+
(Named ``prism.cached`` rather than ``prism.cache`` to avoid clashing with
|
|
55
|
+
the ``prism.cache`` submodule that holds the cache backends.)
|
|
56
|
+
"""
|
|
57
|
+
# `arg` is a plain function -> used as `@prism.cached` with no engine arg.
|
|
58
|
+
if arg is None or not hasattr(arg, "call"):
|
|
59
|
+
engine = get_default_engine()
|
|
60
|
+
if arg is None:
|
|
61
|
+
# `@prism.cached(engine)` form: return the decorator bound to engine.
|
|
62
|
+
return engine.cached
|
|
63
|
+
# `@prism.cached` direct form: arg is the function.
|
|
64
|
+
return engine.cached(arg)
|
|
65
|
+
# `arg` is an engine -> `@prism.cached(engine)` returns its cache decorator.
|
|
66
|
+
return arg.cached
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@contextlib.contextmanager
|
|
70
|
+
def scope(engine, *adapter_classes):
|
|
71
|
+
"""Context manager that wraps a set of adapters around an engine and
|
|
72
|
+
guarantees they are unwrapped on exit (even on exception).
|
|
73
|
+
|
|
74
|
+
Example::
|
|
75
|
+
|
|
76
|
+
from prism import PrismEngine, EngineMode, scope
|
|
77
|
+
from prism.adapters.sklearn_adapter import SklearnAdapter
|
|
78
|
+
|
|
79
|
+
engine = PrismEngine(mode=EngineMode.ACTIVE)
|
|
80
|
+
with scope(engine, SklearnAdapter):
|
|
81
|
+
run_pipeline() # sklearn ops routed through Prism
|
|
82
|
+
# adapters restored on exit
|
|
83
|
+
"""
|
|
84
|
+
adapters = []
|
|
85
|
+
try:
|
|
86
|
+
for cls in adapter_classes:
|
|
87
|
+
a = cls(engine)
|
|
88
|
+
a.wrap()
|
|
89
|
+
adapters.append(a)
|
|
90
|
+
yield engine
|
|
91
|
+
finally:
|
|
92
|
+
for a in adapters:
|
|
93
|
+
try:
|
|
94
|
+
a.unwrap()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def __getattr__(name: str):
|
|
100
|
+
"""Lazy imports — modules are loaded on first attribute access."""
|
|
101
|
+
_lazy = {
|
|
102
|
+
"PrismEngine": ("prism.core", "PrismEngine"),
|
|
103
|
+
"EngineMode": ("prism.core", "EngineMode"),
|
|
104
|
+
"WorkloadClassifier": ("prism.classifier", "WorkloadClassifier"),
|
|
105
|
+
"ClassificationReport": ("prism.classifier", "ClassificationReport"),
|
|
106
|
+
"Observer": ("prism.observer", "Observer"),
|
|
107
|
+
"WasteReport": ("prism.report", "WasteReport"),
|
|
108
|
+
"StatisticalPredictor": ("prism.predictor", "StatisticalPredictor"),
|
|
109
|
+
"LSTMPredictor": ("prism.predictor_lstm", "LSTMPredictor"),
|
|
110
|
+
"MetricsExporter": ("prism.metrics", "MetricsExporter"),
|
|
111
|
+
"RedisCache": ("prism.redis_cache", "RedisCache"),
|
|
112
|
+
"Serializer": ("prism.serialization", "Serializer"),
|
|
113
|
+
"SemanticCache": ("prism.semantic_cache", "SemanticCache"),
|
|
114
|
+
"TelemetryHook": ("prism.telemetry", "TelemetryHook"),
|
|
115
|
+
"CostConfig": ("prism.config", "CostConfig"),
|
|
116
|
+
}
|
|
117
|
+
if name in _lazy:
|
|
118
|
+
import importlib
|
|
119
|
+
|
|
120
|
+
mod_name, attr = _lazy[name]
|
|
121
|
+
mod = importlib.import_module(mod_name)
|
|
122
|
+
return getattr(mod, attr)
|
|
123
|
+
raise AttributeError(f"module 'prism' has no attribute {name!r}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"PrismEngine",
|
|
128
|
+
"EngineMode",
|
|
129
|
+
"WorkloadClassifier",
|
|
130
|
+
"ClassificationReport",
|
|
131
|
+
"Observer",
|
|
132
|
+
"WasteReport",
|
|
133
|
+
"StatisticalPredictor",
|
|
134
|
+
"LSTMPredictor",
|
|
135
|
+
"MetricsExporter",
|
|
136
|
+
"RedisCache",
|
|
137
|
+
"Serializer",
|
|
138
|
+
"SemanticCache",
|
|
139
|
+
"TelemetryHook",
|
|
140
|
+
"CostConfig",
|
|
141
|
+
"PrismConfig",
|
|
142
|
+
"CacheConfig",
|
|
143
|
+
"HashingConfig",
|
|
144
|
+
"PredictorConfig",
|
|
145
|
+
"AdapterConfig",
|
|
146
|
+
"LoggingConfig",
|
|
147
|
+
"load_config",
|
|
148
|
+
"hash_object",
|
|
149
|
+
"hash_call",
|
|
150
|
+
"hash_numpy",
|
|
151
|
+
"fingerprint_array",
|
|
152
|
+
"cached",
|
|
153
|
+
"scope",
|
|
154
|
+
"get_default_engine",
|
|
155
|
+
"set_default_engine",
|
|
156
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Data pipeline adapter — routes pandas/numpy data transforms through Prism.
|
|
2
|
+
|
|
3
|
+
Validated workload: repeated transform() / fit_transform() on identical
|
|
4
|
+
DataFrames or arrays — dedup the transform when input data is unchanged.
|
|
5
|
+
|
|
6
|
+
Patches:
|
|
7
|
+
- numpy: np.sort, np.unique, np.argsort, np.partition, np.searchsorted,
|
|
8
|
+
np.histogram, np.bincount, np.digitize, np.percentile, np.quantile
|
|
9
|
+
- pandas: DataFrame.apply, DataFrame.groupby, DataFrame.merge,
|
|
10
|
+
DataFrame.pivot_table, DataFrame.resample, Series.map,
|
|
11
|
+
Series.value_counts
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from prism.core import PrismEngine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# numpy functions to patch
|
|
23
|
+
_NUMPY_FUNCS = (
|
|
24
|
+
"sort", "unique", "argsort", "partition", "searchsorted",
|
|
25
|
+
"histogram", "bincount", "digitize", "percentile", "quantile",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# pandas DataFrame methods to patch
|
|
29
|
+
_PANDAS_DF_METHODS = (
|
|
30
|
+
"apply", "groupby", "merge", "pivot_table", "resample",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# pandas Series methods to patch
|
|
34
|
+
_PANDAS_SERIES_METHODS = (
|
|
35
|
+
"map", "value_counts",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DataPipelineAdapter:
|
|
40
|
+
"""Patch common data-transform functions to route through Prism.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
adapter = DataPipelineAdapter(engine)
|
|
44
|
+
adapter.wrap()
|
|
45
|
+
...run your data pipeline...
|
|
46
|
+
adapter.unwrap()
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, engine: PrismEngine) -> None:
|
|
50
|
+
self.engine = engine
|
|
51
|
+
self._originals: dict[str, Any] = {}
|
|
52
|
+
|
|
53
|
+
def wrap(self) -> "DataPipelineAdapter":
|
|
54
|
+
# numpy functions
|
|
55
|
+
for fn_name in _NUMPY_FUNCS:
|
|
56
|
+
key = f"np.{fn_name}"
|
|
57
|
+
if key in self._originals:
|
|
58
|
+
continue
|
|
59
|
+
original = getattr(np, fn_name, None)
|
|
60
|
+
if original is None:
|
|
61
|
+
continue
|
|
62
|
+
self._originals[key] = original
|
|
63
|
+
setattr(np, fn_name, self._make_np_wrapper(original, fn_name))
|
|
64
|
+
|
|
65
|
+
# pandas methods (optional)
|
|
66
|
+
try:
|
|
67
|
+
import pandas as pd
|
|
68
|
+
|
|
69
|
+
for method_name in _PANDAS_DF_METHODS:
|
|
70
|
+
key = f"DataFrame.{method_name}"
|
|
71
|
+
if key in self._originals:
|
|
72
|
+
continue
|
|
73
|
+
if hasattr(pd.DataFrame, method_name):
|
|
74
|
+
original = getattr(pd.DataFrame, method_name)
|
|
75
|
+
self._originals[key] = original
|
|
76
|
+
setattr(
|
|
77
|
+
pd.DataFrame,
|
|
78
|
+
method_name,
|
|
79
|
+
self._make_method_wrapper(original, f"DataFrame.{method_name}"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
for method_name in _PANDAS_SERIES_METHODS:
|
|
83
|
+
key = f"Series.{method_name}"
|
|
84
|
+
if key in self._originals:
|
|
85
|
+
continue
|
|
86
|
+
if hasattr(pd.Series, method_name):
|
|
87
|
+
original = getattr(pd.Series, method_name)
|
|
88
|
+
self._originals[key] = original
|
|
89
|
+
setattr(
|
|
90
|
+
pd.Series,
|
|
91
|
+
method_name,
|
|
92
|
+
self._make_method_wrapper(original, f"Series.{method_name}"),
|
|
93
|
+
)
|
|
94
|
+
except ImportError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def unwrap(self) -> None:
|
|
100
|
+
# Restore numpy
|
|
101
|
+
for key, original in list(self._originals.items()):
|
|
102
|
+
if key.startswith("np."):
|
|
103
|
+
setattr(np, key[3:], original)
|
|
104
|
+
del self._originals[key]
|
|
105
|
+
# Restore pandas
|
|
106
|
+
try:
|
|
107
|
+
import pandas as pd
|
|
108
|
+
|
|
109
|
+
for key, original in list(self._originals.items()):
|
|
110
|
+
if key.startswith("DataFrame."):
|
|
111
|
+
setattr(pd.DataFrame, key[len("DataFrame."):], original)
|
|
112
|
+
del self._originals[key]
|
|
113
|
+
elif key.startswith("Series."):
|
|
114
|
+
setattr(pd.Series, key[len("Series."):], original)
|
|
115
|
+
del self._originals[key]
|
|
116
|
+
except ImportError:
|
|
117
|
+
pass
|
|
118
|
+
self._originals.clear()
|
|
119
|
+
|
|
120
|
+
# ─── Context-manager protocol ─────────────────────────────────────────
|
|
121
|
+
def __enter__(self) -> "DataPipelineAdapter":
|
|
122
|
+
return self.wrap()
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *exc) -> bool:
|
|
125
|
+
self.unwrap()
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def _make_np_wrapper(self, original, name: str):
|
|
129
|
+
engine = self.engine
|
|
130
|
+
|
|
131
|
+
def wrapped(*args, **kwargs):
|
|
132
|
+
fn_name = f"np.{name}"
|
|
133
|
+
return engine.call(original, args, kwargs, fn_name=fn_name)
|
|
134
|
+
|
|
135
|
+
wrapped.__name__ = name
|
|
136
|
+
return wrapped
|
|
137
|
+
|
|
138
|
+
def _make_method_wrapper(self, original, name: str):
|
|
139
|
+
engine = self.engine
|
|
140
|
+
|
|
141
|
+
def wrapped(self_obj, *args, **kwargs):
|
|
142
|
+
fn_name = name
|
|
143
|
+
return engine.call(original, (self_obj,) + args, kwargs, fn_name=fn_name)
|
|
144
|
+
|
|
145
|
+
wrapped.__name__ = name.split(".")[-1]
|
|
146
|
+
return wrapped
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""JAX adapter — routes jit-compiled training steps through Prism.
|
|
2
|
+
|
|
3
|
+
Validated workload: training sweep with repeated (params + data) — dedup
|
|
4
|
+
jit-compiled steps. Optional dependency: skips gracefully when jax is absent.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from prism.core import PrismEngine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JAXAdapter:
|
|
14
|
+
"""Patch jax.jit (and jax.pmap) to route through Prism.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
adapter = JAXAdapter(engine)
|
|
18
|
+
adapter.wrap()
|
|
19
|
+
...run your jax workload...
|
|
20
|
+
adapter.unwrap()
|
|
21
|
+
|
|
22
|
+
If jax is not installed, wrap()/unwrap() are no-ops.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, engine: PrismEngine) -> None:
|
|
26
|
+
self.engine = engine
|
|
27
|
+
self._originals: dict[str, Any] = {}
|
|
28
|
+
self._jax_available = self._check_jax()
|
|
29
|
+
|
|
30
|
+
def _check_jax(self) -> bool:
|
|
31
|
+
try:
|
|
32
|
+
import jax # noqa: F401
|
|
33
|
+
|
|
34
|
+
return True
|
|
35
|
+
except ImportError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
def wrap(self) -> "JAXAdapter":
|
|
39
|
+
if not self._jax_available:
|
|
40
|
+
return self
|
|
41
|
+
import jax
|
|
42
|
+
|
|
43
|
+
if "jit" in self._originals:
|
|
44
|
+
return self
|
|
45
|
+
self._originals["jit"] = jax.jit
|
|
46
|
+
engine = self.engine
|
|
47
|
+
original_jit = jax.jit
|
|
48
|
+
|
|
49
|
+
def prism_jit(fun, *args, **kwargs):
|
|
50
|
+
# Wrap the jitted function so each call routes through Prism
|
|
51
|
+
jitted = original_jit(fun, *args, **kwargs)
|
|
52
|
+
|
|
53
|
+
def prism_call(*call_args, **call_kwargs):
|
|
54
|
+
fn_name = getattr(fun, "__qualname__", repr(fun))
|
|
55
|
+
return engine.call(jitted, call_args, call_kwargs, fn_name=fn_name)
|
|
56
|
+
|
|
57
|
+
return prism_call
|
|
58
|
+
|
|
59
|
+
jax.jit = prism_jit
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def unwrap(self) -> None:
|
|
63
|
+
if not self._jax_available or not self._originals:
|
|
64
|
+
return
|
|
65
|
+
import jax
|
|
66
|
+
|
|
67
|
+
for name, original in self._originals.items():
|
|
68
|
+
setattr(jax, name, original)
|
|
69
|
+
self._originals.clear()
|
|
70
|
+
|
|
71
|
+
# ─── Context-manager protocol ─────────────────────────────────────────
|
|
72
|
+
def __enter__(self) -> "JAXAdapter":
|
|
73
|
+
return self.wrap()
|
|
74
|
+
|
|
75
|
+
def __exit__(self, *exc) -> bool:
|
|
76
|
+
self.unwrap()
|
|
77
|
+
return False
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""PyTorch adapter — routes forward passes and eval loops through Prism.
|
|
2
|
+
|
|
3
|
+
Validated workload: eval loop over a model on the same val set across epochs —
|
|
4
|
+
forward passes on unchanged (model + input) are deduped.
|
|
5
|
+
|
|
6
|
+
Patching strategy:
|
|
7
|
+
- `nn.Module.__call__` is patched to intercept all forward passes.
|
|
8
|
+
- A re-entrancy guard prevents intercepting nested submodule calls.
|
|
9
|
+
- In training mode (`model.training == True`), the model's parameters change
|
|
10
|
+
between calls, so we include `model.training` in the hash key and also
|
|
11
|
+
hash the model's parameter state — this means training-step forward passes
|
|
12
|
+
are only deduped if the parameters haven't changed (rare in training, common
|
|
13
|
+
in eval).
|
|
14
|
+
- In eval mode (`model.training == False`), parameters are frozen, so
|
|
15
|
+
repeated forward passes on the same input are always deduped.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from prism.core import PrismEngine
|
|
23
|
+
from prism.hashing import hash_object
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PyTorchAdapter:
|
|
27
|
+
"""Patch torch.nn.Module.__call__ to route through Prism.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
adapter = PyTorchAdapter(engine)
|
|
31
|
+
adapter.wrap()
|
|
32
|
+
...run your torch workload...
|
|
33
|
+
adapter.unwrap()
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
hash_params: If True (default), include model parameter state in the
|
|
37
|
+
cache key. This is essential for correctness in training mode —
|
|
38
|
+
without it, a forward pass after a parameter update would return
|
|
39
|
+
the stale cached result. In eval-only workloads, set to False for
|
|
40
|
+
faster hashing.
|
|
41
|
+
dedup_training: If False (default), don't dedup forward passes when
|
|
42
|
+
model.training == True (training steps almost always change params,
|
|
43
|
+
so dedup is pointless and the param hash is expensive). If True,
|
|
44
|
+
attempt dedup in training mode too (uses param hashing).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
engine: PrismEngine,
|
|
50
|
+
hash_params: bool = True,
|
|
51
|
+
dedup_training: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.engine = engine
|
|
54
|
+
self.hash_params = hash_params
|
|
55
|
+
self.dedup_training = dedup_training
|
|
56
|
+
self._original_call = None
|
|
57
|
+
self._original_state_dict = None
|
|
58
|
+
self._torch_available = self._check_torch()
|
|
59
|
+
self._in_prism_call = threading.local()
|
|
60
|
+
|
|
61
|
+
def _check_torch(self) -> bool:
|
|
62
|
+
try:
|
|
63
|
+
import torch # noqa: F401
|
|
64
|
+
|
|
65
|
+
return True
|
|
66
|
+
except ImportError:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def wrap(self) -> "PyTorchAdapter":
|
|
70
|
+
if not self._torch_available:
|
|
71
|
+
return self
|
|
72
|
+
import torch.nn as nn
|
|
73
|
+
|
|
74
|
+
if self._original_call is not None:
|
|
75
|
+
return self
|
|
76
|
+
self._original_call = nn.Module.__call__
|
|
77
|
+
engine = self.engine
|
|
78
|
+
original = self._original_call
|
|
79
|
+
hash_params = self.hash_params
|
|
80
|
+
dedup_training = self.dedup_training
|
|
81
|
+
|
|
82
|
+
def patched_call(self_mod, *args, **kwargs):
|
|
83
|
+
# Re-entrancy guard
|
|
84
|
+
if getattr(self._in_prism_call, "active", False):
|
|
85
|
+
return original(self_mod, *args, **kwargs)
|
|
86
|
+
|
|
87
|
+
# In training mode, skip dedup unless explicitly enabled
|
|
88
|
+
if self_mod.training and not dedup_training:
|
|
89
|
+
return original(self_mod, *args, **kwargs)
|
|
90
|
+
|
|
91
|
+
self._in_prism_call.active = True
|
|
92
|
+
try:
|
|
93
|
+
fn_name = f"{type(self_mod).__qualname__}.forward"
|
|
94
|
+
|
|
95
|
+
if hash_params and hasattr(self_mod, "state_dict"):
|
|
96
|
+
try:
|
|
97
|
+
param_hash = _hash_model_params(self_mod)
|
|
98
|
+
except Exception:
|
|
99
|
+
param_hash = None
|
|
100
|
+
if param_hash is not None:
|
|
101
|
+
# Pass param_hash as a kwarg to the engine so it's
|
|
102
|
+
# included in the cache key. The wrapper strips it
|
|
103
|
+
# before calling the original function.
|
|
104
|
+
_orig = original
|
|
105
|
+
|
|
106
|
+
def _param_aware_wrapper(*a, **kw):
|
|
107
|
+
kw.pop("__prism_param_hash__", None)
|
|
108
|
+
return _orig(*a, **kw)
|
|
109
|
+
|
|
110
|
+
return engine.call(
|
|
111
|
+
_param_aware_wrapper,
|
|
112
|
+
(self_mod,) + args,
|
|
113
|
+
{**kwargs, "__prism_param_hash__": param_hash},
|
|
114
|
+
fn_name=fn_name,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return engine.call(
|
|
118
|
+
original,
|
|
119
|
+
(self_mod,) + args,
|
|
120
|
+
kwargs,
|
|
121
|
+
fn_name=fn_name,
|
|
122
|
+
)
|
|
123
|
+
finally:
|
|
124
|
+
self._in_prism_call.active = False
|
|
125
|
+
|
|
126
|
+
nn.Module.__call__ = patched_call
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def unwrap(self) -> None:
|
|
130
|
+
if not self._torch_available or self._original_call is None:
|
|
131
|
+
return
|
|
132
|
+
import torch.nn as nn
|
|
133
|
+
|
|
134
|
+
nn.Module.__call__ = self._original_call
|
|
135
|
+
self._original_call = None
|
|
136
|
+
|
|
137
|
+
# ─── Context-manager protocol ─────────────────────────────────────────
|
|
138
|
+
def __enter__(self) -> "PyTorchAdapter":
|
|
139
|
+
return self.wrap()
|
|
140
|
+
|
|
141
|
+
def __exit__(self, *exc) -> bool:
|
|
142
|
+
self.unwrap()
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _hash_model_params(model: Any) -> str:
|
|
147
|
+
"""Hash a model's parameters — used to detect parameter changes between calls.
|
|
148
|
+
|
|
149
|
+
Sums all parameter tensors and hashes the sum + count. This is O(n) in
|
|
150
|
+
parameters but much cheaper than hashing every tensor's full content.
|
|
151
|
+
For large models, this could be replaced with a sampled fingerprint.
|
|
152
|
+
"""
|
|
153
|
+
import torch
|
|
154
|
+
|
|
155
|
+
parts = []
|
|
156
|
+
total_params = 0
|
|
157
|
+
param_sum = torch.tensor(0.0)
|
|
158
|
+
for name, param in model.named_parameters():
|
|
159
|
+
total_params += param.numel()
|
|
160
|
+
parts.append(f"{name}:{param.shape}")
|
|
161
|
+
param_sum = param_sum + param.detach().float().sum()
|
|
162
|
+
# Include buffers (e.g. BatchNorm running stats) — they change in eval too
|
|
163
|
+
for name, buf in model.named_buffers():
|
|
164
|
+
total_params += buf.numel()
|
|
165
|
+
parts.append(f"buf:{name}:{buf.shape}")
|
|
166
|
+
param_sum = param_sum + buf.detach().float().sum()
|
|
167
|
+
return f"params={total_params}|sum={float(param_sum):.10f}|shapes={','.join(parts)}"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""sklearn adapter — routes fit / fit_transform / transform / predict through Prism.
|
|
2
|
+
|
|
3
|
+
Validated workload: GridSearchCV with repeated param combos — fit on identical
|
|
4
|
+
(estimator + params + data) is deduped. Also covers StandardScaler, PCA, etc.
|
|
5
|
+
|
|
6
|
+
Patching strategy: sklearn estimators define `fit` on their own concrete class
|
|
7
|
+
(shadowing BaseEstimator), so we walk all loaded BaseEstimator subclasses and
|
|
8
|
+
patch each class that defines the target method in its own __dict__.
|
|
9
|
+
|
|
10
|
+
fit/fit_transform special handling: sklearn's `fit` mutates `self` in place AND
|
|
11
|
+
returns `self`. On a cache hit, the engine returns a cached deep copy of a
|
|
12
|
+
previously-fitted estimator — but the target estimator is never mutated. So
|
|
13
|
+
the wrapper must copy fitted attributes (those ending in `_`) from the cached
|
|
14
|
+
result onto the target estimator on a hit.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from prism.core import PrismEngine
|
|
22
|
+
from prism.hashing import hash_call
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SklearnAdapter:
|
|
26
|
+
_PATCHED_METHODS = ("fit", "fit_transform", "transform", "predict", "score")
|
|
27
|
+
|
|
28
|
+
def __init__(self, engine: PrismEngine) -> None:
|
|
29
|
+
self.engine = engine
|
|
30
|
+
self._originals: dict[tuple[type, str], Any] = {}
|
|
31
|
+
|
|
32
|
+
def wrap(self) -> "SklearnAdapter":
|
|
33
|
+
from sklearn.base import BaseEstimator
|
|
34
|
+
|
|
35
|
+
classes = self._all_subclasses(BaseEstimator)
|
|
36
|
+
classes.add(BaseEstimator)
|
|
37
|
+
for cls in classes:
|
|
38
|
+
for method_name in self._PATCHED_METHODS:
|
|
39
|
+
key = (cls, method_name)
|
|
40
|
+
if key in self._originals:
|
|
41
|
+
continue
|
|
42
|
+
if method_name not in cls.__dict__:
|
|
43
|
+
continue
|
|
44
|
+
original = cls.__dict__[method_name]
|
|
45
|
+
if getattr(original, "_prism_wrapped", False):
|
|
46
|
+
continue
|
|
47
|
+
self._originals[key] = original
|
|
48
|
+
if method_name in ("fit", "fit_transform"):
|
|
49
|
+
wrapped = self._make_fit_wrapper(original, method_name)
|
|
50
|
+
else:
|
|
51
|
+
wrapped = self._make_simple_wrapper(original, method_name)
|
|
52
|
+
wrapped._prism_wrapped = True
|
|
53
|
+
setattr(cls, method_name, wrapped)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def unwrap(self) -> None:
|
|
57
|
+
for (cls, method_name), original in self._originals.items():
|
|
58
|
+
setattr(cls, method_name, original)
|
|
59
|
+
self._originals.clear()
|
|
60
|
+
|
|
61
|
+
# ─── Context-manager protocol ─────────────────────────────────────────
|
|
62
|
+
def __enter__(self) -> "SklearnAdapter":
|
|
63
|
+
return self.wrap()
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *exc) -> bool:
|
|
66
|
+
self.unwrap()
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def _all_subclasses(self, cls: type) -> set[type]:
|
|
70
|
+
result = set()
|
|
71
|
+
for sub in cls.__subclasses__():
|
|
72
|
+
result.add(sub)
|
|
73
|
+
result.update(self._all_subclasses(sub))
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def _make_simple_wrapper(self, original, method_name: str):
|
|
77
|
+
"""For methods that just return a value (transform, predict, score)."""
|
|
78
|
+
engine = self.engine
|
|
79
|
+
|
|
80
|
+
def wrapped(self_est, *args, **kwargs):
|
|
81
|
+
fn_name = f"{type(self_est).__qualname__}.{method_name}"
|
|
82
|
+
return engine.call(original, (self_est,) + args, kwargs, fn_name=fn_name)
|
|
83
|
+
|
|
84
|
+
wrapped.__name__ = method_name
|
|
85
|
+
wrapped.__qualname__ = f"PrismAdapter.{method_name}"
|
|
86
|
+
return wrapped
|
|
87
|
+
|
|
88
|
+
def _make_fit_wrapper(self, original, method_name: str):
|
|
89
|
+
"""For fit / fit_transform — handles in-place mutation + state restoration."""
|
|
90
|
+
engine = self.engine
|
|
91
|
+
|
|
92
|
+
def wrapped(self_est, *args, **kwargs):
|
|
93
|
+
fn_name = f"{type(self_est).__qualname__}.{method_name}"
|
|
94
|
+
# Use the engine, but we need to know if it was a hit to restore state.
|
|
95
|
+
# Strategy: check engine.stats.misses before and after the call.
|
|
96
|
+
misses_before = engine.stats.misses
|
|
97
|
+
result = engine.call(original, (self_est,) + args, kwargs, fn_name=fn_name)
|
|
98
|
+
was_hit = engine.stats.misses == misses_before # no new miss = hit
|
|
99
|
+
|
|
100
|
+
if was_hit and result is not self_est:
|
|
101
|
+
# Cache hit — `result` is a deep copy of a previously-fitted
|
|
102
|
+
# estimator. Copy its fitted attributes onto `self_est`.
|
|
103
|
+
_restore_fitted_state(result, self_est)
|
|
104
|
+
# For fit_transform, also return the cached transformed data
|
|
105
|
+
# (which is `result`'s transformed output, already returned by
|
|
106
|
+
# engine.call). For fit, return self_est (the now-restored estimator).
|
|
107
|
+
if method_name == "fit":
|
|
108
|
+
return self_est
|
|
109
|
+
# fit_transform: result is the cached transformed array — return it
|
|
110
|
+
return result
|
|
111
|
+
# Miss (real fit ran, self_est is mutated) — return as normal
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
wrapped.__name__ = method_name
|
|
115
|
+
wrapped.__qualname__ = f"PrismAdapter.{method_name}"
|
|
116
|
+
return wrapped
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _restore_fitted_state(source: Any, target: Any) -> None:
|
|
120
|
+
"""Copy fitted attributes (ending in `_`) from source estimator onto target.
|
|
121
|
+
|
|
122
|
+
This is the key to correct fit caching: on a cache hit, the target estimator
|
|
123
|
+
hasn't been mutated by a real fit, so we must transfer the fitted state from
|
|
124
|
+
the cached copy.
|
|
125
|
+
"""
|
|
126
|
+
# Copy all attributes ending in `_` (sklearn convention for fitted attrs)
|
|
127
|
+
# plus any attribute that exists on source but not target.
|
|
128
|
+
for attr in dir(source):
|
|
129
|
+
if attr.startswith("_"):
|
|
130
|
+
continue
|
|
131
|
+
if not attr.endswith("_"):
|
|
132
|
+
continue
|
|
133
|
+
try:
|
|
134
|
+
val = getattr(source, attr)
|
|
135
|
+
except AttributeError:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
setattr(target, attr, copy.deepcopy(val))
|
|
139
|
+
except (AttributeError, TypeError):
|
|
140
|
+
pass
|