Skip to content

Commit ef793e1

Browse files
committed
(cProfile) Improve performance and add benchmarking scripts for load testing
1 parent 1ab70cd commit ef793e1

8 files changed

Lines changed: 251 additions & 65 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,7 @@ docs/_build/
6969

7070
# PyBuilder
7171
target/
72+
73+
# Local benchmark venvs (issue #93)
74+
.venvs/
75+

qwt/scale_div.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,11 @@ def contains(self, value):
235235
:param float value: Value
236236
:return: True/False
237237
"""
238-
min_ = min([self.__lowerBound, self.__upperBound])
239-
max_ = max([self.__lowerBound, self.__upperBound])
240-
return value >= min_ and value <= max_
238+
lb = self.__lowerBound
239+
ub = self.__upperBound
240+
if lb <= ub:
241+
return lb <= value <= ub
242+
return ub <= value <= lb
241243

242244
def invert(self):
243245
"""

qwt/scale_draw.py

Lines changed: 70 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@
3939
from qwt.scale_map import QwtScaleMap
4040
from qwt.text import QwtText
4141

42+
# Plain-int aliases for Qt alignment flags. Qt6 exposes alignment flags as
43+
# IntEnum members and bitwise operations on them go through Python's
44+
# enum machinery (`__and__`/`__call__`), which is one of the dominant costs
45+
# of label layout. Casting to int once and using these constants makes the
46+
# bitwise tests in `labelTransformation` ~10x cheaper without changing
47+
# semantics.
48+
_ALIGN_LEFT = int(Qt.AlignLeft)
49+
_ALIGN_RIGHT = int(Qt.AlignRight)
50+
_ALIGN_TOP = int(Qt.AlignTop)
51+
_ALIGN_BOTTOM = int(Qt.AlignBottom)
52+
4253

4354
class QwtAbstractScaleDraw_PrivateData(QObject):
4455
def __init__(self):
@@ -568,9 +579,12 @@ def orientation(self):
568579
569580
:py:meth:`alignment()`
570581
"""
571-
if self.__data.alignment in (self.TopScale, self.BottomScale):
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:
572586
return Qt.Horizontal
573-
elif self.__data.alignment in (self.LeftScale, self.RightScale):
587+
if align == self.LeftScale or align == self.RightScale:
574588
return Qt.Vertical
575589

576590
def getBorderDistHint(self, font):
@@ -597,34 +611,36 @@ def getBorderDistHint(self, font):
597611
if len(ticks) == 0:
598612
return start, end
599613

614+
scale_map = self.scaleMap()
615+
transform = scale_map.transform
600616
minTick = ticks[0]
601-
minPos = self.scaleMap().transform(minTick)
617+
minPos = transform(minTick)
602618
maxTick = minTick
603619
maxPos = minPos
604620

605621
for tick in ticks:
606-
tickPos = self.scaleMap().transform(tick)
622+
tickPos = transform(tick)
607623
if tickPos < minPos:
608624
minTick = tick
609625
minPos = tickPos
610-
if tickPos > self.scaleMap().transform(maxTick):
626+
if tickPos > maxPos:
611627
maxTick = tick
612628
maxPos = tickPos
613629

614630
s = 0.0
615631
e = 0.0
616632
if self.orientation() == Qt.Vertical:
617633
s = -self.labelRect(font, minTick).top()
618-
s -= abs(minPos - round(self.scaleMap().p2()))
634+
s -= abs(minPos - round(scale_map.p2()))
619635

620636
e = self.labelRect(font, maxTick).bottom()
621-
e -= abs(maxPos - self.scaleMap().p1())
637+
e -= abs(maxPos - scale_map.p1())
622638
else:
623639
s = -self.labelRect(font, minTick).left()
624-
s -= abs(minPos - self.scaleMap().p1())
640+
s -= abs(minPos - scale_map.p1())
625641

626642
e = self.labelRect(font, maxTick).right()
627-
e -= abs(maxPos - self.scaleMap().p2())
643+
e -= abs(maxPos - scale_map.p2())
628644

629645
return max(math.ceil(s), 0), max(math.ceil(e), 0)
630646

@@ -763,27 +779,22 @@ def labelPosition(self, value):
763779
"""
764780
tval = self.scaleMap().transform(value)
765781
dist = self.spacing()
766-
if self.hasComponent(QwtAbstractScaleDraw.Backbone):
767-
dist += max([1, self.penWidth()])
768-
if self.hasComponent(QwtAbstractScaleDraw.Ticks):
782+
hasComponent = self.hasComponent
783+
if hasComponent(QwtAbstractScaleDraw.Backbone):
784+
dist += max(1, self.penWidth())
785+
if hasComponent(QwtAbstractScaleDraw.Ticks):
769786
dist += self.tickLength(QwtScaleDiv.MajorTick)
770787

771-
px = 0
772-
py = 0
773-
if self.alignment() == self.RightScale:
774-
px = self.__data.pos.x() + dist
775-
py = tval
776-
elif self.alignment() == self.LeftScale:
777-
px = self.__data.pos.x() - dist
778-
py = tval
779-
elif self.alignment() == self.BottomScale:
780-
px = tval
781-
py = self.__data.pos.y() + dist
782-
elif self.alignment() == self.TopScale:
783-
px = tval
784-
py = self.__data.pos.y() - dist
785-
786-
return QPointF(px, py)
788+
alignment = self.alignment()
789+
pos = self.__data.pos
790+
if alignment == self.RightScale:
791+
return QPointF(pos.x() + dist, tval)
792+
if alignment == self.LeftScale:
793+
return QPointF(pos.x() - dist, tval)
794+
if alignment == self.BottomScale:
795+
return QPointF(tval, pos.y() + dist)
796+
# TopScale
797+
return QPointF(tval, pos.y() - dist)
787798

788799
def drawTick(self, painter, value, len_):
789800
"""
@@ -1007,17 +1018,19 @@ def labelTransformation(self, pos, size):
10071018
flags = self.labelAlignment()
10081019
if flags == 0:
10091020
flags = self.Flags[self.alignment()]
1021+
# Cast to plain int once to avoid the per-bit Qt6 enum overhead.
1022+
flags = int(flags)
10101023

1011-
if flags & Qt.AlignLeft:
1024+
if flags & _ALIGN_LEFT:
10121025
x = -size.width()
1013-
elif flags & Qt.AlignRight:
1026+
elif flags & _ALIGN_RIGHT:
10141027
x = 0.0
10151028
else:
10161029
x = -(0.5 * size.width())
10171030

1018-
if flags & Qt.AlignTop:
1031+
if flags & _ALIGN_TOP:
10191032
y = -size.height()
1020-
elif flags & Qt.AlignBottom:
1033+
elif flags & _ALIGN_BOTTOM:
10211034
y = 0
10221035
else:
10231036
y = -(0.5 * size.height())
@@ -1039,6 +1052,31 @@ def labelRect(self, font, value):
10391052
lbl, labelSize = self.tickLabel(font, value)
10401053
if not lbl or lbl.isEmpty():
10411054
return QRectF(0.0, 0.0, 0.0, 0.0)
1055+
# Fast path: when the label is not rotated, the contribution of
1056+
# ``pos`` cancels out (transform.translate(pos) followed by
1057+
# br.translate(-pos)). This avoids ``labelPosition``,
1058+
# ``labelTransformation`` and ``QTransform.mapRect`` entirely - all
1059+
# of which are dominant costs in tick-heavy layouts.
1060+
if self.labelRotation() == 0.0:
1061+
flags = self.labelAlignment()
1062+
if flags == 0:
1063+
flags = self.Flags[self.alignment()]
1064+
flags = int(flags)
1065+
w = labelSize.width()
1066+
h = labelSize.height()
1067+
if flags & _ALIGN_LEFT:
1068+
x = -w
1069+
elif flags & _ALIGN_RIGHT:
1070+
x = 0.0
1071+
else:
1072+
x = -0.5 * w
1073+
if flags & _ALIGN_TOP:
1074+
y = -h
1075+
elif flags & _ALIGN_BOTTOM:
1076+
y = 0.0
1077+
else:
1078+
y = -0.5 * h
1079+
return QRectF(x, y, w, h)
10421080
pos = self.labelPosition(value)
10431081
transform = self.labelTransformation(pos, labelSize)
10441082
br = transform.mapRect(QRectF(QPointF(0, 0), labelSize))

qwt/scale_engine.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,10 @@ def contains(self, interval, value):
324324
"""
325325
if not interval.isValid():
326326
return False
327-
eps = abs(1.0e-6 * interval.width())
328-
if interval.minValue() - value > eps or value - interval.maxValue() > eps:
329-
return False
330-
else:
331-
return True
327+
min_v = interval.minValue()
328+
max_v = interval.maxValue()
329+
eps = abs(1.0e-6 * (max_v - min_v))
330+
return not (min_v - value > eps or value - max_v > eps)
332331

333332
def strip(self, ticks, interval):
334333
"""
@@ -340,9 +339,17 @@ def strip(self, ticks, interval):
340339
"""
341340
if not interval.isValid() or not ticks:
342341
return []
343-
if self.contains(interval, ticks[0]) and self.contains(interval, ticks[-1]):
342+
# Inline ``contains`` to avoid one Python call per tick: ``strip`` is
343+
# called by buildTicks for every layout pass and is one of the
344+
# dominant costs in tick-heavy plots.
345+
min_v = interval.minValue()
346+
max_v = interval.maxValue()
347+
eps = abs(1.0e-6 * (max_v - min_v))
348+
lo = min_v - eps
349+
hi = max_v + eps
350+
if lo <= ticks[0] and ticks[-1] <= hi:
344351
return ticks
345-
return [tick for tick in ticks if self.contains(interval, tick)]
352+
return [tick for tick in ticks if lo <= tick <= hi]
346353

347354
def buildInterval(self, value):
348355
"""

qwt/scale_map.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,13 +249,18 @@ def transform(self, *args):
249249
250250
:py:meth:`invTransform()`
251251
"""
252-
if len(args) == 1:
253-
# Scalar transform
254-
return self.transform_scalar(args[0])
255-
elif len(args) == 3 and isinstance(args[2], QPointF):
252+
nargs = len(args)
253+
if nargs == 1:
254+
# Scalar transform: inline the fast path for the dominant case
255+
# (avoids one Python call frame per tick label).
256+
s = args[0]
257+
if self.__transform:
258+
s = self.__transform.transform(s)
259+
return self.__p1 + (s - self.__ts1) * self.__cnv
260+
elif nargs == 3 and isinstance(args[2], QPointF):
256261
xMap, yMap, pos = args
257262
return QPointF(xMap.transform(pos.x()), yMap.transform(pos.y()))
258-
elif len(args) == 3 and isinstance(args[2], QRectF):
263+
elif nargs == 3 and isinstance(args[2], QRectF):
259264
xMap, yMap, rect = args
260265
x1 = xMap.transform(rect.left())
261266
x2 = xMap.transform(rect.right())

qwt/text.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ def __init__(self):
226226
self.qrectf_max = QRectF(0, 0, QWIDGETSIZE_MAX, QWIDGETSIZE_MAX)
227227
self._fm_cache = {}
228228
self._fm_cache_f = {}
229+
self._margins_cache = {}
230+
# Fast path: when textMargins is called repeatedly with the same
231+
# QFont instance, skip the (expensive) font.key() Qt call.
232+
self._margins_last_id = -1
233+
self._margins_last_value = None
229234

230235
def fontmetrics(self, font):
231236
fid = font.toString()
@@ -317,11 +322,19 @@ def textMargins(self, font):
317322
:param QFont font: Font of the text
318323
:return: tuple (left, right, top, bottom) representing margins
319324
"""
320-
left = right = 0
321-
fm = self.fontmetrics(font)
322-
top = fm.ascent() - self.effectiveAscent(font)
323-
bottom = fm.descent()
324-
return left, right, top, bottom
325+
# Fast path: same QFont object as the previous call.
326+
font_id = id(font)
327+
if font_id == self._margins_last_id:
328+
return self._margins_last_value
329+
fkey = font.key()
330+
cached = self._margins_cache.get(fkey)
331+
if cached is None:
332+
fm = self.fontmetrics(font)
333+
cached = (0, 0, fm.ascent() - self.effectiveAscent(font), fm.descent())
334+
self._margins_cache[fkey] = cached
335+
self._margins_last_id = font_id
336+
self._margins_last_value = cached
337+
return cached
325338

326339
def draw(self, painter, rect, flags, text):
327340
"""
@@ -484,10 +497,13 @@ def __init__(self):
484497
class QwtText_LayoutCache(object):
485498
def __init__(self):
486499
self.textSize = None
487-
self.font = None
500+
self.fontKey = None
501+
self.fontId = -1
488502

489503
def invalidate(self):
490504
self.textSize = None
505+
self.fontKey = None
506+
self.fontId = -1
491507

492508

493509
class QwtText(object):
@@ -994,17 +1010,24 @@ def textSize(self, defaultFont):
9941010
:param QFont defaultFont Font, used for the calculation if the text has no font
9951011
:return: Caluclated size
9961012
"""
997-
font = QFont(self.usedFont(defaultFont))
998-
if (
999-
self.__layoutCache.textSize is None
1000-
or not self.__layoutCache.textSize.isValid()
1001-
or self.__layoutCache.font is not font
1002-
):
1003-
self.__layoutCache.textSize = self.__data.textEngine.textSize(
1004-
font, self.__data.renderFlags, self.__data.text
1005-
)
1006-
self.__layoutCache.font = font
1007-
sz = self.__layoutCache.textSize
1013+
font = self.usedFont(defaultFont)
1014+
cache = self.__layoutCache
1015+
font_id = id(font)
1016+
if cache.textSize is not None and cache.fontId == font_id:
1017+
sz = QSizeF(cache.textSize)
1018+
else:
1019+
fkey = font.key()
1020+
if (
1021+
cache.textSize is None
1022+
or not cache.textSize.isValid()
1023+
or cache.fontKey != fkey
1024+
):
1025+
cache.textSize = self.__data.textEngine.textSize(
1026+
font, self.__data.renderFlags, self.__data.text
1027+
)
1028+
cache.fontKey = fkey
1029+
cache.fontId = font_id
1030+
sz = QSizeF(cache.textSize)
10081031
if self.__data.layoutAttributes & self.MinimumLayout:
10091032
(left, right, top, bottom) = self.__data.textEngine.textMargins(font)
10101033
sz -= QSizeF(left + right, top + bottom)
@@ -1072,7 +1095,13 @@ def textEngine(self, text=None, format_=None):
10721095
return self.__map.get(format_)
10731096
elif format_ is not None:
10741097
if format_ == QwtText.AutoText:
1075-
for key, engine in list(self.__map.items()):
1098+
# Fast path: a string with no ``<`` cannot be rich text, so
1099+
# we can return the plain engine without iterating the map
1100+
# and calling Qt.mightBeRichText (which is a hot Qt call
1101+
# for tick labels like " 1.5").
1102+
if "<" not in text:
1103+
return self.__map[QwtText.PlainText]
1104+
for key, engine in self.__map.items():
10761105
if key != QwtText.PlainText:
10771106
if engine and engine.mightRender(text):
10781107
return engine

0 commit comments

Comments
 (0)