Skip to content

Conversation

@scottshambaugh
Copy link
Contributor

@scottshambaugh scottshambaugh commented Jan 20, 2026

PR summary

I've been having a lot of fun profiling the past two days. This PR is the result of optimizing slow bits of the text rendering code paths that are called downstream of axis3d._draw_ticks(). None of these changes are 3D specific, so they should speed up 2D draw times as well. The non-agg-rendering code in this part of the stack is sped up by a cumulative 2.2x, which is an 8% reduction in the total draw time for my test script of an empty 3D plot.

The commits are all self-contained, so I can break them apart if that's easier to review.

Summary of the changes:

text.py:

  • Rework the font property cache to use a plain dict instead of lru_cache, and use a tuple-based cache key. This avoids expensive copying of the whole FontProperties object on every cache lookup.
  • Add @lru_cache for rotation transforms via a _rotate(theta) helper function (common case is only a few angles)
  • Add fast path to skip rotation transform operations when rotation=0 (the most common case)
  • Use direct indexing instead of numpy array operations for several small lists

font_manager.py:

  • Implement __copy__ method on FontProperties that bypasses __init__ validation

lines.py

  • Add fast path for same-shape x/y arrays using direct assignment instead of broadcast_arrays
  • Replace .T unpacking with column slicing for views

path.py

  • Inline shape validation instead of calling _api.check_shape

transforms.py

  • Prefer direct array construction in Bbox.from_extents over np.reshape

Before:
image

After (less time on things that aren't draw_text):
image

Test script:

import time
import matplotlib.pyplot as plt

print("Starting...")

fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')

print("Timing...")

start_time = time.perf_counter()
for i in range(250):
    ax.view_init(elev=i, azim=i)
    fig.canvas.draw()
end_time = time.perf_counter()

plt.close()

print(f"Time taken: {end_time - start_time:.4f} seconds")

PR checklist

@scottshambaugh scottshambaugh marked this pull request as ready for review January 20, 2026 03:19
@scottshambaugh
Copy link
Contributor Author

scottshambaugh commented Jan 20, 2026

Ready for review. @anntzer FYI - you're probably the most familiar with the text sections here

def get_window_extent(self, renderer=None):
x0, x1, y0, y1 = self._extent
bbox = Bbox.from_extents([x0, y0, x1, y1])
bbox = Bbox.from_extents(x0, y0, x1, y1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess Bbox([(x0, y0), (x1, y1)]) would be even better.


self._xy = np.column_stack(np.broadcast_arrays(x, y)).astype(float)
self._x, self._y = self._xy.T # views
# Fast path for common case where x and y have same shape
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably both cases can be kept together (by relying on broadcasting in the assignments):

self._xy = np.empty((max(len(x), len(y)), 2))
self._xy[:, 0] = x
self._xy[:, 1] = y

?

set. This is useful when dealing with logarithmic scales and other
scales where negative bounds result in floating point errors.
"""
bbox = Bbox(np.reshape(args, (2, 2)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe just np.asarray(args, float).reshape((2, 2)) should get you most or all the speed benefits?


# now rotate the positions around the first (x, y) position
xys = M.transform(offset_layout) - (offsetx, offsety)
if rotation != 0:
Copy link
Contributor

@anntzer anntzer Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going for this kind of microoptimizations, I would strongly suspect that it may be faster to convert everything to python scalars (not numpy scalars) and completely avoid the use of numpy here (writing out explicitly the rotation operations and so on), because we're dealing in general with very few characters ("no one" is writing thousands of characters with matplotlib) and numpy's overhead is actually large in this case.

_text_metrics_cache = weakref.WeakKeyDictionary()


def _get_text_metrics_with_cache_impl(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caching was last touched by @tacaswell IIRC, perhaps he can check it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants