|
| 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