Class names in identifiers in rendered output for aggregated frames corresponding to method calls can be misleading
We hit an issue when using pyinstrument to regularly profile a large epidemiological simulation code that the rendered profile output seemed to be grouping method calls incorrectly:
UCL/TLOmodel-profiling/issues/19
Specifically we were finding calls to functions (/ methods) being grouped under class methods which we thought shouldn't ever be able to call these functions.
I've looked in to this a bit and think I've identified what is going on. The key issue seems to be that the class name used in the identifier for a (aggregated) frame corresponding to a method call will be written out as the name of particular subclass (specifically the mostly commonly observed subclass in the aggregated captured frames) even if the method being called is defined on a base class.
As a minimal reproducer, consider the following script corresponding to a very simplified version of our simulation call stack:
import time
def simulate():
events = [EventA(), EventB(), EventA()] * 10
for event in events:
event.run()
class Event:
def run(self):
self.apply()
class EventA(Event):
def apply(self):
time.sleep(0.1)
class EventB(Event):
def apply(self):
time.sleep(0.1)
if __name__ == "__main__":
simulate()
Running pyinstrument on this script (saved as pyinstrument_test.py) gives the following output using the console renderer:
_ ._ __/__ _ _ _ _ _/_ Recorded: 16:12:19 Samples: 31
/_//_/// /_\ / //_// / //_'/ // Duration: 3.015 CPU time: 0.007
/ _/ v4.7.3
Program: pyinstrument_test.py
3.013 <module> pyinstrument_test.py:1
ββ 3.013 simulate pyinstrument_test.py:4
ββ 3.013 EventA.run pyinstrument_test.py:12
ββ 2.009 EventA.apply pyinstrument_test.py:18
β ββ 2.009 sleep <built-in>
ββ 1.005 EventB.apply pyinstrument_test.py:24
ββ 1.005 sleep <built-in>
Here we see that underneath the top-level simulate call, all event apply method calls are grouped under a frame with identifier EventA.run. This corresponds in reality to the run method defined on the base Event class, which we can see from the correct line number reference in to the script. In this case it is fairly easy to spot that there is no run method defined directly in EventA and so that this in reality corresponds to Event, but in our real use case the call stack is a lot more complex and spotting when method calls actually correspond to calls to a base class method is more difficult.
My understanding of how this arises, is that as the concrete classes from which the run method is called are EventA and EventB, when the corresponding frames are subsequently aggregated pyinstrument resolves the ambiguity of which class name to use when rendering by outputting the most commonly observed class name based on the logic in:
https://github.com/joerick/pyinstrument/blob/4b37f8cdc531be41a7f7e57932f0b770244025d5/pyinstrument/frame.py#L308-L309
https://github.com/joerick/pyinstrument/blob/4b37f8cdc531be41a7f7e57932f0b770244025d5/pyinstrument/frame.py#L284-L305
Ideally instead of the above output, for our purposes it would be useful to instead get
_ ._ __/__ _ _ _ _ _/_ Recorded: 16:12:19 Samples: 31
/_//_/// /_\ / //_// / //_'/ // Duration: 3.015 CPU time: 0.007
/ _/ v4.7.3
Program: pyinstrument_test.py
3.013 <module> pyinstrument_test.py:1
ββ 3.013 simulate pyinstrument_test.py:4
ββ 3.013 Event.run pyinstrument_test.py:12
ββ 2.009 EventA.apply pyinstrument_test.py:18
β ββ 2.009 sleep <built-in>
ββ 1.005 EventB.apply pyinstrument_test.py:24
ββ 1.005 sleep <built-in>
that is the class name qualifying the run method identifier being specified as the base class Event it is defined in.
I am not clear however how feasible it would be to output the class name where methods are defined like this, and whether the current behaviour is intentional?
@joerick if you do think it would feasible and desirable to change the behaviour of pyinstrument to instead produce something more like the latter output, I'd be happy to try to work on a PR to try to implement this!
Ooh yes this is an excellent point. Yes I totally agree that the current approach could be improved. The current method looks for a parameter in the function called self or cls and logs the type of that.
This is implemented in C, but you can see a Python version here, which is roughly similar:
https://github.com/joerick/pyinstrument/blob/17ce4c45768be227753f91a6d8091c49389bd4b5/pyinstrument/low_level/stat_profile_python.py#L123-L132
One limitation is that it does need to be fast, because it will be called 1000s of times per second, potentially.
Yes if you have an opportunity to look into other approaches, I'd be very interested to see what you can find out!
For Python 3.11 onwards, I think the codeobject.co_qualname attribute gives what is needed directly without needing to look for self and cls in frame.f_locals. I think the following reimplementation of the snippet in your comment from the Python verstion of get_frame_info would then work on both Python 3.11 and above (where frame.f_code.co_qualname should be available) and earlier versions (where we can extract the qualified name from the __qualname__ attribute of the method itself (with __qualname__ being available for Python 3.3 and above):
code = frame.f_code
class_name = None
if hasattr(code, "co_qualname") and code.co_qualname != code.co_name:
class_name = class_name_from_qualified_name(code.co_qualname)
else:
self_or_cls = frame.f_locals.get("self", frame.f_locals.get("cls", None))
if self_or_cls and hasattr(self_or_cls, code.co_name):
method = getattr(self_or_cls, code.co_name)
if hasattr(method, "__qualname__"):
class_name = class_name_from_qualified_name(method.__qualname__)
Here class_name_from_qualified_name would be defined something like
def class_name_from_qualified_name(qualified_name: str) -> str:
return ".".join(qualified_name.split(".")[:-1])
which should deal with also giving the 'qualified' class name for nested class definitions.
It looks like co_qualname is also available as a field in PyCodeObject in C from Python 3.11 onwards
https://github.com/python/cpython/blob/656b7a3c83c79f99beac950b59c47575562ea729/Objects/codeobject.c#L466
and as _get_class_name_of_frame is already defined differently for Python 3.11+ in
https://github.com/joerick/pyinstrument/blob/17ce4c45768be227753f91a6d8091c49389bd4b5/pyinstrument/low_level/stat_profile.c#L278-L280
I think we could implement something analogous in C, with Python 3.11+ implementation using co_qualname field of PyCodeObject (from reading the comments this possibly also has the side benefit of avoiding the expense of calling PyFrame_GetLocals). The alternative implementation for earlier Python versions could I think do something comparable to Python implementation above using PyObject_HasAttr and PyObject_GetAttr after getting the self or cls PyObject as in the current implementation, but I'm not sure if there is a big performance penalty to using those C API calls?
Sorry for the delay in getting back to you! A change to behaviour in Python 3.11+ to use the statically defined class name from co_qualname would be excellent. If you fancy putting together a PR, that would be great!
Thanks for following up, I'll try to have a go at a PR to implement this.