Skip to content

Commit 95f3190

Browse files
committed
Adding GPU benchmark scripts and python analysis script
1 parent dd89cae commit 95f3190

2 files changed

Lines changed: 671 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import re
5+
from pathlib import Path
6+
7+
import numpy as np
8+
import matplotlib.pyplot as plt
9+
10+
try:
11+
from scipy.optimize import curve_fit
12+
SCIPY_AVAILABLE = True
13+
except ImportError:
14+
SCIPY_AVAILABLE = False
15+
16+
17+
LINE_RE = re.compile(
18+
r"\[[^\]]*gpu-reconstruction[^\]]*\]:\s*"
19+
r"\[(?P<time>\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\[INFO\]\s*"
20+
r"(?P<kind>Processing timeslice:|Done processing timeslice:)"
21+
r"(?P<timeslice>\d+)"
22+
)
23+
24+
25+
def parse_hms_to_seconds(hms: str) -> float:
26+
hhmmss, *frac = hms.split(".")
27+
h, m, s = map(int, hhmmss.split(":"))
28+
29+
seconds = h * 3600 + m * 60 + s
30+
31+
if frac:
32+
seconds += float("0." + frac[0])
33+
34+
return seconds
35+
36+
37+
def gaussian(x, amplitude, mean, sigma):
38+
return amplitude * np.exp(-0.5 * ((x - mean) / sigma) ** 2)
39+
40+
41+
def read_timeslice_durations(logfile: Path):
42+
starts = {}
43+
ends = {}
44+
durations = {}
45+
46+
day_offset = 0.0
47+
previous_raw_timestamp = None
48+
49+
with logfile.open("r", errors="replace") as f:
50+
for line in f:
51+
match = LINE_RE.search(line)
52+
if not match:
53+
continue
54+
55+
raw_timestamp = parse_hms_to_seconds(match.group("time"))
56+
57+
# Handle midnight wraparound in log order
58+
if (
59+
previous_raw_timestamp is not None
60+
and raw_timestamp < previous_raw_timestamp
61+
):
62+
day_offset += 24 * 3600
63+
64+
previous_raw_timestamp = raw_timestamp
65+
timestamp = raw_timestamp + day_offset
66+
67+
timeslice = int(match.group("timeslice"))
68+
kind = match.group("kind")
69+
70+
if kind == "Processing timeslice:":
71+
starts[timeslice] = timestamp
72+
73+
elif kind == "Done processing timeslice:":
74+
if timeslice not in starts:
75+
continue
76+
77+
start = starts[timeslice]
78+
end = timestamp
79+
80+
durations[timeslice] = end - start
81+
ends[timeslice] = end
82+
83+
return durations, starts, ends
84+
85+
def warn_about_processing_downtime(starts, ends, tolerance_s=0.001):
86+
RED = "\033[91m"
87+
YELLOW = "\033[93m"
88+
BOLD = "\033[1m"
89+
RESET = "\033[0m"
90+
91+
common_timeslices = sorted(set(starts) & set(ends))
92+
93+
if len(common_timeslices) <= 4:
94+
return set()
95+
96+
ignored_timeslices = set(common_timeslices[:2] + common_timeslices[-2:])
97+
excluded_timeslices = set()
98+
99+
for ts in common_timeslices:
100+
next_ts = ts + 1
101+
102+
if next_ts not in starts:
103+
continue
104+
105+
# Do not warn if the boundary touches one of the first two or last two
106+
# complete timeslices.
107+
if ts in ignored_timeslices or next_ts in ignored_timeslices:
108+
continue
109+
110+
gap = starts[next_ts] - ends[ts]
111+
112+
if gap > tolerance_s:
113+
affected = {ts - 1, ts, next_ts, next_ts + 1}
114+
affected = {
115+
x for x in affected
116+
if x in common_timeslices and x not in ignored_timeslices
117+
}
118+
119+
print(
120+
f"{YELLOW}{BOLD}WARNING:{RESET} "
121+
f"{YELLOW}Processing downtime detected between "
122+
f"timeslice {ts} and {next_ts}: "
123+
f"end[{ts}] -> start[{next_ts}] gap = {gap * 1000:.3f} ms. "
124+
f"Excluding timeslices {sorted(affected)} from calculation.{RESET}",
125+
flush=True,
126+
)
127+
128+
excluded_timeslices.update(affected)
129+
130+
elif gap < -tolerance_s:
131+
affected = {ts - 1, ts, next_ts, next_ts + 1}
132+
affected = {
133+
x for x in affected
134+
if x in common_timeslices and x not in ignored_timeslices
135+
}
136+
137+
print(
138+
f"{RED}{BOLD}WARNING:{RESET} "
139+
f"{RED}Processing overlap or timestamp ordering issue between "
140+
f"timeslice {ts} and {next_ts}: "
141+
f"start[{next_ts}] is {-gap * 1000:.3f} ms before end[{ts}]. "
142+
f"Excluding timeslices {sorted(affected)} from calculation.{RESET}",
143+
flush=True,
144+
)
145+
146+
excluded_timeslices.update(affected)
147+
148+
return excluded_timeslices
149+
150+
def fit_gaussian_to_histogram(values, bins):
151+
counts, edges = np.histogram(values, bins=bins)
152+
centers = 0.5 * (edges[:-1] + edges[1:])
153+
154+
sample_mean = np.mean(values)
155+
sample_sigma = np.std(values, ddof=1)
156+
157+
if not SCIPY_AVAILABLE:
158+
return None, counts, edges
159+
160+
nonzero = counts > 0
161+
if np.count_nonzero(nonzero) < 3:
162+
return None, counts, edges
163+
164+
x = centers[nonzero]
165+
y = counts[nonzero]
166+
167+
p0 = [np.max(y), sample_mean, sample_sigma]
168+
169+
try:
170+
popt, _ = curve_fit(
171+
gaussian,
172+
x,
173+
y,
174+
p0=p0,
175+
maxfev=10000,
176+
bounds=(
177+
[0.0, -np.inf, 1e-12],
178+
[np.inf, np.inf, np.inf],
179+
),
180+
)
181+
return popt, counts, edges
182+
except Exception:
183+
return None, counts, edges
184+
185+
186+
def main():
187+
parser = argparse.ArgumentParser(
188+
description="Analyze gpu-reconstruction timeslice processing durations."
189+
)
190+
parser.add_argument("-l", "--logfile", type=Path, help="Path to the log file")
191+
parser.add_argument(
192+
"-o",
193+
"--output",
194+
type=Path,
195+
default=Path("gpu_reconstruction_times.png"),
196+
help="Output plot filename",
197+
)
198+
parser.add_argument(
199+
"--bins",
200+
type=int,
201+
default=50,
202+
help="Number of histogram bins",
203+
)
204+
parser.add_argument(
205+
"--unit",
206+
choices=["s", "ms"],
207+
default="s",
208+
help="Plot durations in seconds or milliseconds",
209+
)
210+
parser.add_argument(
211+
"--gap-tolerance-ms",
212+
type=float,
213+
default=1.0,
214+
help="Allowed gap between end of timeslice n and start of timeslice n+1 in ms",
215+
)
216+
217+
args = parser.parse_args()
218+
219+
durations_by_timeslice, starts_by_timeslice, ends_by_timeslice = read_timeslice_durations(
220+
args.logfile
221+
)
222+
223+
excluded_timeslices = warn_about_processing_downtime(
224+
starts_by_timeslice,
225+
ends_by_timeslice,
226+
tolerance_s=args.gap_tolerance_ms / 1000.0,
227+
)
228+
if excluded_timeslices:
229+
print(
230+
f"Excluded timeslices due to downtime/overlap: "
231+
f"{sorted(excluded_timeslices)}"
232+
)
233+
234+
if len(durations_by_timeslice) < 5:
235+
raise RuntimeError(
236+
f"Found only {len(durations_by_timeslice)} complete timeslices. "
237+
"Need at least 5 to drop first two and last two."
238+
)
239+
240+
timeslices = sorted(durations_by_timeslice)
241+
trimmed_timeslices = [
242+
ts for ts in timeslices[2:-2]
243+
if ts not in excluded_timeslices
244+
]
245+
246+
values = np.array(
247+
[durations_by_timeslice[ts] for ts in trimmed_timeslices],
248+
dtype=float,
249+
)
250+
251+
if args.unit == "ms":
252+
values *= 1000.0
253+
unit_label = "ms"
254+
else:
255+
unit_label = "s"
256+
257+
n_total = len(timeslices)
258+
n_used = len(values)
259+
260+
sample_mean = np.mean(values)
261+
sample_sigma = np.std(values, ddof=1)
262+
263+
avg_from_sum = np.mean(values)
264+
265+
fit_result, counts, edges = fit_gaussian_to_histogram(values, args.bins)
266+
267+
plt.figure(figsize=(9, 6))
268+
269+
plt.hist(
270+
values,
271+
bins=args.bins,
272+
histtype="stepfilled",
273+
alpha=0.45,
274+
label="Timeslice duration distribution",
275+
)
276+
277+
if fit_result is not None:
278+
amp, fit_mean, fit_sigma = fit_result
279+
xfit = np.linspace(edges[0], edges[-1], 1000)
280+
yfit = gaussian(xfit, amp, fit_mean, fit_sigma)
281+
282+
plt.plot(
283+
xfit,
284+
yfit,
285+
linewidth=2,
286+
label=f"Gaussian fit: mean={fit_mean:.4g} {unit_label}, sigma={fit_sigma:.4g} {unit_label}",
287+
)
288+
else:
289+
plt.plot([], [], label="Gaussian fit: unavailable")
290+
291+
plt.plot(
292+
[],
293+
[],
294+
label=f"Sample: mean={sample_mean:.4g} {unit_label}, sigma={sample_sigma:.4g} {unit_label}",
295+
)
296+
297+
plt.xlabel(f"Processing time per timeslice [{unit_label}]")
298+
plt.ylabel("Entries")
299+
plt.title("gpu-reconstruction timeslice processing duration")
300+
plt.legend()
301+
plt.tight_layout()
302+
plt.savefig(args.output, dpi=150)
303+
304+
print(f"Input file: {args.logfile}")
305+
print(f"Complete timeslices found: {n_total}")
306+
print(f"Timeslices used after dropping first/last two: {n_used}")
307+
print(f"First used timeslice: {trimmed_timeslices[0]}")
308+
print(f"Last used timeslice: {trimmed_timeslices[-1]}")
309+
print(f"Average duration = sum(durations)/(processed timeslices - 4): {avg_from_sum:.6g} {unit_label}")
310+
print(f"Sample mean: {sample_mean:.6g} {unit_label}")
311+
print(f"Sample sigma: {sample_sigma:.6g} {unit_label}")
312+
313+
if fit_result is not None:
314+
_, fit_mean, fit_sigma = fit_result
315+
print(f"Gaussian fit mean: {fit_mean:.6g} {unit_label}")
316+
print(f"Gaussian fit sigma: {fit_sigma:.6g} {unit_label}")
317+
else:
318+
print("Gaussian fit failed or scipy is unavailable.")
319+
320+
print(f"Saved plot to: {args.output}")
321+
322+
323+
if __name__ == "__main__":
324+
main()

0 commit comments

Comments
 (0)