Skip to content

Commit a344928

Browse files
authored
Merge pull request #347 from TTsangSC/multi-profiler
ENH: use multiple profiler instances
2 parents d62c880 + 6889534 commit a344928

6 files changed

Lines changed: 162 additions & 45 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Changes
1414
* ENH: Added CLI arguments ``-c`` to ``kernprof`` for (auto-)profiling module/package/inline-script execution instead of that of script files; passing ``'-'`` as the script-file name now also reads from and profiles ``stdin``
1515
* ENH: In Python >=3.11, profiled objects are reported using their qualified name.
1616
* ENH: Highlight final summary using rich if enabled
17+
* ENH: Made it possible to use multiple profiler instances simultaneously
1718

1819
4.2.0
1920
~~~~~

line_profiler/_line_profiler.pyx

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ cdef class LineProfiler:
253253
cdef public double timer_unit
254254
cdef public object threaddata
255255

256+
# This is shared between instances and threads
257+
_all_active_instances = {}
258+
256259
def __init__(self, *functions):
257260
self.functions = []
258261
self.code_hash_map = {}
@@ -319,6 +322,16 @@ cdef class LineProfiler:
319322
def __set__(self, value):
320323
self.threaddata.enable_count = value
321324

325+
# This is shared between instances, but thread-local
326+
property _active_instances:
327+
def __get__(self):
328+
thread_id = threading.get_ident()
329+
try:
330+
return self._all_active_instances[thread_id]
331+
except KeyError:
332+
insts = self._all_active_instances[thread_id] = set()
333+
return insts
334+
322335
def enable_by_count(self):
323336
""" Enable the profiler if it hasn't been enabled before.
324337
"""
@@ -345,8 +358,11 @@ cdef class LineProfiler:
345358
# Register `line_profiler` with `sys.monitoring` in Python 3.12
346359
# and above;
347360
# see: https://docs.python.org/3/library/sys.monitoring.html
348-
_sys_monitoring_register()
349-
PyEval_SetTrace(python_trace_callback, self)
361+
instances = self._active_instances
362+
if not instances:
363+
_sys_monitoring_register()
364+
PyEval_SetTrace(python_trace_callback, instances)
365+
instances.add(self)
350366

351367
@property
352368
def c_code_map(self):
@@ -397,12 +413,15 @@ cdef class LineProfiler:
397413

398414

399415
cpdef disable(self):
416+
instances = self._active_instances
400417
self._c_last_time[threading.get_ident()].clear()
401-
unset_trace()
402-
# Deregister `line_profiler` with `sys.monitoring` in Python
403-
# 3.12 and above;
404-
# see: https://docs.python.org/3/library/sys.monitoring.html
405-
_sys_monitoring_deregister()
418+
instances.discard(self)
419+
if not instances:
420+
unset_trace()
421+
# Deregister `line_profiler` with `sys.monitoring` in Python
422+
# 3.12 and above;
423+
# see: https://docs.python.org/3/library/sys.monitoring.html
424+
_sys_monitoring_deregister()
406425

407426
def get_stats(self):
408427
"""
@@ -443,53 +462,59 @@ cdef class LineProfiler:
443462

444463
@cython.boundscheck(False)
445464
@cython.wraparound(False)
446-
cdef extern int python_trace_callback(object self_, PyFrameObject *py_frame,
465+
cdef extern int python_trace_callback(object instances,
466+
PyFrameObject *py_frame,
447467
int what, PyObject *arg):
448468
"""
449469
The PyEval_SetTrace() callback.
450470
451471
References:
452472
https://github.com/python/cpython/blob/de2a4036/Include/cpython/pystate.h#L16
453473
"""
454-
cdef LineProfiler self
474+
cdef object prof_
475+
cdef LineProfiler prof
455476
cdef object code
456477
cdef LineTime entry
457478
cdef LastTime old
458479
cdef int key
459480
cdef PY_LONG_LONG time
481+
cdef int has_time = 0
460482
cdef int64 code_hash
461483
cdef int64 block_hash
462484
cdef unordered_map[int64, LineTime] line_entries
463485
cdef uint64 linenum
464486

465-
self = <LineProfiler>self_
466-
467487
if what == PyTrace_LINE or what == PyTrace_RETURN:
468488
# Normally we'd need to DECREF the return from get_frame_code, but Cython does that for us
469489
block_hash = hash(get_frame_code(py_frame))
470490

471491
linenum = PyFrame_GetLineNumber(py_frame)
472492
code_hash = compute_line_hash(block_hash, linenum)
473493

474-
if self._c_code_map.count(code_hash):
475-
time = hpTimer()
494+
for prof_ in instances:
495+
prof = <LineProfiler>prof_
496+
if not prof._c_code_map.count(code_hash):
497+
continue
498+
if not has_time:
499+
time = hpTimer()
500+
has_time = 1
476501
ident = threading.get_ident()
477-
if self._c_last_time[ident].count(block_hash):
478-
old = self._c_last_time[ident][block_hash]
479-
line_entries = self._c_code_map[code_hash]
502+
if prof._c_last_time[ident].count(block_hash):
503+
old = prof._c_last_time[ident][block_hash]
504+
line_entries = prof._c_code_map[code_hash]
480505
key = old.f_lineno
481506
if not line_entries.count(key):
482-
self._c_code_map[code_hash][key] = LineTime(code_hash, key, 0, 0)
483-
self._c_code_map[code_hash][key].nhits += 1
484-
self._c_code_map[code_hash][key].total_time += time - old.time
507+
prof._c_code_map[code_hash][key] = LineTime(code_hash, key, 0, 0)
508+
prof._c_code_map[code_hash][key].nhits += 1
509+
prof._c_code_map[code_hash][key].total_time += time - old.time
485510
if what == PyTrace_LINE:
486511
# Get the time again. This way, we don't record much time wasted
487512
# in this function.
488-
self._c_last_time[ident][block_hash] = LastTime(linenum, hpTimer())
489-
elif self._c_last_time[ident].count(block_hash):
513+
prof._c_last_time[ident][block_hash] = LastTime(linenum, hpTimer())
514+
elif prof._c_last_time[ident].count(block_hash):
490515
# We are returning from a function, not executing a line. Delete
491516
# the last_time record. It may have already been deleted if we
492517
# are profiling a generator that is being pumped past its end.
493-
self._c_last_time[ident].erase(self._c_last_time[ident].find(block_hash))
518+
prof._c_last_time[ident].erase(prof._c_last_time[ident].find(block_hash))
494519

495520
return 0

line_profiler/line_profiler.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
inspect its output. This depends on the :py:mod:`line_profiler._line_profiler`
55
Cython backend.
66
"""
7-
import pickle
87
import inspect
98
import linecache
10-
import tempfile
119
import os
10+
import pickle
1211
import sys
12+
import tempfile
1313
from argparse import ArgumentError, ArgumentParser
1414

1515
try:
@@ -66,6 +66,21 @@ def _get_underlying_functions(func):
6666
return [type(func).__call__]
6767

6868

69+
class _WrapperInfo:
70+
"""
71+
Helper object for holding the state of a wrapper function.
72+
73+
Attributes:
74+
func (types.FunctionType):
75+
The function it wraps.
76+
profiler_id (int)
77+
ID of the `LineProfiler`.
78+
"""
79+
def __init__(self, func, profiler_id):
80+
self.func = func
81+
self.profiler_id = profiler_id
82+
83+
6984
class LineProfiler(CLineProfiler, ByCountProfilerMixin):
7085
"""
7186
A profiler that records the execution times of individual lines.
@@ -83,7 +98,6 @@ class LineProfiler(CLineProfiler, ByCountProfilerMixin):
8398
>>> func()
8499
>>> profile.print_stats()
85100
"""
86-
87101
def __call__(self, func):
88102
"""
89103
Decorate a function, method, property, partial object etc. to
@@ -104,12 +118,15 @@ def add_callable(self, func):
104118
Returns:
105119
1 if any function is added to the profiler, 0 otherwise
106120
"""
107-
guard = self._already_wrapped
108-
109121
nadded = 0
110122
for impl in _get_underlying_functions(func):
111-
if guard(impl):
123+
info, wrapped_by_this_prof = self._get_wrapper_info(impl)
124+
if wrapped_by_this_prof:
112125
continue
126+
if info:
127+
# It's still a profiling wrapper, just wrapped by
128+
# someone else -> extract the inner function
129+
impl = info.func
113130
self.add_function(impl)
114131
nadded += 1
115132

@@ -150,6 +167,25 @@ def add_module(self, mod):
150167

151168
return nfuncsadded
152169

170+
def _get_wrapper_info(self, func):
171+
info = getattr(func, self._profiler_wrapped_marker, None)
172+
return info, bool(info and id(self) == info.profiler_id)
173+
174+
# Override these mixed-in bookkeeping methods to take care of
175+
# potential multiple profiler sequences
176+
177+
def _already_a_wrapper(self, func):
178+
return self._get_wrapper_info(func)[1]
179+
180+
def _mark_wrapper(self, wrapper):
181+
# Are re-wrapping an existing wrapper (e.g. created by another
182+
# profiler?)
183+
wrapped = wrapper.__wrapped__
184+
info = getattr(wrapped, self._profiler_wrapped_marker, None)
185+
new_info = _WrapperInfo(info.func if info else wrapped, id(self))
186+
setattr(wrapper, self._profiler_wrapped_marker, new_info)
187+
return wrapper
188+
153189

154190
# This could be in the ipython_extension submodule,
155191
# but it doesn't depend on the IPython module so it's easier to just let it stay here.

line_profiler/line_profiler.pyi

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
from typing import List
2-
from typing import Tuple
1+
from typing import Literal, List, Tuple
32
import io
43
from ._line_profiler import LineProfiler as CLineProfiler
54
from .profiler_mixin import ByCountProfilerMixin
@@ -11,8 +10,7 @@ def load_ipython_extension(ip) -> None:
1110

1211

1312
class LineProfiler(CLineProfiler, ByCountProfilerMixin):
14-
15-
def add_callable(self, func) -> None:
13+
def add_callable(self, func) -> Literal[0, 1]:
1614
...
1715

1816
def dump_stats(self, filename) -> None:
@@ -28,7 +26,7 @@ class LineProfiler(CLineProfiler, ByCountProfilerMixin):
2826
rich: bool = ...) -> None:
2927
...
3028

31-
def add_module(self, mod):
29+
def add_module(self, mod) -> int:
3230
...
3331

3432

line_profiler/profiler_mixin.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def wrap_async_generator(self, func):
198198
Wrap an async generator function to profile it.
199199
"""
200200
# Prevent double-wrap
201-
if self._already_wrapped(func):
201+
if self._already_a_wrapper(func):
202202
return func
203203

204204
@functools.wraps(func)
@@ -216,14 +216,14 @@ async def wrapper(*args, **kwds):
216216
self.disable_by_count()
217217
input_ = (yield item)
218218

219-
return self._mark_wrapped(wrapper)
219+
return self._mark_wrapper(wrapper)
220220

221221
def wrap_coroutine(self, func):
222222
"""
223223
Wrap a coroutine function to profile it.
224224
"""
225225
# Prevent double-wrap
226-
if self._already_wrapped(func):
226+
if self._already_a_wrapper(func):
227227
return func
228228

229229
@functools.wraps(func)
@@ -235,14 +235,14 @@ async def wrapper(*args, **kwds):
235235
self.disable_by_count()
236236
return result
237237

238-
return self._mark_wrapped(wrapper)
238+
return self._mark_wrapper(wrapper)
239239

240240
def wrap_generator(self, func):
241241
"""
242242
Wrap a generator function to profile it.
243243
"""
244244
# Prevent double-wrap
245-
if self._already_wrapped(func):
245+
if self._already_a_wrapper(func):
246246
return func
247247

248248
@functools.wraps(func)
@@ -260,14 +260,14 @@ def wrapper(*args, **kwds):
260260
self.disable_by_count()
261261
input_ = (yield item)
262262

263-
return self._mark_wrapped(wrapper)
263+
return self._mark_wrapper(wrapper)
264264

265265
def wrap_function(self, func):
266266
"""
267267
Wrap a function to profile it.
268268
"""
269269
# Prevent double-wrap
270-
if self._already_wrapped(func):
270+
if self._already_a_wrapper(func):
271271
return func
272272

273273
@functools.wraps(func)
@@ -279,14 +279,14 @@ def wrapper(*args, **kwds):
279279
self.disable_by_count()
280280
return result
281281

282-
return self._mark_wrapped(wrapper)
282+
return self._mark_wrapper(wrapper)
283283

284-
def _already_wrapped(self, func):
284+
def _already_a_wrapper(self, func):
285285
return getattr(func, self._profiler_wrapped_marker, None) == id(self)
286286

287-
def _mark_wrapped(self, func):
288-
setattr(func, self._profiler_wrapped_marker, id(self))
289-
return func
287+
def _mark_wrapper(self, wrapper):
288+
setattr(wrapper, self._profiler_wrapped_marker, id(self))
289+
return wrapper
290290

291291
def run(self, cmd):
292292
""" Profile a single executable statment in the main namespace.

0 commit comments

Comments
 (0)