-
-
Notifications
You must be signed in to change notification settings - Fork 8.3k
PERF: Defer tick materialization during Axes init/clear #31525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d0bb8ff
3ac8224
ceef40c
fe563e3
1b72b8b
e80c08e
d0ed04c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| Classes for the ticks and x- and y-axis. | ||
| """ | ||
|
|
||
| import contextlib | ||
| import datetime | ||
| import functools | ||
| import logging | ||
|
|
@@ -243,6 +244,18 @@ def set_clip_path(self, path, transform=None): | |
| self.gridline.set_clip_path(path, transform) | ||
| self.stale = True | ||
|
|
||
| def _configure_for_axis(self, axis): | ||
| """ | ||
| Copy the Axis clip state onto this Tick and its gridline. | ||
|
|
||
| Used by `_LazyTickList` to stamp clip state set via | ||
| `Axis.set_clip_path` onto a lazily-materialized Tick. | ||
| """ | ||
| for artist in (self, self.gridline): | ||
| artist.clipbox = axis.clipbox | ||
| artist._clippath = axis._clippath | ||
| artist._clipon = axis._clipon | ||
|
|
||
| def contains(self, mouseevent): | ||
| """ | ||
| Test whether the mouse event occurred in the Tick marks. | ||
|
|
@@ -536,6 +549,26 @@ def formatter(self, formatter): | |
| self._formatter = formatter | ||
|
|
||
|
|
||
| @contextlib.contextmanager | ||
| def _rc_context_raw(snapshot): | ||
| """ | ||
| Like ``mpl.rc_context(snapshot)`` but bypasses ``RcParams`` validators | ||
| on entry and exit; re-applying a snapshot to its own values must not | ||
| re-trigger one-shot validator warnings (e.g. ``toolbar='toolmanager'``). | ||
| ``snapshot=None`` is a no-op. | ||
| """ | ||
| if snapshot is None: | ||
| yield | ||
| return | ||
| rc = mpl.rcParams | ||
| orig = dict(rc) | ||
| rc._update_raw(snapshot) | ||
| try: | ||
| yield | ||
| finally: | ||
| rc._update_raw(orig) | ||
|
|
||
|
|
||
| class _LazyTickList: | ||
| """ | ||
| A descriptor for lazy instantiation of tick lists. | ||
|
|
@@ -548,26 +581,39 @@ def __init__(self, major): | |
| self._major = major | ||
|
|
||
| def __get__(self, instance, owner): | ||
| """Materialize the descriptor to a list with one configured tick.""" | ||
| if instance is None: | ||
| return self | ||
| else: | ||
| # instance._get_tick() can itself try to access the majorTicks | ||
| # attribute (e.g. in certain projection classes which override | ||
| # e.g. get_xaxis_text1_transform). In order to avoid infinite | ||
| # recursion, first set the majorTicks on the instance temporarily | ||
| # to an empty list. Then create the tick; note that _get_tick() | ||
| # may call reset_ticks(). Therefore, the final tick list is | ||
| # created and assigned afterwards. | ||
| if self._major: | ||
| instance.majorTicks = [] | ||
| tick = instance._get_tick(major=True) | ||
| instance.majorTicks = [tick] | ||
| return instance.majorTicks | ||
| else: | ||
| instance.minorTicks = [] | ||
| tick = instance._get_tick(major=False) | ||
| instance.minorTicks = [tick] | ||
| return instance.minorTicks | ||
| # instance._get_tick() can itself try to access the majorTicks | ||
| # attribute (e.g. in certain projection classes which override | ||
| # e.g. get_xaxis_text1_transform). To avoid infinite recursion, | ||
| # bind the attribute to an empty list before calling _get_tick(). | ||
| # _get_tick() may also call reset_ticks(), which pops the attribute | ||
| # from the instance dict; the final setattr below re-binds the | ||
| # (now non-empty) list so subsequent accesses skip the descriptor. | ||
| attr = 'majorTicks' if self._major else 'minorTicks' | ||
| tick_list = [] | ||
| setattr(instance, attr, tick_list) | ||
| # Build the Tick (and its sub-artists) under the rcParams captured | ||
| # at the last Axis.clear() so that a lazily-materialized Tick | ||
| # matches an eager (pre-lazy) Tick (see Axis._tick_rcParams). | ||
| with _rc_context_raw(instance._tick_rcParams): | ||
| tick = instance._get_tick(major=self._major) | ||
| # Re-apply any set_tick_params() overrides to the fresh Tick. | ||
| # Subclasses of Axis (e.g. the SkewXAxis in the skewt gallery | ||
| # example) sometimes override _get_tick() without forwarding | ||
| # _{major,minor}_tick_kw; calling _apply_params() here guarantees | ||
| # those overrides still take effect, matching the pre-lazy | ||
| # behaviour where the first tick was materialized eagerly and | ||
| # updated in place by set_tick_params(). | ||
| tick_kw = (instance._major_tick_kw if self._major | ||
| else instance._minor_tick_kw) | ||
| if tick_kw: | ||
| tick._apply_params(**tick_kw) | ||
| tick._configure_for_axis(instance) | ||
| tick_list.append(tick) | ||
| setattr(instance, attr, tick_list) | ||
| return tick_list | ||
|
|
||
|
|
||
| class Axis(martist.Artist): | ||
|
|
@@ -672,6 +718,15 @@ def __init__(self, axes, *, pickradius=15, clear=True): | |
| # Initialize here for testing; later add API | ||
| self._major_tick_kw = dict() | ||
| self._minor_tick_kw = dict() | ||
| # Snapshot of rcParams at the time of the last Axis.clear() (or | ||
| # set_tick_params(reset=True)). _LazyTickList re-applies these | ||
| # when it lazily creates a Tick so that the Tick and its | ||
| # sub-artists see the same rcParams an eager (pre-lazy) | ||
| # materialization would have seen. Kept separate from | ||
| # _major_tick_kw/_minor_tick_kw, which hold user-provided | ||
| # set_tick_params() overrides rather than ambient rcParams. See | ||
| # Tick._configure_for_axis() for the clip-state counterpart. | ||
| self._tick_rcParams = None | ||
|
|
||
| if clear: | ||
| self.clear() | ||
|
|
@@ -851,12 +906,14 @@ def _reset_major_tick_kw(self): | |
| self._major_tick_kw['gridOn'] = ( | ||
| mpl.rcParams['axes.grid'] and | ||
| mpl.rcParams['axes.grid.which'] in ('both', 'major')) | ||
| self._tick_rcParams = dict(mpl.rcParams) | ||
|
|
||
| def _reset_minor_tick_kw(self): | ||
| self._minor_tick_kw.clear() | ||
| self._minor_tick_kw['gridOn'] = ( | ||
| mpl.rcParams['axes.grid'] and | ||
| mpl.rcParams['axes.grid.which'] in ('both', 'minor')) | ||
| self._tick_rcParams = dict(mpl.rcParams) | ||
|
|
||
| def clear(self): | ||
| """ | ||
|
|
@@ -887,6 +944,11 @@ def clear(self): | |
| # Clear the callback registry for this axis, or it may "leak" | ||
| self.callbacks = cbook.CallbackRegistry(signals=["units"]) | ||
|
|
||
| # Snapshot current rcParams so that a Tick materialized later by | ||
| # _LazyTickList (possibly outside any rc_context() active now) | ||
| # sees the same rcParams an eager pre-lazy tick would have. | ||
| self._tick_rcParams = dict(mpl.rcParams) | ||
|
|
||
| # whether the grids are on | ||
| self._major_tick_kw['gridOn'] = ( | ||
| mpl.rcParams['axes.grid'] and | ||
|
|
@@ -907,19 +969,46 @@ def reset_ticks(self): | |
|
|
||
| Each list starts with a single fresh Tick. | ||
| """ | ||
| # Restore the lazy tick lists. | ||
| try: | ||
| del self.majorTicks | ||
| except AttributeError: | ||
| pass | ||
| try: | ||
| del self.minorTicks | ||
| except AttributeError: | ||
| pass | ||
| try: | ||
| self.set_clip_path(self.axes.patch) | ||
| except AttributeError: | ||
| pass | ||
| # Drop any materialized tick lists so the _LazyTickList descriptor is | ||
| # reactivated on next access. If ticks were already materialized, | ||
| # re-apply the axes-patch clip path; otherwise skip. | ||
| had_major = bool(self.__dict__.pop('majorTicks', None)) | ||
| had_minor = bool(self.__dict__.pop('minorTicks', None)) | ||
| if had_major or had_minor: | ||
| try: | ||
| self.set_clip_path(self.axes.patch) | ||
| except AttributeError: | ||
| pass | ||
|
Comment on lines
+975
to
+981
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a logical change or just rewriting? AFAICS we can impove the comment to something like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a logical change as |
||
|
|
||
| def _existing_ticks(self, major=None): | ||
| """ | ||
| Yield already-materialized ticks without triggering the lazy descriptor. | ||
|
|
||
| `majorTicks` and `minorTicks` are `_LazyTickList` descriptors that | ||
| create a fresh `.Tick` on first access. Several internal methods | ||
| (`set_clip_path`, `set_tick_params`) need to touch every | ||
| *already-materialized* tick without forcing materialization, because | ||
| doing so would | ||
|
|
||
| (a) create throwaway Tick objects during ``Axes.__init__`` and | ||
| ``Axes.__clear`` | ||
| (b) risk re-entering the | ||
| ``Spine.set_position -> Axis.reset_ticks -> Axis.set_clip_path | ||
| -> _LazyTickList.__get__ -> Tick.__init__ -> Spine.set_position`` | ||
| cascade. | ||
|
|
||
| Reading the instance ``__dict__`` directly bypasses the descriptor. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| major : bool, optional | ||
| If True, yield only major ticks; if False, only minor ticks; | ||
| if None (default), yield major followed by minor. | ||
| """ | ||
| if major is None or major: | ||
| yield from self.__dict__.get('majorTicks', ()) | ||
| if major is None or not major: | ||
| yield from self.__dict__.get('minorTicks', ()) | ||
|
|
||
| def minorticks_on(self): | ||
| """ | ||
|
|
@@ -988,11 +1077,11 @@ def set_tick_params(self, which='major', reset=False, **kwargs): | |
| else: | ||
| if which in ['major', 'both']: | ||
| self._major_tick_kw.update(kwtrans) | ||
| for tick in self.majorTicks: | ||
| for tick in self._existing_ticks(major=True): | ||
| tick._apply_params(**kwtrans) | ||
| if which in ['minor', 'both']: | ||
| self._minor_tick_kw.update(kwtrans) | ||
| for tick in self.minorTicks: | ||
| for tick in self._existing_ticks(major=False): | ||
| tick._apply_params(**kwtrans) | ||
| # labelOn and labelcolor also apply to the offset text. | ||
| if 'label1On' in kwtrans or 'label2On' in kwtrans: | ||
|
|
@@ -1131,7 +1220,7 @@ def _translate_tick_params(cls, kw, reverse=False): | |
|
|
||
| def set_clip_path(self, path, transform=None): | ||
| super().set_clip_path(path, transform) | ||
| for child in self.majorTicks + self.minorTicks: | ||
| for child in self._existing_ticks(): | ||
| child.set_clip_path(path, transform) | ||
| self.stale = True | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the logic complexity grows, we should afford a description.