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

131 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-13 02:28 -0700

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 time 

25from contextlib import contextmanager 

26from typing import ( 

27 TYPE_CHECKING, 

28 Any, 

29 Callable, 

30 Collection, 

31 Iterable, 

32 Iterator, 

33 MutableMapping, 

34 Optional, 

35 Tuple, 

36) 

37 

38from astropy import units as u 

39 

40from .usage import _get_current_rusage, get_current_mem_usage, get_peak_mem_usage 

41 

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

43 from .logging import LsstLoggers 

44 

45 

46_LOG = logging.getLogger(__name__) 

47 

48 

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

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

51 

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

53 

54 Parameters 

55 ---------- 

56 metadata : `dict`-like, optional 

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

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

59 name : `str` 

60 The key to use in the metadata dictionary. 

61 value : Any 

62 Value to store in the list. 

63 """ 

64 try: 

65 try: 

66 # PropertySet should always prefer LongLong for integers 

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

68 except (TypeError, AttributeError): 

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

70 except AttributeError: 

71 pass 

72 else: 

73 return 

74 

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

76 if name not in metadata: 

77 metadata[name] = [] 

78 metadata[name].append(value) 

79 

80 

81def _find_outside_stacklevel() -> int: 

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

83 module. 

84 

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

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

87 

88 Returns 

89 ------- 

90 stacklevel : `int` 

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

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

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

94 

95 Notes 

96 ----- 

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

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

99 keyword parameter ``stacklevel``. 

100 """ 

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

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

103 module = inspect.getmodule(s.frame) 

104 if module is None: 

105 # Stack frames sometimes hang around so explicilty delete. 

106 del s 

107 continue 

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

109 # 0 will be this function. 

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

111 # and so does not need adjustment. 

112 stacklevel = i 

113 break 

114 # Stack frames sometimes hang around so explicilty delete. 

115 del s 

116 

117 return stacklevel 

118 

119 

120def logPairs( 

121 obj: Any, 

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

123 logLevel: int = logging.DEBUG, 

124 metadata: Optional[MutableMapping] = None, 

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

126 stacklevel: Optional[int] = None, 

127) -> None: 

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

129 

130 Parameters 

131 ---------- 

132 obj : `object` 

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

134 

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

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

137 entries to a list. 

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

139 

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

141 or this function will do nothing. 

142 pairs : sequence 

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

144 logLevel : `int, optional 

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

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

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

148 logger : `logging.Logger` 

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

150 stacklevel : `int`, optional 

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

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

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

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

155 1 to account for this caller. 

156 """ 

157 if obj is not None: 

158 if metadata is None: 

159 try: 

160 metadata = obj.metadata 

161 except AttributeError: 

162 pass 

163 if logger is None: 

164 try: 

165 logger = obj.log 

166 except AttributeError: 

167 pass 

168 strList = [] 

169 for name, value in pairs: 

170 if metadata is not None: 

171 _add_to_metadata(metadata, name, value) 

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

173 if logger is not None: 

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

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

176 # message will be issued. 

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

178 if timer_logger.isEnabledFor(logLevel): 

179 if stacklevel is None: 

180 stacklevel = _find_outside_stacklevel() 

181 else: 

182 # Account for the caller stack. 

183 stacklevel += 1 

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

185 

186 

187def logInfo( 

188 obj: Any, 

189 prefix: str, 

190 logLevel: int = logging.DEBUG, 

191 metadata: Optional[MutableMapping] = None, 

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

193 stacklevel: Optional[int] = None, 

194) -> None: 

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

196 

197 Parameters 

198 ---------- 

199 obj : `object` 

200 An object with both or one these two attributes: 

201 

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

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

204 entries to a list. 

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

206 

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

208 or this function will do nothing. 

209 prefix : `str` 

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

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

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

213 logLevel : optional 

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

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

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

217 logger : `logging.Logger` 

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

219 stacklevel : `int`, optional 

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

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

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

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

224 1 to account for this caller. 

225 

226 Notes 

227 ----- 

228 Logged items include: 

229 

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

231 timestamps). 

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

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

234 - ``MaxRss``: maximum resident set size. Always in bytes. 

235 

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

237 processes are excluded. 

238 

239 The metadata will be updated with a ``__version__`` field to indicate the 

240 version of the items stored. If there is no version number it is assumed 

241 to be version 0. 

242 

243 * Version 0: ``MaxResidentSetSize`` units are platform-dependent. 

244 * Version 1: ``MaxResidentSetSize`` will be stored in bytes. 

245 """ 

246 if metadata is None and obj is not None: 

247 try: 

248 metadata = obj.metadata 

249 except AttributeError: 

250 pass 

251 if metadata is not None: 

252 # Log messages already have timestamps. 

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

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

255 

256 # Force a version number into the metadata. 

257 # v1: Ensure that max_rss field is always bytes. 

258 metadata["__version__"] = 1 

259 if stacklevel is not None: 

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

261 # are going one down in the stack. 

262 stacklevel += 1 

263 

264 usage = _get_current_rusage() 

265 logPairs( 

266 obj=obj, 

267 pairs=[(prefix + k[0].upper() + k[1:], v) for k, v in usage.dict().items()], 

268 logLevel=logLevel, 

269 metadata=metadata, 

270 logger=logger, 

271 stacklevel=stacklevel, 

272 ) 

273 

274 

275def timeMethod( 

276 _func: Optional[Any] = None, 

277 *, 

278 metadata: Optional[MutableMapping] = None, 

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

280 logLevel: int = logging.DEBUG, 

281) -> Callable: 

282 """Measure duration of a method. 

283 

284 Parameters 

285 ---------- 

286 func 

287 The method to wrap. 

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

289 Metadata to use as override if the instance object attached 

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

291 logger : `logging.Logger`, optional 

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

293 have a ``log`` property. 

294 logLevel : `int`, optional 

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

296 

297 Notes 

298 ----- 

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

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

301 

302 .. warning:: 

303 

304 This decorator only works with instance methods of any class 

305 with these attributes: 

306 

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

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

309 list. 

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

311 

312 Examples 

313 -------- 

314 To use: 

315 

316 .. code-block:: python 

317 

318 import lsst.utils as utils 

319 import lsst.pipe.base as pipeBase 

320 class FooTask(pipeBase.Task): 

321 pass 

322 

323 @utils.timeMethod 

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

325 pass 

326 """ 

327 

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

329 @functools.wraps(func) 

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

331 # Adjust the stacklevel to account for the wrappers. 

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

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

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

335 stacklevel = 2 

336 logInfo( 

337 obj=self, 

338 prefix=func.__name__ + "Start", 

339 metadata=metadata, 

340 logger=logger, 

341 logLevel=logLevel, 

342 stacklevel=stacklevel, 

343 ) 

344 try: 

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

346 finally: 

347 logInfo( 

348 obj=self, 

349 prefix=func.__name__ + "End", 

350 metadata=metadata, 

351 logger=logger, 

352 logLevel=logLevel, 

353 stacklevel=stacklevel, 

354 ) 

355 return res 

356 

357 return timeMethod_wrapper 

358 

359 if _func is None: 

360 return decorator_timer 

361 else: 

362 return decorator_timer(_func) 

363 

364 

365@contextmanager 

366def time_this( 

367 log: Optional[LsstLoggers] = None, 

368 msg: Optional[str] = None, 

369 level: int = logging.DEBUG, 

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

371 args: Iterable[Any] = (), 

372 mem_usage: bool = False, 

373 mem_child: bool = False, 

374 mem_unit: u.Quantity = u.byte, 

375 mem_fmt: str = ".0f", 

376) -> Iterator[None]: 

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

378 

379 Parameters 

380 ---------- 

381 log : `logging.Logger`, optional 

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

383 be used if none is given. 

384 msg : `str`, optional 

385 Context to include in log message. 

386 level : `int`, optional 

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

388 code block raises an exception the log message will automatically 

389 switch to level ERROR. 

390 prefix : `str`, optional 

391 Prefix to use to prepend to the supplied logger to 

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

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

394 args : iterable of any 

395 Additional parameters passed to the log command that should be 

396 written to ``msg``. 

397 mem_usage : `bool`, optional 

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

399 Defaults, to False. 

400 mem_child : `bool`, optional 

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

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

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

404 mem_fmt : `str`, optional 

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

406 Defaults to '.0f'. 

407 """ 

408 if log is None: 

409 log = logging.getLogger() 

410 if prefix: 

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

412 log = logging.getLogger(log_name) 

413 

414 success = False 

415 start = time.time() 

416 if mem_usage: 

417 current_usages_start = get_current_mem_usage() 

418 peak_usages_start = get_peak_mem_usage() 

419 

420 try: 

421 yield 

422 success = True 

423 finally: 

424 end = time.time() 

425 

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

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

428 # the None message to empty string. 

429 if msg is None: 

430 msg = "" 

431 

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

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

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

435 

436 if not success: 

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

438 # this. 

439 level = logging.ERROR 

440 

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

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

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

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

445 if mem_usage and log.isEnabledFor(level): 

446 current_usages_end = get_current_mem_usage() 

447 peak_usages_end = get_peak_mem_usage() 

448 

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

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

451 

452 current_usage = current_usages_end[0] 

453 current_delta = current_deltas[0] 

454 peak_delta = peak_deltas[0] 

455 if mem_child: 

456 current_usage += current_usages_end[1] 

457 current_delta += current_deltas[1] 

458 peak_delta += peak_deltas[1] 

459 

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

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

462 mem_unit = u.byte 

463 

464 msg += ( 

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

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

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

468 ) 

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