vLLM Explained

The engine core loop: threads, queues and the busy loop

IntermediatevLLM conceptsabout 16 min read + 5 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

The engine core process decodes requests in an input thread, runs engine steps in a busy loop, and sends results from an output thread.

The idea#

The EngineCore is the part of vLLM that decides what the GPU does next. In vllm serve it runs in its own process, one per data parallel rank. The page The process and thread architecture of vllm serve shows where this process sits. This page looks inside it.

The process has one job that must never wait: keep the GPU busy with engine steps. Other work can wait a little. Requests arrive as msgpack bytes and must be decoded. Results must be encoded and sent back. So the process uses three threads:

  • The input thread receives bytes on ZMQ sockets, decodes them and builds the engine's Request objects. It puts them into input_queue.
  • The main thread runs the busy loop. It takes the requests from input_queue, gives them to the scheduler, runs one engine step, and puts the results into output_queue.
  • The output thread takes the results from output_queue, encodes them and sends them back to the front end.

With async scheduling (the default), the busy loop does not wait for a step right after it starts it. Without pipeline parallelism, it keeps up to two steps in a batch queue: it starts step N+1 first, and only then waits for step N. The idea of an engine step loop is the same as in The engine step loop. The page Async scheduling: overlap the CPU and the GPU explains the overlap.

An everyday comparison. Think of a kitchen with a pass-through window on each side. One person takes the order slips from the left window and sorts them. The chef cooks, and starts the next pan before the last one is ready. A third person puts the finished plates through the right window. The chef never walks to a window. The comparison stops in one place: in Python, the three people share one lock, the GIL. The design works because socket I/O releases the GIL, and the chef often waits for the GPU anyway.

The picture#

Figure: the three threads of the engine core process and their two queues (panel 1), what one call of step_with_batch_queue does with the batch queue (panel 2), and the other messages in the queues (panel 3).
Open full size ↗

Read panel 1 from left to right: bytes come in on the left and go out on the right. Only the violet box touches the scheduler and the executor. Panel 2 shows why the first call of step_with_batch_queue returns nothing: the queue is not full yet. From the second call on, each call returns the outputs of the step before.

In the code#

Step 1: the process starts in run_engine_core#

The engine core client starts one process per local data parallel rank. Its target is EngineCoreProc.run_engine_core (vllm/v1/engine/utils.py:279-288). This static method sets the process title, installs signal handlers and picks the class:

            parallel_config.data_parallel_index = dp_rank
            if data_parallel and vllm_config.model_config.is_moe:
                # Set data parallel rank for this engine process.
                parallel_config.data_parallel_rank = dp_rank
                engine_core = DPEngineCoreProc(*args, **kwargs)
            else:
                # Non-MoE DP ranks are completely independent, so treat like DP=1.
                # Note that parallel_config.data_parallel_index will still reflect
                # the original DP rank.
                parallel_config.reconfigure_for_independent_dp_rank()
                engine_core = EngineCoreProc(*args, engine_index=dp_rank, **kwargs)

            assert engine_core is not None

            # Apply a signal received during construction before entering the
            # loop. run_busy_loop calls _handle_shutdown first, so the loop exits
            # right away instead of blocking on an idle input_queue.
            if shutdown_requested:
                engine_core.shutdown_state = EngineShutdownState.REQUESTED

            # Arm the wakeup callback only now that input_queue exists, so a
            # later signal can still wake an idle busy loop.
            signal_callback = SignalCallback(wakeup_engine)

            start_watch_dog(f"engine_{dp_rank}", vllm_config.watchdog_config, logger)
            engine_core.run_busy_loop()
  1. Line 1534: only an MoE model with DP > 1 gets a DPEngineCoreProc (Step 11).
  2. Line 1542: every other engine core treats itself as DP = 1. Dense DP ranks are independent.
  3. Line 1558: run_busy_loop() runs until shutdown. It never returns normally; it raises SystemExit (vllm/v1/engine/core.py:1620).

The signal handler only marks the shutdown as requested. It does not stop the process at once (vllm/v1/engine/core.py:1510-1531). The busy loop sees the flag on its next turn (Step 4).

Step 2: the handshake, the heavy start-up and the ready messages#

EngineCoreProc.__init__ makes the two queues first. The executor failure callback already points at input_queue:

        self.input_queue = queue.Queue[tuple[EngineCoreRequestType, Any]]()
        self.output_queue = queue.Queue[tuple[int, EngineCoreOutputs] | bytes]()
        executor_fail_callback = lambda: self.input_queue.put_nowait(
            (EngineCoreRequestType.EXECUTOR_FAILED, b"")
        )

        self.engine_index = engine_index
        identity = self.engine_index.to_bytes(length=2, byteorder="little")

Then it does a handshake with the front end on a separate ZMQ DEALER socket. The engine sends HELLO and waits up to 5 minutes for an init message with the socket addresses (vllm/v1/engine/core.py:1395-1413, vllm/v1/engine/core.py:113). Inside the with block of this handshake, it runs all the heavy start-up:

        with self._perform_handshakes(
            handshake_address,
            identity,
            local_client,
            vllm_config,
            client_handshake_address,
        ) as addresses:
            # Set up data parallel environment.
            self.has_coordinator = addresses.coordinator_output is not None
            self.frontend_stats_publish_address = (
                addresses.frontend_stats_publish_address
            )
            logger.debug(
                "Has DP Coordinator: %s, stats publish address: %s",
                self.has_coordinator,
                self.frontend_stats_publish_address,
            )
            internal_dp_balancing = (
                self.has_coordinator
                and not vllm_config.parallel_config.data_parallel_external_lb
            )
            # Only publish request queue stats to coordinator for "internal"
            # and "hybrid" LB modes.
            self.publish_dp_lb_stats = internal_dp_balancing
            self.last_counts = (0, 0)

            self.addresses = addresses
            self.process_input_queue_block = True
            self._init_data_parallel(vllm_config)

            super().__init__(
                vllm_config,
                executor_class,
                log_stats,
                executor_fail_callback,
                internal_dp_balancing,
            )

super().__init__ is EngineCore.__init__. It loads the general plugins and builds the executor, with the workers and the model. Then it profiles the memory, allocates the KV cache and makes the scheduler (vllm/v1/engine/core.py:129-185). Then it sets up the batch queue and picks the step function:

        # Setup batch queue for pipeline parallelism.
        # Batch queue for scheduled batches. This enables us to asynchronously
        # schedule and execute batches, and is required by pipeline parallelism
        # to eliminate pipeline bubbles.
        self.batch_queue_size = vllm_config.max_concurrent_batches
        self.batch_queue: (
            deque[tuple[Future[ModelRunnerOutput], SchedulerOutput, Future[Any]]] | None
        ) = None
        if self.batch_queue_size > 1:
            logger.debug("Batch queue is enabled with size %d", self.batch_queue_size)
            self.batch_queue = deque(maxlen=self.batch_queue_size)

        self.is_mm_encoder_only = vllm_config.is_mm_encoder_only
        self.is_pooling_model = vllm_config.model_config.runner_type == "pooling"

        self.request_block_hasher: Callable[[Request], list[BlockHash]] | None = None
        if vllm_config.cache_config.enable_prefix_caching or kv_connector is not None:
            caching_hash_fn = get_hash_fn_by_name(
                vllm_config.cache_config.prefix_caching_hash_algo
            )
            init_none_hash(caching_hash_fn)

            self.request_block_hasher = get_request_block_hasher(
                hash_block_size, caching_hash_fn
            )

        self.step_fn = (
            self.step if self.batch_queue is None else self.step_with_batch_queue
        )

max_concurrent_batches is pp_size + 1 with async scheduling and Model Runner V2, so 2 on one GPU (vllm/config/vllm.py:586-597). Then the I/O threads start (vllm/v1/engine/core.py:1238-1265). Two "ready" messages go out:

  1. The input thread sends an EngineCoreReadyResponse on each input socket. A ROUTER socket can only send to a peer that has sent to it first (vllm/v1/engine/core.py:1961-1968). The client waits for this message, with the timeout VLLM_ENGINE_READY_TIMEOUT_S (vllm/v1/engine/core_client.py:753-771).
  2. When the with block ends, the engine sends {"status": "READY"} on the handshake socket (vllm/v1/engine/core.py:1364-1386). The start-up code of the front end waits for it, with no time limit. It stops only when an engine process exits (vllm/v1/engine/utils.py:1434-1470, vllm/v1/engine/utils.py:1547).

Step 3: the input thread decodes and preprocesses#

            while True:
                for input_socket, _ in poller.poll():
                    # (RequestType, RequestData)
                    type_frame, *data_frames = input_socket.recv_multipart(copy=False)
                    # NOTE(yongji): ignore READY message sent by DP coordinator
                    # that is used to notify newly started engines
                    if type_frame.buffer == b"READY":
                        assert input_socket == coord_socket
                        continue
                    request_type = EngineCoreRequestType(bytes(type_frame.buffer))

                    # Deserialize the request data.
                    request: Any
                    if request_type == EngineCoreRequestType.ADD:
                        req: EngineCoreRequest = add_request_decoder.decode(data_frames)
                        try:
                            request = self.preprocess_add_request(req)
                        except MultiModalCacheMissError as e:
                            # P0/P1 shadow drift -- return a retryable signal (P0
                            # drops the stale entry, client resends with data).
                            self._handle_mm_cache_miss(req, e)
                            continue
                        except VLLMValidationError as e:
                            # Note(arpera):
                            # Currently we validate in engine core
                            # only if request uses the same structured output backend
                            # as engine core latched
                            self._handle_request_validation_error(req, e)
                            continue
                        except Exception:
                            self._handle_request_preproc_error(req)
                            continue
                    elif request_type == EngineCoreRequestType.UTILITY:
                        request = generic_decoder.decode(data_frames)
                        client_idx, call_id, method, args = request
                        if method == FT_UTILITY_METHOD:
                            self.ft_sentinel.handle_command(
                                client_idx, call_id, args[0]
                            )
                            continue
                    else:
                        request = generic_decoder.decode(data_frames)

                        if request_type == EngineCoreRequestType.ABORT:
                            # Aborts are added to *both* queues, allows us to eagerly
                            # process aborts while also ensuring ordering in the input
                            # queue to avoid leaking requests. This is ok because
                            # aborting in the scheduler is idempotent.
                            self.aborts_queue.put_nowait(request)

                    # Push to input queue for core busy loop.
                    self.input_queue.put_nowait((request_type, request))
  1. Line 1978: poller.poll() blocks until a socket has a message. ZMQ releases the GIL while it waits.
  2. Line 1980: the first frame is the request type, one byte: ADD, ABORT, START_DP_WAVE or UTILITY (vllm/v1/engine/__init__.py:300-312).
  3. Lines 1991-1993: an ADD is decoded into an EngineCoreRequest. Then preprocess_add_request makes the Request, with its block hashes, in this thread (vllm/v1/engine/core.py:1114-1136). Its docstring says why: so request set-up runs "in parallel with Model forward" (vllm/v1/engine/core.py:1115-1119).
  4. Lines 1999-2008: a request that fails here does not stop the thread. The engine sends a finish output with an error for that request only (vllm/v1/engine/core.py:2149-2167).
  5. Line 2025: an ABORT goes into aborts_queue as well (Step 9).
  6. Line 2028: everything goes into input_queue, in arrival order.

The fields of EngineCoreRequest and Request are on Data contracts between the parts of vLLM.

Step 4: the busy loop#

    @fault_tolerant_wrapper
    def run_busy_loop(self):
        """Core busy loop of the EngineCore."""
        while self._handle_shutdown():
            # 1) Poll the input queue until there is work to do.
            self._process_input_queue()
            # Publish request counts before and after GPU step to ensure freshness.
            self._maybe_publish_request_counts()
            # 2) Step the engine core and return the outputs.
            self._process_engine_step()
            self._maybe_publish_request_counts()

        raise SystemExit

_handle_shutdown() returns true while the engine runs. After a signal it aborts or drains the requests, and it returns false when no work is left (vllm/v1/engine/core.py:1691-1737). Each turn has two parts: drain the input queue, then run one step. _maybe_publish_request_counts does something only for data parallel load balancing (vllm/v1/engine/core.py:1622-1633).

    def _process_engine_step(self) -> bool:
        """Called only when there are unfinished local requests."""
        get_watch_dog().feed()

        # Step the engine core.
        outputs, model_executed = self.step_fn()
        # Put EngineCoreOutputs into the output queue.
        for output in outputs.items() if outputs else ():
            self.output_queue.put_nowait(output)
        # Post-step hook.
        self.post_step(model_executed)

        # If no model execution happened but there is still scheduler work
        # (e.g. WAITING_FOR_REMOTE_KVS or delayed KV connector frees), yield
        # the GIL briefly to allow background transfer threads to make progress.
        if not model_executed and self.scheduler.has_requests():
            time.sleep(0.001)

        return model_executed
  1. Line 1671: step_fn is step_with_batch_queue by default (Step 5). It returns a dict from client index to EngineCoreOutputs, or None.
  2. Lines 1673-1674: each output goes into output_queue as a pair (client_index, outputs).
  3. Line 1676: post_step takes draft tokens from the workers, but only without async scheduling (vllm/v1/engine/core.py:689-696).
  4. Lines 1681-1682: if no model ran but requests wait (for example for remote KV), the loop sleeps 1 ms so other threads can run.

Step 5: step() and step_with_batch_queue()#

EngineCore.step() is the simple loop: schedule, run, wait, sample, update. It waits for each step before it schedules the next one:

    def step(self) -> tuple[dict[int, EngineCoreOutputs], bool]:
        """Schedule, execute, and make output.

        Returns tuple of outputs and a flag indicating whether the model
        was executed.
        """
        # Check for any requests remaining in the scheduler - unfinished,
        # or finished and not yet removed from the batch.
        if not self.scheduler.has_requests():
            return {}, False
        scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
        future = self.model_executor.execute_model(scheduler_output, non_block=True)
        grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)
        with (
            self.capture_iteration_details(scheduler_output) as iteration_details,
            self.log_error_detail(scheduler_output),
        ):
            model_output = future.result()
            if model_output is None:
                model_output = self.model_executor.sample_tokens(grammar_output)

        # Before processing the model output, process any aborts that happened
        # during the model execution.
        self._process_aborts_queue()
        engine_core_outputs = self.scheduler.update_from_output(
            scheduler_output, model_output
        )
        self._attach_iteration_details(engine_core_outputs, iteration_details)
        self._attach_weight_version(engine_core_outputs)

        return engine_core_outputs, scheduler_output.total_num_scheduled_tokens > 0

The engine uses it only when there is no batch queue, for example with --no-async-scheduling and no pipeline parallelism (vllm/v1/engine/core.py:227-229). The default is step_with_batch_queue(). Its docstring gives the rule: "fulfilling the batch queue has a higher priority than getting model outputs" (vllm/v1/engine/core.py:701-713). The core lines:

        if self.scheduler.has_requests():
            scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
            with self.log_error_detail(scheduler_output):
                exec_future = self.model_executor.execute_model(
                    scheduler_output, non_block=True
                )
            if not self.is_mm_encoder_only:
                model_executed = scheduler_output.total_num_scheduled_tokens > 0

            if self.is_pooling_model or not model_executed:
                # No sampling required (no requests scheduled).
                future = cast(Future[ModelRunnerOutput], exec_future)
            else:
                if not scheduler_output.pending_structured_output_tokens:
                    # We aren't waiting for any tokens, get any grammar output
                    # and sample immediately.
                    grammar_output = self.scheduler.get_grammar_bitmask(
                        scheduler_output
                    )
                    future = self.model_executor.sample_tokens(
                        grammar_output, non_block=True
                    )
                else:
                    # We need to defer sampling until we have processed the model output
                    # from the prior step.
                    deferred_scheduler_output = scheduler_output

            if not deferred_scheduler_output:
                # Add this step's future to the queue.
                batch_queue.appendleft((future, scheduler_output, exec_future))
                if len(batch_queue) < self.batch_queue_size and (
                    model_executed or self.scheduler.has_requests()
                ):
                    # Don't block on next worker response unless the queue is full
                    # or there are no more requests to schedule.
                    return None, model_executed
        # Block until the next result is available.
        future, scheduler_output, exec_model_fut = batch_queue.pop()
        with (
            self.capture_iteration_details(scheduler_output) as iteration_details,
            self.log_error_detail(scheduler_output),
        ):
            model_output = future.result()
            if model_output is None:
                # None from sample_tokens() implies that the original execute_model()
                # call failed - raise that exception.
                exec_model_fut.result()
                raise RuntimeError("unexpected error")

        # Before processing the model output, process any aborts that happened
        # during the model execution.
        self._process_aborts_queue()
        engine_core_outputs = self.scheduler.update_from_output(
            scheduler_output, model_output
        )
        self._attach_iteration_details(engine_core_outputs, iteration_details)
        self._attach_weight_version(engine_core_outputs)
  1. Line 725: the scheduler makes the plan of step N+1. The page The V1 scheduler: one token budget, no phases explains it.
  2. Lines 727-729 and 743-745: execute_model and sample_tokens both get non_block=True. They return futures.
  3. Line 749: with structured output, sampling can wait for the previous step (see Async scheduling: overlap the CPU and the GPU).
  4. Lines 753-759: the new step goes to the left end of the queue. If the queue is not full, the function returns None at once.
  5. Line 768: else it takes the oldest step from the right end, and line 773 waits for its result.
  6. Lines 782-783: it applies the aborts that came in meanwhile, then update_from_output makes the outputs.

The tour Tour: One engine step follows one call of this function with real numbers.

Step 6: when there is no work#

The busy loop does not spin when the engine is idle. _process_input_queue blocks on input_queue.get() while has_work() is false:

    def _process_input_queue(self):
        """Exits when an engine step needs to be performed."""
        waited = False
        while not self.has_work() and self.is_running():
            # Notify callbacks waiting for engine to become idle.
            self._notify_idle_state_callbacks()
            if self.input_queue.empty():
                # Drain aborts queue; all aborts are also processed via input_queue.
                with self.aborts_queue.mutex:
                    self.aborts_queue.queue.clear()
                if logger.isEnabledFor(DEBUG):
                    logger.debug("EngineCore waiting for work.")
                    waited = True
            block = self.process_input_queue_block
            try:
                with get_watch_dog().disable():
                    req = self.input_queue.get(block=block)
                self._handle_client_request(*req)
            except queue.Empty:
                break
            if not block:
                break

        if waited:
            logger.debug("EngineCore loop active.")

        # Handle any more client requests.
        while not self.input_queue.empty():
            req = self.input_queue.get_nowait()
            self._handle_client_request(*req)
    def has_work(self) -> bool:
        """Returns true if the engine should be stepped."""
        return (
            self.engines_running
            or self.scheduler.has_requests()
            or bool(self.batch_queue)
        )
  1. Line 1638: the wait loop runs only while there is no work and no shutdown.
  2. Lines 1648-1651: process_input_queue_block is True for an engine core process (vllm/v1/engine/core.py:1217). So the thread sleeps in get() and uses no CPU.
  3. Lines 1662-1664: when there is work, the loop still takes every message that is already queued, without a wait.

has_work() is also true while the batch queue is not empty. So after the last request finishes, the loop runs until the last in-flight step is processed. A signal during an idle wait puts a WAKEUP message into the queue, so the loop can see the shutdown (vllm/v1/engine/core.py:1503-1508).

Step 7: the output thread#

            while True:
                output = self.output_queue.get()
                if output == EngineCoreProc.ENGINE_CORE_DEAD:
                    for socket in sockets:
                        socket.send(output)
                    break
                assert not isinstance(output, bytes)
                client_index, outputs = output
                outputs.engine_index = engine_index

                if client_index == -1:
                    # Don't reuse buffer for coordinator message
                    # which will be very small.
                    assert coord_socket is not None
                    coord_socket.send_multipart(encoder.encode(outputs))
                    continue

                # Reclaim buffers that zmq is finished with.
                while pending and pending[-1][0].done:
                    reclaimed = pending.pop()[1]
                    if len(reuse_buffers) < max_reuse_bufs:
                        reuse_buffers.append(reclaimed)

                buffer = reuse_buffers.pop() if reuse_buffers else bytearray()
                buffers = encoder.encode_into(outputs, buffer)
                tracker = self._send_msg_tracking_payload(
                    sockets[client_index], buffers
                )
                if not tracker.done:
                    pending.appendleft((tracker, buffer))
                elif len(reuse_buffers) < max_reuse_bufs:
                    # Limit the number of buffers to reuse.
                    reuse_buffers.append(buffer)
  1. Line 2065: the thread blocks until an output is ready.
  2. Lines 2066-2069: ENGINE_CORE_DEAD goes to every front end, and the thread stops (Step 10).
  3. Lines 2074-2079: client index -1 means the DP coordinator (load counts and wave messages).
  4. Lines 2088-2091: encode_into writes into a reused buffer, and the message goes to the socket of the front end that sent the request. A tracker tells when ZMQ has finished with the buffer.

Step 8: utility calls#

A utility call is a remote method call from the front end, for example get_supported_tasks, sleep or collective_rpc. The input thread decodes it into (client_idx, call_id, method_name, args) (vllm/v1/engine/core.py:2009-2011). The busy loop runs it between two steps:

        elif request_type == EngineCoreRequestType.UTILITY:
            client_idx, call_id, method_name, args = request
            if self._reject_utility_in_shutdown(client_idx, call_id, method_name):
                return
            output = UtilityOutput(call_id)
            # Lazily look-up utility method so that failure will be handled/returned.
            get_result = lambda: (
                (method := getattr(self, method_name))
                and method(*self._convert_msgspec_args(method, args))
            )
            enqueue_output = lambda out: self.output_queue.put_nowait(
                (client_idx, EngineCoreOutputs(utility_output=out))
            )
            self._invoke_utility_method(method_name, get_result, output, enqueue_output)
    @staticmethod
    def _invoke_utility_method(
        name: str, get_result: Callable, output: UtilityOutput, enqueue_output: Callable
    ):
        try:
            result = get_result()
            if isinstance(result, Future):
                # Defer utility output handling until future completion.
                callback = lambda future: EngineCoreProc._invoke_utility_method(
                    name, future.result, output, enqueue_output
                )
                result.add_done_callback(callback)
                return
            output.result = UtilityResult(result)
        except Exception as e:
            logger.exception("Invocation of %s method failed", name)
            output.failure_message = f"Call to {name} method failed: {str(e)}"
        enqueue_output(output)
  1. Lines 1758-1761: the method is looked up by name on the engine object, inside the call. So a wrong name becomes a failure message, not a crash.
  2. Lines 1828-1834: a method can return a Future. Then the reply waits until the future is done. sleep() uses this when the pause is not complete yet: it frees GPU memory only after the engine has no more work (vllm/v1/engine/core.py:994-1017).
  3. Lines 1836-1838: an exception becomes failure_message in the UtilityOutput; the engine keeps running.

The reply travels in EngineCoreOutputs.utility_output to the front end that asked (vllm/v1/engine/__init__.py:254-263). The client side is on v1/engine/core_client.py: the engine core clients.

Step 9: aborts#

An abort arrives when a client disconnects or a stop string matches in the front end (The engine client: AsyncLLM in the API server). The input thread puts it into two queues. The comment explains why: aborts can act early, and the order in input_queue stays correct (vllm/v1/engine/core.py:2020-2025). The early path runs just before update_from_output:

    def _process_aborts_queue(self):
        if not self.aborts_queue.empty():
            request_ids = []
            while not self.aborts_queue.empty():
                ids = self.aborts_queue.get_nowait()
                # Should be a list here, but also handle string just in case.
                request_ids.extend((ids,) if isinstance(ids, str) else ids)
            # More efficient to abort all as a single batch.
            self.abort_requests(request_ids)

abort_requests calls scheduler.finish_requests(..., FINISHED_ABORTED), which frees the blocks (vllm/v1/engine/core.py:554-559). The same ABORT then comes again from input_queue; this is safe because "aborting in the scheduler is idempotent" (vllm/v1/engine/core.py:2021-2024). A step that was already running for an aborted request still finishes on the GPU. update_from_output skips its results, because the request is finished (vllm/v1/core/sched/scheduler.py:2184-2192).

Step 10: errors and engine death#

Three kinds of error reach the engine core:

  • One request is bad. The input thread sends an error output for that request and continues (Step 3).
  • A worker process dies. The executor's monitor calls the failure callback, which puts EXECUTOR_FAILED into input_queue (vllm/v1/engine/core.py:1174-1176). The busy loop raises RuntimeError("Executor failed.") (vllm/v1/engine/core.py:1766-1767).
  • Any exception in the busy loop, for example from execute_model. For an error in the model launch or in the wait for its result, log_error_detail first dumps the scheduler output and stats (vllm/v1/engine/core.py:561-575).

An exception that leaves run_busy_loop reaches run_engine_core:

        except Exception as e:
            if engine_core is None:
                logger.exception("EngineCore failed to start.")
            else:
                logger.exception("EngineCore encountered a fatal error.")
                engine_core._send_engine_dead()
            raise e
        finally:
            signal.signal(signal.SIGTERM, signal.SIG_DFL)
            signal.signal(signal.SIGINT, signal.SIG_DFL)
            if signal_callback is not None:
                signal_callback.stop()
            if engine_core is not None:
                engine_core.shutdown()
    def _send_engine_dead(self):
        """Send EngineDead status to the EngineCoreClient."""
        # Put ENGINE_CORE_DEAD in the queue.
        self.output_queue.put_nowait(EngineCoreProc.ENGINE_CORE_DEAD)

        # Wait until msg sent by the daemon before shutdown.
        self.output_thread.join(timeout=5.0)
        if self.output_thread.is_alive():
            logger.fatal(
                "vLLM shutdown signal from EngineCore failed "
                "to send. Please report this issue."
            )

The engine puts ENGINE_CORE_DEAD into the output queue and gives the output thread 5 seconds to send it. The front end then raises EngineDeadError. The page The engine client: AsyncLLM in the API server shows what the API server does next. Open streams get errors, and /health returns HTTP 503 (vllm/entrypoints/serve/instrumentator/health.py:32-33). The server shuts down unless VLLM_KEEP_ALIVE_ON_ENGINE_DEATH is set (vllm/entrypoints/launchers/launcher.py:332-337).

Step 11: DPEngineCoreProc, the data parallel variant for MoE#

With DP > 1, the ranks of a dense model are "completely independent" (the comment at vllm/v1/engine/core.py:1539). The ranks of an MoE model are not. The docs say why: the expert layers of all ranks must synchronize in every forward pass. So the forward passes of all ranks must be aligned (docs/serving/data_parallel_deployment.md:9, Expert parallelism). DPEngineCoreProc changes the busy loop in two ways:

            executed = self._process_engine_step()
            self._maybe_publish_request_counts()

            local_unfinished_reqs = self.scheduler.has_unfinished_requests()
            if not executed:
                if not local_unfinished_reqs and not self.engines_running:
                    # All engines are idle.
                    continue

                # Execute a dummy pass when no ready requests ran, unless the
                # engine is sleeping.
                elif not self.model_executor.is_sleeping:
                    with self.capture_iteration_details(None) as iteration_details:
                        self.execute_dummy_batch()
                    if iteration_details is not None and not self.has_coordinator:
                        stats = self._make_iteration_details_stats(iteration_details)
                        self.output_queue.put_nowait(
                            (0, EngineCoreOutputs(scheduler_stats=stats))
                        )

            # 3) All-reduce operation to determine global unfinished reqs.
            self.engines_running = self._has_global_unfinished_reqs(
                local_unfinished_reqs
            )
  1. Dummy batches. Assume that this rank ran no step, but the group still runs. Then the rank runs execute_dummy_batch(), so the collectives of the other ranks do not hang (lines 2486-2495).
  2. Waves. All ranks agree, with an all-reduce, whether any rank has unfinished requests. They check at the first step of a wave and then only every dp_sync_interval steps, default 16 (vllm/v1/engine/core.py:2542-2559, vllm/config/parallel.py:163). When no rank has work, the wave ends and the engines pause. A new request starts the next wave (vllm/v1/engine/core.py:2360-2373).

The page Data parallel serving: several engine cores behind one endpoint explains waves, the coordinator and load balancing.

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

step_with_batch_queue(), line by line

        batch_queue = self.batch_queue
        assert batch_queue is not None

        # Try to schedule a new batch if the batch queue is not full, but
        # the scheduler may return an empty batch if all requests are scheduled.
        # Note that this is not blocking.
        assert len(batch_queue) < self.batch_queue_size

        model_executed = False
        deferred_scheduler_output = None
        if self.scheduler.has_requests():
            scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
            with self.log_error_detail(scheduler_output):
                exec_future = self.model_executor.execute_model(
                    scheduler_output, non_block=True
                )
            if not self.is_mm_encoder_only:
                model_executed = scheduler_output.total_num_scheduled_tokens > 0

            if self.is_pooling_model or not model_executed:
                # No sampling required (no requests scheduled).
                future = cast(Future[ModelRunnerOutput], exec_future)
            else:
                if not scheduler_output.pending_structured_output_tokens:
                    # We aren't waiting for any tokens, get any grammar output
                    # and sample immediately.
                    grammar_output = self.scheduler.get_grammar_bitmask(
                        scheduler_output
                    )
                    future = self.model_executor.sample_tokens(
                        grammar_output, non_block=True
                    )
                else:
                    # We need to defer sampling until we have processed the model output
                    # from the prior step.
                    deferred_scheduler_output = scheduler_output

            if not deferred_scheduler_output:
                # Add this step's future to the queue.
                batch_queue.appendleft((future, scheduler_output, exec_future))
                if len(batch_queue) < self.batch_queue_size and (
                    model_executed or self.scheduler.has_requests()
                ):
                    # Don't block on next worker response unless the queue is full
                    # or there are no more requests to schedule.
                    return None, model_executed

        elif not batch_queue:
            # Queue is empty. We should not reach here since this method should
            # only be called when the scheduler contains requests or the queue
            # is non-empty.
            return None, False
  1. Line 720. The function must only be called when the queue has room. The busy loop guarantees this: every call that fills the queue also pops one step.
  2. Line 724. has_requests() is true for unfinished requests and for finished requests that the workers do not know yet. With a KV or EC connector, it is also true while the connector has pending push work (vllm/v1/core/sched/scheduler.py:2922-2937).
  3. Line 725. _should_throttle_prefills() is always false for a plain engine core (vllm/v1/engine/core.py:652-655). Only the DP engine overrides it.
  4. Lines 726-729. log_error_detail wraps the launch, so a launch error dumps the scheduler state.
  5. Lines 730-731. A step with zero scheduled tokens does not count as executed. A multimodal encoder-only instance never counts.
  6. Lines 733-735. A pooling model, or a step with no tokens, needs no sampling. The future of execute_model is the result.
  7. Lines 737-745. Normal case: compute the grammar bitmask now (on the CPU, while the GPU runs the forward pass), then queue sampling.
  8. Lines 746-749. Deferred case: a structured output request still waits for its previous token, so the bitmask cannot be computed yet.
  9. Lines 751-759. A queue entry is a tuple (sample future, SchedulerOutput, execute future) (vllm/v1/engine/core.py:206-208). The function returns early only if the queue has room and there is more to do.
  10. Lines 761-765. No requests and an empty queue: nothing to do.
        # Block until the next result is available.
        future, scheduler_output, exec_model_fut = batch_queue.pop()
        with (
            self.capture_iteration_details(scheduler_output) as iteration_details,
            self.log_error_detail(scheduler_output),
        ):
            model_output = future.result()
            if model_output is None:
                # None from sample_tokens() implies that the original execute_model()
                # call failed - raise that exception.
                exec_model_fut.result()
                raise RuntimeError("unexpected error")

        # Before processing the model output, process any aborts that happened
        # during the model execution.
        self._process_aborts_queue()
        engine_core_outputs = self.scheduler.update_from_output(
            scheduler_output, model_output
        )
        self._attach_iteration_details(engine_core_outputs, iteration_details)
        self._attach_weight_version(engine_core_outputs)

        # NOTE(nick): We can either handle the deferred tasks here or save
        # in a field and do it immediately once step_with_batch_queue is
        # re-called. The latter slightly favors TTFT over TPOT/throughput.
        if deferred_scheduler_output:
            # When draft tokens are used with structured output, validate them
            # before computing the grammar bitmask for the deferred request.
            if self.check_for_draft_tokens:
                draft_token_ids = self.model_executor.take_draft_token_ids()
                if draft_token_ids is not None:
                    # Update the draft token ids in the scheduler output to
                    # filter out the invalid spec tokens, which will be padded
                    # with -1 and skipped by the grammar bitmask computation.
                    self.scheduler.update_draft_token_ids_in_output(
                        draft_token_ids, deferred_scheduler_output
                    )
            # We now have the tokens needed to compute the bitmask for the
            # deferred request. Get the bitmask and call sample tokens.
            grammar_output = self.scheduler.get_grammar_bitmask(
                deferred_scheduler_output
            )
            future = self.model_executor.sample_tokens(grammar_output, non_block=True)
            batch_queue.appendleft((future, deferred_scheduler_output, exec_future))

        return engine_core_outputs, model_executed
  1. Line 768. pop() takes the right end, the oldest step, because new steps go in with appendleft.
  2. Lines 773-778. A None result from sample_tokens means that execute_model failed. The code calls exec_model_fut.result() to raise that error.
  3. Lines 789-791. The comment gives the trade-off of the deferred case: handle it here, which "slightly favors TTFT over TPOT/throughput".
  4. Lines 792-810. Now the previous token is known. The code computes the bitmask, starts sampling for the deferred step and puts it into the queue.
Deep dive

Worked example: the batch queue over five calls

One request decodes alone. Queue size 2. "S k" = schedule and start step k; "U k" = wait for step k and update.

CallQueue beforeWorkQueue afterReturns
1[]S 1[1]None
2[1]S 2, U 1[2]token of step 1
3[2]S 3, U 2[3]token of step 2
4 (step 3 sampled EOS)[3]S 4, U 3[4]token of step 3, finish reason
5[4]S 5 with 0 tokens (it only tells the workers that the request finished), U 4[5]nothing for the finished request
6[5]no requests; U 5 only[]nothing; then has_work() is false and the loop blocks

Call 5 still schedules, because a finished request counts in has_requests() until its id goes out in a SchedulerOutput (vllm/v1/core/sched/scheduler.py:2913-2914, vllm/v1/core/sched/scheduler.py:1597-1601). schedule() then clears the set, so call 6 finds no requests (vllm/v1/core/sched/scheduler.py:1731-1734). Step 4 ran on the GPU for a request that was already finished. Its result is ignored (vllm/v1/core/sched/scheduler.py:2184-2192). This extra step is the cost of the overlap. The scheduler avoids it only when it is sure that max_tokens is reached (vllm/v1/core/sched/scheduler.py:720-734). (Table derived from vllm/v1/engine/core.py:724-785.)

Deep dive

Every message type in the two queues

Queue itemPut byHandled by
(ADD, (Request, wave))input thread (vllm/v1/engine/core.py:2028)add_request, unless the engine shuts down or a user pause rejects new requests (vllm/v1/engine/core.py:1745-1749)
(ABORT, [ids])input threadabort_requests (vllm/v1/engine/core.py:1750-1751)
(UTILITY, (client, call_id, name, args))input thread_invoke_utility_method (Step 8)
(START_DP_WAVE, (wave, exclude))input thread, from the DP coordinatorDPEngineCoreProc only (vllm/v1/engine/core.py:2406-2424)
(EXECUTOR_FAILED, b"")executor monitor threadraises RuntimeError (vllm/v1/engine/core.py:1766-1767)
(WAKEUP, None)wakeup callback, after a signalignored; it only ends the blocking get() (vllm/v1/engine/core.py:1743-1744)
(client_index, EngineCoreOutputs)busy loop, utility calls, abort and error pathsoutput thread, to sockets[client_index]
(-1, EngineCoreOutputs)request counts and wave messagesoutput thread, to the DP coordinator (vllm/v1/engine/core.py:2074-2079)
ENGINE_CORE_DEAD (bytes)_send_engine_deadoutput thread: send to all, then stop
Deep dive

Shutdown, step by step

  1. SIGTERM or SIGINT sets shutdown_state = REQUESTED and triggers the wakeup callback (vllm/v1/engine/core.py:1510-1528).
  2. The next _handle_shutdown() picks a mode from shutdown_timeout: 0 (the default) aborts all requests; a positive value drains them (vllm/v1/engine/core.py:1696-1727, vllm/config/vllm.py:453).
  3. In both modes new ADD messages get an ABORT output (vllm/v1/engine/core.py:1773-1782) and utility calls fail with "Server shutting down" (vllm/v1/engine/core.py:1806-1820).
  4. When has_work() is false, the loop ends and raises SystemExit. The finally block calls engine_core.shutdown(), which shuts down the executor and the scheduler (vllm/v1/engine/core.py:824-840).
Deep dive

Configuration and environment variables

NameDefaultWhereEffect here
--async-scheduling / --no-async-schedulingon, unless incompatiblevllm/config/vllm.py:1664-1698Selects step_with_batch_queue and AsyncScheduler.
max_concurrent_batches (derived)pp_size + 1 with MRV2 and asyncvllm/config/vllm.py:586-597Size of the batch queue; 1 means no queue and step().
VLLM_ENGINE_READY_TIMEOUT_S600 svllm/envs.py:812-814How long the client waits for the ready message.
HANDSHAKE_TIMEOUT_MINS (constant)5 minvllm/v1/engine/core.py:113How long the engine waits for the front end's init message.
shutdown_timeout0vllm/config/vllm.py:4530 aborts in-flight requests at shutdown; > 0 drains them.
dp_sync_interval16vllm/config/parallel.py:163MoE DP only: steps between the "any rank has work" all-reduces.
watchdog dump_dir"" (off)vllm/config/watchdog_config.py:28-30When set, a thread dumps all stacks if the busy loop stops feeding it (timeout 300 s, vllm/config/watchdog_config.py:21).

What can go wrong#

Why it matters. Almost every performance question about vLLM comes down to one question: is the GPU always busy? The three threads and the batch queue are vLLM's answer. In a profile, a gap between two GPU steps means that this loop waited. It waited for the scheduler, for a deferred grammar bitmask, or for the input queue.

Check yourself#

  1. 1
    Which thread of the engine core process builds the Request object of a new request?
    easy
    Show answer

    The input thread. After it decodes the EngineCoreRequest, it calls preprocess_add_request, which makes the Request with its block hashes (vllm/v1/engine/core.py:1991-1993, vllm/v1/engine/core.py:1114-1128). The main thread only calls add_request later.

  2. 2
    What does the busy loop do when no request is waiting or running?
    easy
    Show answer

    It blocks in input_queue.get(), because process_input_queue_block is True and has_work() is false (vllm/v1/engine/core.py:1638-1651, vllm/v1/engine/core.py:1217). It uses no CPU until a message arrives.

  3. 3
    The batch queue has size 2. Why does the first call of step_with_batch_queue return no outputs?
    medium
    Show answer

    After it starts step 1, the queue holds one entry, which is less than 2, and the scheduler still has requests. So the function returns None before it waits for any result (vllm/v1/engine/core.py:753-759). The outputs of step 1 come in the second call, after step 2 is started.

  4. 4
    An ABORT arrives while a step runs on the GPU. Which two paths handle it, and why is the second one harmless?
    medium
    Show answer

    The input thread puts it into aborts_queue and into input_queue (vllm/v1/engine/core.py:2020-2028). _process_aborts_queue applies it just before update_from_output (vllm/v1/engine/core.py:780-782). The copy in input_queue calls finish_requests again, which is idempotent (vllm/v1/engine/core.py:2021-2024).

  5. 5
    A request has structured output and async scheduling is on. Why can step_with_batch_queue not call sample_tokens right after execute_model, and what does it do instead?
    hard
    Show answer

    The grammar bitmask of step N+1 depends on the token of step N, which is still in flight. AsyncScheduler marks this with pending_structured_output_tokens (vllm/v1/core/sched/async_scheduler.py:37-39). The engine then keeps the scheduler output as deferred_scheduler_output, first processes step N, and only then computes the bitmask and queues sampling (vllm/v1/engine/core.py:746-749, vllm/v1/engine/core.py:792-810).