Skip to content

Add a new interface for external profilers and debuggers to get the call stack efficiently #115946

Description

@pablogsal

Currently, external profilers and debuggers need to issue one system call per frame object when they are retrieving the call stack from a remote process as these form a linked list. Furthermore, for every frame object they need at least two or three more system calls to retrieve the code object and the code object name and file. This has several disadvantages:

  • For sampling profilers, the status of the runtime can change dramatically as all the data is copied, so this can lead to inaccurate information or higher chances of corrupted memory.
  • This leaves all the tool subjected to any low-level optimisation in frame objects and the call mechanism, which further impacts accuracy and maintainability.
  • Some optimisations such as true function inlining may make the current approach not sufficient as frames will be missing from the normal call stack.

For these reasons, I propose to add a new interface consisting in a contiguous array of pointers that always contains all the code objects necessary to retrieve the call stack. This has the following advantages:

  • The full stack can be efficiently copied in one system call (using for instance process_vm_readv).
  • Having code objects and not frames allows for less system calls and less exposure to implementation details.
  • Optimizations can keep easily and very cheaply this interface running while doing aggressive changes in the real frame management.
  • The continuous cost is just one pointer write, one counter increment and two reads per function call, which i expect to be negligible.

Linked PRs

Activity

  1. added 3 commits that reference this issue on Feb 26, 2024
  2. gvanrossum commented on Mar 4, 2024

    @gvanrossum
    Member

    This certainly requires the attention of @markshannon and @Fidget-Spinner.

    The continuous cost is just one pointer write per function call, which is negligible.

    Looking at the code in the PR I see one pointer write, one counter increment, and several reads. We probably want a benchmark run.

  3. pablogsal commented on Mar 4, 2024

    @pablogsal
    MemberAuthor

    Looking at the code in the PR I see one pointer write, one counter increment, and several reads. We probably want a benchmark run.

    Absolutely! Also the PR doesn't work in the current state because I am missing a lot of the work in the new interpreter structure so I may need some help here to know where to put the tracking code. EDIT: I have also updated the description to reflect that it's not just one write

  4. pablogsal commented on Mar 4, 2024

    @pablogsal
    MemberAuthor

    @gvanrossum Do you think it's worth discussing this in one Faster CPython sync?

  5. gvanrossum commented on Mar 4, 2024

    @gvanrossum
    Member

    Perhaps. Especially if you're not 100% satisfied with my answers. (Wait a day for @markshannon to pitch in as well.)

    I don't know if you still have the meeting in your calendar -- we haven't seen you in a while, but of course you're always welcome! We now meet every other week. The next one is Wed March 13. The Teams meeting is now owned by Michael Droettboom.

  6. pablogsal commented on Mar 4, 2024

    @pablogsal
    MemberAuthor

    Perhaps. Especially if you're not 100% satisfied with my answers. (Wait a day for @markshannon to pitch in as well.)

    I am satisfied (and thanks a lot for taking the time to read my comments and to answer, I really appreciate it). I think is more than it seems that there are some moving pieces and some compromises to be made and I think a realtime discussion can make this easier.

    I don't know if you still have the meeting in your calendar -- we haven't seen you in a while, but of course you're always welcome! We now meet every other week. The next one is Wed March 13. The Teams meeting is now owned by Michael Droettboom.

    I do have it still! Should I reach out to Michael (after leaving time for @markshannon to answer first) to schedule the topic?

  7. gvanrossum commented on Mar 4, 2024

    @gvanrossum
    Member

    Agreed.

    I do have it still! Should I reach out to Michael (after leaving time for @markshannon to answer first) to schedule the topic?

    Wouldn't hurt to get this on the agenda early.

  8. pablogsal commented on Mar 4, 2024

    @pablogsal
    MemberAuthor

    Wouldn't hurt to get this on the agenda early.

    👍 I sent @mdboom an email

  9. markshannon commented on Mar 6, 2024

    @markshannon
    Member

    While this might make life easier for profilers and out-of-process debuggers (what's wrong with in-process debuggers?), it will make all Python programs slower.
    To me, this cost considerably outweighs the benefits.

  10. pablogsal commented on Mar 6, 2024

    @pablogsal
    MemberAuthor

    While this might make life easier for profilers and out-of-process debuggers (what's wrong with in-process debuggers?), it will make all Python programs slower. To me, this cost considerably outweighs the benefits.

    Think of this not just as a way to make the profiler life easier, but also a way to not make it break once we start inlining things or to make the JIT not push frames. If we don't do this, what do you propose we should do to not break these tools once the JIT is there or we start inlining stuff and frames start to be missing?

    (what's wrong with in-process debuggers?)

    That you cannot use them if Python is frozen or deadlocked and you need them if you want to attach to an already-running tool.

  11. gvanrossum commented on Mar 6, 2024

    @gvanrossum
    Member

    I'd like to remind all participants in this conversation to remain open to the issues others are trying to deal with. We will eventually need some kind of compromise. Please try working towards one.

  12. pablogsal commented on Mar 6, 2024

    @pablogsal
    MemberAuthor

    @markshannon What would be an acceptable approach/compromise for you to ensure that we can still offer reliable information for these tools in a way that doesn't impact too much the runtime?

  13. gvanrossum commented on Mar 6, 2024

    @gvanrossum
    Member

    @pablogsal I wonder if you could write a bit more about how such tools currently use the frame stack (and how this has evolved through the latest Python versions, e.g. how they coped with 3.10/3.11 call inlining, whether 3.12 posed any new issues, how different 3.13 is currently? And I'd like to understand how having just a stack of code objects (as this PR seems to be doing) will help. I think I recall you mentioned that reading the frame stack is inefficient -- a bit more detail would be helpful.

    IIUC the current JIT still maintains the frame stack, just as the Tier 2 interpreter does -- this is done using the _PUSH_FRAME and _POP_FRAME uops, and a few related ones that you can find by searching bytecodes.c for _PUSH_FRAME.

    The current (modest) plans for call inlining in 3.13 (if we do it at all) only remove the innermost frame, and only if that frame doesn't make any calls into C code that could recursively enter the interpreter. (This is supported by escape analysis which can be overridden by the "pure" qualifier in bytecodes.c.) When you catch it executing uops (either in the JIT or in the Tier 2 interpreter) the frame is that of the caller and the inlined code just uses some extra stack space. (I realize there's an open issue regarding how we'd calculate the line number when the VM is in this state.)

  14. 13 remaining items

  15. pablogsal commented on Mar 12, 2024

    @pablogsal
    MemberAuthor

    Hummm, that's interesting but it's still not conclusive because we are not seeing full linear independent stacks that we can check that are not missing anything. We should inspect the actual full per-event stacks. We can do that by running "perf script" over the file and check that we are not missing frames.

    I will run a bunch of tests if someone can point me out to some example code that surely gets jitted that has a bunch of stack frames

  16. pablogsal commented on Mar 12, 2024

    @pablogsal
    MemberAuthor

    Additionally there is another check: perf uses libubwind (or libdw) as unwinder. What happens if you try to unwind using libubwind under a kitted frame?

    @brandtbucher and I did the test back in the day and it failed.

    Also what happens if gdb tries to unwind? That's an easier check to do 🤔

  17. brandtbucher commented on Mar 13, 2024

    @brandtbucher
    Member

    I will run a bunch of tests if someone can point me out to some example code that surely gets jitted that has a bunch of stack frames

    import operator
    
    def f(n):
        if n <= 0:
            return
        for _ in range(16):
            operator.call(f, n - 1)
    
    f(6)

    You should usually see n jit frames with C code between them once it's warmed up a bit. Feel free to crank the numbers up higher if you need a deeper stack or a longer-running process.

  18. brandtbucher commented on Mar 13, 2024

    @brandtbucher
    Member

    @brandtbucher and I did the test back in the day and it failed.

    One important thing has changed since then: the JIT no longer omits frame pointers when compiling templates. So this may just be fixed now (not sure).

  19. pablogsal commented on Mar 13, 2024

    @pablogsal
    MemberAuthor

    Ok, I tested with the latest JIT and I can confirm neither gdb nor libunwind can unwind through the JIT. I used this extension:

    #include <Python.h>
    #include <libunwind.h>
    #include <stdio.h>
    
    static PyObject* print_full_stack(PyObject* self, PyObject* args) {
        unw_cursor_t cursor;
        unw_context_t context;
    
        unw_getcontext(&context);
        unw_init_local(&cursor, &context);
    
        while (unw_step(&cursor) > 0) {
            unw_word_t offset, pc;
            char symbol[256];
    
            unw_get_reg(&cursor, UNW_REG_IP, &pc);
            unw_get_proc_name(&cursor, symbol, sizeof(symbol), &offset);
    
            printf("0x%lx: (%s+0x%lx)\n", (unsigned long)pc, symbol, (unsigned long)offset);
        }
    
        return Py_None;
    }
    
    static PyMethodDef methods[] = {
        {"print_full_stack", print_full_stack, METH_NOARGS, "Print the full stack trace"},
        {NULL, NULL, 0, NULL}
    };
    
    static struct PyModuleDef module = {
        PyModuleDef_HEAD_INIT,
        "full_stack_trace",
        NULL,
        -1,
        methods
    };
    
    PyMODINIT_FUNC PyInit_full_stack_trace(void) {
        return PyModule_Create(&module);
    }
    
    

    with @brandtbucher example:

    import operator
    import full_stack_trace
    
    def f(n):
        if n <= 0:
            full_stack_trace.print_full_stack()
            return
        for _ in range(16):
            operator.call(f, n - 1)
    
    f(6)
    

    Attaching with gdb and unwinding shows:

    Breakpoint 1, 0x0000fffff76a0938 in print_full_stack () from /src/full_stack_trace.so
    (gdb) bt
    #0  0x0000fffff76a0938 in print_full_stack () from /src/full_stack_trace.so
    #1  0x0000aaaaaabf8ccc in cfunction_vectorcall_NOARGS (func=0xfffff7b33e20, args=<optimized out>, nargsf=<optimized out>, kwnames=<optimized out>) at Objects/methodobject.c:484
    #2  0x0000aaaaaab97920 in _PyObject_VectorcallTstate (kwnames=<optimized out>, nargsf=<optimized out>, args=<optimized out>, callable=0xfffff7b33e20, tstate=0xaaaaab059a90 <_PyRuntime+231872>)
        at ./Include/internal/pycore_call.h:168
    #3  PyObject_Vectorcall (callable=0xfffff7b33e20, args=<optimized out>, nargsf=<optimized out>, kwnames=<optimized out>) at Objects/call.c:327
    #4  0x0000aaaaaab32b78 in _PyEval_EvalFrameDefault (tstate=0xaaaaab059a90 <_PyRuntime+231872>, frame=0xfffff7fbe3b0, throwflag=-144045772) at Python/generated_cases.c.h:817
    #5  0x0000aaaaaab97920 in _PyObject_VectorcallTstate (kwnames=<optimized out>, nargsf=<optimized out>, args=<optimized out>, callable=0xfffff7ac6e80, tstate=0xaaaaab059a90 <_PyRuntime+231872>)
        at ./Include/internal/pycore_call.h:168
    #6  PyObject_Vectorcall (callable=0xfffff7ac6e80, args=<optimized out>, nargsf=<optimized out>, kwnames=<optimized out>) at Objects/call.c:327
    #7  0x0000fffff76b9578 in ?? ()
    #8  0x0000fffff7b517b0 in ?? ()
    Backtrace stopped: previous frame inner to this frame (corrupt stack?)
    

    and libunwind shows:

    0xaaaaaabf8ccc: (cfunction_vectorcall_NOARGS+0x6c)
    0xffffffffd040: (+0x6c)
    0xffffffffd040: (+0x6c)
    

    This is with frame pointers:

    ./python.exe -m sysconfig | grep frame
    	CFLAGS = "-fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer"
    	LIBEXPAT_CFLAGS = "-I./Modules/expat -fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer -D_Py_JIT -std=c11 -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wstrict-prototypes -Werror=implicit-function-declaration -fvisibility=hidden  -I./Include/internal -I./Include/internal/mimalloc -I. -I./Include -fPIC"
    	LIBHACL_CFLAGS = "-I./Modules/_hacl/include -D_BSD_SOURCE -D_DEFAULT_SOURCE -fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer -D_Py_JIT -std=c11 -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wstrict-prototypes -Werror=implicit-function-declaration -fvisibility=hidden  -I./Include/internal -I./Include/internal/mimalloc -I. -I./Include -fPIC"
    	LIBMPDEC_CFLAGS = "-I./Modules/_decimal/libmpdec -DCONFIG_64=1 -DANSI=1 -DHAVE_UINT128_T=1 -fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer -D_Py_JIT -std=c11 -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wstrict-prototypes -Werror=implicit-function-declaration -fvisibility=hidden  -I./Include/internal -I./Include/internal/mimalloc -I. -I./Include -fPIC"
    	PYTHONFRAMEWORKDIR = "no-framework"
    	PY_BUILTIN_MODULE_CFLAGS = "-fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer -D_Py_JIT -std=c11 -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wstrict-prototypes -Werror=implicit-function-declaration -fvisibility=hidden  -I./Include/internal -I./Include/internal/mimalloc -I. -I./Include -DPy_BUILD_CORE_BUILTIN"
    	PY_CFLAGS = "-fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer"
    	PY_CORE_CFLAGS = "-fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer -D_Py_JIT -std=c11 -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wstrict-prototypes -Werror=implicit-function-declaration -fvisibility=hidden  -I./Include/internal -I./Include/internal/mimalloc -I. -I./Include -DPy_BUILD_CORE"
    	PY_STDMODULE_CFLAGS = "-fno-strict-overflow -Wsign-compare -DNDEBUG -g -O3 -Wall  -fno-omit-frame-pointer -D_Py_JIT -std=c11 -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wstrict-prototypes -Werror=implicit-function-declaration -fvisibility=hidden  -I./Include/internal -I./Include/internal/mimalloc -I. -I./Include"
    
  20. pablogsal commented on Mar 13, 2024

    @pablogsal
    MemberAuthor

    I will check with perf later in the day

  21. pablogsal commented on Mar 13, 2024

    @pablogsal
    MemberAuthor

    I checked and I can confirm perf cannot unwind throughout the JIT (with or without frame pointers). For this I used @brandtbucher script:

    import operator
    
    def f(n):
        if n <= 0:
            return
        for _ in range(16):
            operator.call(f, n - 1)
    
    f(10)
    

    I started it with ./python script.py, let it run for a while so the JIT is warmed up and then in another terminal attached with perf:

    sudo -E perf record -F9999 -g -p PID
    

    I let it run for a while and then run perf script over the result file and got this:

    python 2794949 13914841.174912:     100010 cpu-clock:pppH:
                564f70647239 _PyEval_EvalFrameDefault+0x3e9 (/home/ubuntu/cpython/python)
                564f706b368c PyObject_Vectorcall+0x3c (/home/ubuntu/cpython/python)
                7f8abbece445 [unknown] (/tmp/perf-2794949.map)
                564f70b17440 [unknown] ([unknown])
                564f70aeb940 [unknown] ([unknown])
    
    python 2794949 13914841.175012:     100010 cpu-clock:pppH:
                564f7080bb43 _PyEval_Vector+0xa3 (/home/ubuntu/cpython/python)
                564f706b368c PyObject_Vectorcall+0x3c (/home/ubuntu/cpython/python)
                7f8abbece445 [unknown] (/tmp/perf-2794949.map)
                564f70b17440 [unknown] ([unknown])
                564f70aeb940 [unknown] ([unknown])
    
    python 2794949 13914841.175112:     100010 cpu-clock:pppH:
                564f7064e535 _PyEval_EvalFrameDefault+0x76e5 (/home/ubuntu/cpython/python)
                564f706b368c PyObject_Vectorcall+0x3c (/home/ubuntu/cpython/python)
                7f8abbece445 [unknown] (/tmp/perf-2794949.map)
                564f70b17440 [unknown] ([unknown])
                564f70aeb940 [unknown] ([unknown])
    
    python 2794949 13914841.175212:     100010 cpu-clock:pppH:
                564f70809aa8 _PyEvalFramePushAndInit+0x8 (/home/ubuntu/cpython/python)
                564f7080bb36 _PyEval_Vector+0x96 (/home/ubuntu/cpython/python)
                564f706b368c PyObject_Vectorcall+0x3c (/home/ubuntu/cpython/python)
                7f8abbece445 [unknown] (/tmp/perf-2794949.map)
                564f70b17440 [unknown] ([unknown])
                564f70aeb940 [unknown] ([unknown])
    
    python 2794949 13914841.175312:     100010 cpu-clock:pppH:
                564f70647234 _PyEval_EvalFrameDefault+0x3e4 (/home/ubuntu/cpython/python)
                564f706b368c PyObject_Vectorcall+0x3c (/home/ubuntu/cpython/python)
                7f8abbece445 [unknown] (/tmp/perf-2794949.map)
                564f70b17440 [unknown] ([unknown])
                564f70aeb940 [unknown] ([unknown])
    
    python 2794949 13914841.175412:     100010 cpu-clock:pppH:
                564f70808866 initialize_locals+0x6 (/home/ubuntu/cpython/python)
                564f70809b5e _PyEvalFramePushAndInit+0xbe (/home/ubuntu/cpython/python)
                564f7080bb36 _PyEval_Vector+0x96 (/home/ubuntu/cpython/python)
                564f706b368c PyObject_Vectorcall+0x3c (/home/ubuntu/cpython/python)
                7f8abbece445 [unknown] (/tmp/perf-2794949.map)
                564f70b17440 [unknown] ([unknown])
                564f70aeb940 [unknown] ([unknown])
    

    So as you can see every time perf his a Jitted frame, it fails to unwind. @mdboom I think what you are getting in your script it's these partial frames reordered, not the full stack. You can confirm running perf script and then finding one stack that has fitted frames and check if the topmost frame is main() or __libc_main

  22. pablogsal commented on Mar 13, 2024

    @pablogsal
    MemberAuthor

    Note that if we confirm this it means that any current attribution of Perf to jitted frames it's inaccurate

  23. mdboom commented on Mar 13, 2024

    @mdboom
    Contributor

    @pablogsal: Can you be more specific about how you are running perf script?

    Note that if we confirm this it means that any current attribution of Perf to jitted frames it's inaccurate

    If I'm understanding correctly, I see how this means that where the JIT'ted code in the stack is inaccurate, but I don't see how it follows that time attributed to JIT'ted code is inaccurate (which I realize is the easier subset of this whole problem). The numbers certainly add up and make sense.

  24. pablogsal commented on Mar 13, 2024

    @pablogsal
    MemberAuthor

    @pablogsal: Can you be more specific about how you are running perf script?

    Assuming your result file it's called "perf.data" (the default) you can just run perf script otherwise you need to specify the name of the file with perf script -i

    If I'm understanding correctly, I see how this means that where the JIT'ted code in the stack is inaccurate, but I don't see how it follows that time attributed to JIT'ted code is inaccurate (which I realize is the easier subset of this whole problem). The numbers certainly add up and make sense.

    This is not a problem if you only account for leaf frames and we believe Perf is correctly identifying jitted frames in all cases in these conditions. If you need to account for callers, because it cannot unwind the stack it cannot discover that above a given frame there are multiple jitted frames (like in @brandtbucher example) and therefore if you measure anything other than self-time (as opposed to self-time + children time, as in flamegraphs) it will be inaccurate.

  25. mdboom commented on Mar 13, 2024

    @mdboom
    Contributor

    therefore if you measure anything other than self-time (as opposed to self-time + children time, as in flamegraphs) it will be inaccurate.

    Got it, that makes sense.

    I can confirm the output of perf script doesn't trace all the way to the root. This is true even if you provide perf-$PID.map entries (it was a long shot, but I thought maybe providing the code size metadata might help).

  26. taegyunkim commented on Apr 21, 2025

    @taegyunkim
    Contributor

    Disclaimer: I work on continuous profiler for Python at Datadog.

    Our profiler, and I believe py-spy too, follow the pattern of what's described in @pablogsal's comment here. And our profiler work as an outside-in profiler, meaning it runs in a separate native thread in the same process as the profiled Python application. It doesn't rely on GIL when copying CPython internal states.

    From CPython 3.11+, we've been using datastack_chunk, and it helped reducing the number of process_vm_readv calls we need to make to copy _PyInterpreterFrame objects. However, since 3.13, more specifically since gh-105727, we need to also iterate _PyInterpreterFrame checking whether f_executable is of PyCode_Type, basically doing as noted by @markshannon here. This required us also to issue another process_vm_readv call as f_executable is a pointer.

    This also made me wonder whether there could be something equivalent of datastack_chunk for code objects, too. And was speculating whether faster-cpython/ideas#675 could help us achieve this or not. We already have datastack_chunk basically for data part, and would need control stack holding on to code objects. Correct me if I'm misunderstanding anything here, please.

    More broadly, I'm interested in CPython having a C API that could be used by profiler and debugger tools out there to get stack trace as efficient as possible. I believe there's a Python API for doing so, but the fact that there are quiet a few tools following the same pattern using process_vm_readv suggests that something is missing in C side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions