GH-126910: Add gdb support for unwinding JIT frames#146071
GH-126910: Add gdb support for unwinding JIT frames#146071diegorusso wants to merge 21 commits intopython:mainfrom
Conversation
|
🤖 New build scheduled with the buildbot fleet by @diegorusso for commit ac018d6 🤖 Results will be shown at: https://buildbot.python.org/all/#/grid?branch=refs%2Fpull%2F146071%2Fmerge If you want to schedule another build, you need to add the 🔨 test-with-buildbots label again. |
|
I have some questions about the EH frame generation and how it applies to the different code regions. Looking at
Both end up calling The EH frame in I understand how this is correct for _Py_CODEUNIT *
_JIT_ENTRY(...) {
jit_func_preserve_none jitted = (jit_func_preserve_none)exec->jit_code;
return jitted(exec, frame, stack_pointer, tstate, ...);
}The compiler will emit exactly the prologue/epilogue the EH frame describes. But I don't understand how the same EH frame is correct for The FDE covers the full The test ( Am I missing something about how the stencils interact with the stack, or is the EH frame intentionally approximate for the executor region? |
| struct jit_code_entry *first_entry; | ||
| }; | ||
|
|
||
| static volatile struct jit_descriptor __jit_debug_descriptor = { |
There was a problem hiding this comment.
Should these be non-static? The GDB JIT interface spec says GDB locates __jit_debug_descriptor and __jit_debug_register_code by name in the symbol table. With static linkage they would be invisible in .dynsym on stripped builds and when CPython is loaded as a shared library via dlopen. Am I missing something, or would this silently break in release/packaged builds where .symtab is stripped?
Maybe also worth adding __attribute__((used)) to prevent the linker from eliding them?
There was a problem hiding this comment.
Yes, you are right. Instead of removing the static I've exported with the macro Py_EXPORTED_SYMBOL
| id(42) | ||
| return | ||
|
|
||
| warming_up = True |
There was a problem hiding this comment.
Could this loop hang? When warming_up=True, the call passes warming_up_caller=True which returns immediately at line 8, so the recursive body never actually executes. If the JIT does not activate via some other path, would this not spin forever until the timeout kills it? Should there be a max iteration count as a safety net?
Also, line 16 uses bitwise & instead of and. Was that intentional? It means is_active() is always evaluated even when is_enabled() is False.
There was a problem hiding this comment.
I've simplified the test, the loop is not more controlled and deterministic.
| return; | ||
| } | ||
| _PyJitUnwind_GdbRegisterCode( | ||
| code_addr, (unsigned int)code_size, entry, filename); |
There was a problem hiding this comment.
code_size comes in as size_t but gets cast to unsigned int here. I know JIT regions will not be 4GB, but should the API just take size_t throughout for consistency?
There was a problem hiding this comment.
This is now done.
What this change synthesises for jit_executor is one unwind description for the executor as a whole, not compiler-emitted per-stencil CFI. Because the stencils are musttail-chained, the jumps between stencils do not add extra native call frames. The unwind job here is just to recover the caller of the executor frame. We don't want to describe each stencil as its own frame. When GDB stops at a PC inside
On AArch64, for most of the covered executor range, the synthetic CFI says:
Good catch for the testing gap. I’ve now added a new test that breaks inside the jit executor. It sill breaks at the |
|
@diegorusso @pablogsal I think I may have come up with a solution that works. EDIT: I think I gdb doesn't only use Background info (skip if not interested):
The current issue:
The solution:
This should work with TLDR: frame pointers = eh_frame is simple. |
@diegorusso I have to say that I am tremendously confused here. If GDB or So the real question is not “does the FDE cover the address range?” and it is not “do the stencils form one logical frame?”. The real question is: does the CFI row that applies at that PC actually describe the machine state there? That is the part I do not think has been explained. I agree with the narrow And that is exactly where I think the argument goes off the rails.
A concrete x86_64 example of why this seems wrong to me: with the same sort of flags used for executor stencils, a or, if it needs temporary stack space / spills, something more like In the first case there is no For Also, I rebuilt the branch locally and tried the exact “finish to So this is not just a theoretical concern for me. I still do not understand why the model being described here is supposed to work.I am of course not objecting to the goal. I am saying I still do not see the correctness argument. If the claim is that this is actually a correct unwind description for |
Thanks, thanks for the comment. I regenerated the x86_64 and AArch64 stencils after the recent frame-pointer changes. What we have today is that shim gets a real frame-pointer prologue, but the executor stencils still are not uniformly rbp/x29-framed, so I don’t think the current generated code is enough to justify a single executor-wide CFA = rbp + 16 / x29 + const rule for arbitrary PCs in the blob. The current implementation is still one synthetic executor-wide FDE. The unwinder uses the current PC to select that FDE and apply its CFI to recover the caller frame. That works where the actual machine state matches the synthetic rule at the stop PC, but it is still approximate executor-wide unwind metadata, not exact per-stencil CFI. Separately, once this PR lands, wiring up libgcc-backed backtrace should be fairly easy. We already synthesise .eh_frame; the remaining work is to call the appropriate |
Ok, I think now I understand. After re-checking the generated stencils I agree the current explanation was too bold. musttail only establishes the narrow point that the stencil-to-stencil transitions do not accumulate one native call frame per stencil and it does not by itself establish the stronger property needed for unwinding: that for an arbitrary PC inside jit_executor, the CFA and saved return state always have a shape described by one executor-wide FDE. That stronger property is the missing invariant here. After looking again at the regenerated x86_64 and AArch64 stencils, I don't think we have that invariant today:
I cannot justify the current synthetic executor-wide FDE as being correct for arbitrary PCs in the executor blob. The new test I added is still useful, but it proves something narrower: that the synthetic FDE works for the exercised in-executor stop. It does not prove that the same CFI is exact for every interior PC in the region (like you did in your example) I think the real options are:
The current implementation does not yet have the invariant needed to justify one executor-wide FDE for jit_executor but at the same time I don't really like the suggestions above. Let me think about it |
The current generation reserve the rbp. So all current stencils assume an rbp. Do you think it would fix it if we emitted our own prologue for the very first JIT executor uop ie (
Unfortunately, it seems you're right here. I dug around libgcc a little more and that's the only interface I see that intercepts |
Not all of them. See I'm not entirely sure your statement is true. |
Huh that's surprising! On x86_64, the current |
Oh sorry I'm wrong, not to it to get things like that. |
|
I've just raised 2 PR that will get the invariant that we need for:
@Fidget-Spinner will implement a simple assembly verifier pass in the assembly optimizer that will check on x64 there's no |
|
This works on x86_64 on my system now on 17be0a2: With gdb 17: On lldb-21 |
On latest commit, I once again get practically the same result again on gdb-15+llvm-21, this time I tested both with |
There was a problem hiding this comment.
From my very rudimentary understanding of DWARF. This PR works as long as we maintain the frame pointer invariant (frame pointer-relative CFA). I think it's good to go, but I'll defer approval to @pablogsal as he's the expert here.
This because the two JIT memory regions are visible as unique frame from the GDB point of view.
There was a problem hiding this comment.
One clarification here: the reason this works is not “the synthetic EH frame describes every stencil exactly.” The sound argument is “the whole executor region can be unwound as one logical frame because the caller state stays recoverable in one stable place.” That holds if:
- the only real frame record comes from the shim
- executor stencils tail-chain rather than creating extra frames
- the JIT preserves rbp/x29 across the executor region via Tools/jit/_targets.py and the stencil validator
- the synthetic CFI we create is understood as describing that steady-state caller layout, not the transient internal stack motion of each stencil
I have tested this on my x86_64 machine and couldn't make it break so I think we are good now appart from these comments but someone should stress test this on an aarch64 machine ;)
| shdrs[SH_SYMTAB].sh_addralign = 8; | ||
| shdrs[SH_SYMTAB].sh_entsize = sizeof(Elf64_Sym); | ||
|
|
||
| struct jit_code_entry *entry = PyMem_RawMalloc(sizeof(*entry)); |
There was a problem hiding this comment.
hen we free an executor's JIT code in _PyJIT_Free, aren't we leaking the corresponding GDB JIT entries? The in-memory ELF and the jit_code_entry node are never unregistered or freed, so sh_addr and st_value end up pointing at unmapped memory. If that address range gets reused by a later JIT compilation, wouldn't GDB resolve the new code to the stale old symbol?
There was a problem hiding this comment.
This is a good catch indeed. We should free GDB memory whenever we free executors. I'm stitching something up.
| entry->symfile_addr = (const char *)buf; | ||
| entry->symfile_size = total_size; | ||
| entry->prev = NULL; | ||
| entry->next = __jit_debug_descriptor.first_entry; |
There was a problem hiding this comment.
I am assuming this is all made in a thread-safe context, otherwise multiple threads can corrupt this linked list.
There was a problem hiding this comment.
Another good catch. I don't think at the moment is a bid deal as at the moment the JIT works only in single thread mode but this doesn't meant it won't be in the future. I'm adding some mutex around the list modification.
| DWRF_U8(DWRF_CFA_def_cfa_offset); // CFA = SP + 0 (stack restored) | ||
| DWRF_UV(0); // Back to original stack position | ||
|
|
||
| if (absolute_addr) { |
There was a problem hiding this comment.
On AArch64, when absolute_addr=1, aren't the stuff for the epilogue restore+def_cfa_offset immediately overridden by the rest of the lines? Unless i am missing siomething on x86_64 we skip the epilogue with if !absolute_addr) so should we do the same here to avoid emitting dead CFI bytes? Or is there a reason the AArch64 path needs them?
There was a problem hiding this comment.
I'm reworking the AArch64 CFI to follow the same structure as the x86_64 path. So, like x86_64, the common path now establishes the steady-state FP-based rule, and only the !absolute_addr path appends the epilogue row.
| size_t code_size; | ||
| const char *entry_name; | ||
|
|
||
| if (!PyArg_ParseTuple(args, "OIs", &code_addr_v, &code_size, &entry_name)) |
There was a problem hiding this comment.
PyArg_ParseTuple format "I" writes unsigned int (4 bytes), but code_size is now size_t (8 bytes on 64-bit).
There was a problem hiding this comment.
ok, after some thinking on how to solve this I decided to change all the api to use size_t, parse code_size as Python object and then use PyLong_AsSize_t
| executor->jit_code = NULL; | ||
| executor->jit_size = 0; | ||
| #ifdef PY_HAVE_PERF_TRAMPOLINE | ||
| _PyJitUnwind_GdbUnregisterCode(memory); |
There was a problem hiding this comment.
Registration is conditional but unregistration is not. jit_record_code skips GDB registration when perf callbacks are active, but _PyJIT_Free/_PyJIT_Fini always call _PyJitUnwind_GdbUnregisterCode. This can start a party that has a cost of a mutex acquire + linked-list scan on every executor free under perf. Should mirror the registration condition no?
There was a problem hiding this comment.
yes, you are right. I've refactored a bit the whole logic now and this should fix also the issue of O(n) scan of the linked list.
| } | ||
|
|
||
| static void | ||
| jit_record_code(const void *code_addr, size_t code_size, |
There was a problem hiding this comment.
I will leave this for the future but as this is unconditionally active I assume will have a perf cost we probably want top measure
There was a problem hiding this comment.
I'm measuring it.. it might take some time.
| entry->symfile_size = total_size; | ||
| entry->code_addr = code_addr; | ||
|
|
||
| PyMutex_Lock((PyMutex *)&__jit_debug_descriptor.mutex); |
There was a problem hiding this comment.
Casting away volatile from __jit_debug_descriptor.mutex is technically UB per C11. The mutex doesn't need to be visible to GDB so consider moving it out of the volatile struct into a separate static PyMutex jit_debug_mutex;.
| #endif | ||
| } | ||
|
|
||
| void |
There was a problem hiding this comment.
The lookup is O(n) under the mutex. The list is already doubly-linked so unlink is O(1) if you store a pointer to the jit_code_entry alongside the executor, you skip thes earch entirely.
There was a problem hiding this comment.
I've changed a bit the way I get the memory to free.
| _Static_assert(sizeof(EhFrameHeader) == 20, "EhFrameHeader layout mismatch"); | ||
|
|
||
| /* DWARF encoding constants used in EH frame headers */ | ||
| static const uint8_t DwarfUData4 = 0x03; |
There was a problem hiding this comment.
These DWARF encoding are duplicates of the DWRF_EH_PE_* enum in jit_unwind.c. Since the refactoring's goal was to share DWARF code, consider exposing those constants from the shared header.
| DWRF_ALIGNNOP(sizeof(uintptr_t)); // Align to pointer boundary | ||
| ) | ||
|
|
||
| ctx->eh_frame_p = p; // Remember start of FDE data |
There was a problem hiding this comment.
I think this is dead code as this field doesn't seem to be read after the refactoring.
There was a problem hiding this comment.
thanks! I didn't notice
| @@ -0,0 +1,30 @@ | |||
| #ifndef Py_CORE_JIT_UNWIND_H | |||
There was a problem hiding this comment.
should be Py_INTERNAL_JIT_UNWIND_H
| @@ -0,0 +1,30 @@ | |||
| #ifndef Py_CORE_JIT_UNWIND_H | |||
| #define Py_CORE_JIT_UNWIND_H | |||
|
|
|||
There was a problem hiding this comment.
this is missing Py_BUILD_CORE guard no?
There was a problem hiding this comment.
yes, I've seen now the other headers files.
| #ifndef Py_CORE_JIT_UNWIND_H | ||
| #define Py_CORE_JIT_UNWIND_H | ||
|
|
||
| #ifdef PY_HAVE_PERF_TRAMPOLINE |
There was a problem hiding this comment.
The entire file is gated on PY_HAVE_PERF_TRAMPOLINE, but the GDB JIT interface is conceptually independent of perf no?
There was a problem hiding this comment.
for now, I'll add the bare minimum to address this but I already in mind some refactoring to do with another PR. Let's land this first and then I will refactor the code in light of adding libcc (for gnu backtrace)
There was a problem hiding this comment.
this solution won't be the best, but it will be improved in subsequent PRs. I don't want to keep changing this PR.
|
The failure seems to be an infra issue (cannot to apt-get update) |
The FDE described a push/mov prologue that executor stencils (-mframe-pointer=reserved) never execute, corrupting unwind at the first few bytes of every region. Move the steady-state CFI into the CIE and split the emitter into perf (unchanged) and gdb helpers.
Require linux + x86_64/aarch64 + sys._jit.is_enabled() so unsupported platforms, arches, and interpreter-only tier-2 builds skip cleanly instead of hanging or failing noisily.
Replace the fixed JIT_ENTRY_SINGLE_STEPS=2 loop with a helper that verifies frame.name() after every si, so a stencil or toolchain change that drifts the PC fails loudly instead of matching the tolerant final regex by accident.
621e29a to
77777a6
Compare
|
Hey, I did another pass and found something important in the DWARF/GDB unwind info. As I did not want to keep you pushing again and again, I pushed some commits, please check them out. The issue is that the old GDB CFI was describing the JIT executor like a normal function with a real prologue. On x86_64 it was effectively telling GDB to unwind as if the code began like this: push %rbp
mov %rsp, %rbpand the equivalent DWARF rule was basically: That is a valid description for a normal C-style entry sequence, but it is not what the executor stencils actually do. The executor code keeps the frame pointer pinned across the whole region, so there is no real prologue for GDB to “walk through” instruction by instruction. If GDB stops near the start of the executor and the DWARF says “pretend the prologue already happened”, it computes the CFA from the wrong place and can read the saved frame pointer / return address from the wrong stack slots. The new GDB-only path fixes that by describing the frame layout that is actually true while the executor is running. On x86_64 the equivalent rule is now just: with no per-PC prologue simulation in the FDE. In other words, instead of telling GDB “watch a fake prologue happen”, we now tell it “this is the frame layout for this JIT region, unwind from that”. The same idea is used on AArch64 with x29 / x30. I also validated this by hand in GDB instead of trusting only the Python test harness. I built a clean JIT-enabled tree, broke in builtin_id, finished until the selected frame was I also did a negative check by forcing the wrong unwind mode on purpose, and the backtrace immediately turned into garbage which is a strong sign that this change is fixing a real mismatch between the unwind metadata and the code we actually emit. |
Add a shared helper that asserts exactly one py::jit_entry frame above at least one eval frame, so regressions producing duplicate JIT frames or JIT-below-eval can't pass the old tolerant regex.
77777a6 to
a9c6315
Compare
The PR adds the support to GDB for unwinding JIT frames by emitting eh frames.
It reuses part of the existent infrastructure for the perf_jit from @pablogsal.
This is part of the overall plan laid out here: #126910 (comment)
The output in GDB looks like: