Coverage for oc_meta / lib / timer.py: 23%

158 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-07-25 10:39 +0000

1#!/usr/bin/env python 

2 

3# SPDX-FileCopyrightText: 2025 Arcangelo Massari <arcangelo.massari@unibo.it> 

4# 

5# SPDX-License-Identifier: ISC 

6 

7# -*- coding: utf-8 -*- 

8""" 

9Timing and metrics collection utilities for OpenCitations Meta processing. 

10 

11This module provides reusable timing infrastructure for both production 

12processing and benchmarking, with optional activation to avoid overhead. 

13""" 

14 

15import threading 

16import time 

17from typing import Any, Callable, Dict, List, Optional 

18 

19import psutil 

20 

21 

22class _MemorySampler: 

23 """Background thread that samples RSS to capture true peak memory.""" 

24 

25 def __init__(self, process: psutil.Process, interval: float = 0.1): 

26 self._process = process 

27 self._interval = interval 

28 self._stop = threading.Event() 

29 self._peak: int = 0 

30 self._thread = threading.Thread(target=self._run, daemon=True) 

31 

32 def start(self): 

33 self._thread.start() 

34 

35 def stop(self) -> int: 

36 self._stop.set() 

37 self._thread.join() 

38 return self._peak 

39 

40 def _run(self): 

41 while not self._stop.is_set(): 

42 rss = self._process.memory_info().rss 

43 if rss > self._peak: 

44 self._peak = rss 

45 self._stop.wait(self._interval) 

46 

47 

48class BenchmarkTimer: 

49 """Context manager for timing code blocks and collecting memory metrics.""" 

50 

51 def __init__( 

52 self, 

53 name: str, 

54 verbose: bool = False, 

55 on_exit: Optional[Callable[[], None]] = None, 

56 ): 

57 self.name = name 

58 self.verbose = verbose 

59 self.start_time: Optional[float] = None 

60 self.end_time: Optional[float] = None 

61 self.duration: Optional[float] = None 

62 self.start_memory: Optional[int] = None 

63 self.end_memory: Optional[int] = None 

64 self.peak_memory: Optional[int] = None 

65 self._sampler: Optional[_MemorySampler] = None 

66 self._on_exit: Optional[Callable[[], None]] = on_exit 

67 

68 def __enter__(self): 

69 self.start_time = time.time() 

70 process = psutil.Process() 

71 self.start_memory = process.memory_info().rss 

72 self._sampler = _MemorySampler(process) 

73 self._sampler.start() 

74 if self.verbose: 

75 print(f" [{self.name}] Starting...") 

76 return self 

77 

78 def __exit__(self, exc_type, exc_val, exc_tb): 

79 assert ( 

80 self.start_time is not None 

81 and self.start_memory is not None 

82 and self._sampler is not None 

83 ) 

84 self.end_time = time.time() 

85 self.duration = self.end_time - self.start_time 

86 process = psutil.Process() 

87 end_memory = process.memory_info().rss 

88 self.end_memory = end_memory 

89 sampled_peak = self._sampler.stop() 

90 self.peak_memory = max(self.start_memory, end_memory, sampled_peak) 

91 if self.verbose: 

92 print(f" [{self.name}] Completed in {self.duration:.2f}s") 

93 if self._on_exit: 

94 self._on_exit() 

95 

96 def to_dict(self) -> Dict[str, Any]: 

97 """Convert timing data to dictionary.""" 

98 return { 

99 "name": self.name, 

100 "duration_seconds": round(self.duration, 3) if self.duration else None, 

101 "start_memory_mb": round(self.start_memory / 1024 / 1024, 2) 

102 if self.start_memory 

103 else None, 

104 "end_memory_mb": round(self.end_memory / 1024 / 1024, 2) 

105 if self.end_memory 

106 else None, 

107 "peak_memory_mb": round(self.peak_memory / 1024 / 1024, 2) 

108 if self.peak_memory 

109 else None, 

110 } 

111 

112 

113class DummyTimer: 

114 """No-op timer for when timing is disabled.""" 

115 

116 def __enter__(self): 

117 return self 

118 

119 def __exit__(self, *args): 

120 pass 

121 

122 

123class ProcessTimer: 

124 """Optional timing wrapper for MetaProcess operations.""" 

125 

126 def __init__( 

127 self, 

128 enabled: bool = False, 

129 verbose: bool = False, 

130 on_phase_complete: Optional[Callable[["ProcessTimer"], None]] = None, 

131 ): 

132 self.enabled = enabled 

133 self.verbose = verbose 

134 self.timers: List[BenchmarkTimer] = [] 

135 self.metrics: Dict[str, Any] = {} 

136 self._on_phase_complete: Optional[Callable[["ProcessTimer"], None]] = ( 

137 on_phase_complete 

138 ) 

139 

140 def timer(self, name: str): 

141 """Create a timer context manager (or no-op if disabled).""" 

142 if self.enabled: 

143 # Don't show verbose for total_processing and sub-timers 

144 show_verbose = self.verbose and name not in [ 

145 "total_processing", 

146 "creator_execution", 

147 "provenance_generation", 

148 ] 

149 timer = BenchmarkTimer( 

150 name, verbose=show_verbose, on_exit=self._notify_phase 

151 ) 

152 self.timers.append(timer) 

153 return timer 

154 else: 

155 return DummyTimer() 

156 

157 def _notify_phase(self): 

158 if self._on_phase_complete: 

159 self._on_phase_complete(self) 

160 

161 def record_metric(self, key: str, value: Any): 

162 """Record a metric.""" 

163 if self.enabled: 

164 self.metrics[key] = value 

165 

166 def record_phase(self, name: str, duration: float): 

167 """Record a phase with a specific duration (e.g., 0 for unused phases).""" 

168 if self.enabled: 

169 timer = BenchmarkTimer(name, verbose=False) 

170 timer.start_time = 0 

171 timer.end_time = duration 

172 timer.duration = duration 

173 timer.start_memory = 0 

174 timer.end_memory = 0 

175 timer.peak_memory = 0 

176 self.timers.append(timer) 

177 

178 def get_report(self) -> Dict[str, Any]: 

179 """Generate timing report.""" 

180 if not self.enabled: 

181 return {} 

182 

183 total_time = ( 

184 next( 

185 (t.duration for t in self.timers if t.name == "total_processing"), None 

186 ) 

187 or 0.0 

188 ) 

189 input_records = self.metrics.get("input_records", 0) 

190 

191 return { 

192 "metrics": { 

193 **self.metrics, 

194 "total_duration_seconds": round(total_time, 3), 

195 "throughput_records_per_sec": round(input_records / total_time, 2) 

196 if total_time > 0 

197 else 0, 

198 }, 

199 "phases": [t.to_dict() for t in self.timers], 

200 } 

201 

202 def print_summary(self): 

203 """Print timing summary to console.""" 

204 if not self.enabled: 

205 return 

206 

207 report = self.get_report() 

208 metrics = report["metrics"] 

209 

210 print(f"\n{'=' * 60}") 

211 print("Timing Summary") 

212 print(f"{'=' * 60}") 

213 print(f"Total Duration: {metrics.get('total_duration_seconds', 0)}s") 

214 print(f"Throughput: {metrics.get('throughput_records_per_sec', 0)} records/sec") 

215 print(f"Input Records: {metrics.get('input_records', 0)}") 

216 print(f"Curated Records: {metrics.get('curated_records', 0)}") 

217 print(f"New Entities: {metrics.get('new_entities', 0)}") 

218 print(f"Modified Entities: {metrics.get('modified_entities', 0)}") 

219 print("\nPhase Breakdown:") 

220 for phase in report["phases"]: 

221 if phase["name"] not in [ 

222 "total_processing", 

223 "creator_execution", 

224 "provenance_generation", 

225 ]: 

226 print(f" {phase['name']}: {phase['duration_seconds']}s") 

227 print(f"{'=' * 60}\n") 

228 

229 def print_phase_breakdown(self): 

230 """Print detailed phase breakdown for a single file.""" 

231 if not self.enabled: 

232 return 

233 

234 report = self.get_report() 

235 phases = report["phases"] 

236 

237 print("\n Phase Breakdown:") 

238 for phase in phases: 

239 if phase["name"] == "total_processing": 

240 continue 

241 name = phase["name"] 

242 duration = phase["duration_seconds"] 

243 peak = phase["peak_memory_mb"] 

244 if peak: 

245 delta = phase["end_memory_mb"] - phase["start_memory_mb"] 

246 sign = "+" if delta >= 0 else "" 

247 print( 

248 f" {name:30s} {duration:10.2f}s {peak:10.1f} MB peak {sign}{delta:.1f} MB" 

249 ) 

250 else: 

251 print(f" {name:30s} {duration:10.2f}s") 

252 

253 def print_file_summary(self, filename: str): 

254 """Print complete summary for a single file with metrics and phases.""" 

255 if not self.enabled: 

256 return 

257 

258 report = self.get_report() 

259 metrics = report["metrics"] 

260 

261 total_time = metrics.get("total_duration_seconds", 0) 

262 records = metrics.get("input_records", 0) 

263 entities = metrics.get("new_entities", 0) 

264 throughput = metrics.get("throughput_records_per_sec", 0) 

265 

266 total_phase = next( 

267 (p for p in report["phases"] if p["name"] == "total_processing"), None 

268 ) 

269 peak_mb = total_phase["peak_memory_mb"] if total_phase else 0 

270 delta_mb = ( 

271 (total_phase["end_memory_mb"] - total_phase["start_memory_mb"]) 

272 if total_phase 

273 else 0 

274 ) 

275 

276 print(f" ✓ Completed in {total_time:.2f}s") 

277 self.print_phase_breakdown() 

278 print("\n Metrics:") 

279 print(f" Records processed: {records}") 

280 print(f" New entities: {entities}") 

281 print(f" Throughput: {throughput:.2f} rec/s") 

282 if peak_mb: 

283 sign = "+" if delta_mb >= 0 else "" 

284 print(f" Peak memory (RSS): {peak_mb:.1f} MB") 

285 print(f" Memory growth: {sign}{delta_mb:.1f} MB")