The engine core loop: threads, queues and the busy loop
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
Requestobjects. It puts them intoinput_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 intooutput_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#
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()- Line 1534: only an MoE model with DP > 1 gets a
DPEngineCoreProc(Step 11). - Line 1542: every other engine core treats itself as DP = 1. Dense DP ranks are independent.
- Line 1558:
run_busy_loop()runs until shutdown. It never returns normally; it raisesSystemExit(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:
- The input thread sends an
EngineCoreReadyResponseon 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 timeoutVLLM_ENGINE_READY_TIMEOUT_S(vllm/v1/engine/core_client.py:753-771). - When the
withblock 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))- Line 1978:
poller.poll()blocks until a socket has a message. ZMQ releases the GIL while it waits. - Line 1980: the first frame is the request type, one byte:
ADD,ABORT,START_DP_WAVEorUTILITY(vllm/v1/engine/__init__.py:300-312). - Lines 1991-1993: an ADD is decoded into an
EngineCoreRequest. Thenpreprocess_add_requestmakes theRequest, 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). - 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). - Line 2025: an ABORT goes into
aborts_queueas well (Step 9). - 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- Line 1671:
step_fnisstep_with_batch_queueby default (Step 5). It returns a dict from client index toEngineCoreOutputs, orNone. - Lines 1673-1674: each output goes into
output_queueas a pair(client_index, outputs). - Line 1676:
post_steptakes draft tokens from the workers, but only without async scheduling (vllm/v1/engine/core.py:689-696). - 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 > 0The 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)- Line 725: the scheduler makes the plan of step N+1. The page The V1 scheduler: one token budget, no phases explains it.
- Lines 727-729 and 743-745:
execute_modelandsample_tokensboth getnon_block=True. They return futures. - Line 749: with structured output, sampling can wait for the previous step (see Async scheduling: overlap the CPU and the GPU).
- Lines 753-759: the new step goes to the left end of the queue. If the queue is not full, the function returns
Noneat once. - Line 768: else it takes the oldest step from the right end, and line 773 waits for its result.
- Lines 782-783: it applies the aborts that came in meanwhile, then
update_from_outputmakes 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)
)- Line 1638: the wait loop runs only while there is no work and no shutdown.
- Lines 1648-1651:
process_input_queue_blockisTruefor an engine core process (vllm/v1/engine/core.py:1217). So the thread sleeps inget()and uses no CPU. - 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)- Line 2065: the thread blocks until an output is ready.
- Lines 2066-2069:
ENGINE_CORE_DEADgoes to every front end, and the thread stops (Step 10). - Lines 2074-2079: client index
-1means the DP coordinator (load counts and wave messages). - Lines 2088-2091:
encode_intowrites 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)- 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.
- 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). - Lines 1836-1838: an exception becomes
failure_messagein theUtilityOutput; 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_FAILEDintoinput_queue(vllm/v1/engine/core.py:1174-1176). The busy loop raisesRuntimeError("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_detailfirst 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
)- 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). - 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_intervalsteps, 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 divestep_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- 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.
- 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). - 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. - Lines 726-729.
log_error_detailwraps the launch, so a launch error dumps the scheduler state. - Lines 730-731. A step with zero scheduled tokens does not count as executed. A multimodal encoder-only instance never counts.
- Lines 733-735. A pooling model, or a step with no tokens, needs no sampling. The future of
execute_modelis the result. - Lines 737-745. Normal case: compute the grammar bitmask now (on the CPU, while the GPU runs the forward pass), then queue sampling.
- Lines 746-749. Deferred case: a structured output request still waits for its previous token, so the bitmask cannot be computed yet.
- 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. - 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- Line 768.
pop()takes the right end, the oldest step, because new steps go in withappendleft. - Lines 773-778. A
Noneresult fromsample_tokensmeans thatexecute_modelfailed. The code callsexec_model_fut.result()to raise that error. - Lines 789-791. The comment gives the trade-off of the deferred case: handle it here, which "slightly favors TTFT over TPOT/throughput".
- 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 diveWorked 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.
| Call | Queue before | Work | Queue after | Returns |
|---|---|---|---|---|
| 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 diveEvery message type in the two queues
| Queue item | Put by | Handled 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 thread | abort_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 coordinator | DPEngineCoreProc only (vllm/v1/engine/core.py:2406-2424) |
(EXECUTOR_FAILED, b"") | executor monitor thread | raises RuntimeError (vllm/v1/engine/core.py:1766-1767) |
(WAKEUP, None) | wakeup callback, after a signal | ignored; it only ends the blocking get() (vllm/v1/engine/core.py:1743-1744) |
(client_index, EngineCoreOutputs) | busy loop, utility calls, abort and error paths | output thread, to sockets[client_index] |
(-1, EngineCoreOutputs) | request counts and wave messages | output thread, to the DP coordinator (vllm/v1/engine/core.py:2074-2079) |
ENGINE_CORE_DEAD (bytes) | _send_engine_dead | output thread: send to all, then stop |
Deep diveShutdown, step by step
- SIGTERM or SIGINT sets
shutdown_state = REQUESTEDand triggers the wakeup callback (vllm/v1/engine/core.py:1510-1528). - The next
_handle_shutdown()picks a mode fromshutdown_timeout: 0 (the default) aborts all requests; a positive value drains them (vllm/v1/engine/core.py:1696-1727,vllm/config/vllm.py:453). - 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). - When
has_work()is false, the loop ends and raisesSystemExit. Thefinallyblock callsengine_core.shutdown(), which shuts down the executor and the scheduler (vllm/v1/engine/core.py:824-840).
Deep diveConfiguration and environment variables
| Name | Default | Where | Effect here |
|---|---|---|---|
--async-scheduling / --no-async-scheduling | on, unless incompatible | vllm/config/vllm.py:1664-1698 | Selects step_with_batch_queue and AsyncScheduler. |
max_concurrent_batches (derived) | pp_size + 1 with MRV2 and async | vllm/config/vllm.py:586-597 | Size of the batch queue; 1 means no queue and step(). |
VLLM_ENGINE_READY_TIMEOUT_S | 600 s | vllm/envs.py:812-814 | How long the client waits for the ready message. |
HANDSHAKE_TIMEOUT_MINS (constant) | 5 min | vllm/v1/engine/core.py:113 | How long the engine waits for the front end's init message. |
shutdown_timeout | 0 | vllm/config/vllm.py:453 | 0 aborts in-flight requests at shutdown; > 0 drains them. |
dp_sync_interval | 16 | vllm/config/parallel.py:163 | MoE DP only: steps between the "any rank has work" all-reduces. |
watchdog dump_dir | "" (off) | vllm/config/watchdog_config.py:28-30 | When 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#
- The engine core does not become ready. Loading, profiling and CUDA-graph capture all run inside the handshake (Step 2). With one API server, the API server starts the engine and waits with no time limit (
vllm/v1/engine/core_client.py:695-710,vllm/v1/engine/utils.py:1388-1395). With more than one API server, the API server processes start before the engines are ready (vllm/entrypoints/cli/serve.py:336-382). Then a slow model load can hitVLLM_ENGINE_READY_TIMEOUT_Sin each API server (vllm/v1/engine/core_client.py:756-768). - "EngineCore failed to start." An exception before the busy loop starts. Look at the traceback in the engine core log (
vllm/v1/engine/core.py:1571-1572). - "EngineCore encountered a fatal error." An exception in a step, for example a CUDA error or an out-of-memory error. Every open request fails with EngineDeadError (
vllm/v1/engine/core.py:1573-1575). - "Executor failed." A worker process died (
vllm/v1/engine/core.py:1766-1767). The worker log has the cause; see Executors and workers: from the engine core to the GPUs. - A slow input thread. Request set-up (block hashes, multimodal features, grammar set-up) runs in the input thread, not in the busy loop. Many large prompts at once can delay new requests, but not running steps.
- One extra step after a stop. With async scheduling, a request that stops on EOS has already a next step on the GPU. The scheduler ignores that result (Deep dive, worked example).
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#
- 1Which thread of the engine core process builds theeasy
Requestobject of a new request?Show answer
The input thread. After it decodes the
EngineCoreRequest, it callspreprocess_add_request, which makes theRequestwith its block hashes (vllm/v1/engine/core.py:1991-1993,vllm/v1/engine/core.py:1114-1128). The main thread only callsadd_requestlater. - 2What does the busy loop do when no request is waiting or running?easy
Show answer
It blocks in
input_queue.get(), becauseprocess_input_queue_blockisTrueandhas_work()is false (vllm/v1/engine/core.py:1638-1651,vllm/v1/engine/core.py:1217). It uses no CPU until a message arrives. - 3The batch queue has size 2. Why does the first call ofmedium
step_with_batch_queuereturn no outputs?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
Nonebefore 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. - 4An 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_queueand intoinput_queue(vllm/v1/engine/core.py:2020-2028)._process_aborts_queueapplies it just beforeupdate_from_output(vllm/v1/engine/core.py:780-782). The copy ininput_queuecallsfinish_requestsagain, which is idempotent (vllm/v1/engine/core.py:2021-2024). - 5A request has structured output and async scheduling is on. Why canhard
step_with_batch_queuenot callsample_tokensright afterexecute_model, and what does it do instead?Show answer
The grammar bitmask of step N+1 depends on the token of step N, which is still in flight.
AsyncSchedulermarks this withpending_structured_output_tokens(vllm/v1/core/sched/async_scheduler.py:37-39). The engine then keeps the scheduler output asdeferred_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).