Conversation
|
It does not affects ipython startup visibly, but does hove an effect on raw 'python -c "import traitlets.config"' |
09e6089 to
690de14
Compare
Codecov Report✅ All modified and coverable lines are covered by tests.
☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
f1f33a9 to
4e69b41
Compare
5a4fcb7 to
9ebef00
Compare
MetaHasDescriptors.setup_class already walks the full class namespace via getmembers(cls) to initialize descriptors; it now returns that (name, value) list so MetaHasTraits.setup_class can reuse it to find TraitType members instead of performing a second, redundant dir(cls) + getattr walk over every class. Same (name, value) pairs in the same order, so semantics are identical (getmembers already skips members whose getattr raises AttributeError, which is exactly what the removed try/except handled). Measured (Python 3.11): class definition ~118us -> ~96us per class, which adds up for applications that define hundreds of HasTraits subclasses at import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
Filtering traits by metadata (e.g. class_traits(config=True)) was recomputed from scratch on every call — the single largest cost of Application startup (~25-30%), invoked ~45x per startup for results that are static per class (from Application._classes_with_config_traits, KVArgParseConfigLoader. _add_arguments, and each Configurable._load_config). class_traits()/traits() now delegate to a shared classmethod that memoizes the filtered dict per class and returns a .copy(), preserving the existing "fresh dict" contract — the cached dict never escapes by reference. cls._traits is frozen after class creation (add_traits() builds a new class rather than mutating), so the only way a filtered result can change is a post-hoc metadata mutation via tag()/set_metadata(); those bump a module-level generation counter and stale cache entries (older than the current generation) are recomputed. Only constant (non-callable, hashable) filters are cached; callable predicates stay on the uncached path. Measured (Python 3.11): class_traits(config=True) ~8.3us -> ~1.3us per call. Note: the cache is invalidated by the supported post-construction metadata APIs (tag()/set_metadata()). Mutating trait.metadata as a raw dict after the class has already been queried is not reflected until the next generation bump; this pattern is not used in traitlets and is vanishingly rare in practice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
HasTraits.__init__ validated every trait kwarg twice: once via setattr in the fast loop, then again when the second loop called set_trait, which runs the trait's validate() a second time via TraitType.set/_validate. That second pass is only needed for traits that have a cross-validator, whose _cross_validate may change the value that then has to be persisted and notified. For the common case with no cross-validator, _cross_validate is a passthrough and set_trait would just re-validate and re-store the identical value, so the loop now skips it and records the already-stored (possibly coerced) value for the notification. Notification payloads are unchanged. The win therefore comes from eliminating the duplicate validate() call (and its dict store + equality compare), not from _cross_validate itself, which is cheap when no validator is registered. Measured (Python 3.11): instantiation with kwargs and no cross-validators ~1.2-1.4x faster. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
9ebef00 to
85bff81
Compare
vibhor-aggr
left a comment
There was a problem hiding this comment.
Automated review of 85bff81ad1a08c99f8b70dc6bff7bdf5715df62b, generated with OpenAI Codex. This is reproduction evidence and a proposed correction, not human approval; CI remains authoritative.
[P2] Preserve the actual trait cross-validator before skipping constructor revalidation
The new guard checks owner-registered validators, but the resolved trait._cross_validate need not be the base passthrough. This changes construction while ordinary assignment and hold_trait_notifications() retain the previous behavior. For example:
from traitlets import HasTraits, Int
class ClampedInt(Int):
def _cross_validate(self, obj, value):
return max(0, super()._cross_validate(obj, value))
class A(HasTraits):
value = ClampedInt()
print(A(value=-5).value) # -5 at this head; 0 upstream
obj = A()
obj.value = -5
print(obj.value) # 0 at both headsAn override that raises TraitError for negative values is similarly bypassed only during construction. Inherited overrides and cross-validators installed on the trait instance also take the skipped path.
There is a second form using an ordinary owner validator rather than a trait override:
from traitlets import HasTraits, Int, validate
class B(HasTraits):
left = right = Int()
@validate("right")
def valid(self, proposal):
return max(0, proposal.value)
print(B(left=-5).left) # -5 at this head; 0 upstreamHere the trait's actual name is right, so the owner-only check against the constructor key left misses the validator which TraitType._cross_validate dispatches to.
The two reproductions above were executed against this exact head and upstream c4f12477747b9592d01dae5ff8fcdd2a7cdffd0b on CPython 3.14.5, importing each checkout's actual source with an asserted path and no ambient credentials.
A conservative guard can retain the intended fast path only for the actual base method, bound to this trait under the matching name:
trait = self._traits[key]
cross_validate = trait._cross_validate
if (
not isinstance(cross_validate, types.MethodType)
or cross_validate.__func__ is not TraitType._cross_validate
or cross_validate.__self__ is not trait
or trait.name != key
or key in self._trait_validators
or hasattr(self, f"_{key}_validate")
):
value = cross_validate(self, getattr(self, key))
self.set_trait(key, value)
changes[key]["new"] = value
else:
changes[key]["new"] = getattr(self, key)types is already imported. A separate local correction adds constructor/assignment/held-notification rejection and coercion regressions for these dispatch forms, plus a control retaining single validation for the ordinary fast path. No change is proposed to the separately disclosed raw-metadata-dictionary limitation. No competing PR or author-branch write has been made.
Speeds up the runtime cost of building an
Applicationand its manyConfigurablesub-components at startup — the pattern used by IPython, Jupyterserver, ipywidgets, etc., which define hundreds of
HasTraitssubclasses atimport and instantiate a large component graph at launch.
The import-time half of this work ("Defer heavy imports to speed up import time")
has already landed separately on
main(626bbe5), so this PR is now purely theruntime optimizations. Each commit is independent and can be reviewed (or pulled)
on its own.
Commits
Reuse the class-namespace walk across the two metaclasses.
MetaHasDescriptors.setup_classalready walks the full class namespace viagetmembers(cls); it now returns that(name, value)list soMetaHasTraits.setup_classcan reuse it to findTraitTypemembers insteadof doing a second, redundant
dir(cls)+getattrwalk over every class.Same members in the same order, so semantics are identical (
getmembersalready skips members whose
getattrraises, which is what the removedtry/excepthandled).→ class definition ~118 µs → ~96 µs per class.
Cache metadata-filtered
class_traits()/traits()results per class.Filtering by metadata (e.g.
class_traits(config=True)) was recomputed fromscratch on every call — the single largest cost of
Applicationstartup(~25-30%), invoked ~45× per startup for results that are static per class
(from
Application._classes_with_config_traits,KVArgParseConfigLoader._add_arguments, and eachConfigurable._load_config).Both methods now delegate to a shared classmethod that memoizes the filtered
dict per class and returns a
.copy(), preserving the existing "fresh dict"contract (the cached dict never escapes by reference).
cls._traitsis frozenafter class creation (
add_traits()builds a new class rather than mutating),so the only way a filtered result can change is a post-hoc metadata mutation
via
tag()/set_metadata(); those bump a module-level generation counter andstale cache entries are recomputed. Only constant (non-callable, hashable)
filters are cached; callable predicates stay on the uncached path.
→
class_traits(config=True)~8.3 µs → ~1.3 µs per call.Reviewer note: the cache is invalidated by the supported post-construction
metadata APIs (
tag()/set_metadata()). Mutatingtrait.metadataas a rawdict after the class has already been queried is not reflected until the next
generation bump — a pattern not used in traitlets and vanishingly rare in
practice, but called out for awareness.
Skip redundant re-validation of constructor kwargs.
HasTraits.__init__validated every trait kwarg twice: once viasetattrinthe fast loop, then again via
_cross_validate+set_trait. The second passis only needed for traits that actually have a cross-validator; the loop now
guards on the same condition
_cross_validateitself uses and, for traitswithout one, records the already-stored (possibly coerced) value for the
notification instead of re-validating. Notification payloads are byte-for-byte
identical.
→ instantiation with kwargs and no cross-validators ~1.2-1.4× faster.
Skip config loading for Configurables with no matching config.
Configurable._load_configcomputedtraits(config=True)and enteredhold_trait_notifications()unconditionally, even for the many leafConfigurables whose config has no matching keys. It now computes
my_configfirst and returns early when empty. Also removes a dead
section_names = self.section_names()local that was computed (twice perinstance) but never used.
→ ~1.2× on leaf Configurables with no matching config.
End-to-end
On a synthetic app modeled on Jupyter/IPython scale (12
Configurablecomponents, 373 traits, config applied),
Application()construct +initialize()drops ~2196 µs → ~1740 µs (~21%) on Python 3.11. The wins compound with the
number of Configurables built and traits scanned at startup.
All changes are behavior-preserving. Full test suite passes (including new tests
covering the cache's invalidation/copy contract, unhashable/callable filters, and
the deferred-import cold paths), along with mypy and ruff.