Skip to content

Commit 983c84f

Browse files
authored
fix(profiler): update memalloc guard (#11460)
Previously, the memory allocation profiler would use Python's builtin thread-local storage interfaces in order to set and get the state of a thread-local guard. I've updated a few things here. * I think get/set idioms are slightly problematic for this type of code, since it pushes the responsibility of maintaining clean internal state up to the parent. A consequence of this is that the propagation of the underlying state _by value_ opens the door for race conditions if execution changes between contexts (unlikely here, but I think minimizing indirection is still cleaner). Accordingly, I've updated this to use native thread-local storage * Based on @nsrip-dd's observation, I widened the guard over `free()` operations. I believe this is correct, and if it isn't then the detriment is performance, not correctness. * I got rid of the PY37 failovers We don't have any reproductions for the defects that prompted this change, but I've been running a patched library in an environment that _does_ reproduce the behavior, and I haven't seen any defects. 1. I don't believe this patch is harmful, and if our memory allocation tests pass then I believe it should be fine. 2. I have a reason to believe this fixes a critical defect, which can cause crashes. ## Checklist - [X] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)
1 parent d855c4a commit 983c84f

File tree

8 files changed

+340
-88
lines changed

8 files changed

+340
-88
lines changed

ddtrace/profiling/collector/_memalloc.c

Lines changed: 84 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -42,47 +42,95 @@ static PyObject* object_string = NULL;
4242

4343
#define ALLOC_TRACKER_MAX_COUNT UINT64_MAX
4444

45+
// The data coordination primitives in this and related files are related to a crash we started seeing.
46+
// We don't have a precise understanding of the causal factors within the runtime that lead to this condition,
47+
// since the GIL alone was sufficient in the past for preventing this issue.
48+
// We add an option here to _add_ a crash, in order to observe this condition in a future diagnostic iteration.
49+
// **This option is _intended_ to crash the Python process** do not use without a good reason!
50+
static char g_crash_on_mutex_pass_str[] = "_DD_PROFILING_MEMALLOC_CRASH_ON_MUTEX_PASS";
51+
static const char* g_truthy_values[] = { "1", "true", "yes", "on", "enable", "enabled", NULL }; // NB the sentinel NULL
52+
static memlock_t g_memalloc_lock;
53+
4554
static alloc_tracker_t* global_alloc_tracker;
4655

56+
// This is a multiplatform way to define an operation to happen at static initialization time
57+
static void
58+
memalloc_init(void);
59+
60+
#ifdef _MSC_VER
61+
#pragma section(".CRT$XCU", read)
62+
__declspec(allocate(".CRT$XCU")) void (*memalloc_init_func)(void) = memalloc_init;
63+
64+
#elif defined(__GNUC__) || defined(__clang__)
65+
__attribute__((constructor))
66+
#else
67+
#error Unsupported compiler
68+
#endif
69+
static void
70+
memalloc_init()
71+
{
72+
// Check if we should crash the process on mutex pass
73+
char* crash_on_mutex_pass_str = getenv(g_crash_on_mutex_pass_str);
74+
bool crash_on_mutex_pass = false;
75+
if (crash_on_mutex_pass_str) {
76+
for (int i = 0; g_truthy_values[i]; i++) {
77+
if (strcmp(crash_on_mutex_pass_str, g_truthy_values[i]) == 0) {
78+
crash_on_mutex_pass = true;
79+
break;
80+
}
81+
}
82+
}
83+
memlock_init(&g_memalloc_lock, crash_on_mutex_pass);
84+
}
85+
4786
static void
4887
memalloc_add_event(memalloc_context_t* ctx, void* ptr, size_t size)
4988
{
50-
/* Do not overflow; just ignore the new events if we ever reach that point */
51-
if (global_alloc_tracker->alloc_count >= ALLOC_TRACKER_MAX_COUNT)
89+
uint64_t alloc_count = atomic_add_clamped(&global_alloc_tracker->alloc_count, 1, ALLOC_TRACKER_MAX_COUNT);
90+
91+
/* Return if we've reached the maximum number of allocations */
92+
if (alloc_count == 0)
5293
return;
5394

54-
global_alloc_tracker->alloc_count++;
95+
// Return if we can't take the guard
96+
if (!memalloc_take_guard()) {
97+
return;
98+
}
5599

56-
/* Avoid loops */
57-
if (memalloc_get_reentrant())
100+
// In this implementation, the `global_alloc_tracker` isn't intrinsically protected. Before we read or modify,
101+
// take the lock. The count of allocations is already forward-attributed elsewhere, so if we can't take the lock
102+
// there's nothing to do.
103+
if (!memlock_trylock(&g_memalloc_lock)) {
58104
return;
105+
}
59106

60107
/* Determine if we can capture or if we need to sample */
61108
if (global_alloc_tracker->allocs.count < ctx->max_events) {
62-
/* set a barrier so we don't loop as getting a traceback allocates memory */
63-
memalloc_set_reentrant(true);
64109
/* Buffer is not full, fill it */
65110
traceback_t* tb = memalloc_get_traceback(ctx->max_nframe, ptr, size, ctx->domain);
66-
memalloc_set_reentrant(false);
67-
if (tb)
111+
if (tb) {
68112
traceback_array_append(&global_alloc_tracker->allocs, tb);
113+
}
69114
} else {
70115
/* Sampling mode using a reservoir sampling algorithm: replace a random
71116
* traceback with this one */
72-
uint64_t r = random_range(global_alloc_tracker->alloc_count);
117+
uint64_t r = random_range(alloc_count);
73118

74-
if (r < ctx->max_events) {
75-
/* set a barrier so we don't loop as getting a traceback allocates memory */
76-
memalloc_set_reentrant(true);
119+
// In addition to event size, need to check that the tab is in a good state
120+
if (r < ctx->max_events && global_alloc_tracker->allocs.tab != NULL) {
77121
/* Replace a random traceback with this one */
78122
traceback_t* tb = memalloc_get_traceback(ctx->max_nframe, ptr, size, ctx->domain);
79-
memalloc_set_reentrant(false);
123+
124+
// Need to check not only that the tb returned
80125
if (tb) {
81126
traceback_free(global_alloc_tracker->allocs.tab[r]);
82127
global_alloc_tracker->allocs.tab[r] = tb;
83128
}
84129
}
85130
}
131+
132+
memlock_unlock(&g_memalloc_lock);
133+
memalloc_yield_guard();
86134
}
87135

88136
static void
@@ -98,12 +146,6 @@ memalloc_free(void* ctx, void* ptr)
98146
alloc->free(alloc->ctx, ptr);
99147
}
100148

101-
#ifdef _PY37_AND_LATER
102-
Py_tss_t memalloc_reentrant_key = Py_tss_NEEDS_INIT;
103-
#else
104-
int memalloc_reentrant_key = -1;
105-
#endif
106-
107149
static void*
108150
memalloc_alloc(int use_calloc, void* ctx, size_t nelem, size_t elsize)
109151
{
@@ -233,7 +275,10 @@ memalloc_start(PyObject* Py_UNUSED(module), PyObject* args)
233275

234276
global_memalloc_ctx.domain = PYMEM_DOMAIN_OBJ;
235277

236-
global_alloc_tracker = alloc_tracker_new();
278+
if (memlock_trylock(&g_memalloc_lock)) {
279+
global_alloc_tracker = alloc_tracker_new();
280+
memlock_unlock(&g_memalloc_lock);
281+
}
237282

238283
PyMem_GetAllocator(PYMEM_DOMAIN_OBJ, &global_memalloc_ctx.pymem_allocator_obj);
239284
PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &alloc);
@@ -258,8 +303,11 @@ memalloc_stop(PyObject* Py_UNUSED(module), PyObject* Py_UNUSED(args))
258303

259304
PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &global_memalloc_ctx.pymem_allocator_obj);
260305
memalloc_tb_deinit();
261-
alloc_tracker_free(global_alloc_tracker);
262-
global_alloc_tracker = NULL;
306+
if (memlock_trylock(&g_memalloc_lock)) {
307+
alloc_tracker_free(global_alloc_tracker);
308+
global_alloc_tracker = NULL;
309+
memlock_unlock(&g_memalloc_lock);
310+
}
263311

264312
memalloc_heap_tracker_deinit();
265313

@@ -310,9 +358,15 @@ iterevents_new(PyTypeObject* type, PyObject* Py_UNUSED(args), PyObject* Py_UNUSE
310358
if (!iestate)
311359
return NULL;
312360

313-
iestate->alloc_tracker = global_alloc_tracker;
314361
/* reset the current traceback list */
315-
global_alloc_tracker = alloc_tracker_new();
362+
if (memlock_trylock(&g_memalloc_lock)) {
363+
iestate->alloc_tracker = global_alloc_tracker;
364+
global_alloc_tracker = alloc_tracker_new();
365+
memlock_unlock(&g_memalloc_lock);
366+
} else {
367+
Py_TYPE(iestate)->tp_free(iestate);
368+
return NULL;
369+
}
316370
iestate->seq_index = 0;
317371

318372
PyObject* iter_and_count = PyTuple_New(3);
@@ -326,8 +380,11 @@ iterevents_new(PyTypeObject* type, PyObject* Py_UNUSED(args), PyObject* Py_UNUSE
326380
static void
327381
iterevents_dealloc(IterEventsState* iestate)
328382
{
329-
alloc_tracker_free(iestate->alloc_tracker);
330-
Py_TYPE(iestate)->tp_free(iestate);
383+
if (memlock_trylock(&g_memalloc_lock)) {
384+
alloc_tracker_free(iestate->alloc_tracker);
385+
Py_TYPE(iestate)->tp_free(iestate);
386+
memlock_unlock(&g_memalloc_lock);
387+
}
331388
}
332389

333390
static PyObject*
@@ -442,20 +499,6 @@ PyInit__memalloc(void)
442499
return NULL;
443500
}
444501

445-
#ifdef _PY37_AND_LATER
446-
if (PyThread_tss_create(&memalloc_reentrant_key) != 0) {
447-
#else
448-
memalloc_reentrant_key = PyThread_create_key();
449-
if (memalloc_reentrant_key == -1) {
450-
#endif
451-
#ifdef MS_WINDOWS
452-
PyErr_SetFromWindowsErr(0);
453-
#else
454-
PyErr_SetFromErrno(PyExc_OSError);
455-
#endif
456-
return NULL;
457-
}
458-
459502
if (PyType_Ready(&MemallocIterEvents_Type) < 0)
460503
return NULL;
461504
Py_INCREF((PyObject*)&MemallocIterEvents_Type);

ddtrace/profiling/collector/_memalloc_heap.c

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
typedef struct
1010
{
1111
/* Granularity of the heap profiler in bytes */
12-
uint32_t sample_size;
12+
uint64_t sample_size;
1313
/* Current sample size of the heap profiler in bytes */
14-
uint32_t current_sample_size;
14+
uint64_t current_sample_size;
1515
/* Tracked allocations */
1616
traceback_array_t allocs;
1717
/* Allocated memory counter in bytes */
18-
uint32_t allocated_memory;
18+
uint64_t allocated_memory;
1919
/* True if the heap tracker is frozen */
2020
bool frozen;
2121
/* Contains the ongoing heap allocation/deallocation while frozen */
@@ -26,8 +26,42 @@ typedef struct
2626
} freezer;
2727
} heap_tracker_t;
2828

29+
static char g_crash_on_mutex_pass_str[] = "_DD_PROFILING_MEMHEAP_CRASH_ON_MUTEX_PASS";
30+
static const char* g_truthy_values[] = { "1", "true", "yes", "on", "enable", "enabled", NULL }; // NB the sentinel NULL
31+
static memlock_t g_memheap_lock;
32+
2933
static heap_tracker_t global_heap_tracker;
3034

35+
// This is a multiplatform way to define an operation to happen at static initialization time
36+
static void
37+
memheap_init(void);
38+
39+
#ifdef _MSC_VER
40+
#pragma section(".CRT$XCU", read)
41+
__declspec(allocate(".CRT$XCU")) void (*memheap_init_func)(void) = memheap_init;
42+
43+
#elif defined(__GNUC__) || defined(__clang__)
44+
__attribute__((constructor))
45+
#else
46+
#error Unsupported compiler
47+
#endif
48+
static void
49+
memheap_init()
50+
{
51+
// Check if we should crash the process on mutex pass
52+
char* crash_on_mutex_pass_str = getenv(g_crash_on_mutex_pass_str);
53+
bool crash_on_mutex_pass = false;
54+
if (crash_on_mutex_pass_str) {
55+
for (int i = 0; g_truthy_values[i]; i++) {
56+
if (strcmp(crash_on_mutex_pass_str, g_truthy_values[i]) == 0) {
57+
crash_on_mutex_pass = true;
58+
break;
59+
}
60+
}
61+
}
62+
memlock_init(&g_memheap_lock, crash_on_mutex_pass);
63+
}
64+
3165
static uint32_t
3266
heap_tracker_next_sample_size(uint32_t sample_size)
3367
{
@@ -119,20 +153,30 @@ heap_tracker_thaw(heap_tracker_t* heap_tracker)
119153
void
120154
memalloc_heap_tracker_init(uint32_t sample_size)
121155
{
122-
heap_tracker_init(&global_heap_tracker);
123-
global_heap_tracker.sample_size = sample_size;
124-
global_heap_tracker.current_sample_size = heap_tracker_next_sample_size(sample_size);
156+
157+
if (memlock_trylock(&g_memheap_lock)) {
158+
heap_tracker_init(&global_heap_tracker);
159+
global_heap_tracker.sample_size = sample_size;
160+
global_heap_tracker.current_sample_size = heap_tracker_next_sample_size(sample_size);
161+
memlock_unlock(&g_memheap_lock);
162+
}
125163
}
126164

127165
void
128166
memalloc_heap_tracker_deinit(void)
129167
{
130-
heap_tracker_wipe(&global_heap_tracker);
168+
if (memlock_trylock(&g_memheap_lock)) {
169+
heap_tracker_wipe(&global_heap_tracker);
170+
memlock_unlock(&g_memheap_lock);
171+
}
131172
}
132173

133174
void
134175
memalloc_heap_untrack(void* ptr)
135176
{
177+
if (!memlock_trylock(&g_memheap_lock)) {
178+
return;
179+
}
136180
if (global_heap_tracker.frozen) {
137181
/* Check that we still have space to store the free. If we don't have
138182
enough space, we ignore the untrack. That's sad as there is a change
@@ -144,6 +188,8 @@ memalloc_heap_untrack(void* ptr)
144188
ptr_array_append(&global_heap_tracker.freezer.frees, ptr);
145189
} else
146190
heap_tracker_untrack_thawed(&global_heap_tracker, ptr);
191+
192+
memlock_unlock(&g_memheap_lock);
147193
}
148194

149195
/* Track a memory allocation in the heap profiler.
@@ -157,26 +203,36 @@ memalloc_heap_track(uint16_t max_nframe, void* ptr, size_t size, PyMemAllocatorD
157203
return false;
158204

159205
/* Check for overflow */
160-
global_heap_tracker.allocated_memory = Py_MIN(global_heap_tracker.allocated_memory + size, MAX_HEAP_SAMPLE_SIZE);
206+
uint64_t res = atomic_add_clamped(&global_heap_tracker.allocated_memory, size, MAX_HEAP_SAMPLE_SIZE);
207+
if (0 == res)
208+
return false;
209+
210+
// Take the lock
211+
if (!memlock_trylock(&g_memheap_lock)) {
212+
return false;
213+
}
161214

162215
/* Check if we have enough sample or not */
163-
if (global_heap_tracker.allocated_memory < global_heap_tracker.current_sample_size)
216+
if (global_heap_tracker.allocated_memory < global_heap_tracker.current_sample_size) {
217+
memlock_unlock(&g_memheap_lock);
164218
return false;
219+
}
165220

166221
/* Check if we can add more samples: the sum of the freezer + alloc tracker
167222
cannot be greater than what the alloc tracker can handle: when the alloc
168223
tracker is thawed, all the allocs in the freezer will be moved there!*/
169-
if ((global_heap_tracker.freezer.allocs.count + global_heap_tracker.allocs.count) >= TRACEBACK_ARRAY_MAX_COUNT)
224+
if (global_heap_tracker.freezer.allocs.count + global_heap_tracker.allocs.count >= TRACEBACK_ARRAY_MAX_COUNT) {
225+
memlock_unlock(&g_memheap_lock);
170226
return false;
227+
}
171228

172229
/* Avoid loops */
173-
if (memalloc_get_reentrant())
230+
if (!memalloc_take_guard()) {
231+
memlock_unlock(&g_memheap_lock);
174232
return false;
233+
}
175234

176-
memalloc_set_reentrant(true);
177235
traceback_t* tb = memalloc_get_traceback(max_nframe, ptr, global_heap_tracker.allocated_memory, domain);
178-
memalloc_set_reentrant(false);
179-
180236
if (tb) {
181237
if (global_heap_tracker.frozen)
182238
traceback_array_append(&global_heap_tracker.freezer.allocs, tb);
@@ -189,15 +245,23 @@ memalloc_heap_track(uint16_t max_nframe, void* ptr, size_t size, PyMemAllocatorD
189245
/* Compute the new target sample size */
190246
global_heap_tracker.current_sample_size = heap_tracker_next_sample_size(global_heap_tracker.sample_size);
191247

248+
memalloc_yield_guard();
249+
memlock_unlock(&g_memheap_lock);
192250
return true;
193251
}
194252

253+
memalloc_yield_guard();
254+
memlock_unlock(&g_memheap_lock);
195255
return false;
196256
}
197257

198258
PyObject*
199259
memalloc_heap()
200260
{
261+
if (!memlock_trylock(&g_memheap_lock)) {
262+
return NULL;
263+
}
264+
201265
heap_tracker_freeze(&global_heap_tracker);
202266

203267
PyObject* heap_list = PyList_New(global_heap_tracker.allocs.count);
@@ -213,5 +277,6 @@ memalloc_heap()
213277

214278
heap_tracker_thaw(&global_heap_tracker);
215279

280+
memlock_unlock(&g_memheap_lock);
216281
return heap_list;
217282
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#include "_memalloc_reentrant.h"
2+
3+
bool _MEMALLOC_ON_THREAD = false;

0 commit comments

Comments
 (0)