Coverage for src / tracekit / api / profiling.py: 98%

142 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""Performance profiling for signal analysis operations. 

2 

3This module provides profiling utilities for measuring and analyzing 

4performance of signal processing operations. 

5""" 

6 

7from __future__ import annotations 

8 

9import functools 

10import logging 

11import statistics 

12import time 

13from contextlib import contextmanager 

14from dataclasses import dataclass, field 

15from typing import TYPE_CHECKING, Any 

16 

17if TYPE_CHECKING: 

18 from collections.abc import Callable, Iterator 

19 

20logger = logging.getLogger(__name__) 

21 

22__all__ = [ 

23 "OperationProfile", 

24 "ProfileReport", 

25 "Profiler", 

26 "profile", 

27] 

28 

29 

30@dataclass 

31class OperationProfile: 

32 """Profile data for a single operation. 

33 

34 Attributes: 

35 name: Operation name 

36 calls: Number of calls 

37 total_time: Total time in seconds 

38 min_time: Minimum time 

39 max_time: Maximum time 

40 times: List of individual times 

41 memory_peak: Peak memory usage (bytes) 

42 input_size: Input data size 

43 

44 References: 

45 API-012: Performance Profiling API 

46 """ 

47 

48 name: str 

49 calls: int = 0 

50 total_time: float = 0.0 

51 min_time: float = float("inf") 

52 max_time: float = 0.0 

53 times: list[float] = field(default_factory=list) 

54 memory_peak: int = 0 

55 input_size: int = 0 

56 

57 @property 

58 def mean_time(self) -> float: 

59 """Average time per call.""" 

60 return self.total_time / self.calls if self.calls > 0 else 0.0 

61 

62 @property 

63 def std_time(self) -> float: 

64 """Standard deviation of times.""" 

65 if len(self.times) < 2: 

66 return 0.0 

67 return statistics.stdev(self.times) 

68 

69 @property 

70 def throughput(self) -> float: 

71 """Throughput in items per second.""" 

72 if self.total_time > 0 and self.input_size > 0: 

73 return (self.input_size * self.calls) / self.total_time 

74 return 0.0 

75 

76 def record(self, elapsed: float, size: int = 0) -> None: 

77 """Record a timing. 

78 

79 Args: 

80 elapsed: Elapsed time in seconds 

81 size: Input size 

82 """ 

83 self.calls += 1 

84 self.total_time += elapsed 

85 self.min_time = min(self.min_time, elapsed) 

86 self.max_time = max(self.max_time, elapsed) 

87 self.times.append(elapsed) 

88 if size > 0: 

89 self.input_size = size 

90 

91 def to_dict(self) -> dict[str, Any]: 

92 """Convert to dictionary.""" 

93 return { 

94 "name": self.name, 

95 "calls": self.calls, 

96 "total_time": self.total_time, 

97 "mean_time": self.mean_time, 

98 "min_time": self.min_time if self.min_time != float("inf") else 0, 

99 "max_time": self.max_time, 

100 "std_time": self.std_time, 

101 "throughput": self.throughput, 

102 } 

103 

104 

105@dataclass 

106class ProfileReport: 

107 """Complete profiling report. 

108 

109 Attributes: 

110 profiles: Dictionary of operation profiles 

111 start_time: Report start time 

112 end_time: Report end time 

113 total_operations: Total number of operations 

114 

115 References: 

116 API-012: Performance Profiling API 

117 """ 

118 

119 profiles: dict[str, OperationProfile] = field(default_factory=dict) 

120 start_time: float = 0.0 

121 end_time: float = 0.0 

122 total_operations: int = 0 

123 

124 @property 

125 def total_time(self) -> float: 

126 """Total profiled time.""" 

127 return sum(p.total_time for p in self.profiles.values()) 

128 

129 @property 

130 def wall_time(self) -> float: 

131 """Wall clock time.""" 

132 return self.end_time - self.start_time if self.end_time > 0 else 0.0 

133 

134 def get_slowest(self, n: int = 5) -> list[OperationProfile]: 

135 """Get slowest operations. 

136 

137 Args: 

138 n: Number of operations 

139 

140 Returns: 

141 List of slowest operation profiles 

142 """ 

143 sorted_profiles = sorted(self.profiles.values(), key=lambda p: p.total_time, reverse=True) 

144 return sorted_profiles[:n] 

145 

146 def get_most_called(self, n: int = 5) -> list[OperationProfile]: 

147 """Get most frequently called operations. 

148 

149 Args: 

150 n: Number of operations 

151 

152 Returns: 

153 List of most called operation profiles 

154 """ 

155 sorted_profiles = sorted(self.profiles.values(), key=lambda p: p.calls, reverse=True) 

156 return sorted_profiles[:n] 

157 

158 def summary(self) -> str: 

159 """Generate text summary. 

160 

161 Returns: 

162 Summary string 

163 """ 

164 lines = [ 

165 "Performance Profile Report", 

166 "=" * 50, 

167 f"Total operations: {self.total_operations}", 

168 f"Total profiled time: {self.total_time:.4f}s", 

169 f"Wall clock time: {self.wall_time:.4f}s", 

170 "", 

171 "Slowest Operations:", 

172 "-" * 30, 

173 ] 

174 

175 for profile in self.get_slowest(): 

176 lines.append( 

177 f" {profile.name}: " 

178 f"{profile.total_time:.4f}s ({profile.calls} calls, " 

179 f"{profile.mean_time * 1000:.2f}ms avg)" 

180 ) 

181 

182 return "\n".join(lines) 

183 

184 def to_dict(self) -> dict[str, Any]: 

185 """Convert to dictionary.""" 

186 return { 

187 "total_time": self.total_time, 

188 "wall_time": self.wall_time, 

189 "total_operations": self.total_operations, 

190 "profiles": {name: profile.to_dict() for name, profile in self.profiles.items()}, 

191 } 

192 

193 

194class Profiler: 

195 """Performance profiler for signal analysis operations. 

196 

197 Tracks timing and performance metrics for operations. 

198 

199 Example: 

200 >>> profiler = Profiler() 

201 >>> with profiler.profile("fft"): 

202 ... result = np.fft.fft(data) 

203 >>> report = profiler.report() 

204 >>> print(report.summary()) 

205 

206 References: 

207 API-012: Performance Profiling API 

208 """ 

209 

210 _instance: Profiler | None = None 

211 

212 def __init__(self) -> None: 

213 """Initialize profiler.""" 

214 self._profiles: dict[str, OperationProfile] = {} 

215 self._start_time: float = 0.0 

216 self._enabled: bool = True 

217 self._stack: list[str] = [] 

218 

219 @classmethod 

220 def get_instance(cls) -> Profiler: 

221 """Get global profiler instance.""" 

222 if cls._instance is None: 

223 cls._instance = cls() 

224 return cls._instance 

225 

226 def enable(self) -> None: 

227 """Enable profiling.""" 

228 self._enabled = True 

229 

230 def disable(self) -> None: 

231 """Disable profiling.""" 

232 self._enabled = False 

233 

234 def reset(self) -> None: 

235 """Reset all profiles.""" 

236 self._profiles.clear() 

237 self._start_time = 0.0 

238 

239 @contextmanager 

240 def profile(self, name: str, input_size: int = 0) -> Iterator[None]: 

241 """Context manager for profiling code block. 

242 

243 Args: 

244 name: Operation name 

245 input_size: Input data size 

246 

247 Yields: 

248 None 

249 

250 Example: 

251 >>> with profiler.profile("fft"): 

252 ... result = compute_fft(data) 

253 """ 

254 if not self._enabled: 

255 yield 

256 return 

257 

258 if self._start_time == 0: 

259 self._start_time = time.perf_counter() 

260 

261 if name not in self._profiles: 

262 self._profiles[name] = OperationProfile(name) 

263 

264 self._stack.append(name) 

265 start = time.perf_counter() 

266 

267 try: 

268 yield 

269 finally: 

270 elapsed = time.perf_counter() - start 

271 self._profiles[name].record(elapsed, input_size) 

272 self._stack.pop() 

273 

274 def record(self, name: str, elapsed: float, input_size: int = 0) -> None: 

275 """Manually record a timing. 

276 

277 Args: 

278 name: Operation name 

279 elapsed: Elapsed time 

280 input_size: Input size 

281 """ 

282 if not self._enabled: 

283 return 

284 

285 if name not in self._profiles: 285 ↛ 288line 285 didn't jump to line 288 because the condition on line 285 was always true

286 self._profiles[name] = OperationProfile(name) 

287 

288 self._profiles[name].record(elapsed, input_size) 

289 

290 def get_profile(self, name: str) -> OperationProfile | None: 

291 """Get profile for operation. 

292 

293 Args: 

294 name: Operation name 

295 

296 Returns: 

297 Operation profile or None 

298 """ 

299 return self._profiles.get(name) 

300 

301 def report(self) -> ProfileReport: 

302 """Generate profiling report. 

303 

304 Returns: 

305 Profile report 

306 """ 

307 return ProfileReport( 

308 profiles=self._profiles.copy(), 

309 start_time=self._start_time, 

310 end_time=time.perf_counter(), 

311 total_operations=sum(p.calls for p in self._profiles.values()), 

312 ) 

313 

314 

315def profile(name: str | None = None, input_size_arg: str | None = None) -> Callable: # type: ignore[type-arg] 

316 """Decorator for profiling functions. 

317 

318 Args: 

319 name: Profile name (defaults to function name) 

320 input_size_arg: Argument name for input size 

321 

322 Returns: 

323 Decorated function 

324 

325 Example: 

326 >>> @profile() 

327 >>> def compute_fft(data, nfft=None): 

328 ... return np.fft.fft(data, n=nfft) 

329 

330 References: 

331 API-012: Performance Profiling API 

332 """ 

333 

334 def decorator(func: Callable) -> Callable: # type: ignore[type-arg] 

335 profile_name = name or func.__name__ 

336 

337 @functools.wraps(func) 

338 def wrapper(*args: Any, **kwargs: Any) -> Any: 

339 profiler = Profiler.get_instance() 

340 

341 # Determine input size 

342 input_size = 0 

343 if input_size_arg: 

344 if input_size_arg in kwargs: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 data = kwargs[input_size_arg] 

346 elif args: 

347 data = args[0] 

348 else: 

349 data = None 

350 

351 if hasattr(data, "__len__"): 

352 input_size = len(data) 

353 

354 with profiler.profile(profile_name, input_size): 

355 return func(*args, **kwargs) 

356 

357 return wrapper 

358 

359 return decorator 

360 

361 

362# Convenience functions 

363def get_profiler() -> Profiler: 

364 """Get global profiler instance. 

365 

366 Returns: 

367 Global Profiler instance 

368 

369 References: 

370 API-012: Performance Profiling API 

371 """ 

372 return Profiler.get_instance() 

373 

374 

375def enable_profiling() -> None: 

376 """Enable global profiling.""" 

377 get_profiler().enable() 

378 

379 

380def disable_profiling() -> None: 

381 """Disable global profiling.""" 

382 get_profiler().disable() 

383 

384 

385def reset_profiling() -> None: 

386 """Reset global profiler.""" 

387 get_profiler().reset() 

388 

389 

390def get_profile_report() -> ProfileReport: 

391 """Get global profile report. 

392 

393 Returns: 

394 Profile report 

395 """ 

396 return get_profiler().report()