Speed up Application/Configurable instance startup - #956
Open
Carreau wants to merge 4 commits into
Open
Conversation
Member
Author
|
It does not affects ipython startup visibly, but does hove an effect on raw 'python -c "import traitlets.config"' |
Carreau
force-pushed
the
claude/traitlets-startup-performance-aykeja
branch
2 times, most recently
from
August 3, 2026 19:13
09e6089 to
690de14
Compare
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. Thanks for integrating Codecov - We've got you covered ☂️ |
Carreau
force-pushed
the
claude/traitlets-startup-performance-aykeja
branch
from
August 3, 2026 19:22
690de14 to
f1f33a9
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 via _cross_validate + set_trait. The second pass is only needed for traits that actually have a cross-validator (@Validate handler or a deprecated _<name>_validate method); for the common case with none it re-ran validate() on an already-validated value. The second loop now guards on the same condition _cross_validate itself uses (key in self._trait_validators or a _<name>_validate attribute exists). For traits without a cross-validator it records the already-stored (possibly coerced) value for the notification instead of re-validating, so notification payloads are byte-for-byte identical. 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
Configurable._load_config computed traits(config=True) and entered hold_trait_notifications() unconditionally, even for the many leaf Configurables in an Application graph whose config has no keys matching the instance. It now computes my_config first and returns early when it is empty, before doing any of that work. Also removes a dead `section_names = self.section_names()` local that was computed (section_names() walks the MRO with issubclass checks, twice per instance) but never used — _find_my_config recomputes it internally. The section_names parameter is kept in the signature for backward compatibility. Measured (Python 3.11): ~1.2x on leaf Configurables with no matching config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
Carreau
force-pushed
the
claude/traitlets-startup-performance-aykeja
branch
from
August 4, 2026 07:21
f1f33a9 to
4e69b41
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.