Skip to content

Commit cce93fc

Browse files
committed
perf: 7 performance optimizations for hot paths
1. Cache field names set per class in __getattribute__ (O(1) vs O(n)) 2. Cache field-by-name dict per class (O(1) vs O(n) linear scan) 3. Cache _normalize_to_base results with lru_cache (immutable type registry) 4. Pre-compute camel_to_snake config field name at class creation time 5. Guard expensive debug logging with logger.isEnabledFor(logging.DEBUG) 6. Hoist lazy imports out of hot loops in extract_all_configs 7. Wire up _mro_resolution_cache in resolve_field_inheritance with coordinated invalidation on context push/pop All caches use immutable class metadata or follow existing invalidation patterns. Field name caches lazily populate on first access for classes created outside _create_lazy_dataclass_unified to avoid AttributeError.
1 parent 58caed9 commit cce93fc

File tree

4 files changed

+129
-55
lines changed

4 files changed

+129
-55
lines changed

src/objectstate/context_manager.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import contextvars
2121
import dataclasses
22+
import functools
2223
import inspect
2324
import logging
2425
import threading
@@ -225,16 +226,19 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr
225226
merged_token = current_temp_global.set(merged_config)
226227
type_token = context_type_stack.set(new_types)
227228
layer_token = context_layer_stack.set(new_layers)
228-
# PERFORMANCE: Clear extract cache on context push (new merged config)
229+
# PERFORMANCE: Clear caches on context push (new merged config)
229230
clear_extract_all_configs_cache()
231+
from objectstate.dual_axis_resolver import clear_mro_resolution_cache
232+
clear_mro_resolution_cache()
230233
try:
231234
yield
232235
finally:
233236
current_temp_global.reset(merged_token)
234237
context_type_stack.reset(type_token)
235238
context_layer_stack.reset(layer_token)
236-
# PERFORMANCE: Clear extract cache on context pop
239+
# PERFORMANCE: Clear caches on context pop
237240
clear_extract_all_configs_cache()
241+
clear_mro_resolution_cache()
238242

239243

240244
def get_context_type_stack():
@@ -280,11 +284,15 @@ def get_context_layer_stack() -> Tuple[Tuple[str, Any], ...]:
280284
return context_layer_stack.get()
281285

282286

287+
@functools.lru_cache(maxsize=None)
283288
def _normalize_type(t):
284289
"""Normalize a type by getting its base type if it's a lazy variant.
285290
286291
This function is defined here to avoid circular imports with lazy_factory.
287292
The actual get_base_type_for_lazy is imported lazily when needed.
293+
294+
PERFORMANCE: Results are cached with lru_cache since type->base_type
295+
mappings are immutable after class creation.
288296
"""
289297
try:
290298
from objectstate.lazy_factory import get_base_type_for_lazy
@@ -648,14 +656,15 @@ def build_context_stack(
648656

649657
obj_type_name = obj_type.__name__
650658

651-
# Build ancestor list for logging - support both old and new format
652-
if ancestor_objects_with_scopes:
653-
ancestor_types = [type(o).__name__ for _, o in ancestor_objects_with_scopes]
654-
elif ancestor_objects:
655-
ancestor_types = [type(o).__name__ for o in ancestor_objects]
656-
else:
657-
ancestor_types = []
658-
logger.debug(f"🔧 build_context_stack: obj={obj_type_name}, ancestors={ancestor_types}")
659+
# PERFORMANCE: Only build ancestor list for logging when DEBUG is enabled
660+
if logger.isEnabledFor(logging.DEBUG):
661+
if ancestor_objects_with_scopes:
662+
ancestor_types = [type(o).__name__ for _, o in ancestor_objects_with_scopes]
663+
elif ancestor_objects:
664+
ancestor_types = [type(o).__name__ for o in ancestor_objects]
665+
else:
666+
ancestor_types = []
667+
logger.debug(f"🔧 build_context_stack: obj={obj_type_name}, ancestors={ancestor_types}")
659668

660669
# 1. Global context layer (least specific)
661670
# ALWAYS use LIVE thread-local for global config - it's the SINGLE SOURCE OF TRUTH
@@ -1093,6 +1102,8 @@ def extract_all_configs(context_obj) -> Dict[str, Any]:
10931102

10941103
# Type-driven extraction: Use dataclass field annotations to find config fields
10951104
if is_dataclass(type(context_obj)):
1105+
# PERFORMANCE: Hoist import out of the per-field loop
1106+
from objectstate.lazy_factory import get_base_type_for_lazy
10961107
for field_info in fields(type(context_obj)):
10971108
field_type = field_info.type
10981109
field_name = field_info.name
@@ -1107,7 +1118,6 @@ def extract_all_configs(context_obj) -> Dict[str, Any]:
11071118
if field_value is not None:
11081119
# CRITICAL: Use base type for lazy configs so MRO matching works
11091120
# LazyWellFilterConfig should be stored as WellFilterConfig
1110-
from objectstate.lazy_factory import get_base_type_for_lazy
11111121
instance_type = type(field_value)
11121122
base_type = get_base_type_for_lazy(instance_type) or instance_type
11131123
configs[base_type.__name__] = field_value
@@ -1122,7 +1132,8 @@ def extract_all_configs(context_obj) -> Dict[str, Any]:
11221132

11231133
# Cache result
11241134
_extract_all_configs_cache[obj_id] = configs
1125-
logger.debug(f"Extracted {len(configs)} configs: {list(configs.keys())}")
1135+
if logger.isEnabledFor(logging.DEBUG):
1136+
logger.debug(f"Extracted {len(configs)} configs: {list(configs.keys())}")
11261137
return configs
11271138

11281139

src/objectstate/dual_axis_resolver.py

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import logging
11+
import functools
1112
from typing import Any, Dict, Optional, Tuple
1213
from dataclasses import is_dataclass
1314

@@ -66,11 +67,16 @@ def invalidate_mro_cache_for_field(changed_type: type, field_name: str) -> None:
6667
del _mro_resolution_cache[key]
6768

6869

70+
@functools.lru_cache(maxsize=None)
6971
def _normalize_to_base(t: type) -> type:
7072
"""Normalize lazy type to its base type for comparison.
7173
7274
LazyWellFilterConfig -> WellFilterConfig
7375
WellFilterConfig -> WellFilterConfig
76+
77+
PERFORMANCE: Results are cached with lru_cache since type->base_type
78+
mappings are immutable after class creation. Import is hoisted to
79+
avoid per-call import overhead.
7480
"""
7581
from objectstate.lazy_factory import get_base_type_for_lazy
7682
return get_base_type_for_lazy(t) or t
@@ -129,6 +135,16 @@ def resolve_field_inheritance(
129135
# This works for both LazyDataclass types AND concrete dataclasses with None fields.
130136
obj_base = _normalize_to_base(obj_type)
131137

138+
# PERFORMANCE: Check MRO resolution cache before doing the full walk.
139+
# Cache key uses (obj_type, field_name, config_identity) where config_identity
140+
# captures which config instances are active. Cache is cleared on context push/pop.
141+
_cache_key = (obj_type, field_name, tuple((k, id(v)) for k, v in available_configs.items()))
142+
_cached = _mro_resolution_cache.get(_cache_key, _CACHE_SENTINEL)
143+
if _cached is not _CACHE_SENTINEL:
144+
return _cached
145+
146+
_debug = logger.isEnabledFor(logging.DEBUG)
147+
132148
if needs_resolution:
133149
for config_key, config_instance in available_configs.items():
134150
# Normalize both sides: LazyWellFilterConfig matches WellFilterConfig
@@ -137,18 +153,20 @@ def resolve_field_inheritance(
137153
try:
138154
field_value = object.__getattribute__(config_instance, field_name)
139155
if field_value is not None:
140-
if field_name == 'well_filter':
141-
logger.debug(f"🔍 CONCRETE VALUE: {obj_type.__name__}.{field_name} = {field_value}")
142-
if field_name == 'num_workers':
143-
logger.debug(f"🔍 SAME-TYPE MATCH: {obj_type.__name__}.{field_name} = {field_value!r} (type={type(field_value).__name__}) FROM config_key={config_key}, config_type={type(config_instance).__name__}")
156+
if _debug:
157+
if field_name == 'well_filter':
158+
logger.debug(f"🔍 CONCRETE VALUE: {obj_type.__name__}.{field_name} = {field_value}")
159+
if field_name == 'num_workers':
160+
logger.debug(f"🔍 SAME-TYPE MATCH: {obj_type.__name__}.{field_name} = {field_value!r} (type={type(field_value).__name__}) FROM config_key={config_key}, config_type={type(config_instance).__name__}")
161+
_mro_resolution_cache[_cache_key] = field_value
144162
return field_value
145163
except AttributeError:
146164
continue
147165

148166
# Step 2: MRO-based inheritance - traverse MRO from most to least specific
149167
# Skip the first entry (self type) since we already checked it above (for lazy) or want to skip it (for concrete)
150168
# This finds PARENT class configs with concrete values (sibling inheritance)
151-
if field_name in ['output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode']:
169+
if _debug and field_name in ('output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode'):
152170
logger.debug(f"🔍 MRO-INHERITANCE: Resolving {obj_type.__name__}.{field_name}")
153171
logger.debug(f"🔍 MRO-INHERITANCE: MRO = {[cls.__name__ for cls in obj_type.__mro__]}")
154172

@@ -164,24 +182,28 @@ def resolve_field_inheritance(
164182
if instance_base == mro_base:
165183
try:
166184
value = object.__getattribute__(config_instance, field_name)
167-
if field_name in ['output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode']:
168-
logger.debug(f"🔍 MRO-INHERITANCE: {mro_class.__name__}.{field_name} = {value}")
169-
if field_name == 'num_workers':
170-
logger.debug(f"🔍 MRO-INHERITANCE: {mro_class.__name__}.{field_name} = {value!r} (type={type(value).__name__})")
171-
if value is not None:
172-
if field_name in ['output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode']:
173-
logger.debug(f"🔍 MRO-INHERITANCE: FOUND {mro_class.__name__}.{field_name}: {value} (returning)")
185+
if _debug:
186+
if field_name in ('output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode'):
187+
logger.debug(f"🔍 MRO-INHERITANCE: {mro_class.__name__}.{field_name} = {value}")
174188
if field_name == 'num_workers':
175-
logger.debug(f"🔍 MRO-INHERITANCE: RETURNING {mro_class.__name__}.{field_name} = {value!r}")
189+
logger.debug(f"🔍 MRO-INHERITANCE: {mro_class.__name__}.{field_name} = {value!r} (type={type(value).__name__})")
190+
if value is not None:
191+
if _debug:
192+
if field_name in ('output_dir_suffix', 'sub_dir', 'well_filter', 'well_filter_mode'):
193+
logger.debug(f"🔍 MRO-INHERITANCE: FOUND {mro_class.__name__}.{field_name}: {value} (returning)")
194+
if field_name == 'num_workers':
195+
logger.debug(f"🔍 MRO-INHERITANCE: RETURNING {mro_class.__name__}.{field_name} = {value!r}")
196+
_mro_resolution_cache[_cache_key] = value
176197
return value
177198
except AttributeError:
178199
continue
179200

180201
# No Step 3: If MRO walk finds nothing, return None.
181202
# "If we wanted static class defaults, it wouldn't have been overridden to None"
182203
# For LazyDataclass, class defaults are all None anyway (via rebuild_with_none_defaults).
183-
if field_name in ['output_dir_suffix', 'sub_dir', 'well_filter']:
204+
if _debug and field_name in ('output_dir_suffix', 'sub_dir', 'well_filter'):
184205
logger.debug(f"🔍 NO-RESOLUTION: {obj_type.__name__}.{field_name} = None")
206+
_mro_resolution_cache[_cache_key] = None
185207
return None
186208

187209

@@ -248,7 +270,9 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any,
248270
# - MRO inheritance only applies when NO concrete value exists in the hierarchy
249271
# for the specific config type being resolved
250272

251-
if field_name == 'well_filter':
273+
_debug = logger.isEnabledFor(logging.DEBUG)
274+
275+
if _debug and field_name == 'well_filter':
252276
logger.debug(f"🔍 resolve_with_provenance: container={container_base.__name__}, field={field_name}, layers={len(layers)}")
253277
logger.debug(f"🔍 resolve_with_provenance: mro_types={[t.__name__ for t in mro_types]}")
254278

@@ -271,21 +295,21 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any,
271295
# This gives hierarchy precedence: inner/child scopes override outer/parent scopes
272296
# REVERSED ORDER: Walk from inner to outer so more specific scopes override general scopes
273297
for scope_id, layer_configs in reversed(all_layer_configs):
274-
if field_name == 'well_filter':
298+
if _debug and field_name == 'well_filter':
275299
logger.debug(f"🔍 Phase 1 - Layer scope={scope_id!r}, checking same-type only (inner to outer)")
276300

277301
for config_instance in layer_configs.values():
278302
instance_base = _normalize_to_base(type(config_instance))
279-
if field_name in ('well_filter', 'enabled') and instance_base == container_base:
303+
if _debug and field_name in ('well_filter', 'enabled') and instance_base == container_base:
280304
logger.debug(f"🔍 FOUND same-type config: {instance_base.__name__} @ scope={scope_id}")
281305
if instance_base == container_base: # Same-type only, no MRO
282306
try:
283307
value = object.__getattribute__(config_instance, field_name)
284-
if field_name in ('well_filter', 'enabled'):
308+
if _debug and field_name in ('well_filter', 'enabled'):
285309
logger.debug(f"🔍 {container_base.__name__}.{field_name} @ scope={scope_id} = {value!r} (from object.__getattribute__)")
286310
if value is not None:
287311
# Found concrete value in hierarchy - return immediately
288-
if field_name in ('well_filter', 'enabled'):
312+
if _debug and field_name in ('well_filter', 'enabled'):
289313
logger.debug(f"🔍 FOUND concrete value in hierarchy at scope={scope_id!r}, returning {value!r}")
290314
return value, scope_id, container_base
291315
# Don't set fallback here - let Phase 2 walk MRO to find
@@ -304,7 +328,7 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any,
304328
#
305329
# Therefore: for each MRO type (most→least specific), scan scopes (inner→outer)
306330
# looking for the first non-None value.
307-
if field_name == 'well_filter':
331+
if _debug and field_name == 'well_filter':
308332
logger.debug(f"🔍 Phase 2 - MRO fallback, walking layers inner to outer")
309333

310334
# Track where the "highest-precedence" field exists even if None, so callers
@@ -330,7 +354,7 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any,
330354
except AttributeError:
331355
continue
332356

333-
if field_name == 'well_filter':
357+
if _debug and field_name == 'well_filter':
334358
logger.debug(f"🔍 MRO: {mro_type.__name__}.{field_name} @ {scope_id!r} = {value!r}")
335359

336360
if value is not None:

src/objectstate/lazy_factory.py

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,22 @@ def __getattribute__(self: Any, name: str) -> Any:
477477
"""
478478
# Stage 1: Get instance value
479479
value = object.__getattribute__(self, name)
480-
if value is not None or name not in {f.name for f in fields(self.__class__)}:
480+
# Fast path: non-None values never need resolution
481+
if value is not None:
482+
return value
483+
# PERFORMANCE: Use pre-computed frozenset (O(1) lookup) instead of
484+
# rebuilding {f.name for f in fields(self.__class__)} (O(n)) on every access.
485+
# Use object.__getattribute__ for __class__ to avoid recursion.
486+
# Lazily populate if not yet set (e.g. class created outside _create_lazy_dataclass_unified).
487+
_cls = object.__getattribute__(self, '__class__')
488+
try:
489+
_fns = _cls._field_names_set
490+
except AttributeError:
491+
_ft = fields(_cls)
492+
_fns = frozenset(f.name for f in _ft)
493+
_cls._field_names_set = _fns
494+
_cls._fields_by_name = {f.name: f for f in _ft}
495+
if name not in _fns:
481496
return value
482497

483498
# Stage 2: Simple field path lookup in current scope's merged global
@@ -510,7 +525,13 @@ def __getattribute__(self: Any, name: str) -> Any:
510525
return resolved_value
511526

512527
# For nested dataclass fields, return lazy instance
513-
field_obj = next((f for f in fields(self.__class__) if f.name == name), None)
528+
# PERFORMANCE: O(1) dict lookup instead of O(n) linear scan
529+
_fbm = getattr(self.__class__, '_fields_by_name', None)
530+
if _fbm is None:
531+
_ft = fields(self.__class__)
532+
_fbm = {f.name: f for f in _ft}
533+
self.__class__._fields_by_name = _fbm
534+
field_obj = _fbm.get(name)
514535
if field_obj and is_dataclass(field_obj.type):
515536
return field_obj.type()
516537

@@ -535,7 +556,13 @@ def __getattribute__(self: Any, name: str) -> Any:
535556
return mro_value
536557

537558
# Also check inherited default metadata
538-
field_obj = next((f for f in fields(self.__class__) if f.name == name), None)
559+
# PERFORMANCE: O(1) dict lookup instead of O(n) linear scan
560+
_fbm = getattr(self.__class__, '_fields_by_name', None)
561+
if _fbm is None:
562+
_ft = fields(self.__class__)
563+
_fbm = {f.name: f for f in _ft}
564+
self.__class__._fields_by_name = _fbm
565+
field_obj = _fbm.get(name)
539566
if field_obj and '_inherited_default' in field_obj.metadata:
540567
inherited = field_obj.metadata['_inherited_default']
541568
if inherited is not MISSING:
@@ -719,20 +746,32 @@ def _create_lazy_dataclass_unified(
719746
frozen=base_is_frozen # Match base class frozen status
720747
)
721748

749+
# PERFORMANCE: Pre-compute class-level metadata ONCE at class creation time
750+
# instead of recomputing on every instance construction or attribute access.
751+
import re
752+
_s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', base_class.__name__)
753+
_config_field_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', _s1).lower()
754+
lazy_class._config_field_name_cls = _config_field_name
755+
lazy_class._global_config_type_cls = global_config_type
756+
757+
# PERFORMANCE: Cache field names set and field-by-name dict per class.
758+
# These are derived from dataclasses.fields() which is immutable after class creation.
759+
# Avoids O(n) set creation on every __getattribute__ call and O(n) linear scan
760+
# on every field lookup.
761+
from dataclasses import fields as _dc_fields
762+
_field_tuple = _dc_fields(lazy_class)
763+
lazy_class._field_names_set = frozenset(f.name for f in _field_tuple)
764+
lazy_class._fields_by_name = {f.name: f for f in _field_tuple}
765+
722766
# Add constructor parameter tracking to detect user-set fields
723767
original_init = lazy_class.__init__
724768
def __init_with_tracking__(self, **kwargs):
725769
# Track which fields were explicitly passed to constructor
726770
object.__setattr__(self, '_explicitly_set_fields', set(kwargs.keys()))
727-
# Store the global config type for inheritance resolution
771+
# Store the global config type for inheritance resolution (read from class attr)
728772
object.__setattr__(self, '_global_config_type', global_config_type)
729-
# Store the config field name for simple field path lookup
730-
import re
731-
def _camel_to_snake_local(name: str) -> str:
732-
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
733-
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
734-
config_field_name = _camel_to_snake_local(base_class.__name__)
735-
object.__setattr__(self, '_config_field_name', config_field_name)
773+
# Store the config field name (read from pre-computed class attr)
774+
object.__setattr__(self, '_config_field_name', type(self)._config_field_name_cls)
736775
original_init(self, **kwargs)
737776

738777
lazy_class.__init__ = __init_with_tracking__

0 commit comments

Comments
 (0)