Repository navigation
ucp_init() can deadlock on the GLIBC loader lock in cuInit() #12078
Description
AI disclosure: I used Claude to assist in tracking this down and writing the reproducer below. I wrote the remainder of this report myself with my squishy human brain.
Describe the bug
Calling ucp_init() may deadlock attempting to load the CUDA module if another thread happens to be calling cuInit() at the same time in a process where CUDA is being dynamically loaded at runtime. Or, more generally - cuInit() should not be called from within a location likely to be holding the GLIBC dynamic loader lock.
I experienced this primarily in some multithreaded unit test runners that were spinning up CUDA contexts and UCX contexts simultaneously.
The timeline in the failure mode is:
- Parent process
dlopenslibcudaand thendlsym()scuInit, before spawning two threads ~simultaneously:- Thread 1:
- calls
ucp_init()
- calls
- Thread 2:
- calls
cuInit()
- calls
- Thread 1:
- T2 now holds CUDA's internal init lock inside
cuInit() - T1
dlopen()s the UCXcudamodule, taking the GLIBC loader lock, and callscuInitin the static constructor, before T2 has made progress; it stalls here waiting for the CUDA initialisation lock. - T2 (inside
cuInit())dlopen()slibcuda.so.1itself, attempts to take the GLIBC loader lock held by T1, and then deadlocks against T1
Example stack traces:
Thread 3 "cuInit":
#0 __lll_lock_wait () from libc.so.6
#1 pthread_mutex_lock () from libc.so.6
#2 _dl_open (file="", mode=..., caller_dlopen=<libcuda.so.1+...>) at dl-open.c:819
#7 dlopen () from libc.so.6
#8 ?? () from libcuda.so.1
#11 cuInit () from libcuda.so.1
#12 cu_main () at ucx_cuinit_dlopen_deadlock.c:130
Thread 2 "ucp_init":
#0 sched_yield () from libc.so.6
#1 ?? () from libcuda.so.1
#3 cuInit () from libcuda.so.1
#4 ucs_initializer_ctor0 () at base/cuda_md.c:166
#5 call_init (...) at dl-init.c:70
#7 _dl_init (...) at dl-init.c:117
#11 _dl_open (file=..., caller_dlopen=<ucs_load_modules+915>) at dl-open.c:873
#16 dlopen () from libc.so.6
#17 ucs_module_load_one (framework=..., module_name="cuda", flags=0) at sys/module.c:271
#18 ucs_load_modules (framework="uct", modules=":cuda:ib:rdmacm:cma", ...)
#19 uct_query_components (...) at base/uct_component.c:56
#20 ucp_fill_resources (...) at core/ucp_context.c:1919
#21 ucp_init_version (api_major_version=1, api_minor_version=20, ...)
#23 ucp_main () at ucx_cuinit_dlopen_deadlock.c:106
(gdb) x/4wx (char*)&_rtld_global+0x988 # _dl_load_lock
0x00000002 0x00000001 0x00237303 0x00000001
Claude is of the opinion that the minimal fix is for the UCT_CUDADRV_FUNC_LOG_DEBUG(cuInit(0)); line in src/uct/cuda/base/cuda_md.c file to be moved from the static initialiser to the UCS_MODULE_INIT() initialiser since that's outside the loader lock; I'm not familiar enough with the UCX codebase to determine whether that's a generally correct change to make.
Steps to Reproduce
- UCX version:
# API headers version: 1.20.0
# Git branch '', revision 4b7a6ca
# Configured with: --prefix=/d/sw/ucx/1.20.0 --disable-dependency-tracking --disable-optimizations --disable-logging --disable-debug --disable-assertions --enable-mt --disable-params-check --without-go --without-java --enable-cma --with-verbs --without-knem --with-rdmacm --without-rocm --without-xpmem --without-fuse3 --without-ugni --without-mad --without-ze --with-cuda --with-gdrcopy --with-avx
- Reproducer (Claude-generated):
/*
* ucx_cuinit_dlopen_deadlock.c
*
* Shows that ucp_init() and a concurrent first cuInit() on another thread
* deadlock. Two locks are involved:
*
* - ucp_init() loads the "cuda" uct module with dlopen(). That module's
* UCS_STATIC_INIT (src/uct/cuda/base/cuda_md.c) calls cuInit(0) from the
* library constructor, so cuInit runs while glibc's loader lock
* (_dl_load_lock) is held.
* - The process's first cuInit() takes libcuda's internal init lock and,
* while holding it, calls dlopen("libcuda.so.1"), which needs
* _dl_load_lock.
*
* A barrier releases both threads together. Each thread records when it
* enters and leaves its call. At the deadline the main thread takes one
* snapshot of both and reports one verdict:
*
* OK both calls returned and succeeded exit 0
* FAIL both calls returned, at least one failed exit 1
* DEADLOCK both threads still inside their calls exit 2
* STUCK exactly one thread still inside its call exit 3
*
* For DEADLOCK and STUCK it prints, for each thread still inside its call,
* which call, how long it has been inside, its wchan and its stack.
*
* Each run is one process, because only the first ucp_init() and the first
* cuInit() in a process take these paths.
*
* Build:
* cc -std=gnu11 -O1 -g -o ucx_cuinit_dlopen_deadlock ucx_cuinit_dlopen_deadlock.c \
* -I$UCX/include -L$UCX/lib -Wl,-rpath,$UCX/lib -lucp -lucs -lpthread -ldl
*
* Usage: ucx_cuinit_dlopen_deadlock [timeout_s=10] [cuinit_delay_us=0]
*
* libcuda is not linked: it is dlopen()ed and cuInit is found with dlsym(),
* as a CUDA runtime or a binding such as cudarc does, before the threads
* start. Nothing calls cuInit before the barrier.
*/
#define _GNU_SOURCE
#include <dlfcn.h>
#include <execinfo.h>
#include <pthread.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>
#include <ucp/api/ucp.h>
typedef int (*cuInit_t)(unsigned int);
enum { NOT_STARTED, INSIDE, RETURNED };
#define MAX_FRAMES 48
struct worker {
const char *call; /* the call this thread makes */
pthread_t thread;
atomic_int tid;
atomic_int state; /* NOT_STARTED, INSIDE or RETURNED */
double entered_ms; /* written before state becomes INSIDE */
double returned_ms; /* written before state becomes RETURNED */
int result; /* the call's return value, likewise */
void *frames[MAX_FRAMES];
atomic_int nframes; /* -1 until the stack has been captured */
};
static struct worker ucp_thread = { .call = "ucp_init" };
static struct worker cu_thread = { .call = "cuInit" };
static pthread_barrier_t barrier;
static cuInit_t cu_init;
static ucp_config_t *config;
static ucp_context_h context;
static long cuinit_delay_us;
static double now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1e3 + ts.tv_nsec * 1e-6;
}
/* Captures the interrupted thread's stack. backtrace() was called once in
* main, so it loads nothing here, and its dl_iterate_phdr() takes
* _dl_load_write_lock, not the _dl_load_lock this deadlock holds. */
static void capture_stack(int sig)
{
pid_t tid = (pid_t)syscall(SYS_gettid);
struct worker *w = (tid == atomic_load(&ucp_thread.tid)) ? &ucp_thread : &cu_thread;
(void)sig;
atomic_store(&w->nframes, backtrace(w->frames, MAX_FRAMES));
}
static void enter(struct worker *w)
{
w->entered_ms = now_ms();
atomic_store(&w->state, INSIDE);
}
static void leave(struct worker *w, int result)
{
w->returned_ms = now_ms();
w->result = result;
atomic_store(&w->state, RETURNED);
}
static void *ucp_main(void *arg)
{
ucp_params_t params;
ucs_status_t status;
(void)arg;
atomic_store(&ucp_thread.tid, (int)syscall(SYS_gettid));
pthread_setname_np(pthread_self(), "ucp_init");
memset(¶ms, 0, sizeof(params));
params.field_mask = UCP_PARAM_FIELD_FEATURES;
params.features = UCP_FEATURE_TAG;
pthread_barrier_wait(&barrier);
enter(&ucp_thread);
status = ucp_init(¶ms, config, &context);
leave(&ucp_thread, (int)status);
return NULL;
}
static void *cu_main(void *arg)
{
int rc;
(void)arg;
atomic_store(&cu_thread.tid, (int)syscall(SYS_gettid));
pthread_setname_np(pthread_self(), "cuInit");
pthread_barrier_wait(&barrier);
if (cuinit_delay_us > 0) {
usleep(cuinit_delay_us);
}
enter(&cu_thread);
rc = cu_init(0);
leave(&cu_thread, rc);
return NULL;
}
/* Prints addr as "<file>+0x<offset>" from /proc/self/maps, which needs no
* loader lock (dladdr() takes the one that is held). */
static void print_frame(int i, void *addr)
{
char line[1024], path[768];
uintptr_t a = (uintptr_t)addr, lo, hi, off;
FILE *maps = fopen("/proc/self/maps", "r");
while (maps != NULL && fgets(line, sizeof(line), maps) != NULL) {
path[0] = '\0';
if (sscanf(line, "%lx-%lx %*s %lx %*s %*s %767s", &lo, &hi, &off, path) >= 3 &&
a >= lo && a < hi) {
printf(" #%-2d %p %s+0x%lx\n", i, addr, path[0] ? path : "[anon]",
(unsigned long)(a - lo + off));
fclose(maps);
return;
}
}
if (maps != NULL) {
fclose(maps);
}
printf(" #%-2d %p\n", i, addr);
}
/* Reports one thread as it was in the deadline snapshot. */
static void report_thread(struct worker *w, int state, double deadline_ms)
{
char path[64], wchan[64] = "?";
FILE *f;
int tid = atomic_load(&w->tid), i, n;
if (state == RETURNED) {
printf(" %-8s (tid %d): returned %d after %.1f ms\n", w->call, tid, w->result,
w->returned_ms - w->entered_ms);
return;
}
if (state == NOT_STARTED) {
printf(" %-8s (tid %d): never entered\n", w->call, tid);
return;
}
snprintf(path, sizeof(path), "/proc/self/task/%d/wchan", tid);
if ((f = fopen(path, "r")) != NULL) {
if (fgets(wchan, sizeof(wchan), f) == NULL) {
strcpy(wchan, "?");
}
fclose(f);
}
printf(" %-8s (tid %d): inside %s() for %.1f s at the deadline, wchan=%s\n", w->call,
tid, w->call, (deadline_ms - w->entered_ms) / 1e3, wchan);
atomic_store(&w->nframes, -1);
pthread_kill(w->thread, SIGUSR1);
for (i = 0; i < 100 && atomic_load(&w->nframes) < 0; ++i) {
usleep(10000);
}
n = atomic_load(&w->nframes);
if (n < 0) {
printf(" (stack not captured)\n");
}
for (i = 0; i < n; ++i) {
print_frame(i, w->frames[i]);
}
}
int main(int argc, char **argv)
{
double timeout_s = (argc > 1) ? atof(argv[1]) : 10.0;
struct sigaction sa;
void *libcuda, *warm[1];
double deadline_ms;
int ucp_state, cu_state, inside;
ucs_status_t status;
cuinit_delay_us = (argc > 2) ? atol(argv[2]) : 0;
setvbuf(stdout, NULL, _IOLBF, 0);
libcuda = dlopen("libcuda.so.1", RTLD_NOW | RTLD_GLOBAL);
if (libcuda == NULL || (cu_init = (cuInit_t)dlsym(libcuda, "cuInit")) == NULL) {
fprintf(stderr, "cannot load cuInit from libcuda.so.1: %s\n", dlerror());
return 1;
}
status = ucp_config_read(NULL, NULL, &config);
if (status != UCS_OK) {
fprintf(stderr, "ucp_config_read failed: %s\n", ucs_status_string(status));
return 1;
}
printf("UCX %s, UCX_MODULES=%s, timeout %.0f s, cuInit delay %ld us\n",
ucp_get_version_string(),
getenv("UCX_MODULES") ? getenv("UCX_MODULES") : "<unset>", timeout_s,
cuinit_delay_us);
backtrace(warm, 1); /* loads libgcc_s now, not inside the signal handler */
memset(&sa, 0, sizeof(sa));
sa.sa_handler = capture_stack;
sigaction(SIGUSR1, &sa, NULL);
atomic_store(&ucp_thread.nframes, -1);
atomic_store(&cu_thread.nframes, -1);
pthread_barrier_init(&barrier, NULL, 3);
pthread_create(&ucp_thread.thread, NULL, ucp_main, NULL);
pthread_create(&cu_thread.thread, NULL, cu_main, NULL);
pthread_barrier_wait(&barrier);
deadline_ms = now_ms() + timeout_s * 1e3;
while (now_ms() < deadline_ms) {
if (atomic_load(&ucp_thread.state) == RETURNED &&
atomic_load(&cu_thread.state) == RETURNED) {
break;
}
usleep(10000);
}
/* One snapshot decides the verdict, and the report uses the same one. */
ucp_state = atomic_load(&ucp_thread.state);
cu_state = atomic_load(&cu_thread.state);
if (ucp_state == RETURNED && cu_state == RETURNED) {
pthread_join(ucp_thread.thread, NULL);
pthread_join(cu_thread.thread, NULL);
if (ucp_thread.result != UCS_OK || cu_thread.result != 0) {
printf("FAIL: ucp_init returned %s, cuInit returned CUresult %d\n",
ucs_status_string((ucs_status_t)ucp_thread.result), cu_thread.result);
return 1;
}
printf("OK: ucp_init %.1f ms, cuInit %.1f ms\n",
ucp_thread.returned_ms - ucp_thread.entered_ms,
cu_thread.returned_ms - cu_thread.entered_ms);
ucp_cleanup(context);
ucp_config_release(config);
return 0;
}
inside = (ucp_state == INSIDE) + (cu_state == INSIDE);
if (inside == 2) {
printf("DEADLOCK: ucp_init and cuInit both still inside their calls after %.0f s\n",
timeout_s);
} else {
printf("STUCK: %d of the two calls still inside after %.0f s\n", inside, timeout_s);
}
report_thread(&ucp_thread, ucp_state, deadline_ms);
report_thread(&cu_thread, cu_state, deadline_ms);
_exit(inside == 2 ? 2 : 3); /* exit() would run destructors that may need the held locks */
}Setup and versions
- OS: Rocky Linux 9.6, kernel 5.14.0-570.58.1.1.el9.x86_64
- Nvidia driver 590.48.01, CUDA 13.1 (tested against 13.4 also)
Activity