Skip to content

Commit ff88c5c

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 4f5a87b commit ff88c5c

File tree

2 files changed

+345
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)