Skip to content
Prev Previous commit
Next Next commit
gh-149584: Batch predicted profiler reads
Use the frame cache to predict the next thread state and top frame
address, then batch interpreter/thread/frame reads with process_vm_readv
when profiling a Linux target. Reuse prefetched frame buffers in the
frame walker when the prediction is valid.
  • Loading branch information
pablogsal committed May 10, 2026
commit 5dc0309fb79fa09d4f4961d589982a6cea96247c
13 changes: 12 additions & 1 deletion Modules/_remote_debugging/_remote_debugging.h
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ typedef struct {

typedef struct {
uint64_t thread_id; // 0 = empty slot
uintptr_t thread_state_addr;
uintptr_t addrs[FRAME_CACHE_MAX_FRAMES];
Py_ssize_t num_addrs;
PyObject *frame_list; // owned reference, NULL if empty
Expand Down Expand Up @@ -302,6 +303,7 @@ typedef struct {
int cache_frames;
int collect_stats; // whether to collect statistics
uint32_t stale_invalidation_counter; // counter for throttling frame_cache_invalidate_stale
uintptr_t cached_tstate_addr; // predicted first thread for batched reads
Comment thread
pablogsal marked this conversation as resolved.
Outdated
RemoteDebuggingState *cached_state;
FrameCacheEntry *frame_cache; // preallocated array of FRAME_CACHE_MAX_THREADS entries
UnwinderStats stats; // statistics for performance analysis
Expand Down Expand Up @@ -361,11 +363,14 @@ typedef struct {
typedef struct {
/* Inputs */
uintptr_t frame_addr; // Starting frame address
uintptr_t thread_state_addr; // Owning thread state address
uintptr_t base_frame_addr; // Sentinel at bottom (for validation)
uintptr_t gc_frame; // GC frame address (0 if not tracking)
uintptr_t last_profiled_frame; // Last cached frame (0 if no cache)
StackChunkList *chunks; // Pre-copied stack chunks
int skip_first_frame; // Skip frame_addr itself (continue from its caller)
const char *prefetched_frame; // Optional already-read frame buffer
uintptr_t prefetched_frame_addr; // Remote address for prefetched_frame

/* Outputs */
PyObject *frame_info; // List to append FrameInfo objects
Expand Down Expand Up @@ -548,6 +553,7 @@ extern int process_frame_chain(
extern int frame_cache_init(RemoteUnwinderObject *unwinder);
extern void frame_cache_cleanup(RemoteUnwinderObject *unwinder);
extern FrameCacheEntry *frame_cache_find(RemoteUnwinderObject *unwinder, uint64_t thread_id);
extern FrameCacheEntry *frame_cache_find_by_tstate(RemoteUnwinderObject *unwinder, uintptr_t tstate_addr);
extern int clear_last_profiled_frames(RemoteUnwinderObject *unwinder);
extern void frame_cache_invalidate_stale(RemoteUnwinderObject *unwinder, PyObject *result);
extern int frame_cache_lookup_and_extend(
Expand All @@ -566,6 +572,7 @@ extern int frame_cache_store(
PyObject *frame_list,
const uintptr_t *addrs,
Py_ssize_t num_addrs,
uintptr_t thread_state_addr,
uintptr_t base_frame_addr,
uintptr_t last_frame_visited);

Expand Down Expand Up @@ -605,7 +612,11 @@ extern PyObject* unwind_stack_for_thread(
uintptr_t *current_tstate,
uintptr_t gil_holder_tstate,
uintptr_t gc_frame,
uintptr_t main_thread_tstate
uintptr_t main_thread_tstate,
const char *prefetched_tstate,
uintptr_t prefetched_tstate_addr,
const char *prefetched_frame,
uintptr_t prefetched_frame_addr
);

/* Thread stopping functions (for blocking mode) */
Expand Down
18 changes: 18 additions & 0 deletions Modules/_remote_debugging/frame_cache.c
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ frame_cache_find(RemoteUnwinderObject *unwinder, uint64_t thread_id)
return NULL;
}

FrameCacheEntry *
frame_cache_find_by_tstate(RemoteUnwinderObject *unwinder, uintptr_t tstate_addr)
{
if (!unwinder->frame_cache || tstate_addr == 0) {
return NULL;
}
for (int i = 0; i < FRAME_CACHE_MAX_THREADS; i++) {
if (unwinder->frame_cache[i].thread_state_addr == tstate_addr) {
assert(unwinder->frame_cache[i].num_addrs <= FRAME_CACHE_MAX_FRAMES);
return &unwinder->frame_cache[i];
}
}
return NULL;
}

// Allocate a cache slot for a thread
// Returns NULL if cache is full (graceful degradation)
static FrameCacheEntry *
Expand Down Expand Up @@ -129,6 +144,7 @@ frame_cache_invalidate_stale(RemoteUnwinderObject *unwinder, PyObject *result)
// Clear this entry
Py_CLEAR(unwinder->frame_cache[i].frame_list);
unwinder->frame_cache[i].thread_id = 0;
unwinder->frame_cache[i].thread_state_addr = 0;
unwinder->frame_cache[i].num_addrs = 0;
STATS_INC(unwinder, stale_cache_invalidations);
}
Expand Down Expand Up @@ -216,6 +232,7 @@ frame_cache_store(
PyObject *frame_list,
const uintptr_t *addrs,
Py_ssize_t num_addrs,
uintptr_t thread_state_addr,
uintptr_t base_frame_addr,
uintptr_t last_frame_visited)
{
Expand Down Expand Up @@ -257,6 +274,7 @@ frame_cache_store(
return -1;
}
entry->thread_id = thread_id;
entry->thread_state_addr = thread_state_addr;
memcpy(entry->addrs, addrs, num_addrs * sizeof(uintptr_t));
entry->num_addrs = num_addrs;
assert(entry->num_addrs == num_addrs);
Expand Down
79 changes: 58 additions & 21 deletions Modules/_remote_debugging/frames.c
Original file line number Diff line number Diff line change
Expand Up @@ -186,30 +186,16 @@ is_frame_valid(
return 1;
}

int
parse_frame_object(
static int
parse_frame_buffer(
RemoteUnwinderObject *unwinder,
PyObject** result,
uintptr_t address,
const char *frame,
uintptr_t* address_of_code_object,
uintptr_t* previous_frame
) {
char frame[SIZEOF_INTERP_FRAME];
*address_of_code_object = 0;

Py_ssize_t bytes_read = _Py_RemoteDebug_ReadRemoteMemory(
&unwinder->handle,
address,
SIZEOF_INTERP_FRAME,
frame
);
if (bytes_read < 0) {
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read interpreter frame");
return -1;
}
STATS_INC(unwinder, memory_reads);
STATS_ADD(unwinder, memory_bytes_read, SIZEOF_INTERP_FRAME);

*previous_frame = GET_MEMBER(uintptr_t, frame, unwinder->debug_offsets.interpreter_frame.previous);
uintptr_t code_object = GET_MEMBER_NO_TAG(uintptr_t, frame, unwinder->debug_offsets.interpreter_frame.executable);
int frame_valid = is_frame_valid(unwinder, (uintptr_t)frame, code_object);
Expand Down Expand Up @@ -237,6 +223,31 @@ parse_frame_object(
return parse_code_object(unwinder, result, &code_ctx);
}

int
parse_frame_object(
RemoteUnwinderObject *unwinder,
PyObject** result,
uintptr_t address,
uintptr_t* address_of_code_object,
uintptr_t* previous_frame
) {
char frame[SIZEOF_INTERP_FRAME];
Py_ssize_t bytes_read = _Py_RemoteDebug_ReadRemoteMemory(
&unwinder->handle,
address,
SIZEOF_INTERP_FRAME,
frame
);
if (bytes_read < 0) {
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read interpreter frame");
return -1;
}
STATS_INC(unwinder, memory_reads);
STATS_ADD(unwinder, memory_bytes_read, SIZEOF_INTERP_FRAME);

return parse_frame_buffer(unwinder, result, frame, address_of_code_object, previous_frame);
}

int
parse_frame_from_chunks(
RemoteUnwinderObject *unwinder,
Expand Down Expand Up @@ -312,15 +323,32 @@ process_frame_chain(
}
assert(frame_count <= MAX_FRAMES);

if (parse_frame_from_chunks(unwinder, &frame, frame_addr, &next_frame_addr, &stackpointer, ctx->chunks) < 0) {
if (ctx->chunks && ctx->chunks->count > 0) {
if (parse_frame_from_chunks(unwinder, &frame, frame_addr, &next_frame_addr, &stackpointer, ctx->chunks) == 0) {
goto parsed_frame;
}
PyErr_Clear();
}
{
uintptr_t address_of_code_object = 0;
if (parse_frame_object(unwinder, &frame, frame_addr, &address_of_code_object, &next_frame_addr) < 0) {
int parse_result;
if (ctx->prefetched_frame && ctx->prefetched_frame_addr == frame_addr) {
parse_result = parse_frame_buffer(
unwinder, &frame, ctx->prefetched_frame,
&address_of_code_object, &next_frame_addr);
}
else {
parse_result = parse_frame_object(
unwinder, &frame, frame_addr,
&address_of_code_object, &next_frame_addr);
}
if (parse_result < 0) {
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse frame object in chain");
return -1;
}
}

parsed_frame:
// Skip first frame if requested (used for cache miss continuation)
if (ctx->skip_first_frame && frame_count == 1) {
Py_XDECREF(frame);
Expand Down Expand Up @@ -501,8 +529,16 @@ try_full_cache_hit(
PyObject *current_frame = NULL;
uintptr_t code_object_addr = 0;
uintptr_t previous_frame = 0;
int parse_result = parse_frame_object(unwinder, &current_frame, ctx->frame_addr,
int parse_result;
if (ctx->prefetched_frame && ctx->prefetched_frame_addr == ctx->frame_addr) {
parse_result = parse_frame_buffer(unwinder, &current_frame,
ctx->prefetched_frame,
&code_object_addr, &previous_frame);
}
else {
parse_result = parse_frame_object(unwinder, &current_frame, ctx->frame_addr,
&code_object_addr, &previous_frame);
}
if (parse_result < 0) {
return -1;
}
Expand Down Expand Up @@ -606,7 +642,8 @@ collect_frames_with_cache(
}

if (frame_cache_store(unwinder, thread_id, ctx->frame_info, ctx->frame_addrs, ctx->num_addrs,
ctx->base_frame_addr, ctx->last_frame_visited) < 0) {
ctx->thread_state_addr, ctx->base_frame_addr,
ctx->last_frame_visited) < 0) {
return -1;
}

Expand Down
76 changes: 71 additions & 5 deletions Modules/_remote_debugging/module.c
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ _remote_debugging_RemoteUnwinder___init___impl(RemoteUnwinderObject *self,
self->cache_frames = cache_frames;
self->collect_stats = stats;
self->stale_invalidation_counter = 0;
self->cached_tstate_addr = 0;
self->debug = debug;
self->only_active_thread = only_active_thread;
self->mode = mode;
Expand Down Expand Up @@ -473,6 +474,46 @@ _remote_debugging_RemoteUnwinder___init___impl(RemoteUnwinderObject *self,
return 0;
}

static int
read_interp_state_and_maybe_thread_frame(
RemoteUnwinderObject *unwinder,
uintptr_t interpreter_addr,
char *interp_state_buffer,
uintptr_t predicted_tstate_addr,
char *tstate_buffer,
int *tstate_read,
uintptr_t predicted_frame_addr,
char *frame_buffer,
int *frame_read)
{
*tstate_read = 0;
*frame_read = 0;
if (predicted_tstate_addr != 0) {
size_t tstate_size = (size_t)unwinder->debug_offsets.thread_state.size;
_Py_RemoteReadSegment segments[3] = {
{interpreter_addr, interp_state_buffer, INTERP_STATE_BUFFER_SIZE},
{predicted_tstate_addr, tstate_buffer, tstate_size},
{predicted_frame_addr, frame_buffer, SIZEOF_INTERP_FRAME},
};
int nsegs = predicted_frame_addr != 0 ? 3 : 2;
Py_ssize_t nread = _Py_RemoteDebug_BatchedReadRemoteMemory(
&unwinder->handle, segments, nsegs);
if (nread >= (Py_ssize_t)INTERP_STATE_BUFFER_SIZE) {
Py_ssize_t with_tstate = (Py_ssize_t)INTERP_STATE_BUFFER_SIZE
+ (Py_ssize_t)tstate_size;
*tstate_read = nread >= with_tstate;
*frame_read = nsegs == 3
&& nread == with_tstate + (Py_ssize_t)SIZEOF_INTERP_FRAME;
return 0;
}
}
return _Py_RemoteDebug_ReadRemoteMemory(
Comment thread
pablogsal marked this conversation as resolved.
&unwinder->handle,
interpreter_addr,
INTERP_STATE_BUFFER_SIZE,
interp_state_buffer);
}

/*[clinic input]
@permit_long_docstring_body
@critical_section
Expand Down Expand Up @@ -537,11 +578,29 @@ _remote_debugging_RemoteUnwinder_get_stack_trace_impl(RemoteUnwinderObject *self
while (current_interpreter != 0) {
// Read interpreter state to get the interpreter ID
char interp_state_buffer[INTERP_STATE_BUFFER_SIZE];
if (_Py_RemoteDebug_ReadRemoteMemory(
&self->handle,
char prefetched_tstate[SIZEOF_THREAD_STATE];
char prefetched_frame[SIZEOF_INTERP_FRAME];
int have_prefetched_tstate = 0;
int have_prefetched_frame = 0;
uintptr_t predicted_tstate_addr = self->cache_frames ? self->cached_tstate_addr : 0;
uintptr_t predicted_frame_addr = 0;
if (predicted_tstate_addr != 0) {
FrameCacheEntry *entry = frame_cache_find_by_tstate(self, predicted_tstate_addr);
if (entry && entry->num_addrs > 0) {
predicted_frame_addr = entry->addrs[0];
}
}

if (read_interp_state_and_maybe_thread_frame(
self,
current_interpreter,
INTERP_STATE_BUFFER_SIZE,
interp_state_buffer) < 0) {
interp_state_buffer,
predicted_tstate_addr,
prefetched_tstate,
&have_prefetched_tstate,
predicted_frame_addr,
prefetched_frame,
&have_prefetched_frame) < 0) {
set_exception_cause(self, PyExc_RuntimeError, "Failed to read interpreter state buffer");
Py_CLEAR(result);
goto exit;
Expand Down Expand Up @@ -611,6 +670,9 @@ _remote_debugging_RemoteUnwinder_get_stack_trace_impl(RemoteUnwinderObject *self
// Target specific thread (only process first interpreter)
current_tstate = self->tstate_addr;
}
if (current_tstate != 0) {
self->cached_tstate_addr = current_tstate;
}

// Acquire main thread state information
uintptr_t main_thread_tstate = GET_MEMBER(uintptr_t, interp_state_buffer,
Expand All @@ -621,7 +683,11 @@ _remote_debugging_RemoteUnwinder_get_stack_trace_impl(RemoteUnwinderObject *self
PyObject* frame_info = unwind_stack_for_thread(self, &current_tstate,
gil_holder_tstate,
gc_frame,
main_thread_tstate);
main_thread_tstate,
have_prefetched_tstate ? prefetched_tstate : NULL,
predicted_tstate_addr,
have_prefetched_frame ? prefetched_frame : NULL,
predicted_frame_addr);
if (!frame_info) {
// Check if this was an intentional skip due to mode-based filtering
if ((self->mode == PROFILING_MODE_CPU || self->mode == PROFILING_MODE_GIL ||
Expand Down
Loading