File size: 11,684 Bytes
e11e4fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
"""
Lightweight, hierarchical timers for profiling sections of code.

Example:

@timed
def foo(t):
    time.sleep(t)

def main():
    for i in range(3):
        foo(i + 1)
    with hierarchical_timer("context"):
        foo(1)

    print(get_timer_tree())

This would produce a timer tree like
    (root)
        "foo"
        "context"
            "foo"

The total time and counts are tracked for each block of code; in this example "foo" and "context.foo" are considered
distinct blocks, and are tracked separately.

The decorator and contextmanager are equivalent; the context manager may be more useful if you want more control
over the timer name, or are splitting up multiple sections of a large function.
"""

import math
import sys
import time
import threading

from contextlib import contextmanager
from typing import Any, Callable, Dict, Generator, Optional, TypeVar

TIMER_FORMAT_VERSION = "0.1.0"


class TimerNode:
    """
    Represents the time spent in a block of code.
    """

    __slots__ = ["children", "total", "count", "is_parallel"]

    def __init__(self):
        # Note that since dictionary keys are the node names, we don't explicitly store the name on the TimerNode.
        self.children: Dict[str, TimerNode] = {}
        self.total: float = 0.0
        self.count: int = 0
        self.is_parallel = False

    def get_child(self, name: str) -> "TimerNode":
        """
        Get the child node corresponding to the name (and create if it doesn't already exist).
        """
        child = self.children.get(name)
        if child is None:
            child = TimerNode()
            self.children[name] = child
        return child

    def add_time(self, elapsed: float) -> None:
        """
        Accumulate the time spent in the node (and increment the count).
        """
        self.total += elapsed
        self.count += 1

    def merge(
        self, other: "TimerNode", root_name: str = None, is_parallel: bool = True
    ) -> None:
        """
        Add the other node to this node, then do the same recursively on its children.
        :param other: The other node to merge
        :param root_name: Optional name of the root node being merged.
        :param is_parallel: Whether or not the code block was executed in parallel.
        :return:
        """
        if root_name:
            node = self.get_child(root_name)
        else:
            node = self

        node.total += other.total
        node.count += other.count
        node.is_parallel |= is_parallel
        for other_child_name, other_child_node in other.children.items():
            child = node.get_child(other_child_name)
            child.merge(other_child_node, is_parallel=is_parallel)


class GaugeNode:
    """
    Tracks the most recent value of a metric. This is analogous to gauges in statsd.
    """

    __slots__ = ["value", "min_value", "max_value", "count", "_timestamp"]

    def __init__(self, value: float):
        self.value = value
        self.min_value = value
        self.max_value = value
        self.count = 1
        # Internal timestamp so we can determine priority.
        self._timestamp = time.time()

    def update(self, new_value: float) -> None:
        self.min_value = min(self.min_value, new_value)
        self.max_value = max(self.max_value, new_value)
        self.value = new_value
        self.count += 1
        self._timestamp = time.time()

    def merge(self, other: "GaugeNode") -> None:
        if self._timestamp < other._timestamp:
            # Keep the "later" value
            self.value = other.value
            self._timestamp = other._timestamp
        self.min_value = min(self.min_value, other.min_value)
        self.max_value = max(self.max_value, other.max_value)
        self.count += other.count

    def as_dict(self) -> Dict[str, float]:
        return {
            "value": self.value,
            "min": self.min_value,
            "max": self.max_value,
            "count": self.count,
        }


class TimerStack:
    """
    Tracks all the time spent. Users shouldn't use this directly, they should use the contextmanager below to make
    sure that pushes and pops are already matched.
    """

    __slots__ = ["root", "stack", "start_time", "gauges", "metadata"]

    def __init__(self):
        self.root = TimerNode()
        self.stack = [self.root]
        self.start_time = time.perf_counter()
        self.gauges: Dict[str, GaugeNode] = {}
        self.metadata: Dict[str, str] = {}
        self._add_default_metadata()

    def reset(self):
        self.root = TimerNode()
        self.stack = [self.root]
        self.start_time = time.perf_counter()
        self.gauges: Dict[str, GaugeNode] = {}
        self.metadata: Dict[str, str] = {}
        self._add_default_metadata()

    def push(self, name: str) -> TimerNode:
        """
        Called when entering a new block of code that is timed (e.g. with a contextmanager).
        """
        current_node: TimerNode = self.stack[-1]
        next_node = current_node.get_child(name)
        self.stack.append(next_node)
        return next_node

    def pop(self) -> None:
        """
        Called when exiting a new block of code that is timed (e.g. with a contextmanager).
        """
        self.stack.pop()

    def get_root(self) -> TimerNode:
        """
        Update the total time and count of the root name, and return it.
        """
        root = self.root
        root.total = time.perf_counter() - self.start_time
        root.count = 1
        return root

    def get_timing_tree(self, node: TimerNode = None) -> Dict[str, Any]:
        """
        Recursively build a tree of timings, suitable for output/archiving.
        """
        res: Dict[str, Any] = {}
        if node is None:
            # Special case the root - total is time since it was created, and count is 1
            node = self.get_root()
            res["name"] = "root"

            # Only output gauges at top level
            if self.gauges:
                res["gauges"] = self._get_gauges()

            if self.metadata:
                self.metadata["end_time_seconds"] = str(int(time.time()))
                res["metadata"] = self.metadata

        res["total"] = node.total
        res["count"] = node.count

        if node.is_parallel:
            # Note when the block ran in parallel, so that it's less confusing that a timer is less that its children.
            res["is_parallel"] = True

        child_total = 0.0
        child_dict = {}
        for child_name, child_node in node.children.items():
            child_res: Dict[str, Any] = self.get_timing_tree(child_node)
            child_dict[child_name] = child_res
            child_total += child_res["total"]

        # "self" time is total time minus all time spent on children
        res["self"] = max(0.0, node.total - child_total)
        if child_dict:
            res["children"] = child_dict

        return res

    def set_gauge(self, name: str, value: float) -> None:
        if math.isnan(value):
            return
        gauge_node = self.gauges.get(name)
        if gauge_node:
            gauge_node.update(value)
        else:
            self.gauges[name] = GaugeNode(value)

    def add_metadata(self, key: str, value: str) -> None:
        self.metadata[key] = value

    def _get_gauges(self) -> Dict[str, Dict[str, float]]:
        gauges = {}
        for gauge_name, gauge_node in self.gauges.items():
            gauges[gauge_name] = gauge_node.as_dict()
        return gauges

    def _add_default_metadata(self):
        self.metadata["timer_format_version"] = TIMER_FORMAT_VERSION
        self.metadata["start_time_seconds"] = str(int(time.time()))
        self.metadata["python_version"] = sys.version
        self.metadata["command_line_arguments"] = " ".join(sys.argv)


# Maintain a separate "global" timer per thread, so that they don't accidentally conflict with each other.
_thread_timer_stacks: Dict[int, TimerStack] = {}


def _get_thread_timer() -> TimerStack:
    ident = threading.get_ident()
    if ident not in _thread_timer_stacks:
        timer_stack = TimerStack()
        _thread_timer_stacks[ident] = timer_stack
    return _thread_timer_stacks[ident]


def get_timer_stack_for_thread(t: threading.Thread) -> Optional[TimerStack]:
    if t.ident is None:
        # Thread hasn't started, shouldn't ever happen
        return None
    return _thread_timer_stacks.get(t.ident)


@contextmanager
def hierarchical_timer(name: str, timer_stack: TimerStack = None) -> Generator:
    """
    Creates a scoped timer around a block of code. This time spent will automatically be incremented when
    the context manager exits.
    """
    timer_stack = timer_stack or _get_thread_timer()
    timer_node = timer_stack.push(name)
    start_time = time.perf_counter()

    try:
        # The wrapped code block will run here.
        yield timer_node
    finally:
        # This will trigger either when the context manager exits, or an exception is raised.
        # We'll accumulate the time, and the exception (if any) gets raised automatically.
        elapsed = time.perf_counter() - start_time
        timer_node.add_time(elapsed)
        timer_stack.pop()


# This is used to ensure the signature of the decorated function is preserved
# See also https://github.com/python/mypy/issues/3157
FuncT = TypeVar("FuncT", bound=Callable[..., Any])


def timed(func: FuncT) -> FuncT:
    """
    Decorator for timing a function or method. The name of the timer will be the qualified name of the function.
    Usage:
        @timed
        def my_func(x, y):
            return x + y
    Note that because this doesn't take arguments, the global timer stack is always used.
    """

    def wrapped(*args, **kwargs):
        with hierarchical_timer(func.__qualname__):
            return func(*args, **kwargs)

    return wrapped  # type: ignore


def set_gauge(name: str, value: float, timer_stack: TimerStack = None) -> None:
    """
    Updates the value of the gauge (or creates it if it hasn't been set before).
    """
    timer_stack = timer_stack or _get_thread_timer()
    timer_stack.set_gauge(name, value)


def merge_gauges(gauges: Dict[str, GaugeNode], timer_stack: TimerStack = None) -> None:
    """
    Merge the gauges from another TimerStack with the provided one (or the
    current thread's stack if none is provided).
    :param gauges:
    :param timer_stack:
    :return:
    """
    timer_stack = timer_stack or _get_thread_timer()
    for n, g in gauges.items():
        if n in timer_stack.gauges:
            timer_stack.gauges[n].merge(g)
        else:
            timer_stack.gauges[n] = g


def add_metadata(key: str, value: str, timer_stack: TimerStack = None) -> None:
    timer_stack = timer_stack or _get_thread_timer()
    timer_stack.add_metadata(key, value)


def get_timer_tree(timer_stack: TimerStack = None) -> Dict[str, Any]:
    """
    Return the tree of timings from the TimerStack as a dictionary (or the
     current thread's  stack if none is provided)
    """
    timer_stack = timer_stack or _get_thread_timer()
    return timer_stack.get_timing_tree()


def get_timer_root(timer_stack: TimerStack = None) -> TimerNode:
    """
    Get the root TimerNode of the timer_stack (or the current thread's
    TimerStack if not specified)
    """
    timer_stack = timer_stack or _get_thread_timer()
    return timer_stack.get_root()


def reset_timers(timer_stack: TimerStack = None) -> None:
    """
    Reset the timer_stack (or the current thread's TimerStack if not specified)
    """
    timer_stack = timer_stack or _get_thread_timer()
    timer_stack.reset()