Skip to content

Commit a0e0809

Browse files
committed
MNT: improve how we manage the cache for font metrics
1 parent 64d45cb commit a0e0809

File tree

2 files changed

+118
-11
lines changed

2 files changed

+118
-11
lines changed

lib/matplotlib/tests/test_text.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from datetime import datetime
2+
import gc
3+
import inspect
24
import io
35
import warnings
46

@@ -881,7 +883,12 @@ def test_pdf_chars_beyond_bmp():
881883

882884
@needs_usetex
883885
def test_metrics_cache():
884-
mpl.text._get_text_metrics_with_cache_impl.cache_clear()
886+
# dig into the signature to get the mutable default used as a cache
887+
renderer_cache = inspect.signature(
888+
mpl.text._get_text_metrics_function
889+
).parameters['_cache'].default
890+
891+
renderer_cache.clear()
885892

886893
fig = plt.figure()
887894
fig.text(.3, .5, "foo\nbar")
@@ -890,6 +897,7 @@ def test_metrics_cache():
890897
fig.canvas.draw()
891898
renderer = fig._get_renderer()
892899
ys = {} # mapping of strings to where they were drawn in y with draw_tex.
900+
assert renderer in renderer_cache
893901

894902
def call(*args, **kwargs):
895903
renderer, x, y, s, *_ = args
@@ -904,12 +912,39 @@ def call(*args, **kwargs):
904912
# get incorrectly reused by the first TeX string.
905913
assert len(ys["foo"]) == len(ys["bar"]) == 1
906914

907-
info = mpl.text._get_text_metrics_with_cache_impl.cache_info()
915+
info = renderer_cache[renderer].cache_info()
908916
# Every string gets a miss for the first layouting (extents), then a hit
909917
# when drawing, but "foo\nbar" gets two hits as it's drawn twice.
910918
assert info.hits > info.misses
911919

912920

921+
def test_metrics_cache2():
922+
# dig into the signature to get the mutable default used as a cache
923+
renderer_cache = inspect.signature(
924+
mpl.text._get_text_metrics_function
925+
).parameters['_cache'].default
926+
gc.collect()
927+
assert len(renderer_cache) == 0
928+
929+
def helper():
930+
fig, ax = plt.subplots()
931+
fig.draw_without_rendering()
932+
# show we hit the outer cache
933+
assert len(renderer_cache) == 1
934+
func = renderer_cache[fig.canvas.get_renderer()]
935+
cache_info = func.cache_info()
936+
# show we hit the inner cache
937+
assert cache_info.currsize > 0
938+
assert cache_info.currsize == cache_info.misses
939+
assert cache_info.hits > cache_info.misses
940+
plt.close(fig)
941+
942+
helper()
943+
gc.collect()
944+
# show the outer cache has a lifetime tied to the renderer (via the figure)
945+
assert len(renderer_cache) == 0
946+
947+
913948
def test_annotate_offset_fontsize():
914949
# Test that offset_fontsize parameter works and uses accurate values
915950
fig, ax = plt.subplots()

lib/matplotlib/text.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,89 @@ def _get_textbox(text, renderer):
6464

6565
def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi):
6666
"""Call ``renderer.get_text_width_height_descent``, caching the results."""
67-
# Cached based on a copy of fontprop so that later in-place mutations of
68-
# the passed-in argument do not mess up the cache.
69-
return _get_text_metrics_with_cache_impl(
70-
weakref.ref(renderer), text, fontprop.copy(), ismath, dpi)
7167

68+
# hit the outer cache layer and get the function to compute the metrics
69+
# for this renderer instance
70+
get_text_metrics = _get_text_metrics_function(renderer)
71+
# call the function to compute the metrics and return
72+
#
73+
# We pass a copy of the fontprop because FontProperties is both mutable and
74+
# has a `__hash__` that depends on that mutable state. This is not ideal
75+
# as it means the hash of an object is not stable over time which leads to
76+
# very confusing behavior when used as keys in dictionaries or hashes.
77+
return get_text_metrics(text, fontprop.copy(), ismath, dpi)
7278

73-
@functools.lru_cache(4096)
74-
def _get_text_metrics_with_cache_impl(
75-
renderer_ref, text, fontprop, ismath, dpi):
76-
# dpi is unused, but participates in cache invalidation (via the renderer).
77-
return renderer_ref().get_text_width_height_descent(text, fontprop, ismath)
79+
80+
def _get_text_metrics_function(input_renderer, _cache=weakref.WeakKeyDictionary()):
81+
"""
82+
Helper function to provide a two-layered cache for font metrics
83+
84+
85+
To get the rendered size of a size of string we need to know:
86+
- what renderer we are using
87+
- the current dpi of the renderer
88+
- the string
89+
- the font properties
90+
- is it math text or not
91+
92+
We do this as a two-layer cache with the outer layer being tied to a
93+
renderer instance and the inner layer handling everything else.
94+
95+
The outer layer is implemented as `.WeakKeyDictionary` keyed on the
96+
renderer. As long as someone else is holding a hard ref to the renderer
97+
we will keep the cache alive, but it will be automatically dropped when
98+
the renderer is garbage collected.
99+
100+
The inner layer is provided by an lru_cache with a large maximum size (such
101+
that we do not expect it to be hit in very few actual use cases). As the
102+
dpi is mutable on the renderer, we need to explicitly include it as part of
103+
the cache key on the inner layer even though we do not directly use it (it is
104+
used in the method call on the renderer).
105+
106+
This function takes a renderer and returns a function that can be used to
107+
get the font metrics.
108+
109+
Parameters
110+
----------
111+
input_renderer : maplotlib.backend_bases.RendererBase
112+
The renderer to set the cache up for.
113+
114+
_cache : dict, optional
115+
We are using the mutable default value to attach the cache to the function.
116+
117+
In principle you could pass a different dict-like to this function to inject
118+
a different cache, but please don't. This is an internal function not meant to
119+
be reused outside of the narrow context we need it for.
120+
121+
There is a possible race condition here between threads, we may need to drop the
122+
mutable default and switch to a threadlocal variable in the future.
123+
124+
"""
125+
if (_text_metrics := _cache.get(input_renderer, None)) is None:
126+
# We are going to include this in the closure we put as values in the
127+
# cache. Closing over a hard-ref would create an unbreakable reference
128+
# cycle.
129+
renderer_ref = weakref.ref(input_renderer)
130+
131+
# define the function locally to get a new lru_cache per renderer
132+
@functools.lru_cache(4096)
133+
# dpi is unused, but participates in cache invalidation (via the renderer).
134+
def _text_metrics(text, fontprop, ismath, dpi):
135+
# this should never happen under normal use, but this is a better error to
136+
# raise than an AttributeError on `None`
137+
if (local_renderer := renderer_ref()) is None:
138+
raise RuntimeError(
139+
"Trying to get text metrics for a renderer that no longer exists. "
140+
"This should never happen and is evidence of a bug elsewhere."
141+
)
142+
# do the actual method call we need and return the result
143+
return local_renderer.get_text_width_height_descent(text, fontprop, ismath)
144+
145+
# stash the function for later use.
146+
_cache[input_renderer] = _text_metrics
147+
148+
# return the inner function
149+
return _text_metrics
78150

79151

80152
@_docstring.interpd

0 commit comments

Comments
 (0)