Skip to content

Commit 2e20566

Browse files
authored
Decrease state access overhead in infras (#219)
1 parent 3d34d4b commit 2e20566

10 files changed

Lines changed: 323 additions & 115 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## [Unreleased]
44

5+
## 0.5.19
6+
7+
### Changed
8+
- Reduced per-call overhead on cached MapInfra/TaskInfra paths by ~76% via ephemeral `_state` dataclass bypassing pydantic's `__getattr__`.
9+
510
## 0.5.18
611

712
### Changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# MapInfra per-call overhead when data is fully cached
2+
3+
## Problem
4+
5+
Profiling a MapInfra-decorated method where all outputs are pre-cached
6+
(3 items, `keep_in_ram=True`). The cache-lookup path
7+
(`_method_override``_method_override_futures``_find_missing`)
8+
dominated call time with zero computation, because pydantic's
9+
`__getattr__` was triggered on every private-attribute access.
10+
11+
The main offenders: `_factory()` (recomputed from 7+ attributes each
12+
call), `cache_dict` property (`hasattr` check), `_check_configs`
13+
flag, and `uid()` — all going through pydantic dispatch.
14+
15+
## Fix: `_state` dataclass + `_fast_state` accessor
16+
17+
A per-class **ephemeral state dataclass** stored as a `PrivateAttr`
18+
holds all recomputable values. Each method caches into `_state`
19+
internally; callers just call the method normally.
20+
21+
```python
22+
@dataclasses.dataclass
23+
class _BaseInfraState:
24+
checked_configs: bool = False
25+
factory: str | None = None
26+
uid: str | None = None
27+
infra_method: InfraMethod | None = None
28+
method_override: tp.Any = None
29+
30+
@dataclasses.dataclass
31+
class _TaskInfraState(_BaseInfraState):
32+
cache: tp.Any = dataclasses.field(default_factory=Sentinel)
33+
34+
@dataclasses.dataclass
35+
class _MapInfraState(_BaseInfraState):
36+
cache_dict: CacheDict | None = None
37+
```
38+
39+
Each infra subclass overrides `_state` with its own type:
40+
41+
```python
42+
class MapInfra(BaseInfra):
43+
_state: _MapInfraState = PrivateAttr(default_factory=_MapInfraState)
44+
```
45+
46+
Hot-path methods access `_state` via `_fast_state(self)`, a free
47+
function that reads `__pydantic_private__` directly, bypassing
48+
`__getattr__`. `test_fast_state_no_fallback` guards against pydantic
49+
internal changes.
50+
51+
### What goes in `_state`
52+
53+
Anything **temporary / recomputable on demand**:
54+
55+
| Attribute | Was in | Recomputed by |
56+
|-----------|--------|---------------|
57+
| `checked_configs` | `_checked_configs` (pydantic private) | `_check_configs()` |
58+
| `factory` | (new) | `_factory()` |
59+
| `uid` | `_uid` (pydantic private) | `uid()` |
60+
| `infra_method` | `_infra_method` (`PrivateAttr`) | lazy-cached from `_infra_method` |
61+
| `method_override` | (new) | cached by `InfraMethod.__call__` |
62+
| `cache` | `_cache` (`PrivateAttr(Sentinel)`) | `job().results()` |
63+
| `cache_dict` | `_cache_dict` (`PrivateAttr`) | recreated from folder |
64+
65+
### Design rules
66+
67+
- **Methods own their caching.** `_factory()`, `uid()`,
68+
`cache_dict`, `_check_configs()` read/write `_state` internally.
69+
Callers never see `_state`.
70+
- **Pickling resets `_state`.** `BaseInfra.__getstate__` replaces
71+
`_state` with a fresh default. This replaces the per-subclass
72+
`__getstate__` overrides that previously popped/reset individual
73+
private attrs.
74+
75+
- **`InfraMethod.__call__` caches its dispatch.** The property fget
76+
resolves `infra._method_override` once, then returns the cached
77+
`method_override` on subsequent calls. `infra_method` doubles as
78+
the identity key (needed because parent/child InfraMethods share
79+
one infra in inheritance).
80+
81+
### Results
82+
83+
| Scenario | Before | After |
84+
|----------|--------|-------|
85+
| DEBUG off (production) | 14.4 µs | 3.5 µs (**−76 %**) |
86+
| DEBUG on (test suite) | 32 µs | ~22 µs (**−31 %**) |

exca/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
from .task import SubmitInfra as SubmitInfra
1313
from .task import TaskInfra as TaskInfra
1414

15-
__version__ = "0.5.18"
15+
__version__ = "0.5.19"

exca/base.py

Lines changed: 115 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,32 @@ class Sentinel:
3535
pass
3636

3737

38+
@dataclasses.dataclass
39+
class _BaseInfraState:
40+
"""Ephemeral/recomputable state, stored as a PrivateAttr in
41+
__pydantic_private__ and accessed via _fast_state() to bypass
42+
pydantic's __getattr__ on hot paths. Reset on pickle."""
43+
44+
checked_configs: bool = False
45+
factory: str | None = None
46+
uid: str | None = None
47+
infra_method: "InfraMethod | None" = None
48+
method_override: tp.Any = None
49+
50+
51+
def _fast_state(infra: "BaseInfra") -> _BaseInfraState:
52+
"""Read _state directly from __pydantic_private__, bypassing __getattr__
53+
for fast access.
54+
55+
Falls back to normal access if pydantic's storage changes.
56+
See test_fast_state_no_fallback for upgrade detection.
57+
"""
58+
try:
59+
return infra.__pydantic_private__["_state"] # type: ignore[index]
60+
except (KeyError, TypeError):
61+
return infra._state
62+
63+
3864
@pydantic.model_validator(mode="after")
3965
def model_with_infra_validator_after(obj: pydantic.BaseModel) -> pydantic.BaseModel:
4066
return _add_name(obj, propagate_defaults=True)
@@ -115,13 +141,20 @@ class BaseInfra(pydantic.BaseModel):
115141
# {factory} will be replaced by method name and version tag
116142
# {uid} by the owner class uid
117143
_uid_string: str = "{method},{version}/{uid}"
118-
# information stored for fast access after apply is called
119-
_uid: str | None = None # stored uid once computed (and once model is frozen)
144+
_state: _BaseInfraState = pydantic.PrivateAttr(default_factory=_BaseInfraState)
145+
_uid: str | None = None # deprecated: now in _state, kept for backward compat
120146
_obj: tp.Any = pydantic.PrivateAttr() # pydantic model the infra is an attribute of
121-
_checked_configs: bool = False # only do it once
122147
_infra_name: str = ""
123148
_infra_method: "InfraMethod | None" = pydantic.PrivateAttr(None) # method container
124149

150+
def __getstate__(self) -> dict[str, tp.Any]:
151+
out = super().__getstate__()
152+
# Reset ephemeral state so pickled copies start fresh
153+
state = out["__pydantic_private__"].get("_state")
154+
if state is not None:
155+
out["__pydantic_private__"]["_state"] = type(state)()
156+
return out
157+
125158
def __setstate__(self, state: tp.Any) -> None:
126159
if "__dict__" in state:
127160
d = state["__dict__"]
@@ -131,6 +164,8 @@ def __setstate__(self, state: tp.Any) -> None:
131164
if "__pydantic_private__" in state:
132165
d = state["__pydantic_private__"]
133166
d.setdefault("_uid_string", "{method},{version}/{uid}")
167+
if "_state" not in d:
168+
d["_state"] = type(self)._state.default_factory() # type: ignore
134169
# infra method can lose the name, so let's recover it
135170
# (in private infra in particular)
136171
iname = d.get("_infra_name", None)
@@ -195,7 +230,8 @@ def config(self, uid: bool = True, exclude_defaults: bool = False) -> ConfDict:
195230
return cdict
196231

197232
def _check_configs(self, write: bool = True) -> None:
198-
if self._checked_configs:
233+
state = _fast_state(self)
234+
if state.checked_configs:
199235
return # already done
200236
xpfolder = self.uid_folder()
201237
if xpfolder is None:
@@ -207,7 +243,7 @@ def _check_configs(self, write: bool = True) -> None:
207243
full_uid=self.config(uid=True, exclude_defaults=False),
208244
)
209245
dump.check_and_write(xpfolder, write=write)
210-
self._checked_configs = True
246+
state.checked_configs = True
211247
# Set permissions on written files
212248
if write:
213249
for name in ("uid", "full-uid", "config"):
@@ -219,6 +255,9 @@ def _check_configs(self, write: bool = True) -> None:
219255
pass
220256

221257
def _factory(self) -> str:
258+
state = _fast_state(self)
259+
if state.factory is not None:
260+
return state.factory
222261
cls = self._obj.__class__
223262
if self._infra_method is None:
224263
raise RuntimeError(f"Infra {self!r} was not applied to a method")
@@ -238,52 +277,61 @@ def _factory(self) -> str:
238277
current_m = getattr(cls, m.__name__)
239278
if isinstance(current_m, property) and self._infra_method is current_m.fget:
240279
factory = f"{cls.__module__}.{cls.__qualname__ }.{name}"
280+
state.factory = factory
241281
return factory
242282

243283
def uid(self) -> str:
244284
"""Returns the unique uid of the task"""
285+
state = _fast_state(self)
286+
if state.uid is not None:
287+
return state.uid
288+
# deprecated path: check legacy _uid attribute for backward compat
245289
if not hasattr(self, "_uid"):
246290
self._uid = None # backward-compatibility
247-
if self._uid is None:
248-
cfg = self.config(uid=True, exclude_defaults=True)
249-
uid = cfg.to_uid()
250-
uid = uid if uid else "default"
251-
params = dict(method=self._factory(), version=self.version, uid=uid)
252-
parsed = string.Formatter().parse(self._uid_string)
253-
names = {v[1] for v in parsed if v[1] is not None}
254-
if names != set(params):
255-
msg = f"uid_string {self._uid_string!r} should contain exactly {set(params)}"
256-
msg += f"\nbut got {names} for infra applied on {self._obj!r}"
257-
raise ValueError(msg)
258-
self._uid = self._uid_string.format(**params)
259-
utils.recursive_freeze(self._obj)
260-
msg = "Froze instance %s after computing its uid: %s"
261-
logger.debug(msg, repr(self._obj), self._uid)
262-
# compat
263-
if self.folder is not None and uid != "default":
264-
folder = Path(self.folder) / self._uid
265-
if not folder.exists():
266-
params["uid"] = cfg.to_uid(version=2)
267-
old = Path(self.folder) / self._uid_string.format(**params)
268-
if old.exists():
269-
# rename all folders in cache at once if possible
270-
from exca import helpers
271-
272-
helpers.update_uids(self.folder, dryrun=False)
273-
if old.exists():
274-
# if this very cache was not updated
275-
# (eg: because of unexpected uid_string), then fix it manually
276-
msg = "Automatic update fail, manual update to new uid: '%s' -> '%s'"
277-
logger.warning(msg, old, folder)
278-
shutil.move(old, folder)
279-
latest = ConfDict.LATEST_UID_VERSION
280-
# warn for mixture of versioning
281-
if self.folder is not None and ConfDict.UID_VERSION != latest:
282-
new = Path(self.folder) / cfg.to_uid(version=latest)
283-
if new.exists():
284-
msg = "Found folder with latest version %s but currently using %s"
285-
logger.warning(msg, latest, ConfDict.UID_VERSION)
286-
return self._uid
291+
if self._uid is not None:
292+
state.uid = self._uid
293+
return self._uid
294+
cfg = self.config(uid=True, exclude_defaults=True)
295+
uid = cfg.to_uid()
296+
uid = uid if uid else "default"
297+
params = dict(method=self._factory(), version=self.version, uid=uid)
298+
parsed = string.Formatter().parse(self._uid_string)
299+
names = {v[1] for v in parsed if v[1] is not None}
300+
if names != set(params):
301+
msg = f"uid_string {self._uid_string!r} should contain exactly {set(params)}"
302+
msg += f"\nbut got {names} for infra applied on {self._obj!r}"
303+
raise ValueError(msg)
304+
computed = self._uid_string.format(**params)
305+
state.uid = computed
306+
self._uid = computed # deprecated: kept for backward compat
307+
utils.recursive_freeze(self._obj)
308+
msg = "Froze instance %s after computing its uid: %s"
309+
logger.debug(msg, repr(self._obj), computed)
310+
# compat
311+
if self.folder is not None and uid != "default":
312+
folder = Path(self.folder) / computed
313+
if not folder.exists():
314+
params["uid"] = cfg.to_uid(version=2)
315+
old = Path(self.folder) / self._uid_string.format(**params)
316+
if old.exists():
317+
# rename all folders in cache at once if possible
318+
from exca import helpers
319+
320+
helpers.update_uids(self.folder, dryrun=False)
321+
if old.exists():
322+
# if this very cache was not updated
323+
# (eg: because of unexpected uid_string), then fix it manually
324+
msg = "Automatic update fail, manual update to new uid: '%s' -> '%s'"
325+
logger.warning(msg, old, folder)
326+
shutil.move(old, folder)
327+
latest = ConfDict.LATEST_UID_VERSION
328+
# warn for mixture of versioning
329+
if self.folder is not None and ConfDict.UID_VERSION != latest:
330+
new = Path(self.folder) / cfg.to_uid(version=latest)
331+
if new.exists():
332+
msg = "Found folder with latest version %s but currently using %s"
333+
logger.warning(msg, latest, ConfDict.UID_VERSION)
334+
return computed
287335

288336
def uid_folder(self, create: bool = False) -> Path | None:
289337
"""Folder where this task instance is stored"""
@@ -438,7 +486,16 @@ class InfraMethod(BaseInfraMethod):
438486
default_infra: BaseInfra | None = None # COMPAT
439487

440488
def __call__(self, obj: pydantic.BaseModel) -> tp.Any:
441-
if self.infra_name is None or not self.infra_name:
489+
# fast path: return cached result if this InfraMethod already resolved
490+
infra_name = self.infra_name
491+
if infra_name:
492+
infra = getattr(obj, infra_name, None)
493+
if infra is not None:
494+
state = _fast_state(infra)
495+
if state.infra_method is self and state.method_override is not None:
496+
return state.method_override
497+
# full dispatch (first call or legacy/override paths)
498+
if not infra_name:
442499
default_infra = getattr(self, "default_infra", None) # LEGACY
443500
if default_infra is not None:
444501
self.infra_name = default_infra._infra_name
@@ -455,9 +512,13 @@ def __call__(self, obj: pydantic.BaseModel) -> tp.Any:
455512
raise TypeError("infra can only be added to pydantic.BaseModel")
456513
# get default
457514
if infra_name in type(obj).model_fields:
458-
default_imethod = type(obj).model_fields[infra_name].default._infra_method
515+
default_imethod = (
516+
type(obj)
517+
.model_fields[infra_name]
518+
.default.__pydantic_private__["_infra_method"]
519+
)
459520
elif infra_name.startswith("_"):
460-
default_imethod = obj.__private_attributes__[infra_name].default._infra_method # type: ignore
521+
default_imethod = obj.__private_attributes__[infra_name].default.__pydantic_private__["_infra_method"] # type: ignore
461522
else:
462523
raise RuntimeError(f"Could not find infra named {infra_name!r} on {obj!r}")
463524
if default_imethod is None:
@@ -468,12 +529,16 @@ def __call__(self, obj: pydantic.BaseModel) -> tp.Any:
468529
msg = "This should only happen when unpickling config which was modified from legacy to decorator"
469530
logger.warning(msg)
470531
return None
471-
if not hasattr(infra, "_obj"):
532+
if "_obj" not in (infra.__pydantic_private__ or {}):
472533
infra._obj = obj # only for legacy to decorator change compatibility
473534
if default_imethod is not self:
474535
# bypassing infra as it was overriden
475536
return functools.partial(self.method, obj)
476-
return infra._method_override
537+
result = infra._method_override
538+
state = _fast_state(infra)
539+
state.infra_method = self
540+
state.method_override = result
541+
return result
477542

478543
def check_method_signature(self) -> None:
479544
sig = inspect.signature(self.method)

0 commit comments

Comments
 (0)