Coverage for python/lsst/utils/timer.py: 17%

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

132 statements  

1# This file is part of utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11# 

12 

13"""Utilities for measuring execution time. 

14""" 

15 

16from __future__ import annotations 

17 

18__all__ = ["logInfo", "timeMethod", "time_this"] 

19 

20import datetime 

21import functools 

22import inspect 

23import logging 

24import resource 

25import time 

26from contextlib import contextmanager 

27from typing import ( 

28 TYPE_CHECKING, 

29 Any, 

30 Callable, 

31 Collection, 

32 Iterable, 

33 Iterator, 

34 MutableMapping, 

35 Optional, 

36 Tuple, 

37) 

38 

39from astropy import units as u 

40 

41from .usage import get_current_mem_usage, get_peak_mem_usage 

42 

43if TYPE_CHECKING: 43 ↛ 44line 43 didn't jump to line 44, because the condition on line 43 was never true

44 from .logging import LsstLoggers 

45 

46 

47_LOG = logging.getLogger(__name__) 

48 

49 

50def _add_to_metadata(metadata: MutableMapping, name: str, value: Any) -> None: 

51 """Add a value to dict-like object, creating list as needed. 

52 

53 The list grows as more values are added for that key. 

54 

55 Parameters 

56 ---------- 

57 metadata : `dict`-like, optional 

58 `dict`-like object that can store keys. Uses `add()` method if 

59 one is available, else creates list and appends value if needed. 

60 name : `str` 

61 The key to use in the metadata dictionary. 

62 value : Any 

63 Value to store in the list. 

64 """ 

65 try: 

66 try: 

67 # PropertySet should always prefer LongLong for integers 

68 metadata.addLongLong(name, value) # type: ignore 

69 except (TypeError, AttributeError): 

70 metadata.add(name, value) # type: ignore 

71 except AttributeError: 

72 pass 

73 else: 

74 return 

75 

76 # Fallback code where `add` is not implemented. 

77 if name not in metadata: 

78 metadata[name] = [] 

79 metadata[name].append(value) 

80 

81 

82def _find_outside_stacklevel() -> int: 

83 """Find the stack level corresponding to caller code outside of this 

84 module. 

85 

86 This can be passed directly to `logging.Logger.log()` to ensure 

87 that log messages are issued as if they are coming from caller code. 

88 

89 Returns 

90 ------- 

91 stacklevel : `int` 

92 The stack level to use to refer to a caller outside of this module. 

93 A ``stacklevel`` of ``1`` corresponds to the caller of this internal 

94 function and that is the default expected by `logging.Logger.log()`. 

95 

96 Notes 

97 ----- 

98 Intended to be called from the function that is going to issue a log 

99 message. The result should be passed into `~logging.Logger.log` via the 

100 keyword parameter ``stacklevel``. 

101 """ 

102 stacklevel = 1 # the default for `Logger.log` 

103 for i, s in enumerate(inspect.stack()): 

104 module = inspect.getmodule(s.frame) 

105 if module is None: 

106 # Stack frames sometimes hang around so explicilty delete. 

107 del s 

108 continue 

109 if not module.__name__.startswith("lsst.utils"): 

110 # 0 will be this function. 

111 # 1 will be the caller which will be the default for `Logger.log` 

112 # and so does not need adjustment. 

113 stacklevel = i 

114 break 

115 # Stack frames sometimes hang around so explicilty delete. 

116 del s 

117 

118 return stacklevel 

119 

120 

121def logPairs( 

122 obj: Any, 

123 pairs: Collection[Tuple[str, Any]], 

124 logLevel: int = logging.DEBUG, 

125 metadata: Optional[MutableMapping] = None, 

126 logger: Optional[logging.Logger] = None, 

127 stacklevel: Optional[int] = None, 

128) -> None: 

129 """Log ``(name, value)`` pairs to ``obj.metadata`` and ``obj.log`` 

130 

131 Parameters 

132 ---------- 

133 obj : `object` 

134 An object with one or both of these two attributes: 

135 

136 - ``metadata`` a `dict`-like container for storing metadata. 

137 Can use the ``add(name, value)`` method if found, else will append 

138 entries to a list. 

139 - ``log`` an instance of `logging.Logger` or subclass. 

140 

141 If `None`, at least one of ``metadata`` or ``logger`` should be passed 

142 or this function will do nothing. 

143 pairs : sequence 

144 A sequence of ``(name, value)`` pairs, with value typically numeric. 

145 logLevel : `int, optional 

146 Log level (an `logging` level constant, such as `logging.DEBUG`). 

147 metadata : `collections.abc.MutableMapping`, optional 

148 Metadata object to write entries to. Ignored if `None`. 

149 logger : `logging.Logger` 

150 Log object to write entries to. Ignored if `None`. 

151 stacklevel : `int`, optional 

152 The stack level to pass to the logger to adjust which stack frame 

153 is used to report the file information. If `None` the stack level 

154 is computed such that it is reported as the first package outside 

155 of the utils package. If a value is given here it is adjusted by 

156 1 to account for this caller. 

157 """ 

158 if obj is not None: 

159 if metadata is None: 

160 try: 

161 metadata = obj.metadata 

162 except AttributeError: 

163 pass 

164 if logger is None: 

165 try: 

166 logger = obj.log 

167 except AttributeError: 

168 pass 

169 strList = [] 

170 for name, value in pairs: 

171 if metadata is not None: 

172 _add_to_metadata(metadata, name, value) 

173 strList.append(f"{name}={value}") 

174 if logger is not None: 

175 # Want the file associated with this log message to be that 

176 # of the caller. This is expensive so only do it if we know the 

177 # message will be issued. 

178 timer_logger = logging.getLogger("timer." + logger.name) 

179 if timer_logger.isEnabledFor(logLevel): 

180 if stacklevel is None: 

181 stacklevel = _find_outside_stacklevel() 

182 else: 

183 # Account for the caller stack. 

184 stacklevel += 1 

185 timer_logger.log(logLevel, "; ".join(strList), stacklevel=stacklevel) 

186 

187 

188def logInfo( 

189 obj: Any, 

190 prefix: str, 

191 logLevel: int = logging.DEBUG, 

192 metadata: Optional[MutableMapping] = None, 

193 logger: Optional[logging.Logger] = None, 

194 stacklevel: Optional[int] = None, 

195) -> None: 

196 """Log timer information to ``obj.metadata`` and ``obj.log``. 

197 

198 Parameters 

199 ---------- 

200 obj : `object` 

201 An object with both or one these two attributes: 

202 

203 - ``metadata`` a `dict`-like container for storing metadata. 

204 Can use the ``add(name, value)`` method if found, else will append 

205 entries to a list. 

206 - ``log`` an instance of `logging.Logger` or subclass. 

207 

208 If `None`, at least one of ``metadata`` or ``logger`` should be passed 

209 or this function will do nothing. 

210 prefix : `str` 

211 Name prefix, the resulting entries are ``CpuTime``, etc.. For example 

212 `timeMethod` uses ``prefix = Start`` when the method begins and 

213 ``prefix = End`` when the method ends. 

214 logLevel : optional 

215 Log level (a `logging` level constant, such as `logging.DEBUG`). 

216 metadata : `collections.abc.MutableMapping`, optional 

217 Metadata object to write entries to, overriding ``obj.metadata``. 

218 logger : `logging.Logger` 

219 Log object to write entries to, overriding ``obj.log``. 

220 stacklevel : `int`, optional 

221 The stack level to pass to the logger to adjust which stack frame 

222 is used to report the file information. If `None` the stack level 

223 is computed such that it is reported as the first package outside 

224 of the utils package. If a value is given here it is adjusted by 

225 1 to account for this caller. 

226 

227 Notes 

228 ----- 

229 Logged items include: 

230 

231 - ``Utc``: UTC date in ISO format (only in metadata since log entries have 

232 timestamps). 

233 - ``CpuTime``: System + User CPU time (seconds). This should only be used 

234 in differential measurements; the time reference point is undefined. 

235 - ``MaxRss``: maximum resident set size. 

236 

237 All logged resource information is only for the current process; child 

238 processes are excluded. 

239 """ 

240 cpuTime = time.process_time() 

241 res = resource.getrusage(resource.RUSAGE_SELF) 

242 if metadata is None and obj is not None: 

243 try: 

244 metadata = obj.metadata 

245 except AttributeError: 

246 pass 

247 if metadata is not None: 

248 # Log messages already have timestamps. 

249 utcStr = datetime.datetime.utcnow().isoformat() 

250 _add_to_metadata(metadata, name=prefix + "Utc", value=utcStr) 

251 if stacklevel is not None: 

252 # Account for the caller of this routine not knowing that we 

253 # are going one down in the stack. 

254 stacklevel += 1 

255 logPairs( 

256 obj=obj, 

257 pairs=[ 

258 (prefix + "CpuTime", cpuTime), 

259 (prefix + "UserTime", res.ru_utime), 

260 (prefix + "SystemTime", res.ru_stime), 

261 (prefix + "MaxResidentSetSize", int(res.ru_maxrss)), 

262 (prefix + "MinorPageFaults", int(res.ru_minflt)), 

263 (prefix + "MajorPageFaults", int(res.ru_majflt)), 

264 (prefix + "BlockInputs", int(res.ru_inblock)), 

265 (prefix + "BlockOutputs", int(res.ru_oublock)), 

266 (prefix + "VoluntaryContextSwitches", int(res.ru_nvcsw)), 

267 (prefix + "InvoluntaryContextSwitches", int(res.ru_nivcsw)), 

268 ], 

269 logLevel=logLevel, 

270 metadata=metadata, 

271 logger=logger, 

272 stacklevel=stacklevel, 

273 ) 

274 

275 

276def timeMethod( 

277 _func: Optional[Any] = None, 

278 *, 

279 metadata: Optional[MutableMapping] = None, 

280 logger: Optional[logging.Logger] = None, 

281 logLevel: int = logging.DEBUG, 

282) -> Callable: 

283 """Measure duration of a method. 

284 

285 Parameters 

286 ---------- 

287 func 

288 The method to wrap. 

289 metadata : `collections.abc.MutableMapping`, optional 

290 Metadata to use as override if the instance object attached 

291 to this timer does not support a ``metadata`` property. 

292 logger : `logging.Logger`, optional 

293 Logger to use when the class containing the decorated method does not 

294 have a ``log`` property. 

295 logLevel : `int`, optional 

296 Log level to use when logging messages. Default is `~logging.DEBUG`. 

297 

298 Notes 

299 ----- 

300 Writes various measures of time and possibly memory usage to the 

301 metadata; all items are prefixed with the function name. 

302 

303 .. warning:: 

304 

305 This decorator only works with instance methods of any class 

306 with these attributes: 

307 

308 - ``metadata``: an instance of `collections.abc.Mapping`. The ``add`` 

309 method will be used if available, else entries will be added to a 

310 list. 

311 - ``log``: an instance of `logging.Logger` or subclass. 

312 

313 Examples 

314 -------- 

315 To use: 

316 

317 .. code-block:: python 

318 

319 import lsst.utils as utils 

320 import lsst.pipe.base as pipeBase 

321 class FooTask(pipeBase.Task): 

322 pass 

323 

324 @utils.timeMethod 

325 def run(self, ...): # or any other instance method you want to time 

326 pass 

327 """ 

328 

329 def decorator_timer(func: Callable) -> Callable: 

330 @functools.wraps(func) 

331 def timeMethod_wrapper(self: Any, *args: Any, **keyArgs: Any) -> Any: 

332 # Adjust the stacklevel to account for the wrappers. 

333 # stacklevel 1 would make the log message come from this function 

334 # but we want it to come from the file that defined the method 

335 # so need to increment by 1 to get to the caller. 

336 stacklevel = 2 

337 logInfo( 

338 obj=self, 

339 prefix=func.__name__ + "Start", 

340 metadata=metadata, 

341 logger=logger, 

342 logLevel=logLevel, 

343 stacklevel=stacklevel, 

344 ) 

345 try: 

346 res = func(self, *args, **keyArgs) 

347 finally: 

348 logInfo( 

349 obj=self, 

350 prefix=func.__name__ + "End", 

351 metadata=metadata, 

352 logger=logger, 

353 logLevel=logLevel, 

354 stacklevel=stacklevel, 

355 ) 

356 return res 

357 

358 return timeMethod_wrapper 

359 

360 if _func is None: 

361 return decorator_timer 

362 else: 

363 return decorator_timer(_func) 

364 

365 

366@contextmanager 

367def time_this( 

368 log: Optional[LsstLoggers] = None, 

369 msg: Optional[str] = None, 

370 level: int = logging.DEBUG, 

371 prefix: Optional[str] = "timer", 

372 args: Iterable[Any] = (), 

373 mem_usage: bool = False, 

374 mem_child: bool = False, 

375 mem_unit: u.Quantity = u.byte, 

376 mem_fmt: str = ".0f", 

377) -> Iterator[None]: 

378 """Time the enclosed block and issue a log message. 

379 

380 Parameters 

381 ---------- 

382 log : `logging.Logger`, optional 

383 Logger to use to report the timer message. The root logger will 

384 be used if none is given. 

385 msg : `str`, optional 

386 Context to include in log message. 

387 level : `int`, optional 

388 Python logging level to use to issue the log message. If the 

389 code block raises an exception the log message will automatically 

390 switch to level ERROR. 

391 prefix : `str`, optional 

392 Prefix to use to prepend to the supplied logger to 

393 create a new logger to use instead. No prefix is used if the value 

394 is set to `None`. Defaults to "timer". 

395 args : iterable of any 

396 Additional parameters passed to the log command that should be 

397 written to ``msg``. 

398 mem_usage : `bool`, optional 

399 Flag indicating whether to include the memory usage in the report. 

400 Defaults, to False. 

401 mem_child : `bool`, optional 

402 Flag indication whether to include memory usage of the child processes. 

403 mem_unit : `astropy.units.Unit`, optional 

404 Unit to use when reporting the memory usage. Defaults to bytes. 

405 mem_fmt : `str`, optional 

406 Format specifier to use when displaying values related to memory usage. 

407 Defaults to '.0f'. 

408 """ 

409 if log is None: 

410 log = logging.getLogger() 

411 if prefix: 

412 log_name = f"{prefix}.{log.name}" if not isinstance(log, logging.RootLogger) else prefix 

413 log = logging.getLogger(log_name) 

414 

415 success = False 

416 start = time.time() 

417 if mem_usage: 

418 current_usages_start = get_current_mem_usage() 

419 peak_usages_start = get_peak_mem_usage() 

420 

421 try: 

422 yield 

423 success = True 

424 finally: 

425 end = time.time() 

426 

427 # The message is pre-inserted to allow the logger to expand 

428 # the additional args provided. Make that easier by converting 

429 # the None message to empty string. 

430 if msg is None: 

431 msg = "" 

432 

433 # Convert user provided parameters (if any) to mutable sequence to make 

434 # mypy stop complaining when additional parameters will be added below. 

435 params = list(args) if args else [] 

436 

437 if not success: 

438 # Something went wrong so change the log level to indicate 

439 # this. 

440 level = logging.ERROR 

441 

442 # Specify stacklevel to ensure the message is reported from the 

443 # caller (1 is this file, 2 is contextlib, 3 is user) 

444 params += (": " if msg else "", end - start) 

445 msg += "%sTook %.4f seconds" 

446 if mem_usage and log.isEnabledFor(level): 

447 current_usages_end = get_current_mem_usage() 

448 peak_usages_end = get_peak_mem_usage() 

449 

450 current_deltas = [end - start for end, start in zip(current_usages_end, current_usages_start)] 

451 peak_deltas = [end - start for end, start in zip(peak_usages_end, peak_usages_start)] 

452 

453 current_usage = current_usages_end[0] 

454 current_delta = current_deltas[0] 

455 peak_delta = peak_deltas[0] 

456 if mem_child: 

457 current_usage += current_usages_end[1] 

458 current_delta += current_deltas[1] 

459 peak_delta += peak_deltas[1] 

460 

461 if not mem_unit.is_equivalent(u.byte): 

462 _LOG.warning("Invalid memory unit '%s', using '%s' instead", mem_unit, u.byte) 

463 mem_unit = u.byte 

464 

465 msg += ( 

466 f"; current memory usage: {current_usage.to(mem_unit):{mem_fmt}}" 

467 f", delta: {current_delta.to(mem_unit):{mem_fmt}}" 

468 f", peak delta: {peak_delta.to(mem_unit):{mem_fmt}}" 

469 ) 

470 log.log(level, msg, *params, stacklevel=3)