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

202 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-26 08:43 +0000

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 

15from __future__ import annotations 

16 

17__all__ = ["duration_from_timeMethod", "logInfo", "profile", "timeMethod", "time_this"] 

18 

19import dataclasses 

20import datetime 

21import functools 

22import logging 

23import os 

24import platform 

25import sys 

26import time 

27import traceback 

28from collections.abc import Callable, Collection, Iterable, Iterator, MutableMapping 

29from contextlib import contextmanager, suppress 

30from typing import TYPE_CHECKING, Any, TypeVar, overload 

31 

32from astropy import units as u 

33 

34from .introspection import find_outside_stacklevel 

35from .logging import LsstLoggers, _is_structlog_logger 

36from .usage import _get_current_rusage, get_current_mem_usage, get_peak_mem_usage 

37 

38if TYPE_CHECKING: 

39 import cProfile 

40 

41 

42_LOG = logging.getLogger(__name__) 

43 

44 

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

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

47 

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

49 

50 Parameters 

51 ---------- 

52 metadata : `dict`-like, optional 

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

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

55 name : `str` 

56 The key to use in the metadata dictionary. 

57 value : Any 

58 Value to store in the list. 

59 """ 

60 try: 

61 try: 

62 # PropertySet should always prefer LongLong for integers 

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

64 except (TypeError, AttributeError): 

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

66 except AttributeError: 

67 pass 

68 else: 

69 return 

70 

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

72 if name not in metadata: 

73 metadata[name] = [] 

74 metadata[name].append(value) 

75 

76 

77def logPairs( 

78 obj: Any, 

79 pairs: Collection[tuple[str, Any]], 

80 logLevel: int = logging.DEBUG, 

81 metadata: MutableMapping | None = None, 

82 logger: LsstLoggers | None = None, 

83 stacklevel: int | None = None, 

84) -> None: 

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

86 

87 Parameters 

88 ---------- 

89 obj : `object` 

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

91 

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

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

94 entries to a list. 

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

96 

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

98 or this function will do nothing. 

99 pairs : sequence 

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

101 logLevel : `int, optional 

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

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

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

105 logger : `logging.Logger` or `lsst.utils.logging.LsstLogAdapter` 

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

107 stacklevel : `int`, optional 

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

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

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

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

112 1 to account for this caller. 

113 """ 

114 if obj is not None: 

115 if metadata is None: 

116 with suppress(AttributeError): 

117 metadata = obj.metadata 

118 

119 if logger is None: 

120 with suppress(AttributeError): 

121 logger = obj.log 

122 

123 strList = [] 

124 for name, value in pairs: 

125 if metadata is not None: 

126 _add_to_metadata(metadata, name, value) 

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

128 if logger is not None: 

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

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

131 # message will be issued. 

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

133 if timer_logger.isEnabledFor(logLevel): 

134 if stacklevel is None: 

135 stacklevel = find_outside_stacklevel("lsst.utils") 

136 else: 

137 # Account for the caller stack. 

138 stacklevel += 1 

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

140 

141 

142def logInfo( 

143 obj: Any, 

144 prefix: str, 

145 logLevel: int = logging.DEBUG, 

146 metadata: MutableMapping | None = None, 

147 logger: LsstLoggers | None = None, 

148 stacklevel: int | None = None, 

149) -> None: 

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

151 

152 Parameters 

153 ---------- 

154 obj : `object` 

155 An object with both or one these two attributes: 

156 

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

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

159 entries to a list. 

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

161 

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

163 or this function will do nothing. 

164 prefix : `str` 

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

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

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

168 logLevel : `int`, optional 

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

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

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

172 logger : `logging.Logger` or `lsst.utils.logging.LsstLogAdapter` 

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

174 stacklevel : `int`, optional 

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

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

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

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

179 1 to account for this caller. 

180 

181 Notes 

182 ----- 

183 Logged items include: 

184 

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

186 timestamps). 

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

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

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

190 

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

192 processes are excluded. 

193 

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

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

196 to be version 0. 

197 

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

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

200 """ 

201 if metadata is None and obj is not None: 

202 with suppress(AttributeError): 

203 metadata = obj.metadata 

204 

205 if metadata is not None: 

206 # Log messages already have timestamps. 

207 if sys.version_info < (3, 11, 0): 

208 now = datetime.datetime.utcnow() 

209 else: 

210 now = datetime.datetime.now(datetime.UTC) 

211 utcStr = now.isoformat() 

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

213 

214 # Force a version number into the metadata. 

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

216 metadata["__version__"] = 1 

217 

218 # Add node information. 

219 metadata["nodeName"] = platform.node() 

220 if stacklevel is not None: 

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

222 # are going one down in the stack. 

223 stacklevel += 1 

224 

225 usage = _get_current_rusage() 

226 logPairs( 

227 obj=obj, 

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

229 logLevel=logLevel, 

230 metadata=metadata, 

231 logger=logger, 

232 stacklevel=stacklevel, 

233 ) 

234 

235 

236_F = TypeVar("_F", bound=Callable) 

237 

238 

239@overload 

240def timeMethod(_func: _F) -> _F: ... 240 ↛ exitline 240 didn't return from function 'timeMethod' because

241 

242 

243@overload 

244def timeMethod( 244 ↛ exitline 244 didn't return from function 'timeMethod' because

245 *, 

246 metadata: MutableMapping | None = None, 

247 logger: LsstLoggers | None = None, 

248 logLevel: int = logging.DEBUG, 

249) -> Callable[[_F], _F]: ... 

250 

251 

252def timeMethod( 

253 _func: Any | None = None, 

254 *, 

255 metadata: MutableMapping | None = None, 

256 logger: LsstLoggers | None = None, 

257 logLevel: int = logging.DEBUG, 

258) -> Callable: 

259 """Measure duration of a method. 

260 

261 Set LSST_UTILS_DISABLE_TIMER in your environment to disable this method. 

262 

263 Parameters 

264 ---------- 

265 _func : `~collections.abc.Callable` or `None` 

266 The method to wrap. 

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

268 Metadata to use as override if the instance object attached 

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

270 logger : `logging.Logger` or `lsst.utils.logging.LsstLogAdapter`, optional 

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

272 have a ``log`` property. 

273 logLevel : `int`, optional 

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

275 

276 Notes 

277 ----- 

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

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

280 

281 .. warning:: 

282 

283 This decorator only works with instance methods of any class 

284 with these attributes: 

285 

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

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

288 list. 

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

290 

291 Examples 

292 -------- 

293 To use: 

294 

295 .. code-block:: python 

296 

297 import lsst.utils as utils 

298 import lsst.pipe.base as pipeBase 

299 class FooTask(pipeBase.Task): 

300 pass 

301 

302 @utils.timeMethod 

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

304 pass 

305 """ 

306 

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

308 @functools.wraps(func) 

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

310 # Adjust the stacklevel to account for the wrappers. 

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

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

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

314 stacklevel = 2 

315 logInfo( 

316 obj=self, 

317 prefix=func.__name__ + "Start", 

318 metadata=metadata, 

319 logger=logger, 

320 logLevel=logLevel, 

321 stacklevel=stacklevel, 

322 ) 

323 try: 

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

325 finally: 

326 logInfo( 

327 obj=self, 

328 prefix=func.__name__ + "End", 

329 metadata=metadata, 

330 logger=logger, 

331 logLevel=logLevel, 

332 stacklevel=stacklevel, 

333 ) 

334 return res 

335 

336 return timeMethod_wrapper 

337 

338 if "LSST_UTILS_DISABLE_TIMER" in os.environ: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 if _func is not None: 

340 return _func 

341 else: 

342 

343 def pass_through(func: Callable) -> Callable: 

344 """Pass through decorator. 

345 

346 This decorator will not appear in the call stack. 

347 """ 

348 return func 

349 

350 return pass_through 

351 

352 if _func is None: 

353 return decorator_timer 

354 else: 

355 return decorator_timer(_func) 

356 

357 

358@dataclasses.dataclass 

359class _TimerResult: 

360 duration: float = 0.0 

361 """Duration of context in seconds.""" 

362 mem_current_usage: float | None = None 

363 """Memory usage at end of context in requested units.""" 

364 mem_peak_delta: float | None = None 

365 """Peak differential between entering and leaving context.""" 

366 mem_current_delta: float | None = None 

367 """Difference in usage between entering and leaving context.""" 

368 

369 

370@contextmanager 

371def time_this( 

372 log: LsstLoggers | None = None, 

373 msg: str | None = None, 

374 level: int = logging.DEBUG, 

375 prefix: str | None = "timer", 

376 args: Iterable[Any] = (), 

377 kwargs: dict[str, Any] | None = None, 

378 mem_usage: bool = False, 

379 mem_child: bool = False, 

380 mem_unit: u.Unit = u.byte, 

381 mem_fmt: str = ".0f", 

382 force_mem_usage: bool = False, 

383) -> Iterator[_TimerResult]: 

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

385 

386 Parameters 

387 ---------- 

388 log : `logging.Logger`, optional 

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

390 be used if none is given. Is also allowed to be a `structlog` bound 

391 logger. 

392 msg : `str`, optional 

393 Context to include in log message. 

394 level : `int`, optional 

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

396 code block raises an exception the log message will include some 

397 information about the exception that occurred. 

398 prefix : `str`, optional 

399 Prefix to use to prepend to the supplied logger to 

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

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

402 args : `~collections.abc.Iterable` [ `typing.Any` ] 

403 Additional parameters passed to the log command that should be 

404 written to ``msg``. 

405 kwargs : `dict`, optional 

406 Additional keyword parameters passed to the log command. If a Structlog 

407 is used then these will be added to the structured data. Otherwise 

408 they will be converted to a single string for inclusion in the log 

409 message. 

410 mem_usage : `bool`, optional 

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

412 Defaults, to False. Does nothing if the log message will not be 

413 generated. 

414 mem_child : `bool`, optional 

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

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

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

418 mem_fmt : `str`, optional 

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

420 Defaults to '.0f'. 

421 force_mem_usage : `bool`, optional 

422 If `True` memory usage is always calculated even if the log message 

423 will not be delivered. Use this if you want the information recorded 

424 by the context manager. 

425 

426 Yields 

427 ------ 

428 timer_result : `_TimerResult` 

429 Simple data class containing the duration of the block in seconds via 

430 the ``duration`` property. 

431 """ 

432 if log is None: 

433 log = logging.getLogger() 

434 is_structlog = _is_structlog_logger(log) 

435 if prefix and not is_structlog: 

436 # Struct log loggers do not have a name property and so the prefix 

437 # is not applied to them. 

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

439 log = logging.getLogger(log_name) 

440 

441 # Some structured data that can be used if we have been given a 

442 # structlog logger. 

443 structured_args: dict[str, Any] = {} 

444 

445 start = time.time() 

446 

447 if mem_usage and not log.isEnabledFor(level): 

448 mem_usage = False 

449 if force_mem_usage: 

450 mem_usage = True 

451 

452 if mem_usage: 

453 current_usages_start = get_current_mem_usage() 

454 peak_usages_start = get_peak_mem_usage() 

455 

456 timer_result = _TimerResult() 

457 errmsg = "" 

458 try: 

459 yield timer_result 

460 except BaseException as e: 

461 frame, lineno = list(traceback.walk_tb(e.__traceback__))[-1] 

462 errmsg = f"{e!r} @ {frame.f_code.co_filename}:{lineno}" 

463 raise 

464 finally: 

465 end = time.time() 

466 

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

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

469 # the None message to empty string. 

470 if msg is None: 

471 msg = "" 

472 

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

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

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

476 

477 duration = end - start 

478 timer_result.duration = duration 

479 

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

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

482 params += (": " if msg else "", duration) 

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

484 structured_args["duration"] = duration 

485 if errmsg: 

486 params += (f" (timed code triggered exception of {errmsg!r})",) 

487 msg += "%s" 

488 if mem_usage: 

489 current_usages_end = get_current_mem_usage() 

490 peak_usages_end = get_peak_mem_usage() 

491 

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

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

494 

495 current_usage = current_usages_end[0] 

496 current_delta = current_deltas[0] 

497 peak_delta = peak_deltas[0] 

498 if mem_child: 

499 current_usage += current_usages_end[1] 

500 current_delta += current_deltas[1] 

501 peak_delta += peak_deltas[1] 

502 

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

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

505 mem_unit = u.byte 

506 

507 current_usage = current_usage.to(mem_unit) 

508 current_delta = current_delta.to(mem_unit) 

509 peak_delta = peak_delta.to(mem_unit) 

510 

511 timer_result.mem_current_usage = float(current_usage.value) 

512 timer_result.mem_current_delta = float(current_delta.value) 

513 timer_result.mem_peak_delta = float(peak_delta.value) 

514 

515 msg += ( 

516 f"; current memory usage: {current_usage:{mem_fmt}}" 

517 f", delta: {current_delta:{mem_fmt}}" 

518 f", peak delta: {peak_delta:{mem_fmt}}" 

519 ) 

520 structured_args["mem_current_usage"] = float(current_usage.value) 

521 structured_args["mem_current_delta"] = float(current_delta.value) 

522 structured_args["mem_peak_delta"] = float(peak_delta.value) 

523 if not is_structlog: 

524 # Can only use the structured content if we have structlog logger 

525 # but stacklevel is only supported by standard loggers. 

526 structured_args = {"stacklevel": 3} 

527 if kwargs is not None: 

528 msg += " %s" 

529 params += ("; ".join(f"{k}={v!r}" for k, v in kwargs.items()),) 

530 elif kwargs: 

531 structured_args.update(kwargs) 

532 

533 log.log(level, msg, *params, **structured_args) 

534 

535 

536@contextmanager 

537def profile(filename: str | None, log: LsstLoggers | None = None) -> Iterator[cProfile.Profile | None]: 

538 """Profile the enclosed code block and save the result to a file. 

539 

540 Parameters 

541 ---------- 

542 filename : `str` or `None` 

543 Filename to which to write profile (profiling disabled if `None` or 

544 empty string). 

545 log : `logging.Logger` or `lsst.utils.logging.LsstLogAdapter`, optional 

546 Log object for logging the profile operations. 

547 

548 Yields 

549 ------ 

550 prof : `cProfile.Profile` or `None` 

551 If profiling is enabled, the context manager returns the 

552 `cProfile.Profile` object (otherwise it returns `None`), 

553 which allows additional control over profiling. 

554 

555 Examples 

556 -------- 

557 You can obtain the `cProfile.Profile` object using the "as" clause, e.g.: 

558 

559 .. code-block:: python 

560 

561 with profile(filename) as prof: 

562 runYourCodeHere() 

563 

564 The output cumulative profile can be printed with a command-line like: 

565 

566 .. code-block:: bash 

567 

568 python -c 'import pstats; \ 

569 pstats.Stats("<filename>").sort_stats("cumtime").print_stats(30)' 

570 """ 

571 if not filename: 

572 # Nothing to do 

573 yield None 

574 return 

575 from cProfile import Profile 

576 

577 profile = Profile() 

578 if log is not None: 

579 log.info("Enabling cProfile profiling") 

580 profile.enable() 

581 yield profile 

582 profile.disable() 

583 profile.dump_stats(filename) 

584 if log is not None: 

585 log.info("cProfile stats written to %s", filename) 

586 

587 

588def duration_from_timeMethod( 

589 metadata: MutableMapping | None, method_name: str, clock: str = "Cpu" 

590) -> float | None: 

591 """Parse the metadata entries from ``timeMethod`` and return a duration. 

592 

593 Parameters 

594 ---------- 

595 metadata : `collections.abc.MutableMapping` 

596 The Task metadata that timing metrics were added to. 

597 method_name : `str` 

598 Name of the timed method to extract a duration for. 

599 clock : `str`, optional 

600 Options are "Cpu", "User", "System", or "Utc". 

601 

602 Returns 

603 ------- 

604 duration : `float` 

605 The time elapsed between the start and end of the timed method. 

606 """ 

607 if metadata is None: 

608 return None 

609 if clock.lower() == "utc": 

610 start = metadata[method_name + "StartUtc"] 

611 end = metadata[method_name + "EndUtc"] 

612 else: 

613 start = metadata[method_name + "Start" + clock + "Time"] 

614 end = metadata[method_name + "End" + clock + "Time"] 

615 if isinstance(start, list): 

616 start = start[-1] 

617 if isinstance(end, list): 

618 end = end[-1] 

619 if isinstance(start, str) and isinstance(end, str): 

620 return (datetime.datetime.fromisoformat(end) - datetime.datetime.fromisoformat(start)).total_seconds() 

621 else: 

622 return end - start