From 0157befc18207262f3bb0230759127db6d36bb30 Mon Sep 17 00:00:00 2001 From: Justin Wood Date: Wed, 24 Jun 2026 05:10:29 +0000 Subject: [PATCH] envs+entrypoint: enable opt-in forkserver path safely PART 1 (env widen): The forkserver multiprocessing method was supported in vllm's open-source surface area in PR #40331 (merged 2026-04-21, reverted #40438 hours later due to an unrelated BG-thread `import transformers` preload that broke tests/entrypoints/pooling/basic/test_truncation.py). The forkserver plumbing -- multiprocessing.set_start_method("forkserver"), set_forkserver_preload(["vllm.v1.engine.async_llm"]), forkserver.ensure_running() -- survived the revert at vllm/entrypoints/openai/api_server.py:83-90, but became unreachable because vllm/envs.py constrained VLLM_WORKER_MULTIPROC_METHOD to {"fork","spawn"} only. This re-widens the env Literal + choices list to include "forkserver", making the surviving code path opt-in. No default change; users must explicitly set VLLM_WORKER_MULTIPROC_METHOD=forkserver to activate it. PART 2 (fix four latent bugs in the surviving path that this widen makes reachable): 1. vllm/utils/system_utils.py: _maybe_force_spawn() previously only short-circuited when VLLM_WORKER_MULTIPROC_METHOD == "spawn". When the user opted into "forkserver" and CUDA was initialized in the parent (or Ray-actor / WSL / --numa-bind), it would silently rewrite the env to "spawn", defeating the opt-in. The forkserver helper process is started by the api_server entrypoint via forkserver.ensure_running() before any CUDA touch, so the CUDA-init / Ray-actor / WSL / NUMA-bind hazards that motivate forcing spawn do not apply to forkserver. Extended the early-return to cover both "spawn" and "forkserver". 2. vllm/entrypoints/openai/api_server.py: multiprocessing.set_start_method was called without force=True; on re-entry (test fixtures that already set a start method, importlib reloads, etc.) it raises RuntimeError. Added force=True so the call is idempotent. 3. vllm/entrypoints/openai/api_server.py: switched the gate from os.getenv("VLLM_WORKER_MULTIPROC_METHOD") to envs.VLLM_WORKER_MULTIPROC_METHOD so the gate, the env_with_choices validator, and _maybe_force_spawn all read the same source-of-truth. 4. vllm/entrypoints/openai/api_server.py: removed the set_forkserver_preload(["vllm.v1.engine.async_llm"]) call. The original landing of forkserver support (PR #40331) was reverted in PR #40438 because preloading vllm.v1.engine.async_llm pulled in a background-thread "import transformers" that broke tests/entrypoints/pooling/basic/test_truncation.py. The eager-import optimization is fully separable from the forkserver opt-in; forkserver still works without it (cold imports per fork), and re-introducing the preload would re-introduce the original regression. If a future PR wants the warm-import speedup, it should land separately with a regression-tested non-eager preload. Tests added in tests/utils_/test_system_utils.py: - forkserver opt-in survives simulated CUDA-init in parent - forkserver opt-in survives --numa-bind - existing spawn opt-in still short-circuits (regression guard) - unset env + CUDA-init still forces spawn (negative case unchanged) Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: terafin --- tests/utils_/test_system_utils.py | 46 +++++++++++++++++++++++++++ vllm/entrypoints/openai/api_server.py | 21 +++++++++--- vllm/envs.py | 4 +-- vllm/utils/system_utils.py | 10 +++++- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/tests/utils_/test_system_utils.py b/tests/utils_/test_system_utils.py index 5ef55877a7c7..dba31184920c 100644 --- a/tests/utils_/test_system_utils.py +++ b/tests/utils_/test_system_utils.py @@ -25,3 +25,49 @@ def test_numa_bind_forces_spawn(monkeypatch): monkeypatch.setattr("sys.argv", ["vllm", "serve", "--numa-bind"]) _maybe_force_spawn() assert os.environ["VLLM_WORKER_MULTIPROC_METHOD"] == "spawn" + +def test_forkserver_opt_in_survives_cuda_init(monkeypatch): + """Regression test: when the user explicitly opts into + VLLM_WORKER_MULTIPROC_METHOD=forkserver, _maybe_force_spawn must NOT + silently rewrite it to "spawn" — even when CUDA is initialized in the + parent. The forkserver helper process forks workers from a clean snapshot + taken before CUDA init, so the CUDA-init hazard that motivates forcing + spawn does not apply.""" + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "forkserver") + monkeypatch.setattr("sys.argv", ["vllm", "serve"]) + # Simulate CUDA initialized in the parent process — without the + # forkserver early-return, _maybe_force_spawn would rewrite to "spawn". + monkeypatch.setattr( + "vllm.utils.system_utils.cuda_is_initialized", lambda: True + ) + _maybe_force_spawn() + assert os.environ["VLLM_WORKER_MULTIPROC_METHOD"] == "forkserver" + + +def test_forkserver_opt_in_survives_numa_bind(monkeypatch): + """Same contract under --numa-bind: forkserver opt-in is preserved.""" + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "forkserver") + monkeypatch.setattr("sys.argv", ["vllm", "serve", "--numa-bind"]) + _maybe_force_spawn() + assert os.environ["VLLM_WORKER_MULTIPROC_METHOD"] == "forkserver" + + +def test_spawn_opt_in_still_short_circuits(monkeypatch): + """Sanity: existing "spawn" early-return still works (regression guard + against accidentally clobbering the original branch).""" + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn") + monkeypatch.setattr("sys.argv", ["vllm", "serve", "--numa-bind"]) + _maybe_force_spawn() + assert os.environ["VLLM_WORKER_MULTIPROC_METHOD"] == "spawn" + + +def test_unset_with_cuda_init_still_forces_spawn(monkeypatch): + """When the user has NOT opted in, CUDA-init still forces spawn.""" + monkeypatch.delenv("VLLM_WORKER_MULTIPROC_METHOD", raising=False) + monkeypatch.setattr("sys.argv", ["vllm", "serve"]) + monkeypatch.setattr( + "vllm.utils.system_utils.cuda_is_initialized", lambda: True + ) + _maybe_force_spawn() + assert os.environ["VLLM_WORKER_MULTIPROC_METHOD"] == "spawn" + diff --git a/vllm/entrypoints/openai/api_server.py b/vllm/entrypoints/openai/api_server.py index e1e2ef72bbdb..fbbc1e6ab85d 100644 --- a/vllm/entrypoints/openai/api_server.py +++ b/vllm/entrypoints/openai/api_server.py @@ -80,12 +80,23 @@ async def build_async_engine_client( usage_context: UsageContext = UsageContext.OPENAI_API_SERVER, client_config: dict[str, Any] | None = None, ) -> AsyncIterator[EngineClient]: - if os.getenv("VLLM_WORKER_MULTIPROC_METHOD") == "forkserver": + if envs.VLLM_WORKER_MULTIPROC_METHOD == "forkserver": # The executor is expected to be mp. - # Pre-import heavy modules in the forkserver process - logger.debug("Setup forkserver with pre-imports") - multiprocessing.set_start_method("forkserver") - multiprocessing.set_forkserver_preload(["vllm.v1.engine.async_llm"]) + # Start the forkserver helper process eagerly so it forks workers + # from a clean parent (before CUDA/heavy imports). force=True so + # re-entry (e.g. test fixtures that already set a method) does not + # raise RuntimeError. + # + # Note: we intentionally do NOT call set_forkserver_preload() here. + # The original landing of forkserver support (PR #40331) was reverted + # in PR #40438 because preloading vllm.v1.engine.async_llm pulled in + # a background-thread "import transformers" that broke the pooling + # tests. The eager-import optimization is fully separable from the + # forkserver opt-in; forkserver still works without it (cold imports + # per fork), and re-introducing the preload would re-introduce the + # original regression. + logger.debug("Setup forkserver") + multiprocessing.set_start_method("forkserver", force=True) forkserver.ensure_running() logger.debug("Forkserver setup complete!") diff --git a/vllm/envs.py b/vllm/envs.py index 265477ea7b93..e5a9ea581369 100755 --- a/vllm/envs.py +++ b/vllm/envs.py @@ -64,7 +64,7 @@ VLLM_USE_RAY_V2_EXECUTOR_BACKEND: bool = False VLLM_DISTRIBUTED_USE_SPLIT_GROUP: bool = False VLLM_XLA_USE_SPMD: bool = False - VLLM_WORKER_MULTIPROC_METHOD: Literal["fork", "spawn"] = "fork" + VLLM_WORKER_MULTIPROC_METHOD: Literal["fork", "spawn", "forkserver"] = "fork" VLLM_ASSETS_CACHE: str = os.path.join(VLLM_CACHE_ROOT, "assets") VLLM_ASSETS_CACHE_MODEL_CLEAN: bool = False VLLM_IMAGE_FETCH_TIMEOUT: int = 5 @@ -861,7 +861,7 @@ def _resolve_rust_frontend_path() -> str | None: # Use dedicated multiprocess context for workers. # Both spawn and fork work "VLLM_WORKER_MULTIPROC_METHOD": env_with_choices( - "VLLM_WORKER_MULTIPROC_METHOD", "fork", ["spawn", "fork"] + "VLLM_WORKER_MULTIPROC_METHOD", "fork", ["spawn", "fork", "forkserver"] ), # Path to the cache for storing downloaded assets "VLLM_ASSETS_CACHE": lambda: os.path.expanduser( diff --git a/vllm/utils/system_utils.py b/vllm/utils/system_utils.py index 7f56f972a4fa..461a5fabcf83 100644 --- a/vllm/utils/system_utils.py +++ b/vllm/utils/system_utils.py @@ -126,8 +126,16 @@ def _sync_visible_devices_env_vars(): def _maybe_force_spawn(): """Check if we need to force the use of the `spawn` multiprocessing start method. + + If the user has explicitly opted into "spawn" or "forkserver", we skip the + force-spawn override. forkserver is only enabled when the parent has not + yet initialized CUDA (the api_server entrypoint pre-starts the forkserver + via forkserver.ensure_running() before any CUDA touch), so the + CUDA-init / Ray-actor / WSL / NUMA-bind hazards that motivate forcing + spawn do not apply, and silently rewriting forkserver to spawn would + defeat the user opt-in. """ - if os.environ.get("VLLM_WORKER_MULTIPROC_METHOD") == "spawn": + if os.environ.get("VLLM_WORKER_MULTIPROC_METHOD") in ("spawn", "forkserver"): return reasons = []