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