blob: 318b0f92b403b7a6d794ac08e6c3946fa54c6ed8 [file] [log] [blame]
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import contextlib
import json
import os
import time
from abc import ABC, abstractmethod
from typing import Any, Generator, Generic, TypeVar
P = TypeVar("P")
R = TypeVar("R")
class ActionTimer(ABC, Generic[P, R]):
"""Base class that times an action and records the time to a file.
This utility class allows a subclass to execute an action 1 or more times,
capture the wallclock time taken for each iteration, and record them to a
file.
Duration measurements will be recorded to a Fuchsia freeform metrics file,
with the suffix `freeform.json`.
"""
def execute(
self,
test_suite: str,
label: str,
results_path: str,
repetitions: int = 1,
) -> None:
"""Executes and measures the duration of an action.
Args:
test_suite: the name of the test suite for the output
freeform.json.
label: the label for the test within the test suite.
results_path: the path where the freeform.json file will be
written.
repetitions: the number of times to execute an action.
"""
time_ns: list[int] = []
self.additional_json: list[Any] = []
for _ in range(repetitions):
pre_result = self.pre_action()
start = time.monotonic_ns()
result = self.action(pre_result)
dur = time.monotonic_ns() - start
time_ns.append(dur)
self.post_action(result)
_write_to_file(
test_suite, label, results_path, time_ns, self.additional_json
)
@abstractmethod
def pre_action(self) -> P:
"""Set up code to execute before the action.
The duration of this method won't be measured.
The output of this method will be passed to the action when executing.
"""
@abstractmethod
def action(self, pre_result: P) -> R:
"""The action which duration will be measured.
The duration of this method will be measured.
The output of this method will be passed to the post_action method when
executing.
Args:
pre_result: the output of self.pre_action.
"""
@abstractmethod
def post_action(self, step_output: R) -> None:
"""Executed after the action.
The duration of this method won't be measured.
Args:
step_output: the output of self.action.
"""
def _write_to_file(
test_suite: str,
label: str,
results_path: str,
time_ns: list[int],
additional_json: list[Any] | None = None,
) -> None:
fuchsiaperf_data = [
{
"test_suite": test_suite,
"label": label,
"values": time_ns,
"unit": "ns",
},
]
if additional_json:
fuchsiaperf_data.extend(additional_json)
test_perf_file = os.path.join(
results_path, f"results.{label.lower()}.freeform.json"
)
with open(test_perf_file, "w") as f:
json.dump(fuchsiaperf_data, f, indent=4)
class _Recorder:
def __init__(self) -> None:
self.time_ns: list[int] = []
@contextlib.contextmanager
def record_iteration(self) -> Generator[None, None, None]:
"""Time how long this context is open and remember it."""
start = time.monotonic_ns()
try:
yield
finally:
self.time_ns.append(time.monotonic_ns() - start)
@contextlib.contextmanager
def timer(
test_suite: str,
label: str,
results_path: str,
) -> Generator[_Recorder, None, None]:
"""Supports timing one or more actions and reporting the data as a set of freeform metrics.
While inside the context created by this context manager, the caller can measure the time
taken by one or more code blocks. The measurements are reported out as freeform metrics when
exiting the context.
Measurements are captured using a nested context:
```
with action_timer.timer("suite", "label", "/path/to/output") as t:
for _ in range(10):
with t.record_iteration():
intermediate = do_first_thing()
side_effect_stuff()
result = do_second_thing(intermediate)
# do stuff with result, if you want
```
Args:
test_suite: The name of the test suite during which these measurements are captured.
label: An explanatory name for this set of measurements.
results_path: A directory in which to emit the metrics.
"""
recorder = _Recorder()
try:
yield recorder
finally:
_write_to_file(test_suite, label, results_path, recorder.time_ns)