Hooks in the engine core and the workers
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
The engine core and the workers read class names from config fields and registries. That is how you replace the scheduler, the worker or the loader.
The idea#
The EngineCore process runs the scheduler. Each worker owns one GPU, builds the model and runs it (Executors and workers: from the engine core to the GPUs). These parts have extension points of two kinds:
- Config hooks. A field of
VllmConfigholds a class name, for example--scheduler-cls my_pkg.sched.MyScheduler. The front end parses it, the other processes get it inside the pickled config, and each process imports the class itself. No registration is needed. - Registries. A module-level table maps a name to a class, for example
--load-formatvalues to model loaders. A general plugin fills it in every process.
One more extension point is an entry-point group: vllm.logits_processors. vLLM loads it in the workers, and in the front end for a request check.
An everyday comparison. Think of a theatre production. The director's script names the actors for the main roles: the scheduler, the worker, an extra helper. Every theatre that plays the script hires these actors itself. The prop room has labelled shelves (registries) that a stage manager fills before the show. The comparison stops here: in vLLM each process is its own theatre, and a shelf filled in one process is empty in the next.
The picture#
Read the numbers in order. Violet items are config fields, blue items are registries, green items load plugins. With one GPU, the uni executor runs panel 2 inside the engine core process.
In the code#
Step 1: scheduler_cls#
The field takes a class or a dotted name (vllm/config/scheduler.py:175-180). The engine core resolves it after the KV cache plan (vllm/v1/engine/core.py:160-164):
def get_scheduler_cls(self) -> type["SchedulerInterface"]:
if self.scheduler_cls is None:
if self.async_scheduling:
from vllm.v1.core.sched.async_scheduler import AsyncScheduler
return AsyncScheduler
from vllm.v1.core.sched.scheduler import Scheduler
return Scheduler
# The first half of this warning can be removed once the Scheduler interface is
# finalized and we can maintain support for scheduler classes that implement it
if not (
isinstance(self.scheduler_cls, str)
and self.scheduler_cls.startswith("vllm.")
):
logger.warning_once(
"Using custom scheduler class %s. This scheduler interface is not "
"public and compatibility may not be maintained. If you have "
"subclassed Scheduler instead of AsyncScheduler, you will see "
"degraded performance due to async scheduling being disabled.",
self.scheduler_cls, # type: ignore[arg-type]
)
if not isinstance(self.scheduler_cls, str):
return cast(type["SchedulerInterface"], self.scheduler_cls)
return resolve_obj_by_qualname(self.scheduler_cls)- Lines 232-239. Not set:
AsyncSchedulerwhen async scheduling is on, elseScheduler.VllmConfigturns async scheduling on when no option conflicts with it (vllm/config/vllm.py:1664-1698). - Lines 243-253. A class object, or a string that does not start with
vllm., logs a WARNING once. It says that the interface "is not public", and that a subclass ofSchedulerinstead ofAsyncSchedulerturns async scheduling off. - Lines 254-256. A class object is used as it is; a string is split at the last dot (
vllm/utils/import_utils.py:119-123). From the CLI the value is always a string, so writepkg.module.Class, notpkg.module:Class.
The test passes a class object, because it builds the engine in the same process:
class DummyV1Scheduler(Scheduler):
def schedule(self, throttle_prefills: bool = False):
raise Exception("Exception raised by DummyV1Scheduler")
def test_scheduler_plugins_v1(monkeypatch: pytest.MonkeyPatch):
with monkeypatch.context() as m:
# Explicitly turn off engine multiprocessing so
# that the scheduler runs in this process
m.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
with pytest.raises(Exception) as exception_info:
engine_args = EngineArgs(
model="facebook/opt-125m",
enforce_eager=True, # reduce test time
scheduler_cls=DummyV1Scheduler,
)
engine = LLMEngine.from_engine_args(engine_args=engine_args)
sampling_params = SamplingParams(max_tokens=1)
engine.add_request("0", "foo", sampling_params)
engine.step()
assert str(exception_info.value) == "Exception raised by DummyV1Scheduler"With vllm serve, the engine core is a separate process, so pass a dotted name that the engine core can import.
Step 2: worker_cls and worker_extension_cls#
Both are strings in ParallelConfig; worker_cls defaults to "auto", which the platform replaces (vllm/config/parallel.py:306-312, vllm/platforms/cuda.py:330-331). The worker wrapper resolves both in init_worker:
parallel_config = vllm_config.parallel_config
if isinstance(parallel_config.worker_cls, str):
worker_class: type[WorkerBase] = resolve_obj_by_qualname(
parallel_config.worker_cls
)
else:
raise ValueError(
"passing worker_cls is no longer supported. "
"Please pass keep the class in a separate module "
"and pass the qualified name of the class as a string."
)
worker_extension_cls: type | None = None
if parallel_config.worker_extension_cls:
worker_extension_cls = resolve_obj_by_qualname(
parallel_config.worker_extension_cls
)
extended_calls = []
if worker_extension_cls not in worker_class.__bases__:
# check any conflicts between worker and worker_extension_cls
for attr in dir(worker_extension_cls):
if attr.startswith("__"):
continue
assert not hasattr(worker_class, attr), (
f"Worker class {worker_class} already has an attribute"
f" {attr}, which conflicts with the worker"
f" extension class {worker_extension_cls}."
)
if callable(getattr(worker_extension_cls, attr)):
extended_calls.append(attr)
# dynamically inherit the worker extension class
worker_class.__bases__ = worker_class.__bases__ + (
worker_extension_cls,
)
logger.info(
"Injected %s into %s for extended collective_rpc calls %s",
worker_extension_cls,
worker_class,
extended_calls,
)- Lines 293-302.
worker_clsmust be a string. A class object raisesValueError"passing worker_cls is no longer supported". - Lines 310-321. Each attribute of the extension must be new, except names that start with
__. Anassertstops start-up when the worker class has it already. Callable attributes are listed as "extended collective_rpc calls". - Lines 323-325. The extension becomes an extra base class of the worker class. The worker object made at line 347 has its methods.
The wrapper also calls an optional hook init_worker_extension(worker) after init_device (vllm/v1/worker/worker_base.py:356-363).
The extension's methods are reached with collective_rpc by name. In each worker, run_method looks the name up on the wrapper, which forwards unknown names to the worker (vllm/v1/serial_utils.py:485-508, vllm/v1/worker/worker_base.py:365-366). AsyncLLM has collective_rpc (vllm/v1/engine/async_llm.py:1247). Over HTTP, the route POST /collective_rpc exists only with VLLM_SERVER_DEV_MODE, and its comment says that it passes only serialized string arguments (vllm/entrypoints/launchers/api_server/routers.py:46-49, vllm/entrypoints/serve/dev/rpc/api_router.py:43-50). An endpoint plugin is the normal way to expose such a method (Plugins in the API server).
vllm serve Qwen/Qwen3-0.6B --worker-extension-cls my_pkg.ext.WeightSync
# an endpoint plugin, or code with the engine client, then calls:
# await engine_client.collective_rpc("update_weights_from", args=("s3://...",))
Step 3: logits processors#
A logits processor changes the logits of the batch before sampling. Two sources feed Model Runner V2, and the loader combines them:
def _load_v2_logitsprocs(
logits_processors: Sequence[str | type] | None,
) -> list[type[LogitsProcessor]]:
"""Load all custom logits processors for the V2 model runner.
Combines installed plugins (validated as V2 processors) with the
user-specified list of processor types and FQCN strings.
"""
return _load_v2_logitsprocs_plugins() + _load_v2_logitsprocs_by_fqcns(
logits_processors or ()
)- The group
vllm.logits_processors. The loader callsentry_points()directly, soVLLM_PLUGINSdoes not filter it. A failed import raisesRuntimeError; a class that is not a V2LogitsProcessorraisesValueError(vllm/v1/worker/gpu/sample/logits_processor/loader.py:37-74). - The field
logits_processors(--logits-processors): classes or"module:Class"strings, with exactly one colon (vllm/config/model.py:407-409,vllm/v1/worker/gpu/sample/logits_processor/loader.py:99-104).
The model runner builds them in load_model (vllm/v1/worker/gpu/model_runner.py:463-466). A pooling model with custom processors raises (vllm/v1/worker/gpu/sample/logits_processor/loader.py:172-179). The front end loads the same classes once, to call validate_params for each request (vllm/v1/engine/input_processor.py:93-107, vllm/v1/worker/gpu/sample/logits_processor/loader.py:186-211).
The V2 interface has five methods:
def __init__( # noqa: B027
self, vllm_config: VllmConfig, req_states: LogitsProcRequestState
):
"""Capture what stays constant for the processor's lifetime.
``req_states`` exposes the on-device token history and batch constants a
processor may read. Treat it as read-only.
"""
@classmethod # noqa: B027
def validate_params(cls, sampling_params: SamplingParams) -> None:
"""Raise ``ValueError`` for invalid per-request arguments.
Runs at request admission, so invalid arguments fail the request
with an error instead of reaching the sampler.
"""
def add_request(self, req_idx: int, sampling_params: SamplingParams) -> bool:
"""Initialize per-slot state for a request entering the batch.
The slot may hold a previous occupant's state; overwrite or
neutralize all of it here.
Returns whether this processor modifies logits for the request.
"""
return True
def apply_staged_writes(self) -> None: # noqa: B027
"""Flush any host-side writes staged by ``add_request()`` to the device.
Called once per step before the forward pass, after the model runner
has flushed ``req_states``, so a processor that stages writes here can
read the request's tokens back on device.
"""
@abstractmethod
def apply(self, logits: torch.Tensor, ctx: LogitsContext) -> torch.Tensor:
"""Modify logits in place or return a new tensor. In-place modification
is preferred for efficiency.
``apply()`` is called once for the whole batch, including rows of
requests this processor declined in ``add_request()``, so filter rows
via ``ctx.expanded_idx_mapping``.
Args:
logits: [num_logits_rows, vocab_size] float32 tensor.
ctx: this step's batch layout.
"""
raise NotImplementedErrorStep 4: model loaders#
A model loader builds the model and loads its weights for one --load-format. The table and the register function:
def _wrapper(model_loader_cls):
if load_format in _LOAD_FORMAT_TO_MODEL_LOADER:
logger.warning(
"Load format `%s` is already registered, and will be "
"overwritten by the new loader class `%s`.",
load_format,
model_loader_cls,
)
if not issubclass(model_loader_cls, BaseModelLoader):
raise ValueError(
"The model loader must be a subclass of `BaseModelLoader`."
)
_LOAD_FORMAT_TO_MODEL_LOADER[load_format] = model_loader_cls
logger.info(
"Registered model loader `%s` with load format `%s`",
model_loader_cls,
load_format,
)
return model_loader_cls
return _wrapper
def get_model_loader(load_config: LoadConfig) -> BaseModelLoader:
"""Get a model loader based on the load format."""
load_format = load_config.load_format
if load_format not in _LOAD_FORMAT_TO_MODEL_LOADER:
raise ValueError(f"Load format `{load_format}` is not supported")
return _LOAD_FORMAT_TO_MODEL_LOADER[load_format](load_config)A taken name logs a WARNING and is overwritten; a class that is not a BaseModelLoader raises ValueError. load_format accepts any string and is lower-cased (vllm/config/load.py:30, vllm/config/load.py:136-138). The worker calls get_model_loader in load_model (vllm/v1/worker/gpu/model_runner.py:398-406). A loader implements download_model and load_weights (vllm/model_executor/model_loader/base_loader.py:38-47); the test is a minimal one (tests/model_executor/model_loader/test_registry.py:19-33). The page "Weight loading: from checkpoint files to GPU parameters" explains the default loader.
Step 5: sleep-mode backends#
Sleep mode frees GPU memory of an idle engine. --enable-sleep-mode turns it on (vllm/config/model.py:370-372). The field docstring says "only cuda and hip", but the platform check also accepts XPU (vllm/config/model.py:666-668, vllm/platforms/interface.py:231-236). The mechanism is a backend class with suspend(level) and resume(tags) (vllm/device_allocator/sleep_mode_backend.py:37-69). The factory mirrors the KV connector factory:
class SleepModeBackendFactory:
"""Registry and resolver for sleep-mode backends.
Mirrors ``KVConnectorFactory``: lazy module/class registration and a
built-in registry populated at import time. Third-party backends register
the same way from a ``vllm.general_plugins`` entry point.
"""
_registry: dict[str, Callable[[], type[SleepModeBackend]]] = {}
@classmethod
def register_backend(cls, name: str, module_path: str, class_name: str) -> None:
"""Register a backend with a lazy-loading module and class name."""
if name in cls._registry:
raise ValueError(f"Sleep-mode backend '{name}' is already registered.")
def loader() -> type[SleepModeBackend]:
module = importlib.import_module(module_path)
return getattr(module, class_name)
cls._registry[name] = loader
@classmethod
def get_backend_class(cls, name: str) -> type[SleepModeBackend]:
"""Resolve a registered backend class by name."""
if name not in cls._registry:
available = ", ".join(sorted(cls._registry)) or "<none>"
raise ValueError(
f"Unsupported sleep-mode backend '{name}'. "
f"Registered backends: {available}."
)
return cls._registry[name]()
@classmethod
def create_backend(cls, model_config: ModelConfig) -> SleepModeBackend:
"""Instantiate the backend selected by ``model_config``."""
name = model_config.sleep_mode_backend
backend_cls = cls.get_backend_class(name)
if not backend_cls.is_supported():
raise ValueError(
f"Sleep-mode backend '{name}' is not supported on this platform."
)
logger.info("Using sleep-mode backend: %s", name)
return backend_cls()
# Register built-in backends here. Registration is lazy: only the module for the
# selected backend is imported. Third-party backends (CUDA checkpoint, CRIU,
# durable snapshot) register the same way through a vllm.general_plugins entry
# point, without changes to vLLM core.
SleepModeBackendFactory.register_backend(
"cumem",
"vllm.device_allocator.sleep_mode_backend",
"CuMemBackend",
)The worker makes the backend on first use, from model_config.sleep_mode_backend (vllm/v1/worker/gpu_worker.py:249-259), and calls suspend and resume (vllm/v1/worker/gpu_worker.py:317, vllm/v1/worker/gpu_worker.py:339). The field defaults to "cumem" (vllm/config/model.py:379-383).
Step 6: register_kv_cache_dtype#
A new --kv-cache-dtype value needs a handler with a name, a torch_dtype() and a quant_mode() (vllm/config/cache.py:73-103). The decorator adds the name to the mutable list KV_CACHE_DTYPES and writes the torch dtype into the shared dtype table:
def _decorate(cls):
if name in KV_CACHE_DTYPES:
logger.warning(
"The kv-cache-dtype '%s' already exists and will be "
"overwritten by handler %s.",
name,
cls,
)
else:
KV_CACHE_DTYPES.append(name)
inst = cls()
# Inject into the shared dtype dict in-place so every existing
# direct [] / `in` access site sees the new entry.
STR_DTYPE_TO_TORCH_DTYPE[name] = inst.torch_dtype()
_KV_CACHE_DTYPE_HANDLERS[name] = inst
return cls
return _decorateThe docstring says that it "Runs at platform activation time (first current_platform access)" (vllm/config/cache.py:112-116). No code in vllm/ calls it at this commit; only a test does (tests/config/test_kv_cache_dtype.py:35). The KV cache code reads the handlers through get_kv_cache_dtype_handler (vllm/v1/kv_cache_interface.py:110-112). A platform plugin or a general plugin would have to call it in every process that parses or uses the dtype. (Derived.)
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 diveAll engine hooks in one table
| Hook | Kind | Flag or API | Read at | Process | Duplicate |
|---|---|---|---|---|---|
| scheduler | config field | --scheduler-cls (vllm/engine/arg_utils.py:1785) | vllm/v1/engine/core.py:164 | engine core | - |
| worker class | config field | --worker-cls (vllm/engine/arg_utils.py:1360) | vllm/v1/worker/worker_base.py:293-296 | workers | - |
| worker extension | config field | --worker-extension-cls (vllm/engine/arg_utils.py:1361-1363) | vllm/v1/worker/worker_base.py:304-331 | workers | assert on shared names |
| logits processors | group + field | vllm.logits_processors, --logits-processors (vllm/engine/arg_utils.py:1058-1060) | vllm/v1/worker/gpu/model_runner.py:463-466 | workers, front end (check) | - |
| model loader | registry | @register_model_loader, --load-format | vllm/v1/worker/gpu/model_runner.py:398 | workers | WARNING, overwrite |
| sleep backend | registry | SleepModeBackendFactory.register_backend | vllm/v1/worker/gpu_worker.py:256-258 | workers | ValueError |
| KV cache dtype | registry | @register_kv_cache_dtype | vllm/v1/kv_cache_interface.py:110-112 | every process that reads the dtype | WARNING, overwrite |
The table on All the ways to extend vLLM puts these hooks next to the other extension points.
Deep diveWorked example: a worker extension for weight sync, with TP = 2
You start vllm serve ... --tensor-parallel-size 2 --worker-extension-cls my_pkg.ext.WeightSync. WeightSync defines update_weights_from(path) and init_worker_extension(worker).
- The front end stores the string in
parallel_config.worker_extension_cls. The workers get it in the pickledVllmConfig. - Each of the 2 worker processes runs
init_worker: it importsmy_pkg.ext, checks thatWorkerhas no attributeupdate_weights_from, and addsWeightSynctoWorker.__bases__(vllm/v1/worker/worker_base.py:304-331). It logs "Injected ... for extended collective_rpc calls ['init_worker_extension', 'update_weights_from']". (The list holds every callable name that does not start with__.) - After
init_device, the wrapper callsWeightSync.init_worker_extension(worker)(vllm/v1/worker/worker_base.py:361-363). - Later,
collective_rpc("update_weights_from", args=(path,))runs the method in both workers and returns a list of 2 results. With TP = 1 the uni executor callsrun_methodon its one in-process worker (vllm/v1/executor/uniproc_executor.py:103).
Note that line 323 changes the class Worker itself, not one object. Every worker object of that process gets the methods. (Derived.)
Deep diveEdge cases
- Extension already a base. If the extension class is already in
worker_class.__bases__, the injection and the clash check are skipped (line 310). - Unknown RPC method.
run_methodraisesNotImplementedError("Method ... is not implemented.")(vllm/v1/serial_utils.py:499-505). - Mixed-case load format.
load_formatis lower-cased (vllm/config/load.py:136-138), butregister_model_loaderstores the name as given. A loader registered as"My_Format"is never found. (Derived.) - Logits processors and
VLLM_PLUGINS. An allowlist does not stop a logits processor plugin. To keep one out, uninstall its package. - Unsupported sleep backend. A backend whose
is_supported()is false raises "Sleep-mode backend ... is not supported on this platform." on first use (vllm/device_allocator/sleep_mode_backend.py:198-201).
What can go wrong#
- The class does not import in the child process. Config hooks are imported in the engine core or the workers. A class that exists only in your launch script fails there. Put it in an installed package.
- Colon instead of dot.
scheduler_cls,worker_clsandworker_extension_clsneedpkg.module.Class;logits_processorsstrings needpkg.module:Class(vllm/utils/import_utils.py:119-123,vllm/v1/worker/gpu/sample/logits_processor/loader.py:99-104). - A name clash in the extension. An attribute that the worker class has already stops start-up with an
AssertionError(vllm/v1/worker/worker_base.py:315-319). - A V1 logits processor. Model Runner V2 rejects it with
ValueErrorat model load (vllm/v1/worker/gpu/sample/logits_processor/loader.py:67-72).
Why it matters. These hooks change the engine itself: the scheduling policy, the worker code, the sampler input and the weight source. They are powerful, and most of them are not public, stable interfaces. Read the code of the version that you run before you rely on one.
Check yourself#
- 1Which class does the engine core use wheneasy
--scheduler-clsis not set and async scheduling is on?Show answer
AsyncScheduler(vllm/config/scheduler.py:232-236). - 2How do the methods of a worker extension class become callable witheasy
collective_rpc?Show answer
init_workeradds the extension class to the bases of the worker class (vllm/v1/worker/worker_base.py:323-325).run_methodthen finds the method by name withgetattr(vllm/v1/serial_utils.py:499-501). - 3You setmedium
VLLM_PLUGINS=acme_admin. Does an installedvllm.logits_processorsplugin still load in the workers?Show answer
Yes. The Model Runner V2 loader calls
entry_points(group=...)directly and does not readVLLM_PLUGINS(vllm/v1/worker/gpu/sample/logits_processor/loader.py:46). - 4A general plugin callsmedium
SleepModeBackendFactory.register_backend("cumem", ...)to replace the built-in backend. What happens?Show answer
The factory raises
ValueError"Sleep-mode backend 'cumem' is already registered." because vLLM registeredcumemat import (vllm/device_allocator/sleep_mode_backend.py:173-174,vllm/device_allocator/sleep_mode_backend.py:210-214). The general plugin does not catch it, so start-up stops (vllm/plugins/__init__.py:89-90). - 5You register a model loader withhard
@register_model_loader("Fast_Load")and start with--load-format Fast_Load. What happens, and why?Show answer
Start-up fails with "Load format `fast_load` is not supported". The config lower-cases
load_format(vllm/config/load.py:136-138), but the table stores"Fast_Load"as given (vllm/model_executor/model_loader/__init__.py:112), soget_model_loaderdoes not find it (vllm/model_executor/model_loader/__init__.py:126-127). Register lower-case names. (Derived from these lines.)