vLLM Explained

v1/engine/core.py: EngineCore and the engine core process

AdvancedCode mapabout 10 min read + 3 min deep dives

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

Purpose#

vllm/v1/engine/core.py is the inner loop of vLLM. It owns the scheduler and the executor, runs the engine steps, and, in vllm serve, wraps all of this in a process that talks ZMQ with the front end.

Filevllm/v1/engine/core.py, 2,847 lines of Python. Seven classes: three engine classes, one enum, three Ray actor classes.
Main classesEngineCore (vllm/v1/engine/core.py:118), EngineCoreProc (vllm/v1/engine/core.py:1153), DPEngineCoreProc (vllm/v1/engine/core.py:2269).
Runs inThe EngineCore process, one per data parallel rank: the main thread (busy loop), the input thread and the output thread. With the uni executor, the worker and the model runner also run in the main thread.
Called byThe engine core client, which starts the process with run_engine_core; with multiprocessing off, InprocClient makes an EngineCore in the caller's process (vllm/v1/engine/core_client.py:384-389).
CallsThe scheduler (schedule, update_from_output, get_grammar_bitmask), the executor (execute_model, sample_tokens, start-up RPCs), the KV cache planner, StructuredOutputManager, msgpack and ZMQ.
Not in this fileThe scheduling algorithm (v1/core/sched/scheduler.py), the client side of the sockets (core_client.py), the process launcher and the handshake on the front-end side (v1/engine/utils.py), the workers (v1/executor/, v1/worker/).

The page The engine core loop: threads, queues and the busy loop explains the ideas. This page reads the file method by method.

Where it sits#

Figure: the three threads of EngineCoreProc, the batch queue, and the other queue messages.
Open full size ↗

Who calls it#

What it calls#

Key classes and functions#

NameLineWhat it does
EngineCore.__init__vllm/v1/engine/core.py:121-243Plugins, executor, KV caches, scheduler, batch queue, block hasher, step_fn.
_initialize_kv_cachesvllm/v1/engine/core.py:245-406Profile memory, plan the KV cache, allocate it on the workers, warm up.
add_request / abort_requestsvllm/v1/engine/core.py:508-559Check a request and give it to the scheduler; finish requests as aborted.
stepvllm/v1/engine/core.py:657-687One synchronous step (no batch queue).
step_with_batch_queuevllm/v1/engine/core.py:698-812The default step: start N+1, then wait for N.
_process_aborts_queuevllm/v1/engine/core.py:814-822Apply early aborts before an update.
sleep / wake_up / pause_schedulervllm/v1/engine/core.py:943-1044Pause scheduling and free or restore GPU memory.
collective_rpcvllm/v1/engine/core.py:1098-1105Forward an RPC to all workers.
preprocess_add_requestvllm/v1/engine/core.py:1114-1136Build the Request (runs in the input thread).
EngineShutdownStatevllm/v1/engine/core.py:1147-1150RUNNING, REQUESTED, SHUTTING_DOWN.
EngineCoreProc.__init__vllm/v1/engine/core.py:1160-1273Queues, handshake, EngineCore.__init__, I/O threads.
_perform_handshakes / startup_handshakevllm/v1/engine/core.py:1275-1447HELLO, init message with addresses, READY.
run_engine_corevllm/v1/engine/core.py:1449-1591Process entry: title, signals, class choice, busy loop, fatal errors.
run_busy_loopvllm/v1/engine/core.py:1608-1620The main loop.
_process_input_queue / _process_engine_stepvllm/v1/engine/core.py:1635-1684Drain the input queue (block when idle); run one step.
_handle_client_requestvllm/v1/engine/core.py:1739-1771Dispatch ADD, ABORT, UTILITY, EXECUTOR_FAILED, WAKEUP.
_invoke_utility_methodvllm/v1/engine/core.py:1822-1839Run a utility call; defer the reply for a Future.
_send_engine_deadvllm/v1/engine/core.py:1858-1869Send ENGINE_CORE_DEAD and wait 5 s for the output thread.
_make_ready_responsevllm/v1/engine/core.py:1871-1919The values that the front end copies into its config.
process_input_socketsvllm/v1/engine/core.py:1921-2028The input thread.
process_output_socketsvllm/v1/engine/core.py:2030-2096The output thread.
DPEngineCoreProcvllm/v1/engine/core.py:2269-2670MoE DP: waves, dummy batches, all-reduce of "has work".
EngineCoreActorMixin and two actor classesvllm/v1/engine/core.py:2673-2847The same engines as Ray actors.

Walk through the main methods#

EngineCore.__init__: build the engine#

        # Setup Model.
        self.model_executor = executor_class(vllm_config)
        self._pooler_config_logged = False
        if executor_fail_callback is not None:
            self.model_executor.register_failure_callback(executor_fail_callback)

        self.available_gpu_memory_for_kv_cache = -1

        if envs.VLLM_ELASTIC_EP_SCALE_UP_LAUNCH:
            self._eep_scale_up_before_kv_init()

        measure_encoder_output_widths(vllm_config, self.collective_rpc)

        # Setup KV Caches and update CacheConfig after profiling.
        kv_cache_config = self._initialize_kv_caches(vllm_config)
        self.structured_output_manager = StructuredOutputManager(vllm_config)

        # Setup scheduler.
        Scheduler = vllm_config.scheduler_config.get_scheduler_cls()

        if len(kv_cache_config.kv_cache_groups) == 0:  # noqa: SIM102
            # Encoder models without KV cache don't support
            # chunked prefill. But do SSM models?
            if vllm_config.scheduler_config.enable_chunked_prefill:
                logger.warning("Disabling chunked prefill for model without KVCache")
                vllm_config.scheduler_config.enable_chunked_prefill = False

        scheduler_block_size, hash_block_size = resolve_kv_cache_block_sizes(
            kv_cache_config, vllm_config
        )

        self.scheduler: SchedulerInterface = Scheduler(
            vllm_config=vllm_config,
            kv_cache_config=kv_cache_config,
            structured_output_manager=self.structured_output_manager,
            include_finished_set=include_finished_set,
            log_stats=self.log_stats,
            block_size=scheduler_block_size,
            hash_block_size=hash_block_size,
        )
  1. Line 147. The executor constructor starts the workers and loads the model. This is the slowest line of start-up.
  2. Line 150. The failure callback (from EngineCoreProc) lets a dead worker stop the busy loop.
  3. Line 160. The KV cache set-up needs the loaded model (next method).
  4. Line 164. get_scheduler_cls() returns the class in scheduler_cls when it is set. Otherwise it returns AsyncScheduler when async scheduling is on, or Scheduler (vllm/config/scheduler.py:231-239).
  5. Lines 166-171. A model with no KV cache groups (an encoder-only model) gets chunked prefill turned off.
  6. Lines 177-185. The scheduler gets the final KV cache config, so its block pool has the real number of blocks.
        # 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
        )
        self.async_scheduling = vllm_config.scheduler_config.async_scheduling

        self.aborts_queue = queue.Queue[list[str]]()

        self._idle_state_callbacks: list[Callable] = []

        # Mark the startup heap as static so that it's ignored by GC.
        # Reduces pause times of oldest generation collections.
        freeze_gc_heap()
        # If enable, attach GC debugger after static variable freeze.
        maybe_attach_gc_debug_callback()
        # Enable environment variable cache (e.g. assume no more
        # environment variable overrides after this point)
        enable_envs_cache()
  1. Lines 205-211. The batch queue is a deque with maxlen = max_concurrent_batches, only when that value is more than 1.
  2. Lines 217-225. The block hasher exists when prefix caching is on or a KV connector is set. The input thread uses it.
  3. Lines 227-229. The step function: step without a batch queue, step_with_batch_queue with one.
  4. Line 232. The abort queue for early aborts.
  5. Line 238. freeze_gc_heap() marks all start-up objects as static, so the garbage collector does not scan them again (comment at lines 236-237).
  6. Line 243. From here on, environment variables are cached.

_initialize_kv_caches: memory and KV cache#

        has_kv_cache = any(kv_cache_spec for kv_cache_spec in kv_cache_specs)
        if has_kv_cache:
            if envs.VLLM_ELASTIC_EP_SCALE_UP_LAUNCH:
                # NOTE(yongji): should already be set
                # during _eep_scale_up_before_kv_init
                assert self.available_gpu_memory_for_kv_cache > 0
                available_gpu_memory = [self.available_gpu_memory_for_kv_cache] * len(
                    kv_cache_specs
                )
            else:
                # Profiles the peak memory usage of the model to determine how
                # much memory can be allocated for kv cache.
                available_gpu_memory = self.model_executor.determine_available_memory()
                self.available_gpu_memory_for_kv_cache = available_gpu_memory[0]
        else:
            # Attention free models don't need memory for kv cache
            available_gpu_memory = [0] * len(kv_cache_specs)

        assert len(kv_cache_specs) == len(available_gpu_memory)

        # Track max_model_len before KV cache config to detect auto-fit changes
        max_model_len_before = vllm_config.model_config.max_model_len

        kv_cache_configs = get_kv_cache_configs(
            vllm_config, kv_cache_specs, available_gpu_memory
        )
        for kv_cache_config in kv_cache_configs:
            kv_cache_config.kv_cache_layout = vllm_config.cache_config.kv_cache_layout

        # If auto-fit reduced max_model_len, sync the new value to workers.
        # This is needed because workers were spawned before memory profiling
        # and have the original (larger) max_model_len cached.
        max_model_len_after = vllm_config.model_config.max_model_len
        if max_model_len_after != max_model_len_before:
            self.collective_rpc("update_max_model_len", args=(max_model_len_after,))

        scheduler_kv_cache_config = generate_scheduler_kv_cache_config(kv_cache_configs)
        vllm_config.cache_config.num_gpu_blocks = scheduler_kv_cache_config.num_blocks
  1. Line 300. When the model has a KV cache (line 289), each worker runs a profile run and returns the bytes left for the KV cache. The page v1/worker/gpu_worker.py: the GPU Worker has the formula and a worked example.
  2. Lines 311-313. get_kv_cache_configs turns bytes into blocks, one config per worker. The page The KV cache manager: blocks, the block pool and memory sizing explains it.
  3. Lines 321-322. If the planner changed max_model_len (for example, auto-fit lowered it to fit the memory), the workers learn the new value by RPC.
  4. Lines 324-325. The scheduler's view of the cache, and the final num_gpu_blocks. The ready message sends this number to the front end (vllm/v1/engine/core.py:1876).
        for kv_cache_config in (*kv_cache_configs, scheduler_kv_cache_config):
            kv_cache_config.hash_block_size = hash_block_size
            kv_cache_config.cache_hit_alignment_tokens = cache_hit_alignment_tokens

        self.model_executor.initialize_from_config(kv_cache_configs)
        kv_transfer_config = vllm_config.kv_transfer_config
        if (
            kv_transfer_config is not None
            and kv_transfer_config.has_connector("SimpleCPUOffloadConnector")
            and vllm_config.cache_config.enable_prefix_caching
        ):
            aligned_num_cpu_blocks = (
                self.model_executor.get_simple_cpu_offload_num_blocks()
            )
            assert aligned_num_cpu_blocks is not None, (
                "SimpleCPUOffloadConnector workers must report their block count"
            )
            vllm_config.cache_config.simple_cpu_offload_num_blocks = (
                aligned_num_cpu_blocks
            )
        if not envs.VLLM_ELASTIC_EP_SCALE_UP_LAUNCH:
            self.model_executor.compile_or_warm_up_model()
  1. Line 362. The workers allocate the KV cache tensors.
  2. Line 379. Compile, warm up and capture CUDA graphs. Only after this line can the engine serve requests.

step: the synchronous step#

    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
  1. Lines 665-666. No requests: no step.
  2. Lines 668-669. The forward pass starts (non-blocking), and the grammar bitmask runs on the CPU meanwhile.
  3. Lines 674-676. Wait for execute_model. A None result means "now call sample_tokens", which here is blocking.
  4. Line 687. The second return value tells post_step whether the model ran.

step_with_batch_queue: the default step#

        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
        # 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)

The page The engine core loop: threads, queues and the busy loop reads this method line by line in a deep dive, and Async scheduling: overlap the CPU and the GPU explains the deferred-sampling branch (line 749). The short version: start step N+1 (lines 725-753); if the queue is full, wait for step N (lines 768-778) and update the scheduler (line 783).

run_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
  1. Line 1608. fault_tolerant_wrapper re-raises at once unless fault tolerance is on (vllm/v1/fault_tolerance/engine_core_sentinel.py:175-183).
  2. Line 1611. _handle_shutdown() is true while running, and also while a requested shutdown drains work.
  3. Line 1613. Blocks when there is no work (vllm/v1/engine/core.py:1638-1656).
  4. Line 1620. The loop ends only with SystemExit. run_engine_core catches it and cleans up (vllm/v1/engine/core.py:1560-1583).

process_input_sockets: the input thread#

        # Msgpack serialization decoding with optional tensor IPC receiver.
        add_request_decoder = MsgpackDecoder(
            EngineCoreRequest, oob_tensor_provider=self.tensor_ipc_receiver
        )
        generic_decoder = MsgpackDecoder(oob_tensor_provider=self.tensor_ipc_receiver)

        with ExitStack() as stack, zmq.Context() as ctx:
            input_sockets = [
                stack.enter_context(
                    make_zmq_socket(
                        ctx, input_address, zmq.DEALER, identity=identity, bind=False
                    )
                )
                for input_address in input_addresses
            ]
            if coord_input_address is None:
                coord_socket = None
            else:
                coord_socket = stack.enter_context(
                    make_zmq_socket(
                        ctx,
                        coord_input_address,
                        zmq.XSUB,
                        identity=identity,
                        bind=False,
                    )
                )
                # Send subscription message to coordinator.
                coord_socket.send(b"\x01")

            # Register sockets with poller.
            poller = zmq.Poller()
            ready_response = self._make_ready_response()
            ready_payload = msgspec.msgpack.encode(ready_response)
            for input_socket in input_sockets:
                # Send initial message to each input socket - this is required
                # before the front-end ROUTER socket can send input messages
                # back to us.
                input_socket.send(ready_payload)
                poller.register(input_socket, zmq.POLLIN)

            if coord_socket is not None:
                # Wait for ready message from coordinator.
                assert coord_socket.recv() == b"READY"
                poller.register(coord_socket, zmq.POLLIN)

            ready_event.set()
  1. Lines 1930-1933. One typed decoder for ADD messages, one generic decoder for the rest. Both can receive tensors through the tensor IPC queue of multimodal inputs.
  2. Lines 1936-1943. One DEALER socket per front end (per API server), with this engine's identity. The front end owns the ROUTER side.
  3. Lines 1961-1968. The first message on each socket is the ready response.
  4. Line 1972. With a DP coordinator, the thread first waits for its READY.
  5. Line 1975. Only now can EngineCoreProc.__init__ finish (vllm/v1/engine/core.py:1267-1273).

The receive loop that follows (lines 1977-2028) is read on The engine core loop: threads, queues and the busy loop, Step 3.

process_output_sockets: the output thread#

        """Output socket IO thread."""
        # Msgpack serialization encoding.
        encoder = MsgpackEncoder()
        # Send buffers to reuse.
        reuse_buffers: list[bytearray] = []
        # Payload buffers that can't be reused yet because zmq may still be
        # sending them.
        # Buffers of the zero-copy tensor/ndarray frames don't need tracking
        # here: zmq itself holds a reference to each until it's done with it.
        pending = deque[tuple[zmq.MessageTracker, bytearray]]()

        # We must set linger to ensure the ENGINE_CORE_DEAD
        # message is sent prior to closing the socket.
        with ExitStack() as stack, zmq.Context() as ctx:
            sockets = [
                stack.enter_context(
                    make_zmq_socket(ctx, output_path, zmq.PUSH, linger=4000)
                )
                for output_path in output_paths
            ]
            coord_socket = (
                stack.enter_context(
                    make_zmq_socket(
                        ctx, coord_output_path, zmq.PUSH, bind=False, linger=4000
                    )
                )
                if coord_output_path is not None
                else None
            )
            max_reuse_bufs = len(sockets) + 1
  1. Line 2035. One encoder for the life of the thread.
  2. Lines 2037 and 2042. Send buffers are reused. A buffer that ZMQ still sends waits in pending with its tracker.
  3. Line 2049. One PUSH socket per front end. linger=4000 gives the ENGINE_CORE_DEAD message 4 seconds to leave (comment at lines 2044-2045).
  4. Line 2062. At most one spare buffer per socket, plus one, is kept.

The send loop (lines 2064-2096) is read on The engine core loop: threads, queues and the busy loop, Step 7.

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

Data flow: types in and out

ValueTypeWhere
ADD messageframes [b"\x00", msgpack(EngineCoreRequest), tensor frames...]vllm/v1/engine/core.py:1980-1991
after preprocessing(Request, wave: int)vllm/v1/engine/core.py:1114-1136
UTILITY message(client_idx: int, call_id: int, method: str, args: tuple)vllm/v1/engine/core.py:2010-2011
batch queue entry(Future[ModelRunnerOutput], SchedulerOutput, Future[Any])vllm/v1/engine/core.py:206-208
step resultdict[int, EngineCoreOutputs] (client index → outputs), or Nonevllm/v1/engine/core.py:698-700
output queue item(client_index, EngineCoreOutputs) or b"ENGINE_CORE_DEAD"vllm/v1/engine/core.py:1173
ready responseEngineCoreReadyResponse (msgpack)vllm/v1/engine/core.py:1871-1919

The fields of these types are on Data contracts between the parts of vLLM.

Deep dive

Processes and threads

ThreadMethods of this fileBlocks in
mainrun_engine_core, run_busy_loop, every EngineCore method, every utility methodinput_queue.get() when idle; future.result() when the batch queue is full
inputprocess_input_sockets, preprocess_add_request, the error-output helperspoller.poll()
outputprocess_output_socketsoutput_queue.get()

The input thread calls preprocess_add_request, which touches mm_receiver_cache and structured_output_manager. Its comments explain why there is no race: only the input thread uses the cache after start-up, and grammar compilation is asynchronous and per request (vllm/v1/engine/core.py:1120-1135).

Deep dive

Worked example: what one engine core sends in four steps

One request, async scheduling, queue size 2, a 3-token answer that ends with EOS. Each line is one call of _process_engine_step (vllm/v1/engine/core.py:1666-1684).

  1. Call 1: schedule step 1 (the prompt) and return None. Nothing goes into output_queue.
  2. Call 2: schedule step 2, wait for step 1, update. output_queue gets (0, EngineCoreOutputs(outputs=[token 1])).
  3. Call 3: schedule step 3, wait for step 2. Output: token 2.
  4. Call 4: schedule step 4, wait for step 3. Output: token 3 = EOS, finish_reason=STOP.
  5. Call 5: has_requests() is still true for one call, because the finished request id must reach the workers in the next SchedulerOutput (vllm/v1/core/sched/scheduler.py:2913-2914, vllm/v1/core/sched/scheduler.py:1597-1601). It schedules an empty step 5, then waits for step 4, whose result is skipped. schedule() clears the finished ids (vllm/v1/core/sched/scheduler.py:1731-1734), so a sixth call only pops the empty step 5.

So 4 GPU steps produce 3 tokens. (Derived from vllm/v1/engine/core.py:724-785 and vllm/v1/core/sched/scheduler.py:2184-2192.)

Deep dive

Error paths: what raises, what only logs

SituationResultWhere
request_id is not a stringTypeError in add_requestvllm/v1/engine/core.py:515-518
pooling task not supportedValueErrorvllm/v1/engine/core.py:520-529
kv_transfer_params without a KV connectorwarning; transfer off for the requestvllm/v1/engine/core.py:531-537
preprocessing fails in the input threadlogged; error output for that request onlyvllm/v1/engine/core.py:1999-2008, vllm/v1/engine/core.py:2149-2167
a utility method raiseslogged; failure_message in the replyvllm/v1/engine/core.py:1836-1839
unknown message typelogged at ERROR; ignoredvllm/v1/engine/core.py:1768-1771
a worker diedRuntimeError("Executor failed.")vllm/v1/engine/core.py:1766-1767
exception in a stepengine state dumped, exception re-raisedvllm/v1/engine/core.py:561-575
exception before the busy loop"EngineCore failed to start."vllm/v1/engine/core.py:1571-1572
exception in the busy loop"EngineCore encountered a fatal error."; ENGINE_CORE_DEADvllm/v1/engine/core.py:1573-1576
no init message in 5 minutesRuntimeErrorvllm/v1/engine/core.py:1408-1413
input thread dies at start-upRuntimeError("Input socket thread died during startup")vllm/v1/engine/core.py:1269-1271
Deep dive

Environment variables and config fields

NameDefaultRead atEffect
VLLM_ELASTIC_EP_SCALE_UP_LAUNCHoffvllm/v1/engine/core.py:154, vllm/v1/engine/core.py:290, vllm/v1/engine/core.py:378Elastic EP only: skip profiling and warm-up for a new rank.
max_concurrent_batches (derived)2 on one GPUvllm/v1/engine/core.py:205Batch queue size.
prefix_caching_hash_algosha256vllm/v1/engine/core.py:218-220Hash of the block hasher.
shutdown_timeout0vllm/v1/engine/core.py:1697Abort or drain at shutdown.
enable_logging_iteration_detailsoffvllm/v1/engine/core.py:581-586Per-step details in the scheduler stats (needs log_stats).

Check yourself#

  1. 1
    Which method does the engine core use as step_fn with the default settings on one GPU?
    easy
    Show answer

    step_with_batch_queue, because the batch queue exists when max_concurrent_batches is more than 1 (vllm/v1/engine/core.py:209-211, vllm/v1/engine/core.py:227-229). With async scheduling and Model Runner V2 it is pp_size + 1 = 2 (vllm/config/vllm.py:591-593).

  2. 2
    In which order does EngineCore.__init__ build the executor, the KV cache and the scheduler, and why this order?
    medium
    Show answer

    Executor (line 147), KV caches (line 160), scheduler (lines 177-185) (vllm/v1/engine/core.py:146-185). The KV cache size depends on a profile run of the loaded model, and the scheduler needs the final KV cache config.

  3. 3
    Which thread runs preprocess_add_request, and what does that buy?
    medium
    Show answer

    The input thread (vllm/v1/engine/core.py:1993). The Request object and its block hashes are built while the main thread runs the model, as the docstring says (vllm/v1/engine/core.py:1115-1119).

  4. 4
    The front end sends a utility call to sleep while requests still run, with the pause mode "wait". When does the reply go out?
    hard
    Show answer

    sleep() pauses the scheduler and gets a Future back, because the engine is not idle yet. It returns a second Future that puts the executor to sleep when the pause completes (vllm/v1/engine/core.py:994-1017). _invoke_utility_method sees a Future and adds a callback, so the reply is sent only when that future is done (vllm/v1/engine/core.py:1828-1834). The busy loop meanwhile keeps stepping the in-flight requests.