Skip to content

Commit a9a87f7

Browse files
fix(profiling): fix issue with boundary asyncio frames
1 parent 37d2e79 commit a9a87f7

4 files changed

Lines changed: 67 additions & 34 deletions

File tree

ddtrace/internal/datadog/profiling/stack/echion/echion/echion_sampler.h

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
// Forward declaration
1818
class Frame;
1919

20+
// Identity of the frame that separates the asyncio machinery from the pure Python stack.
21+
// We memoize the interned name and filename rather than a Frame cache key: the cache key
22+
// mixes in the bytecode offset, so it only matches the boundary frame while it sits on the
23+
// very instruction it happened to be on when we first identified it.
24+
struct BoundaryFrame
25+
{
26+
StringTable::Key name = 0;
27+
StringTable::Key filename = 0;
28+
};
29+
2030
class EchionSampler
2131
{
2232
// Thread Info map (Thread ID -> ThreadInfo)
@@ -39,8 +49,8 @@ class EchionSampler
3949
PyObject* asyncio_eager_tasks_ = nullptr;
4050

4151
// Task unwinding state
42-
std::optional<Frame::Key> asyncio_frame_cache_key_;
43-
std::optional<Frame::Key> uvloop_frame_cache_key_;
52+
std::optional<BoundaryFrame> asyncio_boundary_frame_;
53+
std::optional<BoundaryFrame> uvloop_boundary_frame_;
4454
std::unordered_set<PyObject*> previous_task_objects_;
4555

4656
// Sampling-thread scratch buffer. Only the single sampling thread
@@ -99,8 +109,8 @@ class EchionSampler
99109
asyncio_eager_tasks_ = (eager_tasks != Py_None) ? eager_tasks : nullptr;
100110
}
101111

102-
std::optional<Frame::Key>& asyncio_frame_cache_key() { return asyncio_frame_cache_key_; }
103-
std::optional<Frame::Key>& uvloop_frame_cache_key() { return uvloop_frame_cache_key_; }
112+
std::optional<BoundaryFrame>& asyncio_boundary_frame() { return asyncio_boundary_frame_; }
113+
std::optional<BoundaryFrame>& uvloop_boundary_frame() { return uvloop_boundary_frame_; }
104114
std::unordered_set<PyObject*>& previous_task_objects() { return previous_task_objects_; }
105115

106116
std::unordered_set<PyObject*>& seen_frames_scratch() { return seen_frames_scratch_; }
@@ -149,8 +159,8 @@ class EchionSampler
149159
new (&greenlet_thread_map_) std::unordered_map<uintptr_t, GreenletInfo::ID>();
150160
new (&previous_task_objects_) std::unordered_set<PyObject*>();
151161

152-
asyncio_frame_cache_key_.reset();
153-
uvloop_frame_cache_key_.reset();
162+
asyncio_boundary_frame_.reset();
163+
uvloop_boundary_frame_.reset();
154164
asyncio_task_count_ = 0;
155165
rng_ = std::minstd_rand{ std::random_device{}() };
156166

ddtrace/internal/datadog/profiling/stack/echion/echion/threads.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ class ThreadInfo
6363
[[nodiscard]] Result<void> sample(EchionSampler&, PyThreadState*, microsecond_t);
6464
void unwind(EchionSampler&, PyThreadState*, microsecond_t wall_time_us);
6565

66+
// Number of frames in python_stack from the asyncio boundary frame (inclusive) up to the root,
67+
// that is to say the asyncio machinery plus the synchronous entry point. Returns the size of the
68+
// whole stack when the boundary frame is not there, which is the case for a thread that is not
69+
// running an event loop.
70+
[[nodiscard]] size_t find_upper_python_stack_size(EchionSampler&) const;
71+
6672
// ------------------------------------------------------------------------
6773
#if defined PL_LINUX
6874
ThreadInfo(uintptr_t thread_id, unsigned long native_id, const char* name, clockid_t cpu_clock_id)

ddtrace/internal/datadog/profiling/stack/src/echion/threads.cc

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,37 +39,46 @@ ThreadInfo::unwind(EchionSampler& echion, PyThreadState* tstate, microsecond_t w
3939
}
4040

4141
// ----------------------------------------------------------------------------
42-
Result<void>
43-
ThreadInfo::unwind_tasks(EchionSampler& echion, PyThreadState* tstate, microsecond_t wall_time_us)
42+
size_t
43+
ThreadInfo::find_upper_python_stack_size(EchionSampler& echion) const
4444
{
45-
// The size of the "pure Python" stack (before asyncio Frames).
4645
// Defaults to the full Python stack size (and updated if we find the boundary frame)
4746
size_t upper_python_stack_size = python_stack.size();
4847

4948
// Check if the Python stack contains the asyncio boundary frame.
5049
// For regular asyncio, this is "Handle._run" from asyncio/events.py.
5150
// For uvloop, this is "Runner.run" from asyncio/runners.py (uvloop uses asyncio.Runner internally).
52-
// To avoid having to do string comparisons every time we unwind Tasks, we keep track
53-
// of the cache key of the boundary frame.
51+
// To avoid having to do string comparisons every time we unwind Tasks, we memoize the interned
52+
// name and filename of the boundary Frame the first time we identify it. Those are already
53+
// StringTable keys, so recognizing the boundary afterwards costs two integer comparisons.
54+
//
55+
// Do NOT memoize Frame::cache_key here: it mixes in the bytecode offset, so it would only match
56+
// the boundary Frame while it sits on the instruction it happened to be on when we first saw it.
57+
// A single sample taken while, say, Runner.run is still in its prologue would then hide the
58+
// boundary for the rest of the process, and every Task stack would be rendered with the
59+
// coroutine frames at the leaf and the whole thread stack appended below them.
5460

55-
// Note: We use separate cache keys for asyncio and uvloop because switching between them
61+
// Note: We memoize asyncio and uvloop separately because switching between them
5662
// (though unlikely at runtime) would cause incorrect boundary detection otherwise.
57-
auto& asyncio_frame_cache_key = echion.asyncio_frame_cache_key();
58-
auto& uvloop_frame_cache_key = echion.uvloop_frame_cache_key();
63+
auto& asyncio_boundary_frame = echion.asyncio_boundary_frame();
64+
auto& uvloop_boundary_frame = echion.uvloop_boundary_frame();
65+
66+
auto& boundary_frame = using_uvloop ? uvloop_boundary_frame : asyncio_boundary_frame;
67+
68+
for (size_t i = 0; i < python_stack.size(); i++) {
69+
const auto& frame = python_stack[i];
5970

60-
auto& frame_cache_key = using_uvloop ? uvloop_frame_cache_key : asyncio_frame_cache_key;
71+
bool is_boundary_frame = false;
6172

62-
if (!frame_cache_key) {
63-
for (size_t i = 0; i < python_stack.size(); i++) {
64-
const auto& frame = python_stack[i];
73+
if (boundary_frame) {
74+
is_boundary_frame = frame.name == boundary_frame->name && frame.filename == boundary_frame->filename;
75+
} else {
6576
auto maybe_frame_name = echion.string_table().lookup(frame.name);
6677
if (!maybe_frame_name) {
6778
continue;
6879
}
6980
const auto& frame_name = maybe_frame_name->get();
7081

71-
bool is_boundary_frame = false;
72-
7382
if (using_uvloop) {
7483
// For uvloop, the boundary frame depends on the Python version:
7584
// - Python 3.11+: Runner.run from asyncio/runners.py (uvloop uses asyncio.Runner)
@@ -113,25 +122,26 @@ ThreadInfo::unwind_tasks(EchionSampler& echion, PyThreadState* tstate, microseco
113122
}
114123

115124
if (is_boundary_frame) {
116-
// Although Frames are stored in an LRUCache, the cache key is ALWAYS the same
117-
// even if the Frame gets evicted from the cache.
118-
// This means we can keep the cache key and reuse it to determine
119-
// whether we see the boundary Frame in the Python stack.
120-
frame_cache_key = frame.cache_key;
121-
upper_python_stack_size = python_stack.size() - i;
122-
break;
125+
boundary_frame = BoundaryFrame{ frame.name, frame.filename };
123126
}
124127
}
125-
} else {
126-
for (size_t i = 0; i < python_stack.size(); i++) {
127-
const auto& frame = python_stack[i];
128-
if (frame.cache_key == *frame_cache_key) {
129-
upper_python_stack_size = python_stack.size() - i;
130-
break;
131-
}
128+
129+
if (is_boundary_frame) {
130+
upper_python_stack_size = python_stack.size() - i;
131+
break;
132132
}
133133
}
134134

135+
return upper_python_stack_size;
136+
}
137+
138+
// ----------------------------------------------------------------------------
139+
Result<void>
140+
ThreadInfo::unwind_tasks(EchionSampler& echion, PyThreadState* tstate, microsecond_t wall_time_us)
141+
{
142+
// The size of the "pure Python" stack (before asyncio Frames).
143+
const size_t upper_python_stack_size = find_upper_python_stack_size(echion);
144+
135145
std::vector<TaskInfo::Ref> leaf_tasks;
136146
std::unordered_set<PyObject*> parent_tasks;
137147
std::unordered_map<PyObject*, TaskInfo::Ref> waitee_map; // Indexed by task origin
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
fixes:
3+
- |
4+
profiling: Fixes an issue where asyncio task samples could be reported with the coroutine
5+
frames at the top of the stack and the calling frames duplicated underneath them, making
6+
the flame graph unusable for the affected process. This was most likely to happen with
7+
``uvloop``.

0 commit comments

Comments
 (0)