Skip to content

Commit 9b40707

Browse files
ci(test): fix grpc server GPU-memory leak and run cuopt tests in parallel (#1512)
## Summary - Reap `cuopt_grpc_server` reliably — start it in its own process group and kill the whole group on teardown, so the server and its `--workers` child are cleaned up instead of leaking their GPU context across tests/runs. - Group grpc-server-backed test classes onto a single xdist worker (`--dist loadgroup`) so at most one server runs at a time. - Run the cuopt Python tests in parallel at `-n 4`, with `--max-worker-restart=0` so a crashed worker fails once instead of respawning into a crash loop. - Skip the incumbent-callback test when the problem is solved at the root node (no branch-and-bound, so no incumbent callbacks fire), and fix a latent always-true termination assertion. Authors: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) Approvers: - Trevor McKay (https://github.com/tmckayus) URL: #1512
1 parent 9118636 commit 9b40707

5 files changed

Lines changed: 93 additions & 53 deletions

File tree

ci/run_cuopt_pytests.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ rc=0
3636
if [ "${IS_NIGHTLY}" = "nightly" ]; then
3737
pytest -s --cache-clear --reruns 2 --reruns-delay 5 -p cuopt_rerun_xml "$@" tests || rc=$?
3838
else
39-
pytest -s --cache-clear -n 1 "$@" tests || rc=$?
39+
# loadgroup keeps xdist_group (grpc server) tests on one worker;
40+
# max-worker-restart=0 stops a crashed worker from respawning.
41+
pytest -s --cache-clear -n 4 --dist loadgroup --max-worker-restart=0 "$@" tests || rc=$?
4042
fi
4143

4244
# If not a crash, exit normally

python/cuopt/cuopt/tests/fixtures/grpc_server_fixtures.py

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,54 @@ def server_env():
116116
return env
117117

118118

119+
def _set_pdeathsig():
120+
"""SIGKILL this child if the spawning worker dies (Linux; best effort)."""
121+
try:
122+
import ctypes
123+
124+
PR_SET_PDEATHSIG = 1
125+
ctypes.CDLL("libc.so.6", use_errno=True).prctl(
126+
PR_SET_PDEATHSIG, signal.SIGKILL
127+
)
128+
except Exception:
129+
pass
130+
131+
132+
def spawn_server(cmd, env=None):
133+
"""Start ``cuopt_grpc_server`` in its own process group, dying with the
134+
spawning worker, so it and its ``--workers`` child can be reaped together
135+
(see ``kill_server``) and don't leak GPU memory.
136+
"""
137+
return subprocess.Popen(
138+
cmd,
139+
stdout=subprocess.DEVNULL,
140+
stderr=subprocess.DEVNULL,
141+
env=env,
142+
start_new_session=True,
143+
preexec_fn=_set_pdeathsig,
144+
)
145+
146+
147+
def kill_server(proc):
148+
"""Terminate the server's whole process group (parent + ``--workers`` child)."""
149+
if proc is None:
150+
return
151+
try:
152+
pgid = os.getpgid(proc.pid)
153+
except (ProcessLookupError, OSError):
154+
return
155+
for sig in (signal.SIGTERM, signal.SIGKILL):
156+
try:
157+
os.killpg(pgid, sig)
158+
except (ProcessLookupError, OSError):
159+
return
160+
try:
161+
proc.wait(timeout=5)
162+
return
163+
except subprocess.TimeoutExpired:
164+
continue
165+
166+
119167
# Backward-compatible alias used by tests that yield client env dicts.
120168
cpu_only_env = client_remote_env
121169

@@ -128,7 +176,7 @@ def start_grpc_server(port_offset):
128176

129177
port = int(os.environ.get("CUOPT_TEST_PORT_BASE", "18000")) + port_offset
130178
client_env = client_remote_env(port)
131-
proc = subprocess.Popen(
179+
proc = spawn_server(
132180
[
133181
server_bin,
134182
"--port",
@@ -137,8 +185,6 @@ def start_grpc_server(port_offset):
137185
"1",
138186
"--log-to-console",
139187
],
140-
stdout=subprocess.DEVNULL,
141-
stderr=subprocess.DEVNULL,
142188
env=server_env(),
143189
)
144190
time.sleep(0.5)
@@ -148,8 +194,7 @@ def start_grpc_server(port_offset):
148194
"binary may be unable to load shared libraries in this environment"
149195
)
150196
if not wait_for_grpc_client(port, timeout=30):
151-
proc.kill()
152-
proc.wait()
197+
kill_server(proc)
153198
pytest.fail(
154199
"cuopt_grpc_server TCP port opened but gRPC client could not connect "
155200
"within 30s"
@@ -159,17 +204,8 @@ def start_grpc_server(port_offset):
159204

160205

161206
def stop_grpc_server(proc):
162-
"""Gracefully shut down a server process."""
163-
if proc.poll() is not None:
164-
proc.wait()
165-
return
166-
167-
proc.send_signal(signal.SIGTERM)
168-
try:
169-
proc.wait(timeout=5)
170-
except subprocess.TimeoutExpired:
171-
proc.kill()
172-
proc.wait()
207+
"""Gracefully shut down a server process and its worker child."""
208+
kill_server(proc)
173209

174210

175211
@pytest.fixture(scope="class")

python/cuopt/cuopt/tests/linear_programming/test_cpu_only_execution.py

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
import os
1818
import re
1919
import shutil
20-
import signal
2120
import socket
2221
import subprocess
2322
import sys
@@ -28,6 +27,14 @@
2827
from cuopt import linear_programming
2928
from cuopt.linear_programming.solver.solver_parameters import CUOPT_TIME_LIMIT
3029

30+
# Also re-executed as a standalone script (_run_in_subprocess), where conftest
31+
# hasn't added fixtures/ to sys.path; add it so this import works either way.
32+
sys.path.insert(
33+
0,
34+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "fixtures"),
35+
)
36+
from grpc_server_fixtures import kill_server, spawn_server # noqa: E402
37+
3138
logger = logging.getLogger(__name__)
3239

3340
RAPIDS_DATASET_ROOT_DIR = os.environ.get(
@@ -79,7 +86,7 @@ def _wait_for_port(port, timeout=15):
7986

8087

8188
def _cpu_only_env(port):
82-
"""Return an env dict that hides all GPUs and enables remote mode."""
89+
"""Return a *client* env dict that hides all GPUs and enables remote mode."""
8390
env = os.environ.copy()
8491
for key in [k for k in env if k.startswith("CUOPT_TLS_")]:
8592
env.pop(key)
@@ -435,10 +442,8 @@ def _start_grpc_server_fixture(port_offset):
435442
+ port_offset
436443
+ worker_id
437444
)
438-
proc = subprocess.Popen(
445+
proc = spawn_server(
439446
[server_bin, "--port", str(port), "--workers", "1"],
440-
stdout=subprocess.DEVNULL,
441-
stderr=subprocess.DEVNULL,
442447
)
443448
time.sleep(0.5)
444449
if proc.poll() is not None:
@@ -447,28 +452,23 @@ def _start_grpc_server_fixture(port_offset):
447452
"binary may be unable to load shared libraries in this environment"
448453
)
449454
if not _wait_for_port(port, timeout=15):
450-
proc.kill()
451-
proc.wait()
455+
kill_server(proc)
452456
pytest.fail("cuopt_grpc_server failed to start within 15s")
453457

454458
return proc, _cpu_only_env(port)
455459

456460

457461
def _stop_grpc_server(proc):
458-
"""Gracefully shut down a server process."""
459-
proc.send_signal(signal.SIGTERM)
460-
try:
461-
proc.wait(timeout=5)
462-
except subprocess.TimeoutExpired:
463-
proc.kill()
464-
proc.wait()
462+
"""Gracefully shut down a server process and its worker child."""
463+
kill_server(proc)
465464

466465

467466
# ---------------------------------------------------------------------------
468467
# CPU-only Python tests (subprocess required)
469468
# ---------------------------------------------------------------------------
470469

471470

471+
@pytest.mark.xdist_group(name="grpc_server")
472472
class TestCPUOnlyExecution:
473473
"""Tests that run with CUDA_VISIBLE_DEVICES='' to simulate CPU-only hosts.
474474
@@ -515,6 +515,7 @@ def test_warmstart_cpu_only(self, cpu_only_env_with_server):
515515
# ---------------------------------------------------------------------------
516516

517517

518+
@pytest.mark.xdist_group(name="grpc_server")
518519
class TestCuoptCliCPUOnly:
519520
"""Test that cuopt_cli runs without CUDA in remote-execution mode.
520521
@@ -711,6 +712,7 @@ def test_mip_solution_values(self):
711712
# ---------------------------------------------------------------------------
712713

713714

715+
@pytest.mark.xdist_group(name="grpc_server")
714716
class TestTLSExecution:
715717
"""Test remote execution over a TLS-encrypted channel.
716718
@@ -729,7 +731,7 @@ def tls_env_with_server(self, tmp_path_factory):
729731
pytest.skip("cuopt_grpc_server not found")
730732

731733
port = int(os.environ.get("CUOPT_TEST_PORT_BASE", "18000")) + 850
732-
proc = subprocess.Popen(
734+
proc = spawn_server(
733735
[
734736
server_bin,
735737
"--port",
@@ -742,12 +744,9 @@ def tls_env_with_server(self, tmp_path_factory):
742744
"--tls-key",
743745
os.path.join(cert_dir, "server.key"),
744746
],
745-
stdout=subprocess.DEVNULL,
746-
stderr=subprocess.DEVNULL,
747747
)
748748
if not _wait_for_port(port, timeout=15):
749-
proc.kill()
750-
proc.wait()
749+
kill_server(proc)
751750
pytest.fail("TLS cuopt_grpc_server failed to start within 15s")
752751

753752
env = _tls_env(port, cert_dir, mtls=False)
@@ -768,6 +767,7 @@ def test_lp_solve_tls(self, tls_env_with_server):
768767
# ---------------------------------------------------------------------------
769768

770769

770+
@pytest.mark.xdist_group(name="grpc_server")
771771
class TestMTLSExecution:
772772
"""Test remote execution over an mTLS-encrypted channel.
773773
@@ -787,7 +787,7 @@ def mtls_server_info(self, tmp_path_factory):
787787
pytest.skip("cuopt_grpc_server not found")
788788

789789
port = int(os.environ.get("CUOPT_TEST_PORT_BASE", "18000")) + 900
790-
proc = subprocess.Popen(
790+
proc = spawn_server(
791791
[
792792
server_bin,
793793
"--port",
@@ -803,22 +803,14 @@ def mtls_server_info(self, tmp_path_factory):
803803
os.path.join(cert_dir, "ca.crt"),
804804
"--require-client-cert",
805805
],
806-
stdout=subprocess.DEVNULL,
807-
stderr=subprocess.DEVNULL,
808806
)
809807
if not _wait_for_port(port, timeout=15):
810-
proc.kill()
811-
proc.wait()
808+
kill_server(proc)
812809
pytest.fail("mTLS cuopt_grpc_server failed to start within 15s")
813810

814811
yield {"port": port, "cert_dir": cert_dir}
815812

816-
proc.send_signal(signal.SIGTERM)
817-
try:
818-
proc.wait(timeout=5)
819-
except subprocess.TimeoutExpired:
820-
proc.kill()
821-
proc.wait()
813+
kill_server(proc)
822814

823815
def test_lp_solve_mtls(self, mtls_server_info):
824816
"""LP solve succeeds over an mTLS channel with valid client cert."""

python/cuopt/cuopt/tests/linear_programming/test_grpc_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def _infeasible_lp_problem():
6363
return problem
6464

6565

66+
@pytest.mark.xdist_group(name="grpc_server")
6667
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
6768
class TestGrpcClient:
6869
grpc_port_offset = GRPC_PORT_OFFSET_CLIENT

python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,23 @@ def set_solution(
8686
settings.set_mip_callback(set_callback, user_data)
8787
solution = solver.Solve(data_model_obj, settings)
8888

89-
assert get_callback.n_callbacks > 0
89+
termination = solution.get_termination_status()
90+
assert termination in (
91+
MILPTerminationStatus.FeasibleFound,
92+
MILPTerminationStatus.Optimal,
93+
)
94+
95+
# Incumbent callbacks only fire during branch-and-bound. If the problem is
96+
# solved at the root node (presolve or integral LP relaxation), no
97+
# callbacks are triggered — skip rather than fail in that case.
98+
if get_callback.n_callbacks == 0:
99+
pytest.skip(
100+
f"No incumbent callbacks fired (termination={termination}); "
101+
"problem was likely solved at the root node without branching"
102+
)
103+
90104
if include_set_callback:
91105
assert set_callback.n_callbacks > 0
92-
assert (
93-
solution.get_termination_status()
94-
== MILPTerminationStatus.FeasibleFound
95-
or MILPTerminationStatus.Optimal
96-
)
97106

98107
for sol in get_callback.solutions:
99108
utils.check_solution(

0 commit comments

Comments
 (0)