Skip to content

Commit b831105

Browse files
committed
Add font-cache leak telemetry script
1 parent b64dc5d commit b831105

1 file changed

Lines changed: 158 additions & 0 deletions

File tree

scripts/telemetry_fontcache.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Telemetry probe for the QwtText font-key cache (PythonQwt).
2+
3+
Measures, over repeated build/destroy cycles of a :class:`qwt.QwtPlot`:
4+
5+
* live Qt / Qwt objects (``QFont``, ``QPixmap``, ``QFontMetrics``,
6+
``QwtText``, ``QwtScaleDraw``);
7+
* internal cache sizes (the single-slot font memo plus the metrics caches in
8+
:mod:`qwt.text`);
9+
* per-cycle render time.
10+
11+
It is the tool used to verify that the font-key cache no longer retains
12+
``QFont`` objects (the memory / GDI-handle leak fixed alongside this script):
13+
the ``QFont`` column and the cache sizes must stay flat across cycles instead
14+
of growing towards the old 1024-entry limit.
15+
16+
Prerequisites
17+
-------------
18+
* A Qt binding selected via ``QT_API`` (``pyqt5`` / ``pyqt6`` / ``pyside6``);
19+
run it once per binding to cover them all.
20+
* ``PythonQwt`` importable (current working directory or on ``PYTHONPATH``).
21+
22+
Usage
23+
-----
24+
::
25+
26+
$env:PYTHONPATH = "<...>\\PythonQwt.git"
27+
$env:QT_API = "pyqt5" # or pyqt6 / pyside6
28+
python scripts\\telemetry_fontcache.py --cycles 800 --report-every 100
29+
30+
Pass ``--show`` to exercise the real native window backend (native paint
31+
pipeline, real GDI handles) instead of the default offscreen ``grab()``.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import argparse
37+
import gc
38+
import os
39+
import time
40+
41+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
42+
os.environ.setdefault("PYTHONQWT_UNATTENDED_TESTS", "1")
43+
44+
import numpy as np
45+
from qtpy import API_NAME, QT_VERSION
46+
from qtpy.QtWidgets import QApplication
47+
48+
import qwt
49+
import qwt.text as qtext
50+
51+
52+
def count(typename: str) -> int:
53+
"""Return the number of live Python objects whose type name matches.
54+
55+
:param str typename: Unqualified type name to count (e.g. ``"QFont"``)
56+
:return: Number of live instances
57+
"""
58+
return sum(1 for obj in gc.get_objects() if type(obj).__name__ == typename)
59+
60+
61+
def cache_sizes() -> dict:
62+
"""Return a snapshot of the relevant cache sizes in :mod:`qwt.text`.
63+
64+
Works against both the fixed code (single-slot ``_LAST_FONT`` memo) and any
65+
older revision that still exposes the retaining ``_FONT_KEY_CACHE`` dict, so
66+
the script can be used to compare before/after.
67+
68+
:return: Mapping of cache name to current size
69+
"""
70+
sizes = {}
71+
if hasattr(qtext, "_LAST_FONT"):
72+
sizes["memo_font"] = 0 if qtext._LAST_FONT is None else 1
73+
if hasattr(qtext, "_FONT_KEY_CACHE"):
74+
sizes["FONT_KEY_CACHE"] = len(qtext._FONT_KEY_CACHE)
75+
sizes["ASCENTCACHE"] = len(qtext.ASCENTCACHE)
76+
return sizes
77+
78+
79+
def make_cycle(app: QApplication, show: bool):
80+
"""Build a closure that creates, renders and destroys one plot per call.
81+
82+
:param QApplication app: Running application instance
83+
:param bool show: If True, show a real native window each cycle instead of
84+
rendering offscreen with ``grab()``
85+
:return: A zero-argument callable running a single cycle
86+
"""
87+
x = np.linspace(0, 100, 500)
88+
89+
def cycle() -> None:
90+
plot = qwt.QwtPlot("T")
91+
for k in range(4):
92+
qwt.QwtPlotCurve.make(x, np.sin(x * 0.1 + k) * 1000 + k, f"c{k}", plot)
93+
plot.setAxisScale(qwt.QwtPlot.xBottom, 0, 100)
94+
plot.setAxisScale(qwt.QwtPlot.yLeft, -2000, 2000)
95+
plot.resize(600, 400)
96+
if show:
97+
plot.show()
98+
app.processEvents()
99+
plot.replot()
100+
app.processEvents()
101+
plot.close()
102+
else:
103+
plot.replot()
104+
plot.grab()
105+
plot.deleteLater()
106+
del plot
107+
app.processEvents()
108+
gc.collect()
109+
app.processEvents()
110+
111+
return cycle
112+
113+
114+
def main() -> int:
115+
"""Run the telemetry loop and print a report every ``--report-every`` cycles.
116+
117+
:return: Process exit code
118+
"""
119+
parser = argparse.ArgumentParser(description=__doc__)
120+
parser.add_argument("--cycles", type=int, default=800)
121+
parser.add_argument("--report-every", type=int, default=100)
122+
parser.add_argument(
123+
"--show",
124+
action="store_true",
125+
help="show a real native window each cycle (native paint backend)",
126+
)
127+
args = parser.parse_args()
128+
129+
app = QApplication.instance() or QApplication([])
130+
cycle = make_cycle(app, args.show)
131+
132+
cycle() # warm-up (one-time allocations)
133+
gc.collect()
134+
135+
print(f"{API_NAME} Qt {QT_VERSION} | {args.cycles} cycles | show={args.show}")
136+
print(
137+
f"{'cycle':>6} {'ms/cyc':>7} {'QFont':>6} {'QPixmap':>8} "
138+
f"{'QFontMetrics':>12} {'QwtText':>8} {'QwtScaleDraw':>13} caches"
139+
)
140+
141+
t_block = time.perf_counter()
142+
for i in range(1, args.cycles + 1):
143+
cycle()
144+
if i % args.report_every == 0:
145+
ms = (time.perf_counter() - t_block) / args.report_every * 1000
146+
gc.collect()
147+
print(
148+
f"{i:6d} {ms:7.2f} {count('QFont'):6d} {count('QPixmap'):8d} "
149+
f"{count('QFontMetrics'):12d} {count('QwtText'):8d} "
150+
f"{count('QwtScaleDraw'):13d} {cache_sizes()}"
151+
)
152+
t_block = time.perf_counter()
153+
154+
return 0
155+
156+
157+
if __name__ == "__main__":
158+
raise SystemExit(main())

0 commit comments

Comments
 (0)