Skip to content

Commit 831271e

Browse files
authored
feat(profiling): link task samples to origin task (#19082)
[PROF-15417](https://datadoghq.atlassian.net/jira/software/c/projects/PROF/boards/11?selectedIssue=PROF-15417) local ## Description When an asyncio task offloads blocking work to a `ThreadPoolExecutor` (`await loop.run_in_executor(None, fn)`), that work runs on a pool worker thread that has no asyncio task of its own. Today those samples can't be tied back to the task that submitted and is awaiting them. This adds two pprof labels on the executor worker thread's samples: - origin task id -- `id()` of the submitting asyncio task - origin task name -- that task's name origin task id is deliberately the same value the profiler already emits as task id for the task on its event-loop thread, so the offloaded work correlates back to the awaiting task with no new join key. The `futures` integration already wraps `ThreadPoolExecutor.submit` and runs the work through an intermediate `_wrap_execution` on the worker thread. We capture the current asyncio task at submit time, and at execution time register a `thread_id` -> `{task id, name}` entry in a native per-thread map for the duration of the job, clearing it in a `finally`. The stack renderer stamps the labels by looking that map up by sampled thread id. The worker thread isnt predicted ahead of time, rather, the link is made on whichever worker the pool assigns and torn down when the job completes, so pool reuse is handled correctly. ## Testing <!-- Describe your testing strategy or note what tests are included --> ## Risks <!-- Note any risks associated with this change, or "None" if no risks --> ## Additional Notes <!-- Any other information that would be helpful for reviewers --> [PROF-15417]: https://datadoghq.atlassian.net/browse/PROF-15417?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: gyuheon.oh <gyuheon.oh@datadoghq.com>
1 parent cffe63f commit 831271e

19 files changed

Lines changed: 737 additions & 67 deletions

File tree

ddtrace/contrib/internal/futures/threading.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import sys
2+
from types import ModuleType
13
from typing import Optional
24

35
import ddtrace
@@ -6,6 +8,57 @@
68
from ddtrace.trace import Context
79

810

11+
_PROFILING_STACK_MODULE = "ddtrace.internal.datadog.profiling.stack"
12+
13+
14+
def _profiling_stack() -> Optional[ModuleType]:
15+
"""
16+
Return the profiling stack module iff OriginTaskLinks is enabled.
17+
18+
We deliberately don't import it. The futures patch stays on for APM even when
19+
profiling is off; settings.profiling may still import the stack module to
20+
probe availability, so is_available alone is not enough. OriginTaskLinks is
21+
enabled after stack sampler start (Profiler.start() / DD_PROFILING_ENABLED)
22+
and disabled on stop.
23+
"""
24+
stack = sys.modules.get(_PROFILING_STACK_MODULE)
25+
if (
26+
stack is not None
27+
and getattr(stack, "is_available", False)
28+
and getattr(stack, "is_origin_task_linking_enabled", lambda: False)()
29+
):
30+
return stack
31+
return None
32+
33+
34+
def _current_origin_task() -> tuple[Optional[int], Optional[str]]:
35+
"""
36+
Identity of the asyncio task submitting this work, as (task_id, task_name).
37+
38+
`task_id` is `id(task)`, which matches the profiler's "task id" label (the
39+
task object's address), so executor worker-thread samples can be correlated
40+
back to the awaiting task's samples on the event-loop thread
41+
"""
42+
if _profiling_stack() is None:
43+
return None, None
44+
asyncio = sys.modules.get("asyncio")
45+
46+
if asyncio is None:
47+
# Not an asyncio application; there is no task to attribute this work to.
48+
return None, None
49+
50+
try:
51+
task = asyncio.current_task()
52+
except RuntimeError:
53+
# No running event loop on the submitting thread.
54+
return None, None
55+
56+
if task is None:
57+
return None, None
58+
59+
return id(task), task.get_name()
60+
61+
962
def _wrap_submit(func, args, kwargs):
1063
"""
1164
Wrap `Executor` method used to submit a work executed in another
@@ -18,6 +71,10 @@ def _wrap_submit(func, args, kwargs):
1871
"threading.submit", ()
1972
).llmobs_ctx.value
2073

74+
# Capture the submitting asyncio task so the profiler can link the
75+
# offloaded work back to it
76+
origin_task_id, origin_task_name = _current_origin_task()
77+
2178
# Shallow copy for cross-thread handoff. current_trace_context() returns the
2279
# active Context (or the current span's .context), not a detached copy.
2380
if current_ctx is not None and current_ctx.trace_id is not None and current_ctx.span_id is not None:
@@ -37,21 +94,34 @@ def _wrap_submit(func, args, kwargs):
3794
fn_args = args[1:]
3895
else:
3996
fn, fn_args = args[1], args[2:]
40-
return func(self, _wrap_execution, (current_ctx, llmobs_ctx), fn, fn_args, kwargs)
97+
return func(self, _wrap_execution, (current_ctx, llmobs_ctx, origin_task_id, origin_task_name), fn, fn_args, kwargs)
4198

4299

43-
def _wrap_execution(ctx: tuple[Optional[Context], Optional[Context]], fn, args, kwargs):
100+
def _wrap_execution(
101+
ctx: tuple[Optional[Context], Optional[Context], Optional[int], Optional[str]],
102+
fn,
103+
args,
104+
kwargs,
105+
):
44106
"""
45107
Intermediate target function that is executed in a new thread;
46108
it receives the original function with arguments and keyword
47109
arguments, including our tracing `Context`. The current context
48110
provider sets the Active context in a thread local storage
49111
variable because it's outside the asynchronous loop.
50112
"""
51-
current_ctx, llmobs_ctx = ctx
113+
current_ctx, llmobs_ctx, origin_task_id, origin_task_name = ctx
52114
if llmobs_ctx is not None:
53115
core.dispatch("threading.execution", (llmobs_ctx,))
54-
if current_ctx is not None:
55-
with ddtrace.tracer._activate_context(current_ctx):
56-
return fn(*args, **kwargs)
57-
return fn(*args, **kwargs)
116+
117+
stack = _profiling_stack() if origin_task_id is not None else None
118+
if stack is not None:
119+
stack.link_origin_task(origin_task_id, origin_task_name)
120+
try:
121+
if current_ctx is not None:
122+
with ddtrace.tracer._activate_context(current_ctx):
123+
return fn(*args, **kwargs)
124+
return fn(*args, **kwargs)
125+
finally:
126+
if stack is not None:
127+
stack.unlink_origin_task()

ddtrace/internal/datadog/profiling/dd_wrapper/include/libdatadog_helpers.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ intern_string(std::string_view s);
5454
X(thread_name, "thread name") \
5555
X(task_id, "task id") \
5656
X(task_name, "task name") \
57+
X(origin_task_id, "origin task id") \
58+
X(origin_task_name, "origin task name") \
5759
X(span_id, "span id") \
5860
X(local_root_span_id, "local root span id") \
5961
X(trace_type, "trace type") \

ddtrace/internal/datadog/profiling/dd_wrapper/include/sample.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ class Sample
129129
bool push_threadinfo(int64_t thread_id, int64_t thread_native_id, std::string_view thread_name);
130130
bool push_task_id(uint64_t task_id);
131131
bool push_task_name(std::string_view task_name);
132+
bool push_origin_task_id(uint64_t origin_task_id);
133+
bool push_origin_task_name(std::string_view origin_task_name);
132134
bool push_span_id(uint64_t span_id);
133135
bool push_local_root_span_id(uint64_t local_root_span_id);
134136
bool push_trace_type(std::string_view trace_type);

ddtrace/internal/datadog/profiling/dd_wrapper/src/sample.cpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,35 @@ Datadog::Sample::push_task_name(std::string_view task_name)
641641
return true;
642642
}
643643

644+
bool
645+
Datadog::Sample::push_origin_task_id(uint64_t origin_task_id)
646+
{
647+
static bool already_warned = false; // cppcheck-suppress threadsafety-threadsafety
648+
const int64_t recoded_id =
649+
reinterpret_cast<int64_t&>(origin_task_id); // NOLINT (cppcoreguidelines-pro-type-reinterpret-cast)
650+
if (!push_label(ExportLabelKey::origin_task_id, recoded_id)) {
651+
if (!already_warned) {
652+
already_warned = true;
653+
std::cerr << "bad push" << std::endl;
654+
}
655+
return false;
656+
}
657+
return true;
658+
}
659+
bool
660+
Datadog::Sample::push_origin_task_name(std::string_view origin_task_name)
661+
{
662+
static bool already_warned = false; // cppcheck-suppress threadsafety-threadsafety
663+
if (!push_label(ExportLabelKey::origin_task_name, origin_task_name)) {
664+
if (!already_warned) {
665+
already_warned = true;
666+
std::cerr << "bad push" << std::endl;
667+
}
668+
return false;
669+
}
670+
return true;
671+
}
672+
644673
bool
645674
Datadog::Sample::push_span_id(uint64_t span_id)
646675
{

ddtrace/internal/datadog/profiling/stack/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ add_library(
6969
src/echion/tasks.cc
7070
src/echion/threads.cc
7171
src/echion/vm.cc
72+
src/origin_task_links.cpp
7273
src/sampler.cpp
7374
src/stack.cpp
7475
src/stack_renderer.cpp

ddtrace/internal/datadog/profiling/stack/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,17 @@ def link_span(span: typing.Optional[typing.Union[context.Context, ddspan.Span]])
4141
_propagated_root.span_type = span_type
4242
_stack.link_span(span.span_id, local_root_span_id, span_type)
4343

44+
def link_origin_task(task_id: int, task_name: str) -> None:
45+
"""
46+
Record, for the current thread, the asyncio task that submitted the work now running on it.
47+
"""
48+
_stack.link_origin_task(task_id, task_name)
49+
50+
def unlink_origin_task() -> None:
51+
"""
52+
Clear the originating asyncio task for the current thread.
53+
"""
54+
_stack.unlink_origin_task()
55+
4456
except Exception as e:
4557
failure_msg = str(e)

ddtrace/internal/datadog/profiling/stack/__init__.pyi

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ from ddtrace._trace import span as ddspan
1010
# Core stack v2 functions
1111
def start(min_interval: float = ...) -> bool: ...
1212
def stop() -> None: ...
13+
def is_origin_task_linking_enabled() -> bool: ...
1314
def _native_call_registry_size() -> int: ...
1415

16+
# executor worker thread <-> originating asyncio task association
17+
def link_origin_task(task_id: int, task_name: str) -> None: ...
18+
def unlink_origin_task() -> None: ...
19+
1520
# Sampling configuration
1621
def set_adaptive_sampling(do_adaptive_sampling: bool = False) -> None: ...
1722
def set_target_overhead(target_overhead: float) -> None: ...

ddtrace/internal/datadog/profiling/stack/_stack.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ from typing import Union
1010
# Core stack v2 functions
1111
def start(min_interval: float = ...) -> bool: ...
1212
def stop() -> None: ...
13+
def is_origin_task_linking_enabled() -> bool: ...
1314

1415
# Sampling configuration
1516
def set_adaptive_sampling(do_adaptive_sampling: bool = False) -> None: ...
@@ -30,6 +31,13 @@ def link_span(
3031
span_type: Optional[str] = None,
3132
) -> None: ...
3233

34+
# executor worker thread <-> originating asyncio task association
35+
def link_origin_task(
36+
task_id: int,
37+
task_name: Optional[str] = None,
38+
) -> None: ...
39+
def unlink_origin_task() -> None: ...
40+
3341
# Thread management
3442
def register_thread(python_thread_id: int, native_id: int, name: str) -> None: ...
3543
def unregister_thread(python_thread_id: int) -> None: ...
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#pragma once
2+
3+
#include <atomic>
4+
#include <mutex>
5+
#include <optional>
6+
#include <stdint.h>
7+
#include <string>
8+
#include <unordered_map>
9+
10+
namespace Datadog {
11+
12+
// Identity of the asyncio task that submitted work to a ThreadPoolExecutor.
13+
// task_id is the task object's address (id(task) in Python)
14+
struct OriginTask
15+
{
16+
uint64_t task_id;
17+
std::string task_name;
18+
19+
OriginTask(uint64_t _task_id, std::string _task_name)
20+
: task_id(_task_id)
21+
, task_name(std::move(_task_name))
22+
{
23+
}
24+
25+
// for testing
26+
bool operator==(const OriginTask& other) const { return task_id == other.task_id && task_name == other.task_name; }
27+
};
28+
29+
// Per-(worker)-thread registry of the asyncio task that offloaded the work
30+
// currently running on that thread. Populated by the futures integration for
31+
// the duration of an executor job and accessed by stack renderer.
32+
//
33+
// Lifecycle matches the stack sampler: enable() after Sampler::start() succeeds,
34+
// disable_and_reset() before Sampler::stop(). While disabled, link/unlink/get
35+
// are no-ops so the default-on futures patch is cheap when profiling is off.
36+
class OriginTaskLinks
37+
{
38+
public:
39+
static OriginTaskLinks& get_instance()
40+
{
41+
static OriginTaskLinks instance;
42+
return instance;
43+
}
44+
45+
OriginTaskLinks(OriginTaskLinks const&) = delete;
46+
OriginTaskLinks& operator=(OriginTaskLinks const&) = delete;
47+
48+
void enable();
49+
void disable_and_reset();
50+
bool is_enabled() const { return enabled_.load(std::memory_order_acquire); }
51+
52+
void link_origin_task(uint64_t thread_id, uint64_t task_id, std::string task_name);
53+
const std::optional<OriginTask> get_origin_task(uint64_t thread_id);
54+
void unlink_origin_task(uint64_t thread_id);
55+
56+
static void postfork_child();
57+
58+
private:
59+
std::atomic<bool> enabled_{ false };
60+
std::mutex mtx;
61+
std::unordered_map<uint64_t, OriginTask> thread_id_to_origin_task;
62+
63+
// Private Constructor/Destructor
64+
OriginTaskLinks() = default;
65+
~OriginTaskLinks() = default;
66+
};
67+
68+
}

ddtrace/internal/datadog/profiling/stack/include/sampler.hpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,9 @@ class Sampler
160160
// Restart the sampling thread in the parent after fork
161161
void postfork_parent();
162162

163-
// Restart the sampler after fork if it was running
164-
void restart_after_fork();
163+
// Restart the sampler after fork if it was running.
164+
// Returns true if start() was invoked and succeeded.
165+
bool restart_after_fork();
165166
};
166167

167168
} // namespace Datadog

0 commit comments

Comments
 (0)