Skip to content

Commit ae98819

Browse files
committed
Add a regression test for process-slot reclamation
Covers both halves of the join-path contract: below the sweep threshold a join leaves slots held by dead processes in place, and at the threshold a join reclaims them, so repeated join-and-die cycles cannot grow the table without bound. The test is GPU-free and builds against the production shared-region sources the same way test_postinit_owner_death does, with a small SHARED_REGION_SWEEP_THRESHOLD so the reclaim path is reachable without spawning 768 processes. Signed-off-by: keshav9926 <kkakani160@gmail.com>
1 parent 02279de commit ae98819

2 files changed

Lines changed: 300 additions & 5 deletions

File tree

test/CMakeLists.txt

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,31 @@ foreach(TEST_SCRIPT ${TEST_SCRIPTS})
1414
get_filename_component(TEST_TARGET_NAME ${RELATIVE_TEST_PATH} NAME_WE)
1515
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/${TEST_TARGET_DIR})
1616

17-
if (TEST_TARGET_NAME STREQUAL "test_postinit_owner_death" OR TEST_TARGET_NAME STREQUAL "test_pid_discovery")
18-
if (TEST_TARGET_NAME STREQUAL "test_postinit_owner_death")
17+
if (TEST_TARGET_NAME STREQUAL "test_postinit_owner_death" OR
18+
TEST_TARGET_NAME STREQUAL "test_pid_discovery" OR
19+
TEST_TARGET_NAME STREQUAL "test_proc_slot_reclaim")
20+
# These focused regression tests build against production sources and do
21+
# not invoke any CUDA/NVML entry point at runtime. Section garbage
22+
# collection drops the unrelated GPU-facing production functions.
23+
if (TEST_TARGET_NAME STREQUAL "test_pid_discovery")
1924
add_executable(${TEST_TARGET_NAME}
2025
${TEST_SCRIPT}
21-
${CMAKE_CURRENT_SOURCE_DIR}/../src/multiprocess/multiprocess_memory_limit.c
26+
${CMAKE_CURRENT_SOURCE_DIR}/../src/utils.c
2227
${CMAKE_CURRENT_SOURCE_DIR}/../src/log_utils.c)
2328
else()
2429
add_executable(${TEST_TARGET_NAME}
2530
${TEST_SCRIPT}
26-
${CMAKE_CURRENT_SOURCE_DIR}/../src/utils.c
31+
${CMAKE_CURRENT_SOURCE_DIR}/../src/multiprocess/multiprocess_memory_limit.c
2732
${CMAKE_CURRENT_SOURCE_DIR}/../src/log_utils.c)
2833
endif()
2934
target_compile_definitions(${TEST_TARGET_NAME} PRIVATE
3035
_GNU_SOURCE)
36+
if (TEST_TARGET_NAME STREQUAL "test_proc_slot_reclaim")
37+
# Reach the liveness sweep without spawning three quarters of the
38+
# slot table.
39+
target_compile_definitions(${TEST_TARGET_NAME} PRIVATE
40+
SHARED_REGION_SWEEP_THRESHOLD=8)
41+
endif()
3142
target_compile_options(${TEST_TARGET_NAME} PRIVATE
3243
-ffunction-sections -fdata-sections)
3344
set_target_properties(${TEST_TARGET_NAME} PROPERTIES
@@ -41,7 +52,9 @@ foreach(TEST_SCRIPT ${TEST_SCRIPTS})
4152
endif()
4253

4354
list(APPEND TEST_TARGET_NAMES_LIST ${TEST_TARGET_NAME})
44-
if (TEST_TARGET_NAME STREQUAL "test_postinit_owner_death" OR TEST_TARGET_NAME STREQUAL "test_pid_discovery")
55+
if (TEST_TARGET_NAME STREQUAL "test_postinit_owner_death" OR
56+
TEST_TARGET_NAME STREQUAL "test_pid_discovery" OR
57+
TEST_TARGET_NAME STREQUAL "test_proc_slot_reclaim")
4558
target_link_libraries(${TEST_TARGET_NAME} -lrt -lpthread)
4659
elseif (TEST_TARGET_NAME STREQUAL "test_dlsym_rtld_next")
4760
target_link_libraries(${TEST_TARGET_NAME} -ldl)
@@ -76,6 +89,10 @@ if (TARGET vgpu)
7689
TIMEOUT 10)
7790
endif()
7891

92+
add_test(NAME proc_slot_reclaim
93+
COMMAND test_proc_slot_reclaim)
94+
set_tests_properties(proc_slot_reclaim PROPERTIES TIMEOUT 30)
95+
7996

8097
add_custom_target(python_test ALL
8198
COMMAND cp -r ${CMAKE_CURRENT_SOURCE_DIR}/python ${CMAKE_CURRENT_BINARY_DIR})

test/test_proc_slot_reclaim.c

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/*
2+
* GPU-free regression test for process-slot reclamation on the join path.
3+
*
4+
* Joining the shared region used to sweep every occupied slot for liveness,
5+
* and that sweep reads /proc/<pid>/stat once per slot while the region lock is
6+
* held. The sweep now runs only once slots are scarce, so this test pins both
7+
* halves of that contract: below the threshold a join must leave slots held by
8+
* dead processes alone, and at the threshold a join must reclaim them so the
9+
* table cannot grow without bound.
10+
*
11+
* The target is built with a small SHARED_REGION_SWEEP_THRESHOLD so the
12+
* reclaim path is reachable without spawning three quarters of the table.
13+
*/
14+
#include <errno.h>
15+
#include <fcntl.h>
16+
#include <signal.h>
17+
#include <stdatomic.h>
18+
#include <stdio.h>
19+
#include <stdlib.h>
20+
#include <string.h>
21+
#include <sys/mman.h>
22+
#include <sys/stat.h>
23+
#include <sys/wait.h>
24+
#include <time.h>
25+
#include <unistd.h>
26+
27+
#include "multiprocess/multiprocess_memory_limit.h"
28+
29+
#define TEST_TIMEOUT_MS 5000.0
30+
/* Above this the test would have to spawn too many processes to be useful. */
31+
#define MAX_TEST_THRESHOLD 32
32+
#define DEAD_SLOTS_BELOW_THRESHOLD 2
33+
/* Enough cycles to cross the threshold several times over. */
34+
#define RECLAIM_CYCLES (SHARED_REGION_SWEEP_THRESHOLD * 3)
35+
/* A recycled PID reads as alive and survives one sweep, so allow slack. */
36+
#define OCCUPANCY_SLACK 2
37+
38+
typedef struct {
39+
_Atomic int joined;
40+
} test_state_t;
41+
42+
static test_state_t *state;
43+
/* Read-only view of the cache file, so the test can read proc_num without
44+
* taking a slot of its own or reaching into the module's statics. */
45+
static shared_region_t *region_view;
46+
47+
static double now_ms(void) {
48+
struct timespec ts;
49+
50+
if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) {
51+
return 0.0;
52+
}
53+
return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1000000.0;
54+
}
55+
56+
static void sleep_ms(int milliseconds) {
57+
struct timespec ts;
58+
59+
ts.tv_sec = milliseconds / 1000;
60+
ts.tv_nsec = (milliseconds % 1000) * 1000000L;
61+
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
62+
}
63+
}
64+
65+
static int wait_for_counter(_Atomic int *counter, int expected,
66+
double timeout_ms) {
67+
double deadline = now_ms() + timeout_ms;
68+
69+
while (atomic_load_explicit(counter, memory_order_acquire) < expected) {
70+
if (now_ms() >= deadline) {
71+
return -1;
72+
}
73+
sleep_ms(1);
74+
}
75+
return 0;
76+
}
77+
78+
static void kill_and_reap(pid_t child) {
79+
int status;
80+
81+
if (child <= 0) {
82+
return;
83+
}
84+
kill(child, SIGKILL);
85+
while (waitpid(child, &status, 0) < 0 && errno == EINTR) {
86+
}
87+
}
88+
89+
static int map_region_view(const char *cache_path) {
90+
int fd = open(cache_path, O_RDONLY);
91+
92+
if (fd < 0) {
93+
perror("open(shared-region cache)");
94+
return -1;
95+
}
96+
region_view = mmap(NULL, SHARED_REGION_SIZE_MAGIC, PROT_READ, MAP_SHARED,
97+
fd, 0);
98+
close(fd);
99+
if (region_view == MAP_FAILED) {
100+
perror("mmap(shared-region cache)");
101+
region_view = NULL;
102+
return -1;
103+
}
104+
return 0;
105+
}
106+
107+
static int occupied_slots(void) {
108+
return atomic_load_explicit(&region_view->proc_num, memory_order_acquire);
109+
}
110+
111+
/* Take a slot, announce it, then wait to be killed so the slot is left behind
112+
* with a PID that no longer exists -- exactly what a SIGKILL'd container does,
113+
* since the exit handler never runs. */
114+
static void slot_worker(void) {
115+
ensure_initialized();
116+
atomic_fetch_add_explicit(&state->joined, 1, memory_order_release);
117+
for (;;) {
118+
sleep_ms(10);
119+
}
120+
}
121+
122+
static pid_t spawn_joined_worker(int expected_joins) {
123+
pid_t child = fork();
124+
125+
if (child == 0) {
126+
slot_worker();
127+
_exit(0);
128+
}
129+
if (child < 0) {
130+
perror("fork");
131+
return -1;
132+
}
133+
if (wait_for_counter(&state->joined, expected_joins, TEST_TIMEOUT_MS) != 0) {
134+
fprintf(stderr, "worker %d did not join the shared region\n",
135+
expected_joins);
136+
kill_and_reap(child);
137+
return -1;
138+
}
139+
return child;
140+
}
141+
142+
/* Below the threshold a join must not pay for a liveness sweep, so slots held
143+
* by processes that already died stay in the table. */
144+
static int test_dead_slots_are_kept_below_threshold(int *joins) {
145+
pid_t child;
146+
int expected;
147+
int observed;
148+
int i;
149+
150+
for (i = 0; i < DEAD_SLOTS_BELOW_THRESHOLD; i++) {
151+
child = spawn_joined_worker(++(*joins));
152+
if (child < 0) {
153+
return -1;
154+
}
155+
kill_and_reap(child);
156+
}
157+
158+
child = spawn_joined_worker(++(*joins));
159+
if (child < 0) {
160+
return -1;
161+
}
162+
/* This process holds a slot too, hence the +1. */
163+
expected = 1 + DEAD_SLOTS_BELOW_THRESHOLD + 1;
164+
observed = occupied_slots();
165+
kill_and_reap(child);
166+
167+
if (observed != expected) {
168+
fprintf(stderr,
169+
"join below the sweep threshold changed the table: "
170+
"expected %d occupied slots, saw %d\n",
171+
expected, observed);
172+
return -1;
173+
}
174+
return 0;
175+
}
176+
177+
/* Once slots are scarce a join must reclaim the dead ones, so repeated
178+
* join-and-die cycles cannot grow the table past the threshold. */
179+
static int test_dead_slots_are_reclaimed_at_threshold(int *joins) {
180+
int previous = occupied_slots();
181+
int peak = previous;
182+
int reclaims = 0;
183+
pid_t child;
184+
int observed;
185+
int i;
186+
187+
for (i = 0; i < RECLAIM_CYCLES; i++) {
188+
child = spawn_joined_worker(++(*joins));
189+
if (child < 0) {
190+
return -1;
191+
}
192+
observed = occupied_slots();
193+
kill_and_reap(child);
194+
195+
if (observed > peak) {
196+
peak = observed;
197+
}
198+
if (observed < previous) {
199+
reclaims++;
200+
}
201+
previous = observed;
202+
}
203+
204+
if (peak > SHARED_REGION_SWEEP_THRESHOLD + OCCUPANCY_SLACK) {
205+
fprintf(stderr,
206+
"slot table grew past the sweep threshold: peak %d, "
207+
"threshold %d\n",
208+
peak, (int)SHARED_REGION_SWEEP_THRESHOLD);
209+
return -1;
210+
}
211+
if (reclaims == 0) {
212+
fprintf(stderr, "no join ever reclaimed a dead slot in %d cycles\n",
213+
RECLAIM_CYCLES);
214+
return -1;
215+
}
216+
return 0;
217+
}
218+
219+
int main(void) {
220+
char cache_path[] = "/tmp/hami-proc-slot-reclaim.XXXXXX";
221+
int cache_fd;
222+
int joins = 0;
223+
int failures = 0;
224+
225+
if ((int)SHARED_REGION_SWEEP_THRESHOLD > MAX_TEST_THRESHOLD) {
226+
printf("skipping: sweep threshold %d needs too many processes\n",
227+
(int)SHARED_REGION_SWEEP_THRESHOLD);
228+
return 0;
229+
}
230+
231+
cache_fd = mkstemp(cache_path);
232+
if (cache_fd < 0) {
233+
perror("mkstemp(shared-region cache)");
234+
return 1;
235+
}
236+
close(cache_fd);
237+
unlink(cache_path);
238+
if (setenv(MULTIPROCESS_SHARED_REGION_CACHE_ENV, cache_path, 1) != 0 ||
239+
setenv("CUDA_DEVICE_MEMORY_LIMIT", "1024m", 1) != 0 ||
240+
setenv("LIBCUDA_LOG_LEVEL", "0", 1) != 0) {
241+
perror("setenv");
242+
return 1;
243+
}
244+
245+
state = mmap(NULL, sizeof(*state), PROT_READ | PROT_WRITE,
246+
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
247+
if (state == MAP_FAILED) {
248+
perror("mmap(test state)");
249+
return 1;
250+
}
251+
memset(state, 0, sizeof(*state));
252+
atomic_init(&state->joined, 0);
253+
log_utils_init();
254+
ensure_initialized();
255+
256+
if (map_region_view(cache_path) != 0) {
257+
unlink(cache_path);
258+
return 1;
259+
}
260+
261+
if (test_dead_slots_are_kept_below_threshold(&joins) != 0) {
262+
failures++;
263+
}
264+
if (test_dead_slots_are_reclaimed_at_threshold(&joins) != 0) {
265+
failures++;
266+
}
267+
268+
munmap(region_view, SHARED_REGION_SIZE_MAGIC);
269+
unlink(cache_path);
270+
if (failures != 0) {
271+
fprintf(stderr, "%d process-slot reclamation test(s) failed\n",
272+
failures);
273+
return 1;
274+
}
275+
munmap(state, sizeof(*state));
276+
puts("process-slot reclamation tests passed");
277+
return 0;
278+
}

0 commit comments

Comments
 (0)