Tour: Build a toy plugin
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
We write one small plugin package with four parts, start vllm serve with it, and check in the logs that each part loaded.
The idea#
The tour Tour: Life of a plugin follows an existing package through the start-up. This tour goes the other way: you write the package. It has four parts, and each part uses one extension point:
- a model: a new architecture name in the model registry;
- a layer replacement: your own subclass of
RMSNorm, a CustomOp; - a stat logger that receives the statistics of the server;
- an endpoint: one new HTTP route.
The first two parts come from one general plugin, so they reach every process. The last two parts act only in the front end. Every vLLM call in the toy is one that a test plugin of the vLLM repository also makes. So each stop shows the real test plugin next to the toy.
An everyday comparison. Think of a kit that adds parts to a car. One part goes into the engine bay, one replaces a standard part, one is a gauge on the dashboard and one is a new button. The kit has one label per part, so the factory knows where each part goes. The comparison stops in one place. A vLLM server is like several copies of the car, one per process. The engine-bay parts go into every copy, and the dashboard parts go into the front copy only.
The picture#
Read panel 1 row by row, from the file to the entry point to the effect. Green parts run in every process; blue parts run only in the front end. Panel 2 is the checklist for the start command in stop 8.
| Stop | What you write or do | Template in the repository |
|---|---|---|
| 1 | Package layout and entry points | tests/plugins/*/setup.py; vLLM's pyproject.toml |
| 2 | The general plugin: register a lazy model | vllm_add_dummy_model/__init__.py |
| 3 | The model class | vllm_add_dummy_model/my_opt.py |
| 4 | The op replacement with register_oot | vllm_add_dummy_platform/dummy_custom_ops.py |
| 5 | The switch that makes the op run | vllm_add_dummy_platform/dummy_platform.py |
| 6 | A stat logger | vllm_add_dummy_stat_logger |
| 7 | An endpoint | vllm_add_dummy_endpoint_plugin |
| 8 | Install and start | CI steps in .buildkite/test_areas/plugins.yaml |
| 9 | Check that each part loaded | the log lines of vllm/plugins/__init__.py and others |
1Lay out the package and publish the entry points#
A plugin package is a normal Python package. vLLM finds it only through its entry points. The toy has five modules:
vllm-toy-plugin/
pyproject.toml
vllm_toy_plugin/
__init__.py # register(): the general plugin (stop 2)
model.py # ToyLlamaForCausalLM (stop 3)
ops.py # ToyRMSNorm (stop 4)
stat_logger.py # ToyStatLogger (stop 6)
endpoint.py # ToyEndpointPlugin (stop 7)
The test plugins use setup.py with an entry_points dictionary. The stat logger test plugin is the shortest example:
setup(
name="dummy_stat_logger",
version="0.1",
packages=["dummy_stat_logger"],
entry_points={
"vllm.stat_logger_plugins": [
"dummy_stat_logger = dummy_stat_logger.dummy_stat_logger:DummyStatLogger" # noqa
]
},
)vLLM itself uses the newer pyproject.toml form for its own general plugins:
[project.entry-points."vllm.general_plugins"]
lora_filesystem_resolver = "vllm.plugins.lora_resolvers.filesystem_resolver:register_filesystem_resolver"
lora_hf_hub_resolver = "vllm.plugins.lora_resolvers.hf_hub_resolver:register_hf_hub_resolver"The toy uses the same form, with one table per plugin group:
# pyproject.toml (illustrative)
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "vllm-toy-plugin"
version = "0.1.0"
[tool.setuptools]
packages = ["vllm_toy_plugin"]
[project.entry-points."vllm.general_plugins"]
toy_register = "vllm_toy_plugin:register"
[project.entry-points."vllm.stat_logger_plugins"]
toy_stat_logger = "vllm_toy_plugin.stat_logger:ToyStatLogger"
[project.entry-points."vllm.endpoint_plugins"]
toy_endpoint = "vllm_toy_plugin.endpoint:ToyEndpointPlugin"
The left side of each line is the entry-point name. VLLM_PLUGINS matches this name exactly, not the package name (vllm/plugins/__init__.py:63-64). The right side is module:attribute. Each group expects a different kind of object. The general group needs a function, the stat logger group a StatLoggerBase subclass, and the endpoint group a factory with no arguments. The table in How vLLM finds and loads plugins lists all six groups.
2Write the general plugin: register the model lazily#
vLLM calls a general plugin function with no arguments, once in each process (vllm/plugins/__init__.py:82-90). With TP = 2 that means four processes, and the function must do the same thing each time. The model test plugin shows the pattern: check first, then register. Its last two registrations use the lazy string form:
# Test passing lazy model
if "MyGemma2Embedding" not in ModelRegistry.get_supported_archs():
ModelRegistry.register_model(
"MyGemma2Embedding",
"vllm_add_dummy_model.my_gemma_embedding:MyGemma2Embedding",
)
if "MyLlava" not in ModelRegistry.get_supported_archs():
ModelRegistry.register_model("MyLlava", "vllm_add_dummy_model.my_llava:MyLlava")The toy does the same for one architecture, and also imports the module with the op replacement:
# vllm_toy_plugin/__init__.py (illustrative)
def register():
"""General plugin. vLLM calls it once in every process."""
from vllm import ModelRegistry
# The model: a "module:Class" string. Nothing is imported now.
if "ToyLlamaForCausalLM" not in ModelRegistry.get_supported_archs():
ModelRegistry.register_model(
"ToyLlamaForCausalLM", "vllm_toy_plugin.model:ToyLlamaForCausalLM"
)
# The op: importing the module runs @RMSNorm.register_oot (stop 4).
import vllm_toy_plugin.ops # noqa: F401
from vllm import ModelRegistryis a lazy attribute of thevllmpackage (vllm/__init__.py:26).get_supported_archs()returns the keys of the registry (vllm/model_executor/models/registry.py:1157-1158).- A string must contain exactly one colon. It becomes a lazy entry, and nothing is imported (
vllm/model_executor/models/registry.py:1187-1193). A class would be inspected at once, in every process (vllm/model_executor/models/registry.py:1194-1195). - The import of
opsis inside the function. So the import, and the decorator, run only when vLLM calls the plugin. Python caches the module, so a second call in the same process does not register the op again.
The page Out-of-tree models and the model registry explains the registry and its lazy entries.
3Write the model class#
The model test plugin subclasses an in-tree model and changes one method:
class MyOPTForCausalLM(OPTForCausalLM):
def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
# this dummy model always predicts the first token
logits = super().compute_logits(hidden_states)
if logits is not None:
logits.zero_()
logits[:, 0] += 1.0
return logitsThe toy subclasses Llama and changes nothing, so it runs any Llama-architecture checkpoint under a new name:
# vllm_toy_plugin/model.py (illustrative)
from vllm.model_executor.models.llama import LlamaForCausalLM
class ToyLlamaForCausalLM(LlamaForCausalLM):
"""Llama under a new architecture name. Override methods here."""
The class inherits the constructor __init__(self, *, vllm_config, prefix="", ...) (vllm/model_executor/models/llama.py:484-490). That matters: initialize_model checks for both parameter names, and otherwise warns and guesses old arguments (vllm/model_executor/model_loader/utils.py:96-115). The registry also checks the structure, for example embed_input_ids and compute_logits (vllm/model_executor/models/interfaces_base.py:69-76, vllm/model_executor/models/interfaces_base.py:140-147, Model capability protocols (SupportsPP, SupportsLoRA, ...)).
vLLM finds the class by the architecture name in the checkpoint's config.json. To use a normal Llama checkpoint, rename the architecture at start-up. A vLLM test does the same for its OPT model (tests/distributed/test_distributed_oot.py:20-21):
--hf-overrides '{"architectures": ["ToyLlamaForCausalLM"]}'
On the first start, the front end inspects the lazy entry in a subprocess and caches the result on disk (vllm/model_executor/models/registry.py:1088-1116). That subprocess runs register() too (vllm/model_executor/models/registry.py:1612). Later starts read the cache, until model.py changes.
4Replace an op with register_oot#
The platform test plugin replaces the rotary embedding layer. The decorator goes on a subclass of the in-tree class:
from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding
# Register CustomRotaryEmbedding to CustomOP.
@RotaryEmbedding.register_oot
class DummyRotaryEmbedding(RotaryEmbedding):
"""Original rotary positional embedding."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.addition_config = True
def forward_oot(self, *args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]:
return super().forward_oot(*args, **kwargs)The toy replaces RMSNorm, which every Llama decoder layer builds (vllm/model_executor/models/llama.py:308-309):
# vllm_toy_plugin/ops.py (illustrative)
import torch
from vllm.model_executor.layers.layernorm import RMSNorm
@RMSNorm.register_oot # the key is "RMSNorm", the class name
class ToyRMSNorm(RMSNorm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.toy = True # a visible mark: the swap happened
def forward_cuda(self, x: torch.Tensor, residual: torch.Tensor | None = None):
# Your CUDA kernel goes here. The toy calls the in-tree path.
return super().forward_cuda(x, residual)
The decorator stores the class under a key and gives the class that key as its name:
@classmethod
def register_oot(cls, _decorated_op_cls=None, name: str | None = None):
def decorator(op_cls):
reg_name = name if name is not None else cls.__name__
assert reg_name not in op_registry_oot, f"Duplicate op name: {reg_name}"
op_cls.name = reg_name
op_registry_oot[reg_name] = op_cls
return op_cls
if _decorated_op_cls is None:
# Called with parentheses: @CustomOP.register_oot()
# or @CustomOP.register_oot(name="...")
# So, _decorated_op_cls is None.
# We return the actual decorator function.
return decorator
elif isinstance(_decorated_op_cls, type): # Check if it's a class
# Called without parentheses: @CustomOP.register_oot
# The first argument is the class itself.
# We call the 'decorator' function immediately with the class.
return decorator(_decorated_op_cls)
else:
# Handle other unexpected cases if necessary
raise TypeError("Decorator can only be applied to classes.")- Line 337. Without
name=, the key is the name of the class on which you call the decorator:RMSNorm.register_ootgives"RMSNorm". - Lines 338-340. A second registration with the same key fails the
assert. The toy class getsname = "RMSNorm". - Lines 349-353.
@RMSNorm.register_ootwithout parentheses passes the class as the first argument. - Lines 354-356. A string as the first argument raises
TypeError.
When a Llama layer calls RMSNorm(...), CustomOp.__new__ finds "RMSNorm" in the out-of-tree registry and makes a ToyRMSNorm instead (vllm/model_executor/custom_op.py:107-126). The lookup uses the Python class name of the called class (line 109). So if you use the keyword form, the value must be the class name: @CustomOp.register_oot(name="RMSNorm"), not name="rms_norm". (Derived from lines 109 and 117.)
5Make the op actually run#
Each op picks one method when it is built. A disabled op takes forward_native; an enabled op takes the method of the platform (vllm/model_executor/custom_op.py:183-205). Whether an op is enabled depends on the list compilation_config.custom_ops:
enabled = f"+{cls.name}" in custom_ops
disabled = f"-{cls.name}" in custom_ops
if enabled and disabled:
raise ValueError(
"custom_ops cannot both enable and disable the same operation: "
f"{cls.name}. Remove either the '+' or '-' directive"
)
return (CustomOp.default_on() or enabled) and not disabledThe base mode is "all" or "none". vLLM adds it after it parses the flags:
if all(s not in self.compilation_config.custom_ops for s in ("all", "none")):
if (
self.compilation_config.backend == "inductor"
and self.compilation_config.mode != CompilationMode.NONE
):
self.compilation_config.custom_ops.append("none")
else:
self.compilation_config.custom_ops.append("all")With the default compilation (Inductor, mode VLLM_COMPILE), the base mode is "none". The page CustomOp, out-of-tree ops and IR op providers gives the full rule. So you have two switches:
| Setting | Effect on ToyRMSNorm on CUDA |
|---|---|
| nothing (default) | disabled: forward_native |
--compilation-config '{"custom_ops": ["+RMSNorm"]}' | enabled: ToyRMSNorm.forward_cuda. vLLM still appends "none", so the other ops stay off. |
--compilation-config '{"custom_ops": ["+rms_norm"]}' | disabled: forward_native, with a WARNING that the switch "has no effect" |
--enforce-eager | mode NONE, so base mode "all": every op enabled, the toy included (vllm/config/vllm.py:1745-1746) |
The name quirk. The in-tree class registered the op name rms_norm (vllm/model_executor/layers/layernorm.py:38-39). But register_oot gave ToyRMSNorm the name "RMSNorm" (stop 4, line 339), and enabled() builds the switch from that name (line 282). So +rms_norm matches nothing in this model, and custom_op_log_check warns (vllm/config/compilation.py:1341-1361). (Derived from the cited lines; no test covers it.)
Which method runs on an enabled op depends on the platform. On CUDA it is forward_cuda (line 205 of custom_op.py). On an out-of-tree platform it is forward_oot (lines 202-203). The test platform turns every op on in its config hook (tests/plugins/vllm_add_dummy_platform/vllm_add_dummy_platform/dummy_platform.py:21-23). A general plugin such as the toy has no config hook, so the user must set the switch.
6Add a stat logger#
A stat logger receives the scheduler and iteration statistics in the front end. The test plugin implements the minimal interface:
class DummyStatLogger(StatLoggerBase):
"""A dummy stat logger for testing purposes.
Implements the minimal interface expected by StatLoggerManager.
"""
def __init__(self, vllm_config, engine_idx=0):
self.vllm_config = vllm_config
self.engine_idx = engine_idx
self.recorded = []
self.logged = False
self.engine_initialized = False
def record(
self, scheduler_stats, iteration_stats, mm_cache_stats=None, engine_idx=0
):
self.recorded.append(
(scheduler_stats, iteration_stats, mm_cache_stats, engine_idx)
)
def log(self):
self.logged = True
def log_engine_initialized(self):
self.engine_initialized = TrueThe base class has three abstract methods: the constructor, record and log_engine_initialized (vllm/v1/metrics/loggers.py:54-81). The toy counts the generated tokens and prints them:
# vllm_toy_plugin/stat_logger.py (illustrative)
from vllm.logger import init_logger
from vllm.v1.metrics.loggers import StatLoggerBase
logger = init_logger("vllm.toy_plugin") # a child of "vllm", so INFO lines show
class ToyStatLogger(StatLoggerBase):
def __init__(self, vllm_config, engine_index: int = 0):
self.engine_index = engine_index
self.generated = 0
def record(self, scheduler_stats, iteration_stats,
mm_cache_stats=None, engine_idx=0):
if iteration_stats is not None:
self.generated += iteration_stats.num_generation_tokens
def log(self):
logger.info("toy: engine %d generated %d tokens",
self.engine_index, self.generated)
def log_engine_initialized(self):
logger.info("toy: engine %d is ready", self.engine_index)
- The loader checks the class. An object that is not a
StatLoggerBasesubclass raisesTypeErrorand stops start-up (vllm/v1/metrics/loggers.py:84-98). AsyncLLM.__init__loads the group (vllm/v1/engine/async_llm.py:155-156).StatLoggerManagerwraps a per-engine class in an adapter (vllm/v1/metrics/loggers.py:1408-1414). The adapter callsToyStatLogger(vllm_config, engine_index)once for each engine (vllm/v1/metrics/loggers.py:442-445).recordruns for each batch of engine outputs (vllm/v1/engine/async_llm.py:929-935).IterationStatshas the fieldnum_generation_tokens(vllm/v1/metrics/stats.py:483). The base class docstring warns that these stats classes are not a stable interface (vllm/v1/metrics/loggers.py:55-60).logruns everyVLLM_LOG_STATS_INTERVALseconds, default 10, when stats logging is on (vllm/entrypoints/launchers/utils/server_utils.py:78-84,vllm/envs.py:874-878).- The logger name starts with
vllm.on purpose. vLLM's logging config attaches its handler to the logger"vllm"only (vllm/logger.py:100-105). A logger with another name has no level of its own and gets the level of Python's root logger, which is WARNING by default. So Python drops its INFO lines. (Derived from the config and the Python logging defaults; other code can configure the root logger.)
7Add an endpoint#
An endpoint plugin adds HTTP routes. Its entry point gives a factory with no arguments, and the object must have name, required_tasks, attach_router and init_state (vllm/plugins/endpoint_plugins/interface.py:49-94). The test plugin uses the class itself as the factory:
class DummyAdminEndpointPlugin:
name = "dummy_admin_endpoint_plugin"
required_tasks: tuple[str, ...] | None = None
def attach_router(self, app: FastAPI) -> None:
@app.get("/v1/admin/scheduler_config")
async def scheduler_config(raw_request: Request):
engine_client = raw_request.app.state.dummy_engine_client
if engine_client is None:
raise HTTPException(
status_code=503,
detail="scheduler_config requires an engine, which this "
"server does not have",
)
results = await engine_client.collective_rpc("get_scheduler_config")
return {"scheduler_config": results}
async def init_state(self, engine_client, state, args) -> None:
state.dummy_engine_client = engine_clientThe toy route needs no engine, so its init_state does nothing:
# vllm_toy_plugin/endpoint.py (illustrative)
from fastapi import FastAPI
class ToyEndpointPlugin:
name = "toy_endpoint" # use the entry-point name here too
required_tasks = None # no task needed
def attach_router(self, app: FastAPI) -> None:
@app.get("/v1/toy/hello")
async def hello():
return {"plugin": "vllm_toy_plugin", "status": "loaded"}
async def init_state(self, engine_client, state, args) -> None:
pass # this route does not use the engine
- Phase A.
build_appcallsattach_endpoint_pluginsafter the core routes (vllm/entrypoints/launchers/app.py:47-51). A plugin route with the same path and method as a core route replaces it (vllm/plugins/endpoint_plugins/interface.py:72-74). - Phase B.
init_endpoint_plugins_stateawaitsinit_stateof each attached plugin (vllm/plugins/endpoint_plugins/interface.py:163-164).
The interface module says that an endpoint should not open new paths into the engine. A plugin that needs engine-side code pairs the endpoint with a general plugin (vllm/plugins/endpoint_plugins/interface.py:5-16). The page Plugins in the API server explains the route replacement in detail.
8Install and start the server#
CI installs a test plugin in editable mode, then runs the tests (.buildkite/test_areas/plugins.yaml:37-39). Do the same with the toy, in the same Python environment as vLLM:
pip install -e ./vllm-toy-plugin
# List EVERY plugin you need: the endpoint forces an allowlist (stop 7).
export VLLM_PLUGINS=toy_register,toy_stat_logger,toy_endpoint
vllm serve meta-llama/Llama-3.2-1B-Instruct \
--hf-overrides '{"architectures": ["ToyLlamaForCausalLM"]}' \
--compilation-config '{"custom_ops": ["+RMSNorm"]}'
curl http://localhost:8000/v1/toy/hello
The checkpoint is an example: any Llama-architecture checkpoint works. Three rules about VLLM_PLUGINS matter here:
- The value is split at commas, and the parts are not stripped (
vllm/envs.py:1163-1167). Do not put spaces after the commas. - The list also filters the platform group and vLLM's own general plugins. This list drops the two LoRA resolvers (
pyproject.toml:45-47). Addlora_filesystem_resolverif you use it. - An empty string is a list that matches nothing (
vllm/plugins/__init__.py:107-109).
With TP = 2, register() runs in four processes; the stat logger and the endpoint load once, in the front end. The tour Tour: Life of a plugin shows where.
9Check that each part loaded#
Start with VLLM_LOGGING_LEVEL=DEBUG the first time. The default level is INFO (vllm/envs.py:860), and some of the lines below are DEBUG lines. The table lists what to look for, in start-up order:
| Part | Log line | Level | Where in the code |
|---|---|---|---|
| discovery, general group | "Available plugins for group vllm.general_plugins:" and "- toy_register -> vllm_toy_plugin:register" | DEBUG | vllm/plugins/__init__.py:48-54 |
| allowlist | "Loading plugin toy_register" | DEBUG (general group); INFO (other groups) | vllm/plugins/__init__.py:65-66 |
| broken import | "Failed to load plugin ..." with a traceback | ERROR | vllm/plugins/__init__.py:71-72 |
| model cache | "Cache model info for class ... miss" on the first start | DEBUG | vllm/model_executor/models/registry.py:1100-1104 |
| model | "Resolved architecture: ToyLlamaForCausalLM" | INFO | vllm/config/model.py:744 |
| stat logger, discovery | "Available plugins for group vllm.stat_logger_plugins:" | INFO | vllm/plugins/__init__.py:52-54 |
| op swap (each worker) | "Instantiating custom op: RMSNorm using ..." | DEBUG | vllm/model_executor/custom_op.py:121-125 |
| op switch (each worker) | "enabled custom ops: ..." should contain RMSNorm | DEBUG | vllm/config/compilation.py:1329-1330 |
| stat logger, ready | the toy's own "toy: engine 0 is ready" | INFO | vllm/v1/engine/async_llm.py:214 |
| endpoint | "Loaded endpoint plugin toy_endpoint" | INFO | vllm/plugins/__init__.py:156 |
You can also check the first two parts without a server, in one Python process. The functions below are the ones that vLLM itself calls; the test of the stat logger plugin uses the loader the same way (tests/plugins_tests/test_stats_logger_plugins.py:13-21):
# quick check (illustrative)
from vllm.plugins import load_general_plugins
from vllm.v1.metrics.loggers import load_stat_logger_plugin_factories
load_general_plugins() # runs toy_register once
from vllm import ModelRegistry
from vllm.model_executor.custom_op import op_registry_oot
print("ToyLlamaForCausalLM" in ModelRegistry.get_supported_archs()) # True
print(op_registry_oot) # {'RMSNorm': <class 'vllm_toy_plugin.ops.ToyRMSNorm'>}
print(load_stat_logger_plugin_factories()) # [<class 'vllm_toy_plugin.stat_logger.ToyStatLogger'>]
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 diveWhich process imports which toy module
For vllm serve -tp 2 with the default spawn start method. (Derived from the call sites cited on this page and on How vLLM finds and loads plugins.)
| Module | Front end | Engine core | Each worker | Registry subprocess (cache miss only) |
|---|---|---|---|---|
__init__.py (register) | yes | yes (vllm/v1/engine/core.py:132) | yes (vllm/v1/worker/worker_base.py:284) | yes (vllm/model_executor/models/registry.py:1612) |
ops.py | yes | yes | yes | yes |
model.py | no, for a plain text model (derived) | no, for a plain text model (derived) | yes, when it builds the model (vllm/model_executor/model_loader/utils.py:305-313) | yes: this is the inspection |
stat_logger.py | yes (vllm/v1/engine/async_llm.py:156) | no | no | no |
endpoint.py | yes, if allowlisted (vllm/entrypoints/launchers/app.py:51) | no | no | no |
ops.py runs in every process because register() imports it. Only the workers build layers, so only there does the swap matter. The import in the other processes is cheap but not free: it imports layernorm.py.
The "no" for model.py is derived from the callers of get_model_cls and resolve_model_cls at this commit. The front end and the engine core use only the model info, which the registry subprocess or the disk cache gives (stop 3). Some features ask the loader for the class and so import it: multimodal input processing (vllm/multimodal/registry.py:115-119), speech-to-text serving (vllm/entrypoints/speech_to_text/base/serving.py:158-160), scoring (vllm/entrypoints/pooling/scoring/io_processor.py:457-460) and a model-declared KV cache grouping (vllm/v1/core/kv_cache_planning.py:1669-1681).
Deep diveTwo ways to bring a layer: replacement or platform method
General plugin with register_oot (this toy) | Platform plugin with forward_oot | |
|---|---|---|
| What changes | the class that the model builds | the method that an enabled op calls |
| Platform | any; also CUDA | only the out-of-tree platform (vllm/model_executor/custom_op.py:202-203) |
| Turned on by | the user: +RMSNorm or base mode "all" | the platform's check_and_update_config can set custom_ops (tests/plugins/vllm_add_dummy_platform/vllm_add_dummy_platform/dummy_platform.py:21-23) |
| Method to override | the one of the current platform, for example forward_cuda | forward_oot |
| Conflicts | a second register_oot with the same key fails the assert (vllm/model_executor/custom_op.py:338) | only one out-of-tree platform can be active (vllm/platforms/__init__.py:272-276) |
The test platform combines both: a platform plugin and a general plugin that calls register_oot (tests/plugins/vllm_add_dummy_platform/setup.py:10-17). The page CustomOp, out-of-tree ops and IR op providers also explains the IR op providers, a second level of kernel choice inside RMSNorm.forward_native.
Deep diveTesting the toy like vLLM tests its own plugins
- Model. The online test starts a real server with the plugin model and checks the answer (
tests/plugins_tests/test_oot_registration_online.py:10-39). The offline test setsVLLM_PLUGINS=""and expects the "not supported" error (tests/plugins_tests/test_oot_registration_offline.py:13-22). - Op. A test calls
load_general_plugins(), builds the in-tree class and checks the class name of the result (tests/plugins_tests/test_platform_plugins.py:51-64). The same test works forToyRMSNorm. - Stat logger. The test checks discovery, the empty allowlist and the
TypeErrorfor a wrong class (tests/plugins_tests/test_stats_logger_plugins.py:13-52). - Endpoint. The tests check the allowlist, for example the empty string (
tests/plugins_tests/test_endpoint_plugins.py:107-115), and the replacement of a core route (tests/plugins_tests/test_endpoint_plugins.py:196-220).
vLLM's CI runs the plugin model tests in a fresh process: "it needs a clean process" (.buildkite/test_areas/plugins.yaml:50-51). The reason is the once-per-process guard: after the first load_general_plugins(), a test in the same process cannot load a changed plugin again (vllm/plugins/__init__.py:83-85).
What can go wrong#
- A plugin function that is not re-entrant. vLLM calls it once per process, but in every process (
vllm/plugins/__init__.py:82-90). The design doc asks for a function that "can be called multiple times without causing issues" (docs/design/plugin_system.md:60). Check the registry before you register, as the test plugin does. Keep decorators at module level. A decorator inside the function body runs again when your own code or a test calls the function twice. Thenregister_ootfails itsassert(vllm/model_executor/custom_op.py:338). Side effects outside the process, such as a file write, happen once in each process: four times or more with TP = 2. - Eager class registration.
register_model("ToyLlamaForCausalLM", ToyLlamaForCausalLM)imports the model module in every process and inspects it at once (vllm/model_executor/models/registry.py:1194-1195). The docstring gives the main risk: an import that starts CUDA, and then "Cannot re-initialize CUDA in forked subprocess" (vllm/model_executor/models/registry.py:1167-1173).vllm serveusesspawn, and vLLM switches tospawnwhen CUDA is already started (vllm/utils/system_utils.py:145-162). But vLLM used as a library defaults tofork(vllm/envs.py:936-940). Use the string form. VLLM_PLUGINSdisables the LoRA resolvers. The endpoint forces you to set the list (stop 7). Every name that is not in it is skipped, also vLLM's ownlora_filesystem_resolverandlora_hf_hub_resolver(pyproject.toml:45-47). The loader does not log a skipped name, and the general group logs its plugin list only at DEBUG level. So nothing shows at INFO (vllm/plugins/__init__.py:48-66).- The wrong switch name.
+rms_normdoes not enableToyRMSNorm;+RMSNormdoes (stop 5). - Your INFO lines do not show. A logger that is not under
vllm.has no vLLM handler and gets the WARNING level of the root logger (stop 6). - A broken import is only logged. A typo in the entry-point value gives "Failed to load plugin ..." and the server starts without the part (
vllm/plugins/__init__.py:71-72). An exception insideregister()is not caught and stops the process (vllm/plugins/__init__.py:89-90). - A stale entry point. You renamed a function but did not install the package again. The old value is still in the entry-point metadata, and the import fails (Python entry points).
Why it matters. Most plugin bugs are not in the plugin's own logic. They are in the wiring: the entry-point name, the allowlist, the switch name, the process. The log lines of stop 9 check the wiring before you debug any kernel or model code.
Check yourself#
- 1Whicheasy
VLLM_PLUGINSvalue do you need for the toy with its endpoint, and why can you not leave the variable unset?Show answer
toy_register,toy_stat_logger,toy_endpoint, with no spaces. Endpoint plugins load only whenVLLM_PLUGINSis set and names them (vllm/plugins/__init__.py:122-131). The list then filters every other group too, so the other two names must be in it (vllm/plugins/__init__.py:63-64). - 2What is wrong witheasy
register_model("ToyLlamaForCausalLM", "vllm_toy_plugin.model.ToyLlamaForCausalLM")?Show answer
The string has no colon. The lazy form must be
"module:Class"with exactly one colon, elseregister_modelraisesValueError(vllm/model_executor/models/registry.py:1187-1191). - 3You start the server with no compilation flags. Ismedium
ToyRMSNormbuilt? Does itsforward_cudarun?Show answer
It is built:
CustomOp.__new__swaps everyRMSNormfor it (vllm/model_executor/custom_op.py:117-126). Itsforward_cudadoes not run. The default base mode is"none"(vllm/config/vllm.py:1819-1824), so the op is disabled and usesforward_native(vllm/model_executor/custom_op.py:189-192). - 4You passhard
--compilation-config '{"custom_ops": ["+rms_norm"]}'. What happens, and why?Show answer
ToyRMSNormstays disabled, and vLLM warns that the switch has no effect.register_ootset the attributenameof the class to the key"RMSNorm"(vllm/model_executor/custom_op.py:337-339).enabled()looks for"+RMSNorm"(vllm/model_executor/custom_op.py:282). After the model build,rms_normis not among the op names of the model, socustom_op_log_checklogs the warning (vllm/config/compilation.py:1341-1361). (Derived.) - 5Yourhard
ToyStatLoggeruseslogging.getLogger(__name__), which gives the namevllm_toy_plugin.stat_logger. Itslogger.infolines never show. Why, and what is the fix?Show answer
vLLM attaches its handler only to the logger
"vllm"(vllm/logger.py:100-105). A logger outside that tree has no level of its own. It gets the level of Python's root logger, WARNING by default, and drops INFO records. The root logger also has no handler by default, so Python prints only warnings and errors, through its last-resort handler. Use a name undervllm., for exampleinit_logger("vllm.toy_plugin")(vllm/logger.py:328-335). (Derived from the logging config and the Python logging rules.)