Skip to content

Commit ab835b2

Browse files
committed
perf(profiling): allocate CPU timer TID table lazily
1 parent ce5cd2f commit ab835b2

8 files changed

Lines changed: 430 additions & 51 deletions

File tree

ddtrace/internal/datadog/profiling/docs/timer_create.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ sampling accuracy and reports the component costs for evaluation. A later
8484
rollout may widen the CPU timer interval as a last resort, but it must not
8585
silently claim that the overhead target was met.
8686

87+
### 5.2 Signal-safe TID lookup
88+
89+
The `SIGPROF` handler and nested fault-recovery handler must find the current
90+
thread's `CaptureState` from `gettid()` without locks or allocation. Linux TIDs
91+
are sparse values up to `pid_max`, commonly 4,194,304, so a dense array of one
92+
atomic pointer and one activity flag per possible TID consumes approximately
93+
36 MiB on a 64-bit process even when only a few threads are armed.
94+
95+
The implementation instead uses a two-level table. A small top-level directory
96+
covers the TID namespace in blocks of 1,024. Fixed-size leaves containing state
97+
pointers and handler-activity flags are allocated on the control path before a
98+
timer is armed for any TID in that block. Published leaves are never freed, so
99+
the signal path performs two bounded atomic lookups without requiring table
100+
reclamation. Fork-child cleanup clears only allocated leaves and retains them
101+
for reuse.
102+
103+
With the common `pid_max`, the directory is approximately 32 KiB and each
104+
allocated leaf is approximately 9 KiB. Thread churn can increase the number of
105+
retained leaves over the process lifetime, but normal memory use follows the
106+
number of TID blocks encountered rather than the entire kernel TID namespace.
107+
87108

88109
6. Thread discovery and uvicorn worker fix
89110
------------------------------------------

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ struct DebugStats
2828
uint64_t reused_altstack_too_small_count = 0;
2929
uint64_t blocked_signal_count = 0;
3030
uint64_t tid_out_of_bounds = 0;
31+
uint64_t tid_table_directory_size = 0;
32+
uint64_t tid_table_allocated_pages = 0;
33+
uint64_t tid_table_allocation_failures = 0;
3134
uint64_t timer_syscall_failures = 0;
3235
uint64_t accepted_signal_oob_tid_count = 0;
3336
uint64_t handler_hijack_disable_count = 0;
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#pragma once
2+
3+
#include <atomic>
4+
#include <cstddef>
5+
#include <cstdint>
6+
#include <memory>
7+
#include <new>
8+
9+
namespace Datadog {
10+
namespace CpuTimer {
11+
12+
// Sparse, signal-safe mapping from Linux TIDs to per-thread capture state.
13+
//
14+
// Linux TIDs are sparse values in [1, pid_max], so a single directly indexed
15+
// table consumes roughly 32 MiB for pointers and 4 MiB for handler activity on
16+
// a typical pid_max of 4,194,304. This table retains direct indexing without
17+
// materializing that entire range. The small top-level directory is allocated
18+
// once, and fixed-size leaves are allocated on the control path before a timer
19+
// is armed for any TID in that leaf.
20+
//
21+
// Published leaves are intentionally never freed. Signal and fault handlers can
22+
// therefore perform two bounded atomic lookups without locks, allocation, or a
23+
// reclamation protocol for the table itself. reset() clears entries after fork
24+
// while retaining the already published leaves.
25+
template<typename T, size_t PageSize = 1024>
26+
class CpuTimerTidTable
27+
{
28+
static_assert(PageSize > 0, "TID table pages must contain at least one entry");
29+
static_assert(std::atomic<T*>::is_always_lock_free, "signal handler requires lock-free state pointer atomics");
30+
static_assert(std::atomic<bool>::is_always_lock_free, "signal handler requires lock-free activity atomics");
31+
32+
struct Page
33+
{
34+
std::atomic<T*> states[PageSize];
35+
std::atomic<bool> handler_active[PageSize];
36+
37+
Page() noexcept { reset(); }
38+
39+
void reset() noexcept
40+
{
41+
for (size_t i = 0; i < PageSize; i++) {
42+
states[i].store(nullptr, std::memory_order_relaxed);
43+
handler_active[i].store(false, std::memory_order_relaxed);
44+
}
45+
}
46+
};
47+
48+
static_assert(std::atomic<Page*>::is_always_lock_free,
49+
"signal handler requires lock-free directory pointer atomics");
50+
51+
std::unique_ptr<std::atomic<Page*>[]> directory_;
52+
size_t directory_size_ = 0;
53+
size_t max_tid_ = 0;
54+
55+
[[nodiscard]] static size_t page_index(uint64_t tid) noexcept { return (static_cast<size_t>(tid) - 1) / PageSize; }
56+
57+
[[nodiscard]] static size_t page_offset(uint64_t tid) noexcept { return (static_cast<size_t>(tid) - 1) % PageSize; }
58+
59+
[[nodiscard]] Page* page_for(uint64_t tid) const noexcept
60+
{
61+
if (!contains(tid)) {
62+
return nullptr;
63+
}
64+
return directory_[page_index(tid)].load(std::memory_order_acquire);
65+
}
66+
67+
public:
68+
struct HandlerToken
69+
{
70+
std::atomic<bool>* active = nullptr;
71+
};
72+
73+
CpuTimerTidTable() = default;
74+
~CpuTimerTidTable()
75+
{
76+
for (size_t i = 0; i < directory_size_; i++) {
77+
delete directory_[i].load(std::memory_order_relaxed);
78+
}
79+
}
80+
CpuTimerTidTable(const CpuTimerTidTable&) = delete;
81+
CpuTimerTidTable& operator=(const CpuTimerTidTable&) = delete;
82+
83+
[[nodiscard]] bool initialize(size_t max_tid) noexcept
84+
{
85+
if (directory_ != nullptr) {
86+
return max_tid == max_tid_;
87+
}
88+
if (max_tid == 0) {
89+
return false;
90+
}
91+
92+
const size_t directory_size = (max_tid - 1) / PageSize + 1;
93+
auto* directory = new (std::nothrow) std::atomic<Page*>[directory_size];
94+
if (directory == nullptr) {
95+
return false;
96+
}
97+
for (size_t i = 0; i < directory_size; i++) {
98+
directory[i].store(nullptr, std::memory_order_relaxed);
99+
}
100+
101+
directory_.reset(directory);
102+
directory_size_ = directory_size;
103+
max_tid_ = max_tid;
104+
return true;
105+
}
106+
107+
[[nodiscard]] bool contains(uint64_t tid) const noexcept { return tid > 0 && tid <= max_tid_; }
108+
109+
// Called on the normal control path before publishing or arming a timer.
110+
// Concurrent callers are supported even though the engine currently also
111+
// serializes registration with its registry mutex.
112+
[[nodiscard]] bool ensure(uint64_t tid) noexcept
113+
{
114+
if (!contains(tid)) {
115+
return false;
116+
}
117+
118+
const size_t index = page_index(tid);
119+
Page* page = directory_[index].load(std::memory_order_acquire);
120+
if (page != nullptr) {
121+
return true;
122+
}
123+
124+
auto* candidate = new (std::nothrow) Page();
125+
if (candidate == nullptr) {
126+
return false;
127+
}
128+
129+
Page* expected = nullptr;
130+
if (!directory_[index].compare_exchange_strong(
131+
expected, candidate, std::memory_order_release, std::memory_order_acquire)) {
132+
delete candidate;
133+
}
134+
return true;
135+
}
136+
137+
[[nodiscard]] bool publish(uint64_t tid, T* state) noexcept
138+
{
139+
Page* page = page_for(tid);
140+
if (page == nullptr) {
141+
return false;
142+
}
143+
page->states[page_offset(tid)].store(state, std::memory_order_seq_cst);
144+
return true;
145+
}
146+
147+
void clear(uint64_t tid) noexcept
148+
{
149+
Page* page = page_for(tid);
150+
if (page != nullptr) {
151+
page->states[page_offset(tid)].store(nullptr, std::memory_order_seq_cst);
152+
}
153+
}
154+
155+
[[nodiscard]] T* load(uint64_t tid) const noexcept
156+
{
157+
Page* page = page_for(tid);
158+
if (page == nullptr) {
159+
return nullptr;
160+
}
161+
return page->states[page_offset(tid)].load(std::memory_order_seq_cst);
162+
}
163+
164+
// Marks this TID active before loading its state. This ordering pairs with
165+
// clear() followed by is_handler_active() on the reclamation path.
166+
[[nodiscard]] bool enter_handler(uint64_t tid, T*& state, HandlerToken& token) noexcept
167+
{
168+
state = nullptr;
169+
token.active = nullptr;
170+
Page* page = page_for(tid);
171+
if (page == nullptr) {
172+
return false;
173+
}
174+
const size_t offset = page_offset(tid);
175+
token.active = &page->handler_active[offset];
176+
token.active->store(true, std::memory_order_seq_cst);
177+
state = page->states[offset].load(std::memory_order_seq_cst);
178+
return true;
179+
}
180+
181+
static void leave_handler(HandlerToken token) noexcept
182+
{
183+
if (token.active != nullptr) {
184+
token.active->store(false, std::memory_order_seq_cst);
185+
}
186+
}
187+
188+
[[nodiscard]] bool is_handler_active(uint64_t tid) const noexcept
189+
{
190+
Page* page = page_for(tid);
191+
if (page == nullptr) {
192+
return false;
193+
}
194+
return page->handler_active[page_offset(tid)].load(std::memory_order_seq_cst);
195+
}
196+
197+
// Only called when no inherited signal handler can still be executing, such
198+
// as immediately after fork in the child.
199+
void reset() noexcept
200+
{
201+
for (size_t i = 0; i < directory_size_; i++) {
202+
Page* page = directory_[i].load(std::memory_order_relaxed);
203+
if (page != nullptr) {
204+
page->reset();
205+
}
206+
}
207+
}
208+
209+
[[nodiscard]] size_t allocated_page_count() const noexcept
210+
{
211+
size_t count = 0;
212+
for (size_t i = 0; i < directory_size_; i++) {
213+
if (directory_[i].load(std::memory_order_acquire) != nullptr) {
214+
count++;
215+
}
216+
}
217+
return count;
218+
}
219+
220+
[[nodiscard]] size_t directory_size() const noexcept { return directory_size_; }
221+
[[nodiscard]] size_t max_tid() const noexcept { return max_tid_; }
222+
};
223+
224+
} // namespace CpuTimer
225+
} // namespace Datadog

0 commit comments

Comments
 (0)