Skip to content

Commit 1a280b1

Browse files
committed
contrib: plot getMemoryLoad()
Add Python script to make plots. Assisted-by: GitHub Copilot Assisted-by: OpenAI GPT-5.1-Codex
1 parent cad4762 commit 1a280b1

File tree

2 files changed

+382
-0
lines changed

2 files changed

+382
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ target/
2626
/guix-build-*
2727

2828
/ci/scratch/
29+
30+
plots
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
#!/usr/bin/env python3
2+
"""Plot GetTemplateMemoryUsage durations and template memory footprints over time."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import datetime as dt
7+
import pickle
8+
import re
9+
from pathlib import Path
10+
from typing import List, Tuple
11+
12+
import matplotlib.dates as mdates # type: ignore[import, import-not-found]
13+
import matplotlib.pyplot as plt # type: ignore[import, import-not-found]
14+
from matplotlib import transforms # type: ignore[import, import-not-found]
15+
from matplotlib.ticker import NullFormatter # type: ignore[import, import-not-found]
16+
17+
MEMORY_LOAD_COMPLETED = re.compile(
18+
r"^(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z).*GetTemplateMemoryUsage:.*completed \((?P<ms>\d+\.\d+)ms\)"
19+
)
20+
UPDATE_TIP = re.compile(
21+
r"^(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z).*UpdateTip:.*height=(?P<height>\d+) "
22+
)
23+
TP_LOG_ENTRY = re.compile(
24+
r"^(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?)Z.*Template memory footprint (?P<mib>\d+(?:\.\d+)?) MiB"
25+
)
26+
27+
TIP_LABEL_MIN_FRACTION = 0.02
28+
CLIPPED_LABEL_MIN_FRACTION = 0.01
29+
DEFAULT_DURATION_CAP_MS = 15.0 # Hard cap for GetTemplateMemoryUsage durations
30+
31+
32+
def format_with_machine(base: str, machine: str | None) -> str:
33+
return f"{base} ({machine})" if machine else base
34+
35+
36+
def parse_args() -> argparse.Namespace:
37+
parser = argparse.ArgumentParser(description=__doc__)
38+
parser.add_argument(
39+
"bitcoin_log",
40+
type=Path,
41+
help="Path to bitcoin debug log containing GetTemplateMemoryUsage entries",
42+
)
43+
parser.add_argument(
44+
"--output",
45+
type=Path,
46+
default=Path("getmemoryload-scatter.svg"),
47+
help="Destination image path (default: ./getmemoryload-scatter.svg)",
48+
)
49+
parser.add_argument(
50+
"--show",
51+
action="store_true",
52+
help="Display the plot interactively instead of saving it",
53+
)
54+
parser.add_argument(
55+
"tp_log",
56+
type=Path,
57+
help="SV2 template provider log containing 'Template memory footprint' lines",
58+
)
59+
parser.add_argument(
60+
"--since",
61+
type=str,
62+
default=None,
63+
help=(
64+
"Ignore log events earlier than this ISO 8601 timestamp. Naive timestamps "
65+
"are interpreted as UTC (e.g. '2025-12-02T14:50:00')."
66+
),
67+
)
68+
parser.add_argument(
69+
"--duration-cap",
70+
type=float,
71+
default=DEFAULT_DURATION_CAP_MS,
72+
help=(
73+
"Clip GetTemplateMemoryUsage durations above this value (ms) and annotate their "
74+
f"true value at the top of the chart. Default: {DEFAULT_DURATION_CAP_MS:.0f} ms."
75+
),
76+
)
77+
parser.add_argument(
78+
"--figure-pickle",
79+
type=Path,
80+
default=None,
81+
help=(
82+
"Optional path to write a pickle of the Matplotlib Figure for further "
83+
"inspection."
84+
),
85+
)
86+
parser.add_argument(
87+
"--dpi",
88+
type=int,
89+
default=300,
90+
help="Raster output resolution (applies to PNG/JPEG/TIFF). Default: 300 dpi.",
91+
)
92+
parser.add_argument(
93+
"--machine",
94+
type=str,
95+
default=None,
96+
help="Optional machine label to append to dataset names (e.g. 'M4').",
97+
)
98+
return parser.parse_args()
99+
100+
101+
def parse_timestamp(raw: str) -> dt.datetime:
102+
"""Return timezone-aware UTC timestamps from log entries."""
103+
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
104+
if parsed.tzinfo is None:
105+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
106+
return parsed.astimezone(dt.timezone.utc)
107+
108+
109+
def parse_since_timestamp(raw: str) -> dt.datetime:
110+
sanitized = raw.strip()
111+
if not sanitized:
112+
raise SystemExit("--since timestamp must not be empty")
113+
if sanitized.endswith("Z"):
114+
sanitized = f"{sanitized[:-1]}+00:00"
115+
try:
116+
parsed = dt.datetime.fromisoformat(sanitized)
117+
except ValueError as exc:
118+
raise SystemExit(f"Invalid --since timestamp '{raw}': {exc}") from exc
119+
if parsed.tzinfo is None:
120+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
121+
return parsed.astimezone(dt.timezone.utc)
122+
123+
124+
def load_events(log_path: Path, since: dt.datetime | None = None):
125+
mem_points = []
126+
tip_events = []
127+
with log_path.open("r", encoding="utf-8") as handle:
128+
for line in handle:
129+
mem_match = MEMORY_LOAD_COMPLETED.search(line)
130+
if mem_match:
131+
timestamp = parse_timestamp(mem_match.group("ts"))
132+
if since is not None and timestamp < since:
133+
continue
134+
mem_points.append((timestamp, float(mem_match.group("ms"))))
135+
continue
136+
tip_match = UPDATE_TIP.search(line)
137+
if tip_match:
138+
timestamp = parse_timestamp(tip_match.group("ts"))
139+
if since is not None and timestamp < since:
140+
continue
141+
tip_events.append((timestamp, int(tip_match.group("height"))))
142+
mem_points.sort(key=lambda item: item[0])
143+
tip_events.sort(key=lambda item: item[0])
144+
return mem_points, tip_events
145+
146+
147+
def load_tp_points(
148+
log_path: Path, since: dt.datetime | None = None
149+
) -> List[Tuple[dt.datetime, float]]:
150+
tp_points: List[Tuple[dt.datetime, float]] = []
151+
if not log_path.exists():
152+
raise SystemExit(f"Template provider log '{log_path}' not found")
153+
154+
with log_path.open("r", encoding="utf-8") as handle:
155+
for line in handle:
156+
match = TP_LOG_ENTRY.search(line)
157+
if match:
158+
mib_val = float(match.group("mib"))
159+
timestamp = parse_timestamp(match.group("ts"))
160+
if since is not None and timestamp < since:
161+
continue
162+
tp_points.append((timestamp, mib_val))
163+
164+
tp_points.sort(key=lambda item: item[0])
165+
if not tp_points:
166+
raise SystemExit(
167+
f"No template provider entries were found in '{log_path}'. Does it contain "
168+
"'Template memory footprint' lines?"
169+
)
170+
return tp_points
171+
172+
def plot_memory_load(
173+
mem_points,
174+
tip_events,
175+
tp_points,
176+
duration_cap,
177+
figure_pickle: Path | None,
178+
dpi: int,
179+
machine_label: str | None,
180+
output: Path,
181+
show_plot: bool,
182+
) -> None:
183+
if not mem_points:
184+
raise SystemExit("No GetTemplateMemoryUsage() completion records found in log")
185+
186+
times, durations = zip(*mem_points)
187+
fig, ax = plt.subplots(figsize=(15, 5))
188+
fig.patch.set_facecolor("white")
189+
fig.patch.set_alpha(1.0)
190+
ax.set_facecolor("white")
191+
duration_label = format_with_machine("GetTemplateMemoryUsage() duration", machine_label)
192+
ax.scatter(
193+
times,
194+
durations,
195+
s=5,
196+
color="tab:blue",
197+
label=duration_label,
198+
zorder=3,
199+
)
200+
201+
ax.set_ylabel("Duration (ms)")
202+
ax.set_xlabel("")
203+
ax.set_title("")
204+
ax.xaxis.set_major_formatter(NullFormatter())
205+
ax.margins(x=0)
206+
207+
max_duration = max(durations)
208+
effective_cap = duration_cap
209+
clipped_points = []
210+
if effective_cap is not None and effective_cap < max_duration:
211+
ax.set_ylim(bottom=0, top=effective_cap)
212+
clipped_points = [(timestamp, value) for timestamp, value in mem_points if value > effective_cap]
213+
else:
214+
upper_pad = max_duration * 1.05 if max_duration > 0 else 1
215+
ax.set_ylim(bottom=0, top=upper_pad)
216+
217+
twin_ax = None
218+
if tp_points:
219+
tp_times, tp_mib = zip(*tp_points)
220+
twin_ax = ax.twinx()
221+
twin_ax.margins(x=0)
222+
twin_ax.vlines(
223+
tp_times,
224+
[0] * len(tp_times),
225+
tp_mib,
226+
color="#4fbf73",
227+
alpha=0.5,
228+
linewidth=1,
229+
zorder=1,
230+
label="Template provider memory",
231+
)
232+
twin_ax.set_ylabel("Memory footprint (MiB)", color="#2b8a46")
233+
twin_ax.tick_params(axis="y", colors="#2b8a46")
234+
twin_ax.set_ylim(bottom=0)
235+
236+
if clipped_points:
237+
clip_trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
238+
marker_y = 1.0
239+
clipped_times = [timestamp for timestamp, _ in clipped_points]
240+
clipped_values = [value for _, value in clipped_points]
241+
clipped_time_nums = mdates.date2num(clipped_times)
242+
ax.scatter(
243+
clipped_time_nums,
244+
[marker_y] * len(clipped_points),
245+
marker="o",
246+
s=22,
247+
facecolors="none",
248+
edgecolors="tab:blue",
249+
linewidths=1,
250+
label=f"Clipped duration (> {effective_cap:.2f} ms)",
251+
transform=clip_trans,
252+
clip_on=False,
253+
zorder=4,
254+
)
255+
label_min_delta = (ax.get_xlim()[1] - ax.get_xlim()[0]) * CLIPPED_LABEL_MIN_FRACTION
256+
last_clipped_label_num: float | None = None
257+
for ts_num, value in zip(clipped_time_nums, clipped_values):
258+
if last_clipped_label_num is not None and ts_num - last_clipped_label_num < label_min_delta:
259+
continue
260+
label = f"{value:.0f} ms"
261+
ax.annotate(
262+
label,
263+
xy=(ts_num, marker_y),
264+
xycoords=clip_trans,
265+
xytext=(0, -8),
266+
textcoords="offset points",
267+
ha="center",
268+
va="top",
269+
fontsize=8,
270+
color="tab:blue",
271+
rotation=270,
272+
clip_on=False,
273+
)
274+
last_clipped_label_num = ts_num
275+
276+
if tip_events:
277+
tip_color = "tab:red"
278+
tip_times, tip_heights = zip(*tip_events)
279+
tip_time_nums = mdates.date2num(tip_times)
280+
trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
281+
marker_y = -0.01
282+
ax.scatter(
283+
tip_time_nums,
284+
[marker_y] * len(tip_events),
285+
marker="^",
286+
s=70,
287+
color=tip_color,
288+
edgecolors="none",
289+
label="Tip update",
290+
zorder=4,
291+
transform=trans,
292+
clip_on=False,
293+
)
294+
label_trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
295+
x_start, x_end = ax.get_xlim()
296+
min_delta = (x_end - x_start) * TIP_LABEL_MIN_FRACTION
297+
last_label_num: float | None = None
298+
for ts_num, height in zip(tip_time_nums, tip_heights):
299+
if last_label_num is not None and ts_num - last_label_num < min_delta:
300+
continue
301+
label = f"{height:,}"
302+
ax.annotate(
303+
label,
304+
xy=(ts_num, 0),
305+
xycoords=label_trans,
306+
xytext=(1, -8),
307+
textcoords="offset points",
308+
rotation=45,
309+
ha="right",
310+
va="top",
311+
color=tip_color,
312+
fontsize=8,
313+
clip_on=False,
314+
)
315+
last_label_num = ts_num
316+
317+
handles, labels = ax.get_legend_handles_labels()
318+
if twin_ax is not None:
319+
twin_handles, twin_labels = twin_ax.get_legend_handles_labels()
320+
handles += twin_handles
321+
labels += twin_labels
322+
try:
323+
tip_idx = labels.index("Tip update")
324+
tip_handle = handles.pop(tip_idx)
325+
tip_label = labels.pop(tip_idx)
326+
handles.append(tip_handle)
327+
labels.append(tip_label)
328+
except ValueError:
329+
pass
330+
legend = fig.legend(
331+
handles,
332+
labels,
333+
loc="upper left",
334+
bbox_to_anchor=(0.12, 0.78),
335+
)
336+
legend.set_zorder(10)
337+
frame = legend.get_frame()
338+
frame.set_alpha(0.75)
339+
frame.set_zorder(legend.get_zorder())
340+
ax.grid(True, axis="y", linestyle="--", alpha=0.3)
341+
342+
if figure_pickle is not None:
343+
figure_pickle.parent.mkdir(parents=True, exist_ok=True)
344+
with figure_pickle.open("wb") as handle:
345+
pickle.dump(fig, handle)
346+
347+
if show_plot:
348+
plt.show()
349+
else:
350+
output.parent.mkdir(parents=True, exist_ok=True)
351+
raster_exts = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".gif"}
352+
if output.suffix.lower() in raster_exts:
353+
fig.savefig(output, bbox_inches="tight", facecolor=fig.get_facecolor(), dpi=dpi)
354+
else:
355+
fig.savefig(output, bbox_inches="tight", facecolor=fig.get_facecolor())
356+
print(f"Wrote {output}")
357+
358+
plt.close(fig)
359+
360+
361+
def main() -> None:
362+
args = parse_args()
363+
since_ts = parse_since_timestamp(args.since) if args.since else None
364+
mem_points, tip_events = load_events(args.bitcoin_log, since_ts)
365+
tp_points = load_tp_points(args.tp_log, since_ts)
366+
plot_memory_load(
367+
mem_points,
368+
tip_events,
369+
tp_points,
370+
args.duration_cap,
371+
args.figure_pickle,
372+
args.dpi,
373+
args.machine,
374+
args.output,
375+
args.show,
376+
)
377+
378+
379+
if __name__ == "__main__":
380+
main()

0 commit comments

Comments
 (0)