Executors and workers: from the engine core to the GPUs
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
The executor starts the workers and runs every engine call on all of them. Each worker owns one GPU and holds the model runner.
The idea#
The EngineCore does not touch a GPU itself. It talks to an executor, and the executor talks to the workers. A worker owns one GPU: it sets up the device and the process groups, loads its part of the model, and holds the model runner. With tensor parallelism, one model runs on several GPUs, so there are several workers (Parallelism basics).
The engine core asks the executor for things like "run this step" or "how much memory is free?". The executor turns each request into a collective_rpc: it calls the same worker method on every worker, and collects the results. How it reaches the workers depends on the backend:
- The uni executor keeps the one worker as an object in the engine core process. A call is a plain Python call. By default, vLLM uses it only when TP = PP = DP = 1.
- The multiproc executor starts one worker process per GPU on the node. It writes each call once into a shared-memory MessageQueue, and all workers read it.
- The Ray executor uses the same message queues, but its workers are Ray actors (
vllm/v1/executor/ray_executor_v2.py:239-247).
An everyday comparison. Think of a conductor and an orchestra. The conductor (the engine core) gives one sign, and every musician (worker) plays the same bar on their own instrument (GPU). One musician, the first violin, reports when the bar is done. The comparison stops here: the musicians in vLLM also talk to each other during the bar (NCCL collectives), and the conductor never hears that.
The picture#
Read panel 2 from left to right and back. The call goes out once and reaches every worker. Only one result comes back. Panel 3 explains how a worker keeps the overlap of Async scheduling: overlap the CPU and the GPU: its main thread never waits for the GPU result.
In the code#
Step 1: which backend#
distributed_executor_backend has the default None (vllm/config/parallel.py:290-292). ParallelConfig.__post_init__ fills it in:
if self.distributed_executor_backend is None and self.world_size_across_dp > 1:
# We use multiprocessing by default if world_size fits on the
# current node and we aren't in a ray placement group.
from vllm.v1.executor import ray_utils
backend: DistributedExecutorBackend = "mp" self.distributed_executor_backend = backend
logger.debug("Defaulting to use %s for distributed inference", backend)
if self.distributed_executor_backend is None and self.world_size == 1:
self.distributed_executor_backend = "uni"world_size_across_dp is world_size × data_parallel_size (vllm/config/parallel.py:626-629), and world_size is PP × TP × PCP (vllm/config/parallel.py:965-969). So, when you do not set a backend, "uni" applies only when TP, PP, DP (and the prefill context parallel size) are all 1. With DP = 2 and TP = 1, each engine core gets one worker process (see The process and thread architecture of vllm serve). Ray replaces "mp" only after three earlier tests (vllm/config/parallel.py:1063-1078). TPU with SPMD picks "uni". CUDA with more than one node (--nnodes) keeps "mp". Too few GPUs on the node raise ValueError. Then Ray is used when the data parallel backend is Ray, or when Ray is installed and a Ray placement group is set or active (vllm/config/parallel.py:1079-1095). Executor.get_class then maps the name to a class:
@staticmethod
def get_class(vllm_config: VllmConfig) -> type["Executor"]:
executor_class: type[Executor]
parallel_config = vllm_config.parallel_config
distributed_executor_backend = parallel_config.distributed_executor_backend
# distributed_executor_backend must be set in VllmConfig.__post_init__
if isinstance(distributed_executor_backend, type):
if not issubclass(distributed_executor_backend, Executor):
raise TypeError(
"distributed_executor_backend must be a subclass of "
f"Executor. Got {distributed_executor_backend}."
)
executor_class = distributed_executor_backend
elif distributed_executor_backend == "ray":
from vllm.v1.executor.ray_executor_v2 import RayExecutorV2
executor_class = RayExecutorV2
elif distributed_executor_backend == "mp":
from vllm.v1.executor.multiproc_executor import MultiprocExecutor
executor_class = MultiprocExecutor
elif distributed_executor_backend == "uni":
from vllm.v1.executor.uniproc_executor import UniProcExecutor
executor_class = UniProcExecutor
elif distributed_executor_backend == "external_launcher":
# TODO: make v1 scheduling deterministic
# to support external launcher
executor_class = ExecutorWithExternalLauncher
elif isinstance(distributed_executor_backend, str):
executor_class = resolve_obj_by_qualname(distributed_executor_backend)
if not issubclass(executor_class, Executor):
raise TypeError(
"distributed_executor_backend must be a subclass of "
f"Executor. Got {executor_class}."
)
else:
raise ValueError(
f"Unknown distributed executor backend: {distributed_executor_backend}"
)
return executor_classYou can also pass your own Executor subclass, as a class or as a qualified name (lines 69-75 and 92-98). The Hooks in the engine core and the workers page lists this extension point.
Step 2: the executor interface#
Every executor implements collective_rpc. The docstring defines it: run a method "on all workers", by name or as a callable that gets the worker as self (vllm/v1/executor/abstract.py:209-242). The base class builds the step calls on top of it:
def execute_model(
self, scheduler_output: SchedulerOutput, non_block: bool = False
) -> ModelRunnerOutput | None | Future[ModelRunnerOutput | None]:
output = self.collective_rpc( # type: ignore[call-overload]
"execute_model", args=(scheduler_output,), non_block=non_block
)
return output[0]
@overload
def sample_tokens(
self, grammar_output: GrammarOutput | None, non_block: Literal[False] = False
) -> ModelRunnerOutput:
pass
@overload
def sample_tokens(
self, grammar_output: GrammarOutput | None, non_block: Literal[True] = True
) -> Future[ModelRunnerOutput]:
pass
def sample_tokens(
self, grammar_output: GrammarOutput | None, non_block: bool = False
) -> ModelRunnerOutput | Future[ModelRunnerOutput]:
output = self.collective_rpc( # type: ignore[call-overload]
"sample_tokens", args=(grammar_output,), non_block=non_block
)
return output[0]With non_block=True, the call returns a Future instead of the result. The default loop, step_with_batch_queue, uses this for both calls of a step (vllm/v1/engine/core.py:727-729, vllm/v1/engine/core.py:743-745). In step(), only execute_model uses it (vllm/v1/engine/core.py:668, vllm/v1/engine/core.py:676). Start-up and control calls go the same way. For example, determine_available_memory is collective_rpc("determine_available_memory") (vllm/v1/executor/abstract.py:156-157), and sleep is collective_rpc("sleep", ...) (vllm/v1/executor/abstract.py:393-403).
Step 3: UniProcExecutor, one worker in the same process#
class UniProcExecutor(Executor):
def _init_executor(self) -> None:
"""Initialize the worker and load the model."""
self.driver_worker = WorkerWrapperBase(rpc_rank=0)
distributed_init_method, rank, local_rank = self._distributed_args()
kwargs = dict(
vllm_config=self.vllm_config,
local_rank=local_rank,
rank=rank,
distributed_init_method=distributed_init_method,
is_driver_worker=True,
shared_worker_lock=Lock(),
)
# Set net device env vars for the worker if VLLM_GPU_NIC_PCIE_MAPPING is set
set_worker_net_device(local_rank, self.vllm_config)
self.driver_worker.init_worker(all_kwargs=[kwargs])
self.driver_worker.init_device()
if envs.VLLM_ELASTIC_EP_SCALE_UP_LAUNCH:
self.driver_worker.elastic_ep_execute("load_model")
else:
self.driver_worker.load_model()
current_platform.update_block_size_for_backend(self.vllm_config)The executor wraps the worker in a WorkerWrapperBase, then runs the three start-up steps directly: init_worker, init_device and load_model. A call is run_method on the wrapper, which calls getattr(wrapper, method) (vllm/v1/serial_utils.py:485-508). The wrapper's own methods, such as execute_model, run first; other names go to the worker through __getattr__ (vllm/v1/worker/worker_base.py:365-366):
if not non_block:
result = run_method(self.driver_worker, method, args, kwargs)
if isinstance(result, AsyncModelRunnerOutput):
result = result.get_output()
return result if single_value else [result]
try:
result = run_method(self.driver_worker, method, args, kwargs)
if isinstance(result, AsyncModelRunnerOutput):
return AsyncOutputFuture(result, single_value)
future = Future[Any]()
future.set_result(result if single_value else [result])
except Exception as e:
future = Future[Any]()
future.set_exception(e)
return futureWith non_block=True, the method still runs at once, in the engine core's main thread. Only an AsyncModelRunnerOutput result becomes a future that waits later, in get_output() (vllm/v1/executor/uniproc_executor.py:32-48). So non_block does not make the call faster. It moves the wait for the GPU result to the moment when the engine core pops the step from its batch queue.
Step 4: MultiprocExecutor starts the worker processes#
_init_executor does four things in order (vllm/v1/executor/multiproc_executor.py:119-268):
- It makes the broadcast queue, with one reader per worker and a chunk size from
VLLM_MQ_MAX_CHUNK_BYTES_MB(16 MiB by default):max_chunk_bytes = envs.VLLM_MQ_MAX_CHUNK_BYTES_MB * 1024 * 1024 mq_connect_ip = get_ip() logger.info( "DP group leader: node_rank=%d, node_rank_within_dp=%d, " "master_addr=%s, mq_connect_ip=%s (local), " "world_size=%d, local_world_size=%d", self.parallel_config.node_rank, self.parallel_config.node_rank_within_dp, self.parallel_config.master_addr, mq_connect_ip, self.world_size, self.local_world_size, ) self.rpc_broadcast_mq = MessageQueue( self.world_size, self.local_world_size, max_chunk_bytes=max_chunk_bytes, connect_ip=mq_connect_ip, enable_shm_tensor_arena=(self.parallel_config.enable_shm_tensor_arena), ) scheduler_output_handle = self.rpc_broadcast_mq.export_handle() - It starts one process per local rank with
WorkerProc.make_worker_process(vllm/v1/executor/multiproc_executor.py:191-207). The process name isVllmWorker-<rank>, and it uses the context ofget_mp_context(), normally spawn (vllm/v1/executor/multiproc_executor.py:723-750). - It waits until every worker sends
READYthrough a pipe, with the handle of its reply queue (vllm/v1/executor/multiproc_executor.py:786-822). - It starts the worker monitor (Step 6), connects the reply queues, and computes
output_rank(vllm/v1/executor/multiproc_executor.py:224-268).
Step 5: collective_rpc over shared memory#
if isinstance(method, str):
send_method = method
else:
send_method = cloudpickle.dumps(method, protocol=pickle.HIGHEST_PROTOCOL)
self.rpc_broadcast_mq.enqueue((send_method, args, kwargs, output_rank))
response_mqs: Sequence[MessageQueue] = self.response_mqs
if output_rank is not None:
response_mqs = (response_mqs[output_rank],)
def get_response():
responses = []
for mq in response_mqs:
dequeue_timeout = (
None if deadline is None else max(0.0, deadline - time.monotonic())
)
try:
status, result = mq.dequeue(timeout=dequeue_timeout)
except TimeoutError as e:
raise TimeoutError(f"RPC call to {method} timed out.") from e
if status != WorkerProc.ResponseStatus.SUCCESS:
raise RuntimeError(
f"Worker failed with error '{result}', please check the"
" stack trace above for the root cause"
)
responses.append(result)
return responses[0] if output_rank is not None else responses
future = FutureWrapper(
self.futures_queue, get_response=get_response, aggregate=aggregate
)
return future if non_block else future.result()- Line 428: the call goes into the broadcast queue once, as
(method, args, kwargs, output_rank). The queue pickles it (vllm/distributed/device_communicators/shm_broadcast.py:925-941). - Lines 430-432: with an
output_rank, the executor reads only that worker's reply queue. - Lines 444-448: when the worker that replies failed, it sends a
FAILUREstatus, and the executor raisesRuntimeErrorwith the error text. - Lines 452-456: the reply is read later, in
FutureWrapper.result().
For execute_model and sample_tokens, the output_rank is the first TP worker of the last PP stage (vllm/v1/executor/multiproc_executor.py:342-366, vllm/v1/executor/multiproc_executor.py:549-563). The other workers run the same method but send nothing back. With a KV or EC connector, all workers reply, and an aggregator merges their outputs (vllm/v1/executor/multiproc_executor.py:405-419, vllm/v1/engine/core.py:191-194).
The replies come back in call order, and FutureWrapper uses that. Each future joins a shared deque. When you ask a future for its result, it first reads the replies of all older futures (vllm/v1/executor/multiproc_executor.py:79-110). So the engine core can wait for the sample_tokens future of step N, and the execute_model reply of step N is read on the way.
Step 6: detecting a dead worker#
Two threads watch the processes. In the engine core, MultiprocWorkerMonitor waits on the sentinels of all worker processes:
def monitor_workers():
sentinels = [h.proc.sentinel for h in workers]
died = multiprocessing.connection.wait(sentinels)
_self = self_ref()
if not _self or getattr(_self, "shutting_down", False):
logger.debug("MultiprocWorkerMonitor: shutdown already initiated")
return
_self.is_failed = True
proc = next(h.proc for h in workers if h.proc.sentinel == died[0])
logger.error(
"Worker proc %s died unexpectedly (exit code: %s), "
"shutting down executor.",
proc.name,
proc.exitcode,
)
_self.shutdown()
callback = _self.failure_callback
if callback is not None:
_self.failure_callback = None
callback()The callback puts EXECUTOR_FAILED into the engine core's input queue, and the busy loop raises (The engine core loop: threads, queues and the busy loop, Step 10). In each worker, DeathPipeMonitor blocks on a pipe from the parent. When the parent process ends, the pipe returns EOFError, and the worker shuts down its queues (vllm/v1/executor/multiproc_executor.py:835-859). The wait for a call that hangs also ends: execute_model and sample_tokens use the timeout VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS, 300 s by default (vllm/v1/executor/multiproc_executor.py:350, vllm/envs.py:1861-1863). The engine core then gets TimeoutError (vllm/v1/executor/multiproc_executor.py:440-443); the hung worker keeps running.
Step 7: WorkerProc, the worker process#
The process target is WorkerProc.worker_main. It installs signal handlers, builds the WorkerProc, sends READY and enters the busy loop (vllm/v1/executor/multiproc_executor.py:861-948). The constructor is the same start-up as in the uni executor:
wrapper.init_worker(all_kwargs)
self.worker = wrapper
self.setup_proc_title_and_log_prefix(
enable_ep=vllm_config.parallel_config.enable_expert_parallel
)
# Load model
self.worker.init_device()
# Update process title now that parallel groups are initialized
self.setup_proc_title_and_log_prefix(
enable_ep=vllm_config.parallel_config.enable_expert_parallel
)
if envs.VLLM_ELASTIC_EP_SCALE_UP_LAUNCH:
self.worker.elastic_ep_execute("load_model")
else:
self.worker.load_model()
scheduler_config = vllm_config.scheduler_config
self.use_async_scheduling = scheduler_config.async_scheduling
if self.use_async_scheduling:
self.async_output_queue: queue.Queue = queue.Queue()
self.async_output_copy_thread = Thread(
target=self.async_output_busy_loop,
daemon=True,
name="WorkerAsyncOutputCopy",
)
self.async_output_copy_thread.start()init_worker loads the general plugins and makes the worker class from parallel_config.worker_cls (vllm/v1/worker/worker_base.py:268-347). On CUDA that is vllm.v1.worker.gpu_worker.Worker, when worker_cls is "auto" (vllm/platforms/cuda.py:330-331). The busy loop reads calls forever:
def worker_busy_loop(self):
"""Main busy loop for Multiprocessing Workers."""
assert self.rpc_broadcast_mq is not None
while True:
with get_watch_dog().disable():
rpc_request = self.rpc_broadcast_mq.dequeue(indefinite=True)
self._execute_worker_rpc(rpc_request)
del rpc_request
def _execute_worker_rpc(
self,
rpc_request: tuple[str | bytes, tuple[Any, ...], dict[str, Any], int | None],
) -> None:
"""Execute one RPC in a separate frame from the dequeue loop."""
method, args, kwargs, output_rank = rpc_request
try:
if isinstance(method, str):
func = getattr(self.worker, method)
elif isinstance(method, bytes):
func = partial(cloudpickle.loads(method), self.worker)
watchdog = get_watch_dog()
watchdog.feed()
output = func(*args, **kwargs)
# Feed again: the RPC itself may run close to the watchdog
# timeout, so the pre-call feed can go stale while the result
# is serialized/enqueued in handle_output().
watchdog.feed()
if output_rank is None or self.rank == output_rank:
self.handle_output(output)
except Exception as e:
# Notes have been introduced in python 3.11
if hasattr(e, "add_note"):
e.add_note(traceback.format_exc())
logger.exception("WorkerProc hit an exception.")
# enqueue_output converts the exception to a FAILURE response
# containing its string representation before transport.
if output_rank is None or self.rank == output_rank:
self.handle_output(e)Line 1070: only the output rank (or every worker when output_rank is None) sends a reply. With async scheduling, handle_output passes the result to the WorkerAsyncOutputCopy thread, which waits for the GPU copy and then writes the reply (vllm/v1/executor/multiproc_executor.py:1000-1002, vllm/v1/executor/multiproc_executor.py:1019-1039).
Step 8: the GPU worker#
The class Worker in vllm/v1/worker/gpu_worker.py does the per-GPU work. The engine core calls its methods in this order at start-up and during serving:
| Method | Called from | What it does |
|---|---|---|
init_device | executor start-up | sets the CUDA device, starts the process groups (NCCL), takes a memory snapshot, builds the model runner (vllm/v1/worker/gpu_worker.py:446-549) |
load_model | executor start-up | loads the weights through the model runner (vllm/v1/worker/gpu_worker.py:591-598) |
get_kv_cache_spec | EngineCore._initialize_kv_caches (vllm/v1/engine/core.py:253) | the KV cache layout that each layer needs |
determine_available_memory | same (vllm/v1/engine/core.py:300) | runs the profile run and returns the bytes left for the KV cache (vllm/v1/worker/gpu_worker.py:638-811) |
initialize_from_config | same (vllm/v1/engine/core.py:362) | allocates the KV cache tensors (vllm/v1/worker/gpu_worker.py:857-909) |
compile_or_warm_up_model | same (vllm/v1/engine/core.py:379) | compiles, warms up and captures CUDA graphs (vllm/v1/worker/gpu_worker.py:924-1115) |
execute_model, sample_tokens | every engine step | forward to the model runner (vllm/v1/worker/gpu_worker.py:1316-1383) |
sleep, wake_up | utility calls, for example from the dev-mode /sleep route | free and restore GPU memory (vllm/v1/worker/gpu_worker.py:298-364) |
The page v1/worker/gpu_worker.py: the GPU Worker reads these methods line by line, with a memory example.
Step 9: the worker builds Model Runner V2#
def _make_model_runner(self) -> "GPUModelRunner":
"""Construct the platform's model runner; subclasses override this."""
if self.use_v2_model_runner:
if self.vllm_config.is_mm_encoder_only:
from vllm.v1.worker.mm_encoder_model_runner import (
MMEncoderModelRunner as GPUModelRunnerV2,
)
else:
from vllm.v1.worker.gpu.model_runner import ( # type: ignore[assignment]
GPUModelRunner as GPUModelRunnerV2,
)
# HACK(woosuk): This is a temporary fix to avoid type errors.
return GPUModelRunnerV2(self.vllm_config, self.device) # type: ignore
from vllm.v1.worker.gpu_model_runner import GPUModelRunner as GPUModelRunnerV1
return GPUModelRunnerV1(self.vllm_config, self.device)use_v2_model_runner is true by default when Triton is present and no unsupported feature is set (vllm/config/vllm.py:713-748). A multimodal encoder-only instance gets MMEncoderModelRunner (lines 570-573). Otherwise the worker falls back to Model Runner V1 (lines 581-583); the page The model runner (Model Runner V2) names the cases.
Step 10: Ray in one paragraph#
RayExecutorV2 subclasses MultiprocExecutor. Its docstring says it reuses "the MQ-based control plane and NCCL data plane", with Ray actors as workers (vllm/v1/executor/ray_executor_v2.py:239-247). Each actor runs a RayWorkerProc, a WorkerProc that learns its GPU after Ray places it (vllm/v1/executor/ray_executor_v2.py:79-100). So steps 5, 8 and 9 of this page also hold for Ray, and so does the worker busy loop. Process creation, placement and the monitoring differ: Ray has its own RayWorkerMonitor thread (vllm/v1/executor/ray_executor_v2.py:487-537), and RayWorkerProc.run() calls worker_busy_loop without worker_main (vllm/v1/executor/ray_executor_v2.py:223-231). The inherited supports_async_scheduling() is true, so async scheduling stays on.
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveStart-up: every executor call, in order
For vllm serve with TP = 2 on one node. (Derived from the call sites below.)
MultiprocExecutor(vllm_config)inEngineCore.__init__(vllm/v1/engine/core.py:147): startsVllmWorker-0andVllmWorker-1. Each runsinit_worker,init_device(NCCL starts here) andload_model, then sendsREADY.measure_encoder_output_widthsmay callcollective_rpc(vllm/v1/engine/core.py:157).get_kv_cache_specs(),get_supported_kv_cache_layouts(),set_kv_cache_layout()(vllm/v1/engine/core.py:253,vllm/v1/engine/core.py:281-286).determine_available_memory()returns one value per worker, only when the model has a KV cache (vllm/v1/engine/core.py:288-300).update_max_model_len, only when the KV cache planning changedmax_model_len(vllm/v1/engine/core.py:320-322).initialize_from_config(kv_cache_configs): the wrapper picks the config of its own rank (vllm/v1/worker/worker_base.py:350-354).compile_or_warm_up_model(): the executor keeps the largest compile time of the workers (vllm/v1/executor/abstract.py:133-148).
Deep diveThe broadcast queue in numbers
| Property | Value | Source |
|---|---|---|
| Writers / readers | 1 writer (the executor), world_size readers, local_world_size of them through shared memory | vllm/v1/executor/multiproc_executor.py:165-171 |
| Chunks | 10 (default of max_chunks) | vllm/distributed/device_communicators/shm_broadcast.py:491-492 |
| Chunk size | 16 MiB (VLLM_MQ_MAX_CHUNK_BYTES_MB); the class default is 24 MiB | vllm/envs.py:1856-1858, vllm/v1/executor/multiproc_executor.py:152 |
| Format | pickle; buffers of 1 MiB or more go out-of-band | vllm/distributed/device_communicators/shm_broadcast.py:906-914 |
| Overflow | a message of a chunk size or more goes over a ZMQ XPUB socket; the chunk only carries a flag | vllm/distributed/device_communicators/shm_broadcast.py:942-946 |
| Reply queue | MessageQueue(1, 1) per worker, on a single node | vllm/v1/executor/multiproc_executor.py:618-626 |
A SchedulerOutput of a normal decode step is small. A grammar bitmask for a large batch can be large. The comment on the class default says that 24 MiB is "large enough to accommodate grammar bitmask tensors for large batches (1024 requests)" (vllm/distributed/device_communicators/shm_broadcast.py:489-491). The page distributed/device_communicators/shm_broadcast.py: MessageQueue explains the ring buffer.
Deep diveErrors: what raises and where
| Situation | Result | Source |
|---|---|---|
| a worker method raises | the worker logs "WorkerProc hit an exception.". Only the replying worker (the output rank, or every worker when output_rank is None) sends FAILURE; then the executor raises RuntimeError("Worker failed with error ..."). A failure on another rank is only logged, and the executor waits for the output rank or the timeout | vllm/v1/executor/multiproc_executor.py:1072-1080, vllm/v1/executor/multiproc_executor.py:444-448 |
| a worker fails during start-up | "WorkerProc failed to start."; the executor sees EOFError on the ready pipe and raises | vllm/v1/executor/multiproc_executor.py:956-957, vllm/v1/executor/multiproc_executor.py:814-816 |
| a worker process dies | "Worker proc ... died unexpectedly"; executor shut down; failure callback | vllm/v1/executor/multiproc_executor.py:314-326 |
| any call after a failure | RuntimeError("Executor failed.") | vllm/v1/executor/multiproc_executor.py:399-400 |
| no reply in time | TimeoutError("RPC call to ... timed out.") | vllm/v1/executor/multiproc_executor.py:440-443 |
check_health | a collective_rpc with a 10 s timeout; the uni executor is always healthy | vllm/v1/executor/multiproc_executor.py:545-547, vllm/v1/executor/uniproc_executor.py:147-150 |
Deep diveEnvironment variables
| Variable | Default | Read at | Effect |
|---|---|---|---|
VLLM_MQ_MAX_CHUNK_BYTES_MB | 16 (vllm/envs.py:1856-1858) | vllm/v1/executor/multiproc_executor.py:152 | Chunk size of the broadcast queue. |
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS | 300 (vllm/envs.py:1861-1863) | vllm/v1/executor/multiproc_executor.py:350 | Timeout of one step call in the mp executor. |
VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS | 5 (vllm/envs.py:1865-1867) | vllm/v1/executor/multiproc_executor.py:484-486 | Grace period before SIGTERM, then SIGKILL after 4 more seconds. |
VLLM_WORKER_MULTIPROC_METHOD | fork; the CLI sets spawn | vllm/envs.py:938-940 | Start method of the worker processes. |
What can go wrong#
- "World size (...) is larger than the number of available GPUs". With the mp backend, all workers of one engine must fit on the node (
vllm/config/parallel.py:1067-1078). - "RPC call to execute_model timed out." A step took more than 300 s, or a worker hangs in a collective. Check the logs of every worker; the hung rank is often not the one that reports (
vllm/v1/executor/multiproc_executor.py:440-443). - "Worker proc VllmWorker-1 died unexpectedly". Often the kernel OOM killer or a CUDA crash. The engine then fails all requests (The engine core loop: threads, queues and the busy loop, Step 10).
- The error message has no stack trace. The reply carries only
str(exception)(vllm/v1/executor/multiproc_executor.py:1007-1008). The traceback is in the worker's own log line "WorkerProc hit an exception.". - An out-of-tree executor turns off async scheduling. See Async scheduling: overlap the CPU and the GPU.
Why it matters. The executor is the boundary between "what to run" and "where it runs". To debug a multi-GPU server, you must know that every worker runs every call, that only one replies, and which thread waits where. To size a server, you must know that TP = 1 and DP = 1 give no worker process at all.
Check yourself#
- 1Which executor backend doeseasy
vllm serveuse with one GPU and default flags, and why is there no worker process?Show answer
"uni", becauseworld_sizeis 1 and DP is 1 (vllm/config/parallel.py:1099-1100).UniProcExecutormakes the worker as an object in the engine core process and calls it directly (vllm/v1/executor/uniproc_executor.py:51-75). - 2With TP = 4, how many times does the executor write onemedium
execute_modelcall into shared memory, and how many replies does it read?Show answer
Once, into
rpc_broadcast_mq, which all four workers read (vllm/v1/executor/multiproc_executor.py:428). It reads one reply, fromoutput_rank = world_size - TP × PCP = 0(vllm/v1/executor/multiproc_executor.py:549-563). With a KV connector, it reads all four and aggregates them (vllm/v1/executor/multiproc_executor.py:405-419). - 3The engine core waits only for themedium
sample_tokensfuture of a step. How does theexecute_modelreply of the same step get read from the reply queue?Show answer
FutureWrapper.result()first drains all older futures in the sharedfutures_queue, in order (vllm/v1/executor/multiproc_executor.py:92-100). Theexecute_modelfuture is older, so its reply is read first. - 4Async scheduling is on and the executor is mp. Which thread of the worker process waits for the copy of the sampled tokens to the CPU? Why not the main thread?hard
Show answer
The
WorkerAsyncOutputCopythread.handle_outputputs the result intoasync_output_queue(vllm/v1/executor/multiproc_executor.py:1014-1022), and that thread callsenqueue_output, which callsget_output()(vllm/v1/executor/multiproc_executor.py:995-1002,vllm/v1/executor/multiproc_executor.py:1037-1039). So the main thread can dequeue and start the next call at once, which keeps the GPU stream full.