Skip to content

Commit 27a0e17

Browse files
committed
(line-profiler) Optimize performance by removing unnecessary QObject inheritance and adding font key caching
1 parent ef793e1 commit 27a0e17

3 files changed

Lines changed: 149 additions & 19 deletions

File tree

qwt/scale_draw.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,22 @@
5151
_ALIGN_BOTTOM = int(Qt.AlignBottom)
5252

5353

54-
class QwtAbstractScaleDraw_PrivateData(QObject):
55-
def __init__(self):
56-
QObject.__init__(self)
54+
class QwtAbstractScaleDraw_PrivateData(object):
55+
# See QwtText_PrivateData: ``QObject`` inheritance is unused and the
56+
# base class' ``__init__`` is a measurable cost in tick-heavy renders.
57+
__slots__ = (
58+
"spacing",
59+
"penWidth",
60+
"minExtent",
61+
"components",
62+
"tick_length",
63+
"tick_lighter_factor",
64+
"map",
65+
"scaleDiv",
66+
"labelCache",
67+
)
5768

69+
def __init__(self):
5870
self.spacing = 4
5971
self.penWidth = 0
6072
self.minExtent = 0.0
@@ -481,12 +493,25 @@ def invalidateCache(self):
481493
self.__data.labelCache.clear()
482494

483495

484-
class QwtScaleDraw_PrivateData(QObject):
485-
def __init__(self):
486-
QObject.__init__(self)
496+
class QwtScaleDraw_PrivateData(object):
497+
# See QwtText_PrivateData: ``QObject`` inheritance is unused and the
498+
# base class' ``__init__`` is a measurable cost in tick-heavy renders.
499+
__slots__ = (
500+
"len",
501+
"alignment",
502+
"orientation",
503+
"labelAlignment",
504+
"labelRotation",
505+
"labelAutoSize",
506+
"pos",
507+
)
487508

509+
def __init__(self):
488510
self.len = 0
489511
self.alignment = QwtScaleDraw.BottomScale
512+
# Cached orientation - kept in sync by ``QwtScaleDraw.setAlignment``
513+
# so that the very hot ``orientation()`` accessor avoids any test.
514+
self.orientation = Qt.Horizontal
490515
self.labelAlignment = 0
491516
self.labelRotation = 0.0
492517
self.labelAutoSize = True
@@ -565,6 +590,11 @@ def setAlignment(self, align):
565590
:py:meth:`alignment()`
566591
"""
567592
self.__data.alignment = align
593+
# Keep cached orientation in sync (see ``orientation()``).
594+
if align == self.BottomScale or align == self.TopScale:
595+
self.__data.orientation = Qt.Horizontal
596+
else:
597+
self.__data.orientation = Qt.Vertical
568598

569599
def orientation(self):
570600
"""
@@ -579,13 +609,8 @@ def orientation(self):
579609
580610
:py:meth:`alignment()`
581611
"""
582-
# Direct comparisons (no tuple ``in`` membership check) — this is
583-
# called per-tick in label layout and shows up in profiles.
584-
align = self.__data.alignment
585-
if align == self.BottomScale or align == self.TopScale:
586-
return Qt.Horizontal
587-
if align == self.LeftScale or align == self.RightScale:
588-
return Qt.Vertical
612+
# Pre-computed by ``setAlignment`` - this method is called per tick.
613+
return self.__data.orientation
589614

590615
def getBorderDistHint(self, font):
591616
"""

qwt/text.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,31 @@ def draw(self, painter, rect, flags, text):
189189

190190
ASCENTCACHE = {}
191191

192+
# Module-level cache: ``id(font) -> (font_strong_ref, font.key())``.
193+
# Computing ``QFont.key()`` is one of the dominant per-tick costs (it shows
194+
# up around 25% of ``QwtText.textSize`` total time and 60% of
195+
# ``QwtPlainTextEngine.textMargins``). We can avoid the call entirely as long
196+
# as we cache by the QFont's Python id; we keep a strong reference next to
197+
# the cached key so the id cannot be reused for a different live QFont.
198+
# The cache is bounded; once it grows past the limit it is rebuilt to keep
199+
# only the most recently inserted entry. Tick-rendering uses a tiny number
200+
# of fonts in practice so a small cap is sufficient.
201+
_FONT_KEY_CACHE: dict = {}
202+
_FONT_KEY_CACHE_LIMIT = 1024
203+
204+
205+
def font_key_cached(font) -> str:
206+
"""Return ``font.key()`` using a process-wide id-keyed cache."""
207+
fid = id(font)
208+
entry = _FONT_KEY_CACHE.get(fid)
209+
if entry is not None:
210+
return entry[1]
211+
if len(_FONT_KEY_CACHE) >= _FONT_KEY_CACHE_LIMIT:
212+
_FONT_KEY_CACHE.clear()
213+
key = font.key()
214+
_FONT_KEY_CACHE[fid] = (font, key)
215+
return key
216+
192217

193218
def get_screen_resolution():
194219
"""Return screen resolution: tuple of floats (DPIx, DPIy)"""
@@ -275,7 +300,7 @@ def textSize(self, font, flags, text):
275300

276301
def effectiveAscent(self, font):
277302
global ASCENTCACHE
278-
fontKey = font.key()
303+
fontKey = font_key_cached(font)
279304
ascent = ASCENTCACHE.get(fontKey)
280305
if ascent is not None:
281306
return ascent
@@ -326,7 +351,7 @@ def textMargins(self, font):
326351
font_id = id(font)
327352
if font_id == self._margins_last_id:
328353
return self._margins_last_value
329-
fkey = font.key()
354+
fkey = font_key_cached(font)
330355
cached = self._margins_cache.get(fkey)
331356
if cached is None:
332357
fm = self.fontmetrics(font)
@@ -477,10 +502,26 @@ def textMargins(self, font):
477502
return 0, 0, 0, 0
478503

479504

480-
class QwtText_PrivateData(QObject):
481-
def __init__(self):
482-
QObject.__init__(self)
505+
class QwtText_PrivateData(object):
506+
# ``QObject`` was previously used as the base class but no Qt signals
507+
# or events are ever emitted from ``_PrivateData`` containers and the
508+
# ``QObject.__init__`` call dominates ``QwtText.__init__`` (it is the
509+
# single most expensive line for tick-label-heavy renders, see
510+
# https://github.com/PlotPyStack/PythonQwt/issues/93).
511+
__slots__ = (
512+
"renderFlags",
513+
"borderRadius",
514+
"borderPen",
515+
"backgroundBrush",
516+
"paintAttributes",
517+
"layoutAttributes",
518+
"textEngine",
519+
"text",
520+
"font",
521+
"color",
522+
)
483523

524+
def __init__(self):
484525
self.renderFlags = Qt.AlignCenter
485526
self.borderRadius = 0
486527
self.borderPen = Qt.NoPen
@@ -1016,7 +1057,7 @@ def textSize(self, defaultFont):
10161057
if cache.textSize is not None and cache.fontId == font_id:
10171058
sz = QSizeF(cache.textSize)
10181059
else:
1019-
fkey = font.key()
1060+
fkey = font_key_cached(font)
10201061
if (
10211062
cache.textSize is None
10221063
or not cache.textSize.isValid()

scripts/lineprofile_loadtest.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Line-profile the hot functions identified by cProfile.
2+
3+
Run with the active Qt binding selected via QT_API.
4+
5+
Usage::
6+
7+
python scripts/lineprofile_loadtest.py [funcname1 funcname2 ...]
8+
9+
If no function names are given, a default set of hotspots is profiled.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
import sys
16+
17+
os.environ.setdefault("PYTHONQWT_UNATTENDED_TESTS", "1")
18+
19+
from line_profiler import LineProfiler # noqa: E402
20+
21+
import qwt.scale_div # noqa: E402
22+
import qwt.scale_draw # noqa: E402
23+
import qwt.scale_engine # noqa: E402
24+
import qwt.scale_map # noqa: E402
25+
import qwt.text # noqa: E402
26+
from qwt.tests import test_loadtest # noqa: E402
27+
28+
# (module, qualified-name) — only methods listed here are line-profiled.
29+
HOTSPOTS = {
30+
"textSize": qwt.text.QwtText.textSize,
31+
"textEngine": qwt.text.QwtText.textEngine,
32+
"QwtText.__init__": qwt.text.QwtText.__init__,
33+
"PlainTextEngine.textMargins": qwt.text.QwtPlainTextEngine.textMargins,
34+
"labelRect": qwt.scale_draw.QwtScaleDraw.labelRect,
35+
"labelPosition": qwt.scale_draw.QwtScaleDraw.labelPosition,
36+
"labelTransformation": qwt.scale_draw.QwtScaleDraw.labelTransformation,
37+
"getBorderDistHint": qwt.scale_draw.QwtScaleDraw.getBorderDistHint,
38+
"draw": qwt.scale_draw.QwtScaleDraw.draw,
39+
"drawLabel": qwt.scale_draw.QwtScaleDraw.drawLabel,
40+
"drawTick": qwt.scale_draw.QwtScaleDraw.drawTick,
41+
"drawBackbone": qwt.scale_draw.QwtScaleDraw.drawBackbone,
42+
"scale_map.transform": qwt.scale_map.QwtScaleMap.transform,
43+
"scale_engine.strip": qwt.scale_engine.QwtScaleEngine.strip,
44+
"scale_engine.contains": qwt.scale_engine.QwtScaleEngine.contains,
45+
"scale_div.contains": qwt.scale_div.QwtScaleDiv.contains,
46+
"orientation": qwt.scale_draw.QwtScaleDraw.orientation,
47+
}
48+
49+
50+
def main() -> None:
51+
selected = sys.argv[1:] or list(HOTSPOTS)
52+
profiler = LineProfiler()
53+
for name in selected:
54+
if name not in HOTSPOTS:
55+
print(f"Unknown hotspot: {name!r}", file=sys.stderr)
56+
continue
57+
profiler.add_function(HOTSPOTS[name])
58+
59+
profiler.runcall(test_loadtest.test_loadtest)
60+
profiler.print_stats(stream=sys.stdout, output_unit=1e-6)
61+
62+
63+
if __name__ == "__main__":
64+
main()

0 commit comments

Comments
 (0)