Skip to content

Commit ffa5396

Browse files
authored
[None][test] Optimize LLM test startup overhead (#15777)
Signed-off-by: qgai <qgai@nvidia.com>
1 parent 046952a commit ffa5396

8 files changed

Lines changed: 982 additions & 5 deletions

File tree

tests/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Fallback wiring of the session-reuse plugin for test dirs WITHOUT an ini.
4+
5+
tests/unittest and tests/integration/defs load the plugin through the ``-p``
6+
option in their own pytest.ini; their rootdir sits below this file, so this
7+
conftest is never collected there (no double registration). Any other
8+
directory under tests/ (current or future) resolves its rootdir at the repo
9+
root and picks the hooks up from here, so automatic MPI session reuse covers
10+
every test under tests/.
11+
"""
12+
13+
import os
14+
import sys
15+
16+
sys.path.insert(0, os.path.dirname(__file__)) # make test_common importable
17+
18+
from test_common.session_reuse_hooks import ( # noqa: E402,F401
19+
pytest_configure,
20+
pytest_runtest_setup,
21+
pytest_sessionfinish,
22+
)

tests/integration/defs/pytest.ini

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
[pytest]
22
asyncio_default_fixture_loop_scope = module
33
threadleak = True
4-
threadleak_exclude = asyncio_\d+
4+
# Thread-\d+ \(_manager_spawn\) / session-reuse-* belong to a pool cached for
5+
# reuse by the NEXT test (tests/test_common/session_reuse.py) and legitimately
6+
# outlive the test they start under.
7+
threadleak_exclude = asyncio_\d+|Thread-\d+ \(_manager_spawn\)|session-reuse-\w+
58
junit_family=legacy
6-
addopts = --ignore-glob="*perf/test_perf.py" --ignore-glob="*perf/disagg/*" --ignore-glob="*test_list_validation.py" --ignore-glob="*llm-test-workspace*" --durations=0 -W ignore::DeprecationWarning --unused-fixtures
9+
addopts = --ignore-glob="*perf/test_perf.py" --ignore-glob="*perf/disagg/*" --ignore-glob="*test_list_validation.py" --ignore-glob="*llm-test-workspace*" --durations=0 -W ignore::DeprecationWarning --unused-fixtures -p test_common.session_reuse_hooks
710
pythonpath =
811
../../../examples/auto_deploy ../../
912
norecursedirs = ./triton/perf ./perf/disagg
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Worker-side helpers for tests that reuse one ``MpiPoolSession``.
4+
5+
Used by the automatic session-reuse layer (``test_common/session_reuse.py``).
6+
This is test infrastructure only and must not be used on production inference
7+
paths. Heavy imports are done lazily so importing this module never triggers a
8+
circular import during package initialization.
9+
"""
10+
11+
12+
def _barrier_run(fn):
13+
"""Runs inside a worker; module-level so it is picklable.
14+
15+
The leading barrier is what makes ``submit_sync_per_worker`` exactly-once:
16+
a worker that picked up one of the ``n_workers`` submitted tasks blocks
17+
here until every other worker holds a task of its own, so no worker can
18+
drain a second one (pigeonhole). The workers' ``MPI_COMM_WORLD`` is the
19+
spawned worker world (the parent process is not a member), so the barrier
20+
spans exactly the pool's ``n_workers`` ranks.
21+
"""
22+
from mpi4py import MPI
23+
24+
MPI.COMM_WORLD.barrier()
25+
return (MPI.COMM_WORLD.Get_rank(), fn())
26+
27+
28+
def submit_sync_per_worker(mpi_session, fn, timeout: float = 60.0) -> list:
29+
"""Run ``fn`` exactly ONCE on every worker; return results ordered by rank.
30+
31+
``MpiPoolSession.submit_sync`` enqueues ``n_workers`` tasks, but
32+
``MPIPoolExecutor`` gives no one-task-per-worker guarantee: a fast task can
33+
be drained twice by one idle worker, leaving another untouched. Each task
34+
therefore opens with a barrier across the worker world (see
35+
``_barrier_run``), which pins the tasks one-per-worker.
36+
37+
This doubles as the pool health probe: completing the barrier proves every
38+
worker is alive AND that the worker world's collectives still function —
39+
the thing tests actually depend on. The wait is bounded by ``timeout``: a
40+
wedged worker (e.g. stuck in a collective after the previous test's
41+
executor died mid-shutdown) never completes the barrier, so an unbounded
42+
``result()`` would hang the whole session. A timeout is reported as probe
43+
failure, making the caller retire the pool and spawn a fresh one.
44+
"""
45+
import concurrent.futures
46+
47+
futures = mpi_session.submit(_barrier_run, fn)
48+
done, not_done = concurrent.futures.wait(futures, timeout=timeout)
49+
if not_done:
50+
raise RuntimeError(
51+
f"health probe timed out after {timeout}s: "
52+
f"{len(not_done)}/{len(futures)} worker tasks never returned"
53+
)
54+
by_rank = dict(f.result() for f in done) # .result() re-raises worker errors
55+
missing = set(range(mpi_session.n_workers)) - set(by_rank)
56+
if missing: # unreachable given the barrier; guards scheduler surprises
57+
raise RuntimeError(f"no task ran on worker ranks {sorted(missing)}")
58+
return [result for _, result in sorted(by_rank.items())]
59+
60+
61+
def reset_worker_torch_compile_state() -> None:
62+
"""Reset per-worker torch.compile / Dynamo state (runs inside each worker).
63+
64+
Dynamo's recompile counter is process-global and per-code-object. When
65+
worker processes are reused across LLMs (shared ``MpiPoolSession``), each
66+
``torch_compile`` case recompiles the same ``model.forward`` code object
67+
under new guards; the count accumulates and eventually trips
68+
``recompile_limit`` (16), which is a HARD failure under ``fullgraph=True``
69+
(``FailOnRecompileLimitHit``) and aborts the whole MPI job. Resetting
70+
between cases makes each LLM start from a clean compile cache, like a fresh
71+
process. Run on every worker via ``submit_sync_per_worker``.
72+
"""
73+
import torch
74+
75+
torch._dynamo.reset()

0 commit comments

Comments
 (0)