Skip to content

Commit c48f52c

Browse files
authored
fix(models): eagerly register LoRA adapters with vLLM (#382)
* fix(models): eagerly register LoRA adapters with vLLM - The adapter sidecar downloads each adapter into the vLLM's expected LoRA cache dir correctly. - However, vLLM's LoRA filesystem resolver only loads/lists an adapter on the *first inference request that names it*. - Nothing triggered that load, so the model-provider reconciler (which discovers served models from the provider's `v1/models`, never sees the adapter and it never propagates to the gateway. This is what QA discovered, and the root cause of the bug. **Fix:** the sidecar now eagerly registers each adapter with vLLM via the already-enabled runtime LoRA API, so it appears in `v1/models`. Signed-off-by: Albert Cui <albcui@nvidia.com> * Tighten behaviour: - failed downloads - retries Signed-off-by: Albert Cui <albcui@nvidia.com> * fix lint Signed-off-by: Albert Cui <albcui@nvidia.com> * thanks CodeRabbit Signed-off-by: Albert Cui <albcui@nvidia.com> --------- Signed-off-by: Albert Cui <albcui@nvidia.com>
1 parent 4b286c7 commit c48f52c

4 files changed

Lines changed: 860 additions & 20 deletions

File tree

services/core/models/src/nmp/core/models/controllers/backends/docker/creation_reconciler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,13 @@ async def cleanup_and_error(status_message: str, error_details: dict) -> tuple[D
10981098
# base_model_name_or_path equals vLLM's --model value (the local
10991099
# model path). Tell the sidecar to rewrite each adapter to match.
11001100
sidecar_envs["VLLM_LORA_BASE_MODEL_OVERRIDE"] = vllm_compiler.MODEL_STORE_PATH
1101+
# Endpoint the sidecar uses to eagerly (un)load adapters via vLLM's
1102+
# runtime LoRA API. Without this the filesystem resolver only loads an
1103+
# adapter on the first request that names it, so it never appears in
1104+
# /v1/models and model-provider discovery never surfaces it. Reuse the
1105+
# same host URL the controller uses to reach the deployment, which is
1106+
# reachable from the sidecar across the supported networking modes.
1107+
sidecar_envs["VLLM_ENDPOINT"] = self.get_host_url(container_name, host_port)
11011108
if state.model_entity is not None:
11021109
sidecar_envs["NMP_MODEL_ENTITY_WORKSPACE"] = state.model_entity.workspace
11031110
sidecar_envs["NMP_MODEL_ENTITY_NAME"] = state.model_entity.name

services/core/models/src/nmp/core/models/sidecars/adapters/main.py

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import shutil
1111
import signal
1212
import threading
13+
import urllib.error
14+
import urllib.request
1315

1416
from nemo_platform import NeMoPlatform, NotFoundError
1517
from nemo_platform.types.models import ModelEntity
@@ -64,6 +66,15 @@ def __init__(self, stop_signal: threading.Event | None = None):
6466
# which scans the directory and does not require this equality.
6567
self.base_model_name_override = os.getenv("VLLM_LORA_BASE_MODEL_OVERRIDE", "")
6668

69+
# vLLM-only: base URL of the sibling vLLM server (e.g. http://localhost:49152).
70+
# When set, the sidecar eagerly (un)loads adapters through vLLM's runtime LoRA
71+
# API so they are advertised in ``/v1/models`` immediately, instead of relying
72+
# on the filesystem resolver to lazily load them on the first inference request
73+
# (which leaves the adapter invisible to model-provider discovery). Unset for
74+
# NIM, which scans ``NIM_PEFT_SOURCE`` itself.
75+
self.vllm_endpoint = os.getenv("VLLM_ENDPOINT", "").rstrip("/")
76+
self.vllm_request_timeout = float(os.getenv("VLLM_LORA_LOAD_TIMEOUT", "120"))
77+
6778
self._stop_signal = stop_signal
6879

6980
self._loop = asyncio.new_event_loop()
@@ -107,7 +118,19 @@ def step(self):
107118
self._update_prompt_tuned_models(dirs_to_keep)
108119

109120
for name in set(os.listdir(self.nim_peft_source)) - dirs_to_keep:
110-
shutil.rmtree(f"{self.nim_peft_source}/{name}")
121+
# Staging temp dirs (".{dir}.tmp") were never loaded into vLLM;
122+
# just reap them.
123+
if name.startswith("."):
124+
shutil.rmtree(f"{self.nim_peft_source}/{name}")
125+
continue
126+
# Unload from vLLM before deleting on disk so a removed/disabled
127+
# adapter stops being served (no-op for NIM). Only delete the
128+
# directory once the unload is confirmed (or vLLM has no endpoint):
129+
# if vLLM is currently unreachable, keep the dir so the unload is
130+
# retried next cycle rather than orphaning a still-loaded adapter
131+
# in vLLM until it restarts (the dir is the only state driving GC).
132+
if self._unload_vllm_adapter(name):
133+
shutil.rmtree(f"{self.nim_peft_source}/{name}")
111134

112135
except Exception:
113136
logger.exception(f"Failed to fetch {self.workspace}/{self.model_name}'s model_entity")
@@ -195,7 +218,7 @@ def _adapter_changed(self, adapter_dir: str, adapter: Adapter) -> bool:
195218

196219
return False
197220

198-
def _download_adapter(self, adapter_dir: str, adapter: Adapter, adapter_workspace: str) -> None:
221+
def _download_adapter(self, adapter_dir: str, adapter: Adapter, adapter_workspace: str) -> bool:
199222
"""Download adapter fileset atomically using a temp directory + rename.
200223
201224
Downloads into a sibling temp directory inside nim_peft_source, then
@@ -216,6 +239,13 @@ def _download_adapter(self, adapter_dir: str, adapter: Adapter, adapter_workspac
216239
means it lives in the adapter's workspace. Using the base model's workspace
217240
instead would silently mis-route fileset fetches whenever the adapter and
218241
base model live in different workspaces.
242+
243+
Returns ``True`` if the adapter was (re)published into ``adapter_dir``;
244+
``False`` if the download produced no files (e.g. an empty fileset), in
245+
which case ``adapter_dir`` is left untouched (a new adapter has no
246+
directory; an existing one keeps its previous weights). Raises on
247+
unexpected errors after cleaning up the staging directory. Callers use
248+
the result to decide whether to (re)register the adapter with vLLM.
219249
"""
220250
parts = adapter.fileset.split("/", 1)
221251
if len(parts) == 1:
@@ -243,8 +273,10 @@ def _download_adapter(self, adapter_dir: str, adapter: Adapter, adapter_workspac
243273
if os.path.isdir(adapter_dir):
244274
shutil.rmtree(adapter_dir)
245275
os.rename(temp_dir, adapter_dir)
276+
return True
246277
else:
247278
shutil.rmtree(temp_dir, ignore_errors=True)
279+
return False
248280
except Exception:
249281
shutil.rmtree(temp_dir, ignore_errors=True)
250282
raise
@@ -267,6 +299,93 @@ def _resolve_adapter_workspace(adapter: Adapter, base_model_workspace: str) -> s
267299
"""
268300
return getattr(adapter, "workspace", None) or base_model_workspace
269301

302+
def _vllm_api_call(self, route: str, payload: dict) -> tuple[int, str]:
303+
"""POST ``payload`` to the local vLLM server at ``route``.
304+
305+
Returns ``(status_code, body)``. HTTP error responses (4xx/5xx) are
306+
returned, not raised. Transport-level failures (vLLM not yet reachable)
307+
raise ``urllib.error.URLError``/``OSError`` so callers can retry next cycle.
308+
"""
309+
url = f"{self.vllm_endpoint}{route}"
310+
data = json.dumps(payload).encode("utf-8")
311+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
312+
try:
313+
with urllib.request.urlopen(req, timeout=self.vllm_request_timeout) as resp:
314+
return resp.status, resp.read().decode("utf-8", "replace")
315+
except urllib.error.HTTPError as e:
316+
return e.code, e.read().decode("utf-8", "replace")
317+
318+
def _load_vllm_adapter(self, lora_name: str, adapter_dir: str) -> None:
319+
"""Register an adapter with vLLM via ``POST /v1/load_lora_adapter``.
320+
321+
Tolerates the "already loaded" response so this is safe to call every
322+
reconcile cycle. Raises on transport errors (handled by the caller).
323+
"""
324+
status, body = self._vllm_api_call("/v1/load_lora_adapter", {"lora_name": lora_name, "lora_path": adapter_dir})
325+
if status == 200:
326+
logger.info(f"Loaded LoRA adapter {lora_name!r} into vLLM from {adapter_dir}")
327+
elif status == 400 and "already" in body.lower():
328+
logger.debug(f"LoRA adapter {lora_name!r} already loaded in vLLM")
329+
else:
330+
logger.warning(f"vLLM load_lora_adapter failed for {lora_name!r} (status={status}): {body[:300]}")
331+
332+
def _unload_vllm_adapter(self, lora_name: str) -> bool:
333+
"""Best-effort ``POST /v1/unload_lora_adapter``; never raises.
334+
335+
Returns ``True`` when it is safe to drop the adapter's on-disk directory:
336+
either there is no vLLM endpoint (NIM, which unloads purely by directory
337+
removal) or vLLM *confirmed* the unload — a 2xx, or a non-server-error
338+
response such as 404 "not loaded" (the adapter is not loaded either way).
339+
Returns ``False`` when vLLM was unreachable **or** returned a 5xx server
340+
error: the unload was not confirmed and the adapter may still be loaded,
341+
so the GC caller keeps the directory (the only state driving
342+
reconciliation) and retries next cycle instead of orphaning a
343+
still-loaded adapter in vLLM until it restarts.
344+
"""
345+
if not self.vllm_endpoint:
346+
return True
347+
try:
348+
status, body = self._vllm_api_call("/v1/unload_lora_adapter", {"lora_name": lora_name})
349+
except (urllib.error.URLError, OSError) as e:
350+
logger.debug(f"vLLM unload_lora_adapter for {lora_name!r} skipped (vLLM unreachable): {e}")
351+
return False
352+
if status == 200:
353+
logger.info(f"Unloaded LoRA adapter {lora_name!r} from vLLM")
354+
return True
355+
if status >= 500:
356+
# Server-side error: the unload was not confirmed and the adapter may
357+
# still be loaded. Keep the directory and retry next cycle rather than
358+
# deleting it and orphaning the adapter in vLLM until a restart.
359+
logger.warning(
360+
f"vLLM unload_lora_adapter for {lora_name!r} returned server error "
361+
f"(status={status}), will retry: {body[:200]}"
362+
)
363+
return False
364+
# Non-2xx, non-5xx (e.g. 404 "not loaded"): vLLM answered and the adapter
365+
# is not loaded, so it is safe to drop the directory.
366+
logger.debug(f"vLLM unload_lora_adapter for {lora_name!r} (status={status}): {body[:200]}")
367+
return True
368+
369+
def _ensure_vllm_adapter_loaded(self, lora_name: str, adapter_dir: str, reload: bool) -> None:
370+
"""Eagerly (re)register a downloaded adapter with vLLM.
371+
372+
No-op for NIM (``vllm_endpoint`` unset), which discovers adapters by
373+
scanning ``NIM_PEFT_SOURCE``. For vLLM, this makes the adapter appear in
374+
``/v1/models`` without requiring a first inference request, so the
375+
model-provider reconciler can discover and surface it through the gateway.
376+
"""
377+
if not self.vllm_endpoint:
378+
return
379+
try:
380+
if reload:
381+
# Adapter dir was (re)written; drop any stale copy so vLLM loads
382+
# the new weights rather than keeping the previous version.
383+
self._unload_vllm_adapter(lora_name)
384+
self._load_vllm_adapter(lora_name, adapter_dir)
385+
except (urllib.error.URLError, OSError) as e:
386+
# vLLM may still be initializing; retried on the next reconcile cycle.
387+
logger.warning(f"Could not reach vLLM to load adapter {lora_name!r}, will retry: {e}")
388+
270389
def _update_lora_adapters(self, dirs_to_keep: set[str]):
271390
"""Materialize each enabled LoRA adapter into ``{nim_peft_source}/{adapter_ws}--{adapter_name}/``.
272391
@@ -297,8 +416,22 @@ def _update_lora_adapters(self, dirs_to_keep: set[str]):
297416
dir_name = f"{adapter_workspace}--{adapter.name}"
298417
dirs_to_keep.add(dir_name)
299418
adapter_dir = f"{self.nim_peft_source}/{dir_name}"
300-
if not os.path.isdir(adapter_dir) or self._adapter_changed(adapter_dir, adapter):
301-
self._download_adapter(adapter_dir, adapter, adapter_workspace)
419+
dir_existed = os.path.isdir(adapter_dir)
420+
needs_download = not dir_existed or self._adapter_changed(adapter_dir, adapter)
421+
published = False
422+
if needs_download:
423+
published = self._download_adapter(adapter_dir, adapter, adapter_workspace)
424+
# Eagerly register with vLLM so the adapter is advertised in /v1/models
425+
# without waiting for a first inference request (no-op for NIM). Gate on
426+
# the adapter actually being on disk: a brand-new adapter whose download
427+
# failed (e.g. empty fileset) has no directory to load, and a changed
428+
# adapter whose re-download failed keeps its previous weights, which must
429+
# not be unloaded. Only unload first when we just re-published over an
430+
# adapter we already had on disk (``published and dir_existed``); a new
431+
# adapter has nothing to unload, and an unchanged or download-failed one
432+
# is (idempotently) reloaded so it survives a vLLM restart without flapping.
433+
if os.path.isdir(adapter_dir):
434+
self._ensure_vllm_adapter_loaded(dir_name, adapter_dir, reload=(published and dir_existed))
302435

303436

304437
def get_health_status() -> dict:
@@ -370,4 +503,12 @@ def run(parent_stop_signal: threading.Event | None = None):
370503

371504

372505
if __name__ == "__main__":
506+
# Configure root logging so the controller's INFO progress (adapter
507+
# download/load, GC) is visible in `docker logs`. Without this only
508+
# WARNING+ reaches stderr via logging's last-resort handler, which made
509+
# the sidecar look idle during normal operation.
510+
logging.basicConfig(
511+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
512+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
513+
)
373514
run()

0 commit comments

Comments
 (0)