Skip to content

Commit acd7687

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 7229cf3 commit acd7687

File tree

2 files changed

+346
-0
lines changed

2 files changed

+346
-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: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
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+
"--duration-cap",
61+
type=float,
62+
default=DEFAULT_DURATION_CAP_MS,
63+
help=(
64+
"Clip GetTemplateMemoryUsage durations above this value (ms) and annotate their "
65+
f"true value at the top of the chart. Default: {DEFAULT_DURATION_CAP_MS:.0f} ms."
66+
),
67+
)
68+
parser.add_argument(
69+
"--figure-pickle",
70+
type=Path,
71+
default=None,
72+
help=(
73+
"Optional path to write a pickle of the Matplotlib Figure for further "
74+
"inspection."
75+
),
76+
)
77+
parser.add_argument(
78+
"--dpi",
79+
type=int,
80+
default=300,
81+
help="Raster output resolution (applies to PNG/JPEG/TIFF). Default: 300 dpi.",
82+
)
83+
parser.add_argument(
84+
"--machine",
85+
type=str,
86+
default=None,
87+
help="Optional machine label to append to dataset names (e.g. 'M4').",
88+
)
89+
return parser.parse_args()
90+
91+
92+
def parse_timestamp(raw: str) -> dt.datetime:
93+
return dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
94+
95+
96+
def load_events(log_path: Path):
97+
mem_points = []
98+
tip_events = []
99+
with log_path.open("r", encoding="utf-8") as handle:
100+
for line in handle:
101+
mem_match = MEMORY_LOAD_COMPLETED.search(line)
102+
if mem_match:
103+
mem_points.append(
104+
(parse_timestamp(mem_match.group("ts")), float(mem_match.group("ms")))
105+
)
106+
continue
107+
tip_match = UPDATE_TIP.search(line)
108+
if tip_match:
109+
tip_events.append(
110+
(parse_timestamp(tip_match.group("ts")), int(tip_match.group("height")))
111+
)
112+
mem_points.sort(key=lambda item: item[0])
113+
tip_events.sort(key=lambda item: item[0])
114+
return mem_points, tip_events
115+
116+
117+
def load_tp_points(log_path: Path) -> List[Tuple[dt.datetime, float]]:
118+
tp_points: List[Tuple[dt.datetime, float]] = []
119+
if not log_path.exists():
120+
raise SystemExit(f"Template provider log '{log_path}' not found")
121+
122+
with log_path.open("r", encoding="utf-8") as handle:
123+
for line in handle:
124+
match = TP_LOG_ENTRY.search(line)
125+
if match:
126+
mib_val = float(match.group("mib"))
127+
tp_points.append((parse_timestamp(match.group("ts")), mib_val))
128+
129+
tp_points.sort(key=lambda item: item[0])
130+
if not tp_points:
131+
raise SystemExit(
132+
f"No template provider entries were found in '{log_path}'. Does it contain "
133+
"'Template memory footprint' lines?"
134+
)
135+
return tp_points
136+
137+
def plot_memory_load(
138+
mem_points,
139+
tip_events,
140+
tp_points,
141+
duration_cap,
142+
figure_pickle: Path | None,
143+
dpi: int,
144+
machine_label: str | None,
145+
output: Path,
146+
show_plot: bool,
147+
) -> None:
148+
if not mem_points:
149+
raise SystemExit("No GetTemplateMemoryUsage() completion records found in log")
150+
151+
times, durations = zip(*mem_points)
152+
fig, ax = plt.subplots(figsize=(15, 5))
153+
fig.patch.set_facecolor("white")
154+
fig.patch.set_alpha(1.0)
155+
ax.set_facecolor("white")
156+
duration_label = format_with_machine("GetTemplateMemoryUsage() duration", machine_label)
157+
ax.scatter(
158+
times,
159+
durations,
160+
s=5,
161+
color="tab:blue",
162+
label=duration_label,
163+
zorder=3,
164+
)
165+
166+
ax.set_ylabel("Duration (ms)")
167+
ax.set_xlabel("")
168+
ax.set_title("")
169+
ax.xaxis.set_major_formatter(NullFormatter())
170+
ax.margins(x=0)
171+
172+
max_duration = max(durations)
173+
effective_cap = duration_cap
174+
clipped_points = []
175+
if effective_cap is not None and effective_cap < max_duration:
176+
ax.set_ylim(bottom=0, top=effective_cap)
177+
clipped_points = [(timestamp, value) for timestamp, value in mem_points if value > effective_cap]
178+
else:
179+
upper_pad = max_duration * 1.05 if max_duration > 0 else 1
180+
ax.set_ylim(bottom=0, top=upper_pad)
181+
182+
twin_ax = None
183+
if tp_points:
184+
tp_times, tp_mib = zip(*tp_points)
185+
twin_ax = ax.twinx()
186+
twin_ax.margins(x=0)
187+
twin_ax.vlines(
188+
tp_times,
189+
[0] * len(tp_times),
190+
tp_mib,
191+
color="#4fbf73",
192+
alpha=0.5,
193+
linewidth=1,
194+
zorder=1,
195+
label="Template provider memory",
196+
)
197+
twin_ax.set_ylabel("Memory footprint (MiB)", color="#2b8a46")
198+
twin_ax.tick_params(axis="y", colors="#2b8a46")
199+
twin_ax.set_ylim(bottom=0)
200+
201+
if clipped_points:
202+
clip_trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
203+
marker_y = 1.0
204+
clipped_times = [timestamp for timestamp, _ in clipped_points]
205+
clipped_values = [value for _, value in clipped_points]
206+
clipped_time_nums = mdates.date2num(clipped_times)
207+
ax.scatter(
208+
clipped_time_nums,
209+
[marker_y] * len(clipped_points),
210+
marker="o",
211+
s=22,
212+
facecolors="none",
213+
edgecolors="tab:blue",
214+
linewidths=1,
215+
label=f"Clipped duration (> {effective_cap:.2f} ms)",
216+
transform=clip_trans,
217+
clip_on=False,
218+
zorder=4,
219+
)
220+
label_min_delta = (ax.get_xlim()[1] - ax.get_xlim()[0]) * CLIPPED_LABEL_MIN_FRACTION
221+
last_clipped_label_num: float | None = None
222+
for ts_num, value in zip(clipped_time_nums, clipped_values):
223+
if last_clipped_label_num is not None and ts_num - last_clipped_label_num < label_min_delta:
224+
continue
225+
label = f"{value:.0f} ms"
226+
ax.annotate(
227+
label,
228+
xy=(ts_num, marker_y),
229+
xycoords=clip_trans,
230+
xytext=(0, -8),
231+
textcoords="offset points",
232+
ha="center",
233+
va="top",
234+
fontsize=8,
235+
color="tab:blue",
236+
rotation=270,
237+
clip_on=False,
238+
)
239+
last_clipped_label_num = ts_num
240+
241+
if tip_events:
242+
tip_color = "tab:red"
243+
tip_times, tip_heights = zip(*tip_events)
244+
tip_time_nums = mdates.date2num(tip_times)
245+
trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
246+
marker_y = -0.01
247+
ax.scatter(
248+
tip_time_nums,
249+
[marker_y] * len(tip_events),
250+
marker="^",
251+
s=70,
252+
color=tip_color,
253+
edgecolors="none",
254+
label="Tip update",
255+
zorder=4,
256+
transform=trans,
257+
clip_on=False,
258+
)
259+
label_trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
260+
x_start, x_end = ax.get_xlim()
261+
min_delta = (x_end - x_start) * TIP_LABEL_MIN_FRACTION
262+
last_label_num: float | None = None
263+
for ts_num, height in zip(tip_time_nums, tip_heights):
264+
if last_label_num is not None and ts_num - last_label_num < min_delta:
265+
continue
266+
label = f"{height:,}"
267+
ax.annotate(
268+
label,
269+
xy=(ts_num, 0),
270+
xycoords=label_trans,
271+
xytext=(1, -8),
272+
textcoords="offset points",
273+
rotation=45,
274+
ha="right",
275+
va="top",
276+
color=tip_color,
277+
fontsize=8,
278+
clip_on=False,
279+
)
280+
last_label_num = ts_num
281+
282+
handles, labels = ax.get_legend_handles_labels()
283+
if twin_ax is not None:
284+
twin_handles, twin_labels = twin_ax.get_legend_handles_labels()
285+
handles += twin_handles
286+
labels += twin_labels
287+
try:
288+
tip_idx = labels.index("Tip update")
289+
tip_handle = handles.pop(tip_idx)
290+
tip_label = labels.pop(tip_idx)
291+
handles.append(tip_handle)
292+
labels.append(tip_label)
293+
except ValueError:
294+
pass
295+
legend = fig.legend(
296+
handles,
297+
labels,
298+
loc="upper left",
299+
bbox_to_anchor=(0.12, 0.78),
300+
)
301+
legend.set_zorder(10)
302+
frame = legend.get_frame()
303+
frame.set_alpha(0.75)
304+
frame.set_zorder(legend.get_zorder())
305+
ax.grid(True, axis="y", linestyle="--", alpha=0.3)
306+
307+
if figure_pickle is not None:
308+
figure_pickle.parent.mkdir(parents=True, exist_ok=True)
309+
with figure_pickle.open("wb") as handle:
310+
pickle.dump(fig, handle)
311+
312+
if show_plot:
313+
plt.show()
314+
else:
315+
output.parent.mkdir(parents=True, exist_ok=True)
316+
raster_exts = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".gif"}
317+
if output.suffix.lower() in raster_exts:
318+
fig.savefig(output, bbox_inches="tight", facecolor=fig.get_facecolor(), dpi=dpi)
319+
else:
320+
fig.savefig(output, bbox_inches="tight", facecolor=fig.get_facecolor())
321+
print(f"Wrote {output}")
322+
323+
plt.close(fig)
324+
325+
326+
def main() -> None:
327+
args = parse_args()
328+
mem_points, tip_events = load_events(args.bitcoin_log)
329+
tp_points = load_tp_points(args.tp_log)
330+
plot_memory_load(
331+
mem_points,
332+
tip_events,
333+
tp_points,
334+
args.duration_cap,
335+
args.figure_pickle,
336+
args.dpi,
337+
args.machine,
338+
args.output,
339+
args.show,
340+
)
341+
342+
343+
if __name__ == "__main__":
344+
main()

0 commit comments

Comments
 (0)