Skip to content

[Metrics][LoRA] Publish LoRA load events and expose loaded adapters in metrics - #54830

Open
wseaton wants to merge 20 commits into
vllm-project:mainfrom
wseaton:weaton/lora-load-events
Open

wseaton wants to merge 20 commits into
vllm-project:mainfrom
wseaton:weaton/lora-load-events

Conversation

@wseaton

@wseaton wseaton commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Stack

  1. [Engine Core] Gather engine notifications from workers to frontends #51433 engine notification channel
  2. [Metrics][LoRA] Publish LoRA load events and expose loaded adapters in metrics #54830 LoRA load events, doorbell, and Python Prometheus metrics (this PR)
  3. [Rust][Metrics] Consume LoRA load events in the Rust frontend #54833 Rust frontend consumer for the same metrics

Each PR is based on main and includes the commits of the ones before it; review only the last commits of each.

End-to-end testing on the Rust frontend needs #54836 (--lora-modules support), tracked separately.

Note: the metrics report worker-side residency. /v1/unload_lora_adapter on the Python server only drops the frontend registry entry (#54839), so an unloaded adapter keeps its series until LRU eviction. This bug is now actually visible!

@mergify

mergify Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--54830.org.readthedocs.build/en/54830/

@atantawi

atantawi commented Sep 3, 2026

Copy link
Copy Markdown

Downstream consumer here — @wseaton pointed me at this stack from
llm-d/llm-d-router#1500. Putting the consumer-side case on the record, since "who needs
this metric" is what a metrics PR usually has to answer.

llm-d-router's LoRA scorer is broken today in two directions, and this PR fixes both.
It parses vllm:lora_requests_info into ActiveModels / WaitingModels and scores
endpoints in four tiers. Read at llm-d-router 7f1a611e against vLLM 67e86d1e6f:

  • LoRARequestStates.update_scheduler_stats (vllm/v1/metrics/stats.py:637-639) is the
    only writer of either dict and writes every key of self.requests into both;
    loggers.py:1168-1172 renders each as ",".join(...keys()). The two label values are
    two renderings of one key set, so the router's "adapter queued to load" tier is dead
    code
    waiting implies active, tested first.
  • Its capacity tier tests len(active)+len(waiting) < max_lora, which is 2N for N
    active adapters and trips at N ≥ max_lora/2. Meanwhile an adapter resident with no
    traffic is invisible, so a full pod can read as empty. One expression, wrong in both
    directions.

Confirmed by running the router's own extractor and scorer, kept as mutation-tested Go
tests: with running = waiting = "target,other" and max_lora = 4, a queued target scores
1.0 instead of the intended 0.6, and a cold target scores 0.0 with two of four slots
free. Filed as llm-d/llm-d-router#2605 before I knew this stack existed.

vllm:num_gpu_loaded_lora_adapters against max_lora is exactly the comparison that
capacity tier fails to be, and vllm:lora_adapter_loaded{level="gpu"} is the residency its
tier names already claim to test. On cardinality, since that is the usual objection to a
per-adapter label: it is bounded by max_cpu_loras, not by the registered population, which
in our survey of adapter populations runs to 10²–10³ per base-model family with only a
handful resident.

I read the producer rather than the label names — misreading a label name is what put me
wrong on the llm-d thread to begin with — and the residency claim holds: activate_adapter
evicts only at len(_active_adapters) >= lora_slots and _apply_adapters only ever adds,
so an idle adapter stays in gpu_adapters. Three questions from that reading:

  1. Running vs queued stays unavailable. The stack leaves the info-metric alone, so a
    consumer still cannot reconstruct scheduled_loras (scheduler.py:764-769, 814-824) —
    the quantity a capacity-aware scheduler actually wants. Is splitting the running set in
    scope here, or is the intent that consumers use residency plus activity and not try?
  2. TP > 1 labelling. The gather flattens every rank and the labels carry no rank, so
    _record_lora_load_event is last-write-wins per engine_idx against
    _lora_loaded_series. Identical sets across ranks make that idempotent — guaranteed, or
    can a transient disagreement flap a series?
  3. The idle-engine claim. docs/design/metrics.md says the gauges update "including on
    an idle engine with statically configured adapters". I could not confirm that
    --lora-modules puts the adapter in the worker's cache before the first request, as
    opposed to the frontend registry [Bug]: /v1/unload_lora_adapter never removes the adapter from the engine #54839 is about. Worth a test if scrape-before-traffic
    is load-bearing.

@atantawi

atantawi commented Sep 3, 2026

Copy link
Copy Markdown

Cross-linking one downstream consumer: @dmitripikus owns
llm-d-router#709 (dynamic LoRA
placement/balancing) and asked to be pointed at the relevant work. This stack is the first
per-adapter residency signal a placement- or eviction-aware router could use;
vllm:lora_requests_info reports in-flight activity instead, which is the failure mode
described in my review
above.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mergify

mergify Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @wseaton.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 4, 2026
@atantawi

atantawi commented Sep 4, 2026

Copy link
Copy Markdown

Withdrawing my third question: it was already answered, on #54833, before I asked it.

Your test report there (comment) shows the startup scrape under --lora-modules alice=…, before any request:

vllm:num_gpu_loaded_lora_adapters{engine="0"} 1
vllm:lora_adapter_loaded{engine="0",adapter_name="alice",level="gpu",pinned="false"} 1

with the Python frontend giving the same numbers. That is exactly the scrape-before-traffic case I said I could not confirm, and it is worker-side rather than a frontend registry — the event's lists come from LoRAModelManager.get_loaded_state() reading _active_adapters / _registered_adapters. I reviewed one PR of a stack without reading its sibling's test report; sorry for the noise.

Questions 1 and 2 stand, and 1 is the one that matters downstream: with the info-metric untouched, a consumer still cannot separate running from queued, so llm-d-router's "adapter queued to load" tier stays dead code even after this lands, and scheduled_loras stays unreconstructible. If splitting the running set is out of scope for this stack, that is a perfectly good answer — I would just rather know, so the router-side consumer can be written against what this will actually emit.

Worker-side producers publish EngineNotification values that the model
runner attaches to ModelRunnerOutput; EngineCore forwards them on
EngineCoreOutputs.engine_notifications, broadcasting from EngineCoreProc
so every API server sees the same events.

EngineCoreOutputs is array_like and omit_defaults does not trim trailing
fields, so the appended field lengthens every message. serde_tuple
rejects a longer array than it knows, so the Rust wire struct has to move
in the same commit.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
Build a local ModelRunnerOutput instead of mutating the shared
EMPTY_MODEL_RUNNER_OUTPUT singleton, and add cover for the appended
array_like field surviving a msgpack round-trip and for EngineCoreProc
fanning one event out to every frontend.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
The fixture mirror lacked the new field, so the cross-language test only
proved the tolerated direction: Python encoding 8 elements into a Rust
struct that knows 9. Encoding all 9 is the direction this PR depends on.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
…ation

take_worker_notifications returns None instead of building a list on every
step when no producer is installed. Drop gc=False from CustomNotification,
whose payload is plugin-supplied: a cycle through an untracked struct is
never collected.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
Draining into ModelRunnerOutput only reached the executor's output_rank,
so producers on other ranks were discarded, and paths that return early
(non-last PP rank via with_kv_conn_output_only) never drained at all,
accumulating in the worker for the life of the process.

Gather with collective_rpc("take_notifications") instead, keeping every
rank's events. The gather is an rpc, so it runs between steps rather than
inside one: once before serving, on in-tree producers' own state changes,
and on VLLM_WORKER_NOTIFICATION_POLL_INTERVAL when set.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
The DP override of run_busy_loop never called the startup gather or the
interval poll, so notifications were never collected under data
parallelism. Trim the gather docstrings and drop the docstring example's
nonexistent StatLoggerBase kwarg while here.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
Pin the DP busy loop's startup gather and per-iteration poll (the wiring
that previously shipped missing), and the poll's off-by-default and
interval gating. Drop test_empty_drain_allocates_nothing; the roundtrip
test already asserts the quiet drain returns None.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
A real engine proc and worker: publish_worker_notification runs in the
worker via collective_rpc, the interval poll gathers it, and the frontend
client reads it off EngineCoreOutputs alongside generation output.


Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
The idle input-queue wait now times out at the poll interval and gathers,
so an event published while the engine is idle reaches the frontend
without waiting for the next request; the knob now bounds notification
latency in both states. The interval poll moves to post_step, which both
the busy loops and InprocClient reach, closing the in-process gap
(InprocClient also gathers load-time events at construction). The gather
no longer swallows executor failures: MultiprocExecutor stops dequeuing
at the first failed rank, so a swallowed error leaves stale rank replies
queued for the next collective. The worker buffer is capped at 1024,
dropping the oldest, and the payload contract (string keys, msgpack
values) is documented. The e2e now proves idle delivery with no request
in flight and bounds every blocking wait.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
InprocClient.get_output now flushes after post_step; step_fn flushes
before the gather runs, so a notification gathered on the final step had
no later flush and sat in _pending_notifications until a call that a
request-less frontend never makes. The worker buffer becomes a deque
under a lock: the bounded list's len-check-then-delete raced the
drainer's swap, and a publisher could del from the freshly emptied list.
deque(maxlen) drops the oldest without a second mutation. The e2e wait
helper re-raises drain-thread exceptions so an engine failure surfaces
as itself instead of a timeout.


Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
Co-authored-by: Wentao Ye <44945378+yewentao256@users.noreply.github.com>

Signed-off-by: Will Eaton <weaton@redhat.com>
The notification channel only gathered at startup or on an opt-in poll,
so a producer that publishes during a step (LoRA activation evicting a
GPU slot) was invisible by default. The output rank now sets
ModelRunnerOutput.worker_notifications_pending when its buffer is
non-empty and the engine core gathers from every rank after that step,
so a quiet step still costs no rpc. add_lora/remove_lora/pin_lora gather
after the executor call, since an idle engine has no step to ring the
doorbell.

LoRALoadEvent joins the tagged union on both the Python and Rust sides:
a full snapshot of the worker's adapter caches by name, so consumers
replace rather than merge.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
LoRAModelManager exposes the ids resident in each cache tier and the
worker manager resolves them to adapter names, remembering names from
_load_adapter and pruning them once an adapter is evicted. The model
runner mixin snapshots that state after every activation, add, remove,
pin and reset, and publishes a LoRALoadEvent only when the set changed.
Dummy warmup adapters go through the manager directly, so they never
publish.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
vllm:lora_requests_info only shows adapters with requests in flight, so
routers cannot tell which replica already has an adapter warm.
LoRALoadEvent notifications now feed three gauges: one series per
resident adapter (adapter_name, level gpu|cpu, pinned) plus per-tier
counts. Each event is a full snapshot, so series for evicted adapters
are zeroed and removed; the zero matters under prometheus multiprocess
mode, where remove() leaves the mmap-backed sample, and under Ray, which
cannot delete series at all.

Stat loggers receive notifications through a new non-abstract
record_engine_notifications hook rather than a new record() parameter,
so out-of-tree loggers keep working unchanged. lora_requests_info is
marked deprecated in its help text.

Assisted-by: Claude
Signed-off-by: Will Eaton <weaton@redhat.com>
A router comparing occupancy against capacity needs max_loras before
any adapter has served, but the only place it was exposed is the
max_lora label on the deprecated vllm:lora_requests_info gauge, which
appears on first use. Add vllm:max_gpu_lora_adapters and
vllm:max_cpu_lora_adapters, set once per engine at startup.

Signed-off-by: Will Eaton <weaton@redhat.com>
The worker LoRA manager times each disk load and each CPU-to-GPU
activation and ships them on the next LoRALoadEvent. The Prometheus
logger observes them in vllm:lora_adapter_load_seconds{transition},
so a router can price a request that lands on a server without the
adapter instead of assuming a fixed penalty.

Signed-off-by: Will Eaton <weaton@redhat.com>
Rank is a proxy for adapter size and so for what a miss costs. The
worker snapshot carries each resident adapter's rank and the gauge
exposes it as a label, so a router can weigh a rank-64 miss differently
from a rank-8 one.

Signed-off-by: Will Eaton <weaton@redhat.com>
@wseaton
wseaton marked this pull request as ready for review September 10, 2026 13:46

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@wseaton

wseaton commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Marking as ready for review (have end to end tests now in the llm-d router), but still needs it's first PR in the stack reviewed first.

The design doc said the old gauge was replaced by "three gauges" while
listing five and a histogram, and still said deprecation was a future
decision. Keep the old gauge's notes with its description, state that it
is still emitted under the deprecation policy, and point downstream users
at the replacement metrics.

Signed-off-by: Will Eaton <weaton@redhat.com>
The Rust frontend exported the old HELP text, so the deprecation notice
depended on which frontend served /metrics. Use the same text as the
Python logger. Also correct the design doc: the gauge is registered with
multiprocess_mode="sum", not "livemostrecent".

Signed-off-by: Will Eaton <weaton@redhat.com>
@mergify

mergify Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @wseaton.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation mrv2 Model Runner V2 specific needs-rebase rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants