Skip to content

Commit 8a969c4

Browse files
fix(callgrind): seed every live thread at instrumentation start
Only the thread issuing CALLGRIND_START_INSTRUMENTATION had its shadow call stack seeded from the native one. A thread parked in a syscall at the transition has a non-empty native stack and an empty shadow stack just the same, so its first ret underflows: handleUnderflow pushes the returned-into frame as a fresh top-level context without consulting fn->skip, and an obj-skipped frame becomes a visible root carrying everything the thread runs afterwards. Under pytest-codspeed this put a CPython interpreter frame at the root of every thread-pool worker, holding ~100% of that thread's cost. With CPython 3.14's tail-calling interpreter the leaked frame is a _TAIL_CALL_<OPCODE> handler, named after whichever opcode was mid-execution when the worker parked in queue.get(). Seed all live threads instead, each under its own switch_thread since the call stack, context chain and fn stack live in globals belonging to whichever thread is switched in. A thread other than the running one is unwound from the register state saved when it was descheduled; if that yields a single frame the unwinder never got past the syscall, and seeding one entry with a fabricated entry SP would re-parent the thread's later work under it, so leave it empty. Threads created after the transition are deliberately not covered: they start at their real entry point with a genuinely empty stack. Closes COD-2349 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b19e730 commit 8a969c4

1 file changed

Lines changed: 46 additions & 4 deletions

File tree

callgrind/callstack.c

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
#include "global.h"
2828
#include "pub_tool_stacktrace.h"
29+
#include "pub_tool_threadstate.h"
2930
#if defined(VGA_arm64)
3031
#include "pub_tool_guest.h" /* VexGuestArchState, for guest_X30 */
3132
#endif
@@ -500,9 +501,10 @@ Int CLG_(unwind_call_stack)(Addr sp, Int minpops)
500501
*
501502
* Called on the OFF->ON instrumentation transition: the client (e.g.
502503
* pytest_codspeed) typically reaches CALLGRIND_START_INSTRUMENTATION several
503-
* libpython frames deep. Without seeding, csp stays at 0 while the real
504-
* stack is non-empty, and every subsequent ret trips handleUnderflow and
505-
* leaks the returned-into fn as a top-level fn= block.
504+
* libpython frames deep, and other threads sit parked mid-stack. Without
505+
* seeding, csp stays at 0 while the real stack is non-empty, and every
506+
* subsequent ret trips handleUnderflow and leaks the returned-into fn as a
507+
* top-level fn= block.
506508
*
507509
* We push a (jcc=0, skip-style) call_entry for every native frame so
508510
* SP-based unwind works. For frames that should appear in the output
@@ -512,7 +514,9 @@ Int CLG_(unwind_call_stack)(Addr sp, Int minpops)
512514
* deliberately excluded from the cxt chain — they get SP-only entries. */
513515
#define CLG_RECON_MAX_FRAMES 256
514516

515-
void CLG_(reconstruct_call_stack_from_native)(ThreadId tid)
517+
/* Seeds the call stack currently installed in the globals, which must be the
518+
* one belonging to tid. */
519+
static void seed_call_stack_from_native(ThreadId tid)
516520
{
517521
Addr ips[CLG_RECON_MAX_FRAMES];
518522
Addr sps[CLG_RECON_MAX_FRAMES];
@@ -523,6 +527,18 @@ void CLG_(reconstruct_call_stack_from_native)(ThreadId tid)
523527
UInt n = VG_(get_StackTrace)(tid, ips, CLG_RECON_MAX_FRAMES, sps, NULL, 0);
524528
if (n == 0) return;
525529

530+
/* A thread other than the running one is unwound from the register state
531+
* saved when it was descheduled, usually inside a syscall. A single frame
532+
* means the unwinder got no further than that point: the one entry would
533+
* carry a made-up entry SP (see the ce->sp comment below), and everything
534+
* the thread runs next would be re-parented under it. Starting empty is
535+
* the lesser evil. */
536+
if (n < 2 && tid != VG_(get_running_tid)()) {
537+
CLG_DEBUG(1, " seed: thread %u unwound to a single frame, skipping\n",
538+
tid);
539+
return;
540+
}
541+
526542
/* Push bottom-up: oldest caller first, current frame last. */
527543
for (Int frame = n - 1; frame >= 0; frame--) {
528544
fn_node* fn = CLG_(get_fn_node_for_addr)(ips[frame]);
@@ -581,4 +597,30 @@ void CLG_(reconstruct_call_stack_from_native)(ThreadId tid)
581597
ensure_stack_size(cs->sp + 1);
582598
cs->entry[cs->sp].cxt = 0;
583599
}
600+
601+
CLG_DEBUG(1, " seed: thread %u seeded with %u frame(s), csp=%d\n",
602+
tid, n, cs->sp);
603+
}
604+
605+
void CLG_(reconstruct_call_stack_from_native)(ThreadId requesting_tid)
606+
{
607+
/* Every live thread is seeded, not only the requesting one. A thread parked
608+
* in a syscall at the transition has a non-empty native stack and an empty
609+
* shadow stack just the same, and its first ret underflows: handleUnderflow
610+
* pushes the returned-into fn as a fresh top-level context without
611+
* consulting fn->skip, so an obj-skipped frame becomes a visible root and
612+
* everything the thread does afterwards is recorded under it.
613+
*
614+
* The call stack, context chain and fn stack live in globals belonging to
615+
* whichever thread is switched in, so each thread is seeded under its own
616+
* switch_thread. */
617+
for (ThreadId tid = 1; tid < VG_N_THREADS; tid++) {
618+
/* Zero for any tid that is not a live thread. */
619+
if (VG_(get_thread_lwpid)(tid) == 0) continue;
620+
621+
CLG_(switch_thread)(tid);
622+
seed_call_stack_from_native(tid);
623+
}
624+
625+
CLG_(switch_thread)(requesting_tid);
584626
}

0 commit comments

Comments
 (0)