vLLM Explained

Hooks in the engine core and the workers

AdvancedvLLM conceptsabout 10 min read + 2 min deep dives

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 VllmConfig holds 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-format values 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#

Figure: the hooks of the engine core (panel 1), of the worker start-up (panel 2) and at run time (panel 3).
Open full size ↗

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)
  1. Lines 232-239. Not set: AsyncScheduler when async scheduling is on, else Scheduler. VllmConfig turns async scheduling on when no option conflicts with it (vllm/config/vllm.py:1664-1698).
  2. 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 of Scheduler instead of AsyncScheduler turns async scheduling off.
  3. 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 write pkg.module.Class, not pkg.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,
                )
  1. Lines 293-302. worker_cls must be a string. A class object raises ValueError "passing worker_cls is no longer supported".
  2. Lines 310-321. Each attribute of the extension must be new, except names that start with __. An assert stops start-up when the worker class has it already. Callable attributes are listed as "extended collective_rpc calls".
  3. 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 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 NotImplementedError

Step 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 _decorate

The 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 dive

All engine hooks in one table

HookKindFlag or APIRead atProcessDuplicate
schedulerconfig field--scheduler-cls (vllm/engine/arg_utils.py:1785)vllm/v1/engine/core.py:164engine core-
worker classconfig field--worker-cls (vllm/engine/arg_utils.py:1360)vllm/v1/worker/worker_base.py:293-296workers-
worker extensionconfig field--worker-extension-cls (vllm/engine/arg_utils.py:1361-1363)vllm/v1/worker/worker_base.py:304-331workersassert on shared names
logits processorsgroup + fieldvllm.logits_processors, --logits-processors (vllm/engine/arg_utils.py:1058-1060)vllm/v1/worker/gpu/model_runner.py:463-466workers, front end (check)-
model loaderregistry@register_model_loader, --load-formatvllm/v1/worker/gpu/model_runner.py:398workersWARNING, overwrite
sleep backendregistrySleepModeBackendFactory.register_backendvllm/v1/worker/gpu_worker.py:256-258workersValueError
KV cache dtyperegistry@register_kv_cache_dtypevllm/v1/kv_cache_interface.py:110-112every process that reads the dtypeWARNING, overwrite

The table on All the ways to extend vLLM puts these hooks next to the other extension points.

Deep dive

Worked 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).

  1. The front end stores the string in parallel_config.worker_extension_cls. The workers get it in the pickled VllmConfig.
  2. Each of the 2 worker processes runs init_worker: it imports my_pkg.ext, checks that Worker has no attribute update_weights_from, and adds WeightSync to Worker.__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 __.)
  3. After init_device, the wrapper calls WeightSync.init_worker_extension(worker) (vllm/v1/worker/worker_base.py:361-363).
  4. 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 calls run_method on 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 dive

Edge 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_method raises NotImplementedError("Method ... is not implemented.") (vllm/v1/serial_utils.py:499-505).
  • Mixed-case load format. load_format is lower-cased (vllm/config/load.py:136-138), but register_model_loader stores 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#

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#

  1. 1
    Which class does the engine core use when --scheduler-cls is not set and async scheduling is on?
    easy
    Show answer
  2. 2
    How do the methods of a worker extension class become callable with collective_rpc?
    easy
    Show answer

    init_worker adds the extension class to the bases of the worker class (vllm/v1/worker/worker_base.py:323-325). run_method then finds the method by name with getattr (vllm/v1/serial_utils.py:499-501).

  3. 3
    You set VLLM_PLUGINS=acme_admin. Does an installed vllm.logits_processors plugin still load in the workers?
    medium
    Show answer

    Yes. The Model Runner V2 loader calls entry_points(group=...) directly and does not read VLLM_PLUGINS (vllm/v1/worker/gpu/sample/logits_processor/loader.py:46).

  4. 4
    A general plugin calls SleepModeBackendFactory.register_backend("cumem", ...) to replace the built-in backend. What happens?
    medium
    Show answer

    The factory raises ValueError "Sleep-mode backend 'cumem' is already registered." because vLLM registered cumem at 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).

  5. 5
    You register a model loader with @register_model_loader("Fast_Load") and start with --load-format Fast_Load. What happens, and why?
    hard
    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), so get_model_loader does not find it (vllm/model_executor/model_loader/__init__.py:126-127). Register lower-case names. (Derived from these lines.)