Skip to content

Speed up startup: defer heavy imports and cut redundant class setup work#956

Open
Carreau wants to merge 3 commits into
mainfrom
claude/traitlets-startup-performance-aykeja
Open

Speed up startup: defer heavy imports and cut redundant class setup work#956
Carreau wants to merge 3 commits into
mainfrom
claude/traitlets-startup-performance-aykeja

Conversation

@Carreau

@Carreau Carreau commented Jul 21, 2026

Copy link
Copy Markdown
Member

Optimize both import-time and runtime startup cost, which matters for
applications (IPython, Jupyter, etc.) that import traitlets and build/
instantiate many HasTraits/Configurable/Application classes at startup.

Import-time: traitlets and its core utils imported inspect (which pulls
in ast, dis, tokenize, linecache), pathlib, and ast at module top level,
and traitlets.config additionally imported logging.config (pulling
logging.handlers, socket, pickle, dataclasses), pprint and json eagerly.
All of these are only needed on cold paths (string parsing, filesystem
Path/CRegExp traits, help/config-dump output, logging configuration at
Application startup). Because the modules use from __future__ import annotations, annotations referring to these names are never evaluated at
runtime, so the imports can be deferred to their actual (rare) use sites.
inspect.isclass(x) is replaced with isinstance(x, type) and
inspect.currentframe() with sys._getframe() to avoid needing inspect at
all on the class-definition hot path.

Runtime:

  • MetaHasDescriptors.setup_class already walks the full class namespace
    via getmembers(cls); it now returns that result so MetaHasTraits.
    setup_class can reuse it instead of doing a second dir()+getattr walk
    over every class. Same (name, value) pairs, identical semantics.
  • traits()/class_traits() normalize their metadata filters once instead
    of rebuilding a _SimpleTest wrapper for every trait on every call.

Measured (Python 3.11): import traitlets ~28ms -> ~16ms, import
traitlets.config ~47ms -> ~28ms, class definition ~118us -> ~96us/class,
class_traits(config=True) ~7.5us -> ~4.2us/call. Full test suite (582
tests), mypy, and ruff all pass.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk

claude added 3 commits July 21, 2026 13:31
Optimize both import-time and runtime startup cost, which matters for
applications (IPython, Jupyter, etc.) that import traitlets and build/
instantiate many HasTraits/Configurable/Application classes at startup.

Import-time: traitlets and its core utils imported inspect (which pulls
in ast, dis, tokenize, linecache), pathlib, and ast at module top level,
and traitlets.config additionally imported logging.config (pulling
logging.handlers, socket, pickle, dataclasses), pprint and json eagerly.
All of these are only needed on cold paths (string parsing, filesystem
Path/CRegExp traits, help/config-dump output, logging configuration at
Application startup). Because the modules use `from __future__ import
annotations`, annotations referring to these names are never evaluated at
runtime, so the imports can be deferred to their actual (rare) use sites.
inspect.isclass(x) is replaced with isinstance(x, type) and
inspect.currentframe() with sys._getframe() to avoid needing inspect at
all on the class-definition hot path.

Runtime:
- MetaHasDescriptors.setup_class already walks the full class namespace
  via getmembers(cls); it now returns that result so MetaHasTraits.
  setup_class can reuse it instead of doing a second dir()+getattr walk
  over every class. Same (name, value) pairs, identical semantics.
- traits()/class_traits() normalize their metadata filters once instead
  of rebuilding a _SimpleTest wrapper for every trait on every call.

Measured (Python 3.11): import traitlets ~28ms -> ~16ms, import
traitlets.config ~47ms -> ~28ms, class definition ~118us -> ~96us/class,
class_traits(config=True) ~7.5us -> ~4.2us/call. Full test suite (582
tests), mypy, and ruff all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
Targets the runtime cost of constructing an Application and its many
Configurable sub-components at startup (profiled on a synthetic 12-component,
373-trait app modeled on Jupyter/IPython scale). Three changes:

1. Memoize metadata-filtered class_traits()/traits() results per class.
   `config=True` filtering was the single largest startup cost (~25-30%),
   recomputed from scratch ~45x per startup (Application._classes_with_config_traits,
   KVArgParseConfigLoader._add_arguments, and each Configurable._load_config) for
   results that are static per class. class_traits/traits now delegate to a shared
   classmethod that caches the filtered dict per class and returns a .copy() (the
   existing "fresh dict" contract is preserved — the cached dict never escapes by
   reference). cls._traits is frozen after class creation, so the only staleness
   vector is a post-hoc metadata mutation via tag()/set_metadata(); those bump a
   module-level generation counter and stale cache entries are recomputed. Only
   constant (non-callable, hashable) filters are cached; callable predicates stay
   on the uncached path. class_traits(config=True): ~8.3us -> ~1.3us/call.

2. Skip the redundant re-validate pass in HasTraits.__init__. Constructor kwargs
   were validated 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; for the common case with none it re-ran
   validate() on an already-validated value. Now guarded on the same condition
   _cross_validate itself uses, recording the coerced stored value for the
   notification. Instantiation with kwargs (no cross-validators): ~1.2-1.4x.

3. Configurable._load_config early-returns when no config applies to the instance,
   skipping traits(config=True) and an empty hold_trait_notifications() block for
   the many leaf Configurables that carry no matching config. Also removes a dead
   `section_names = self.section_names()` local that was computed (twice per
   instance) but never used.

Measured on the synthetic app (Python 3.11): construct+initialize ~2196us ->
~1740us (~21%). Full test suite (583, incl. a new cache-invalidation regression
test), mypy, and ruff all pass. Pickling, instance/subclass observers and
validators, add_traits, and end-to-end Application config loading verified.

Note for reviewers: the class_traits/traits cache is invalidated by tag() and
set_metadata(), the supported post-construction metadata-mutation APIs. 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, but is called out for awareness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
Collapse the MetaHasTraits.setup_class signature onto a single line to
satisfy the ruff-format pre-commit hook (Test Lint CI job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaKDJ3fpGf7anQeYeBsJbk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants