vLLM Explained

Executors and workers: from the engine core to the GPUs

IntermediatevLLM conceptsabout 12 min read + 3 min deep dives

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#

Figure: the three executor backends (panel 1), the path of one execute_model call with TP = 2 (panel 2), and the threads inside a worker process (panel 3).
Open full size ↗

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_class

You 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 future

With 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):

  1. 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()
  2. It starts one process per local rank with WorkerProc.make_worker_process (vllm/v1/executor/multiproc_executor.py:191-207). The process name is VllmWorker-<rank>, and it uses the context of get_mp_context(), normally spawn (vllm/v1/executor/multiproc_executor.py:723-750).
  3. It waits until every worker sends READY through a pipe, with the handle of its reply queue (vllm/v1/executor/multiproc_executor.py:786-822).
  4. 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()
  1. 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).
  2. Lines 430-432: with an output_rank, the executor reads only that worker's reply queue.
  3. Lines 444-448: when the worker that replies failed, it sends a FAILURE status, and the executor raises RuntimeError with the error text.
  4. 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:

MethodCalled fromWhat it does
init_deviceexecutor start-upsets 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_modelexecutor start-uploads the weights through the model runner (vllm/v1/worker/gpu_worker.py:591-598)
get_kv_cache_specEngineCore._initialize_kv_caches (vllm/v1/engine/core.py:253)the KV cache layout that each layer needs
determine_available_memorysame (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_configsame (vllm/v1/engine/core.py:362)allocates the KV cache tensors (vllm/v1/worker/gpu_worker.py:857-909)
compile_or_warm_up_modelsame (vllm/v1/engine/core.py:379)compiles, warms up and captures CUDA graphs (vllm/v1/worker/gpu_worker.py:924-1115)
execute_model, sample_tokensevery engine stepforward to the model runner (vllm/v1/worker/gpu_worker.py:1316-1383)
sleep, wake_uputility calls, for example from the dev-mode /sleep routefree 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 dive

Start-up: every executor call, in order

For vllm serve with TP = 2 on one node. (Derived from the call sites below.)

  1. MultiprocExecutor(vllm_config) in EngineCore.__init__ (vllm/v1/engine/core.py:147): starts VllmWorker-0 and VllmWorker-1. Each runs init_worker, init_device (NCCL starts here) and load_model, then sends READY.
  2. measure_encoder_output_widths may call collective_rpc (vllm/v1/engine/core.py:157).
  3. 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).
  4. determine_available_memory() returns one value per worker, only when the model has a KV cache (vllm/v1/engine/core.py:288-300).
  5. update_max_model_len, only when the KV cache planning changed max_model_len (vllm/v1/engine/core.py:320-322).
  6. initialize_from_config(kv_cache_configs): the wrapper picks the config of its own rank (vllm/v1/worker/worker_base.py:350-354).
  7. compile_or_warm_up_model(): the executor keeps the largest compile time of the workers (vllm/v1/executor/abstract.py:133-148).
Deep dive

The broadcast queue in numbers

PropertyValueSource
Writers / readers1 writer (the executor), world_size readers, local_world_size of them through shared memoryvllm/v1/executor/multiproc_executor.py:165-171
Chunks10 (default of max_chunks)vllm/distributed/device_communicators/shm_broadcast.py:491-492
Chunk size16 MiB (VLLM_MQ_MAX_CHUNK_BYTES_MB); the class default is 24 MiBvllm/envs.py:1856-1858, vllm/v1/executor/multiproc_executor.py:152
Formatpickle; buffers of 1 MiB or more go out-of-bandvllm/distributed/device_communicators/shm_broadcast.py:906-914
Overflowa message of a chunk size or more goes over a ZMQ XPUB socket; the chunk only carries a flagvllm/distributed/device_communicators/shm_broadcast.py:942-946
Reply queueMessageQueue(1, 1) per worker, on a single nodevllm/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 dive

Errors: what raises and where

SituationResultSource
a worker method raisesthe 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 timeoutvllm/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 raisesvllm/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 callbackvllm/v1/executor/multiproc_executor.py:314-326
any call after a failureRuntimeError("Executor failed.")vllm/v1/executor/multiproc_executor.py:399-400
no reply in timeTimeoutError("RPC call to ... timed out.")vllm/v1/executor/multiproc_executor.py:440-443
check_healtha collective_rpc with a 10 s timeout; the uni executor is always healthyvllm/v1/executor/multiproc_executor.py:545-547, vllm/v1/executor/uniproc_executor.py:147-150
Deep dive

Environment variables

VariableDefaultRead atEffect
VLLM_MQ_MAX_CHUNK_BYTES_MB16 (vllm/envs.py:1856-1858)vllm/v1/executor/multiproc_executor.py:152Chunk size of the broadcast queue.
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS300 (vllm/envs.py:1861-1863)vllm/v1/executor/multiproc_executor.py:350Timeout of one step call in the mp executor.
VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS5 (vllm/envs.py:1865-1867)vllm/v1/executor/multiproc_executor.py:484-486Grace period before SIGTERM, then SIGKILL after 4 more seconds.
VLLM_WORKER_MULTIPROC_METHODfork; the CLI sets spawnvllm/envs.py:938-940Start method of the worker processes.

What can go wrong#

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#

  1. 1
    Which executor backend does vllm serve use with one GPU and default flags, and why is there no worker process?
    easy
    Show answer

    "uni", because world_size is 1 and DP is 1 (vllm/config/parallel.py:1099-1100). UniProcExecutor makes the worker as an object in the engine core process and calls it directly (vllm/v1/executor/uniproc_executor.py:51-75).

  2. 2
    With TP = 4, how many times does the executor write one execute_model call into shared memory, and how many replies does it read?
    medium
    Show answer

    Once, into rpc_broadcast_mq, which all four workers read (vllm/v1/executor/multiproc_executor.py:428). It reads one reply, from output_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).

  3. 3
    The engine core waits only for the sample_tokens future of a step. How does the execute_model reply of the same step get read from the reply queue?
    medium
    Show answer

    FutureWrapper.result() first drains all older futures in the shared futures_queue, in order (vllm/v1/executor/multiproc_executor.py:92-100). The execute_model future is older, so its reply is read first.

  4. 4
    Async 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 WorkerAsyncOutputCopy thread. handle_output puts the result into async_output_queue (vllm/v1/executor/multiproc_executor.py:1014-1022), and that thread calls enqueue_output, which calls get_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.