Skip to content

Commit d5e024a

Browse files
authored
Merge pull request #774 from superlinked/codex/issue-762-sglang-runtime-lora
feat(server): load declared SGLang LoRAs on demand
2 parents 426ba9a + ae6064c commit d5e024a

42 files changed

Lines changed: 4311 additions & 188 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/sie_sdk/src/sie_sdk/types.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,9 @@ class ModelInfo(TypedDict, total=False):
435435
profiles: dict[str, ProfileInfo]
436436
"""Servable profiles keyed by name; address one as ``"model@profile"``."""
437437

438+
lora_adapters: list[str]
439+
"""Single-server ready LoRA names; absent on gateways (not fleet residency)."""
440+
438441
capabilities: ModelCapabilities | None
439442
"""``None`` for models that declare no ``generate`` task."""
440443

‎packages/sie_server/README.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,6 +1159,72 @@ auto-retries; see `packages/sie_sdk/README.md` for client-side controls.
11591159
| `SIE_MAX_LORAS_PER_MODEL` | `10` | Maximum concurrent LoRA adapters per base model. A request loads or selects only the adapters its selected profile declares. `options.lora` or `options.lora_id` naming any other is rejected with HTTP 400: `INVALID_INPUT` from this server, and `INVALID_REQUEST` from the gateway (code `unknown_lora_adapter` where it answers in the OpenAI error envelope). |
11601160
| `SIE_MAX_ITEM_TEXT_BYTES` | `2097152` (2 MiB) | Most bytes of UTF-8 one encode, score, or extract item may carry in its `text` and `metadata` together. A larger item is rejected with `INVALID_INPUT` (HTTP 400) before it is tokenized. Generation prompts are not items and are not affected. |
11611161

1162+
SGLang generation and embedding profiles can defer declared LoRAs until a
1163+
request selects them. Keep one source allowlist in `lora_paths` and use
1164+
`preload_loras` to select startup adapters:
1165+
1166+
```yaml
1167+
adapter_options:
1168+
loadtime:
1169+
mem_fraction_static: 0.80
1170+
lora_paths:
1171+
support:
1172+
id: acme/support-lora
1173+
revision: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # replace with the repository's immutable commit
1174+
legal: acme/legal-lora
1175+
preload_loras: []
1176+
max_lora_rank: 16
1177+
lora_target_modules: [q_proj, v_proj]
1178+
max_loras_per_batch: 2
1179+
```
1180+
1181+
Omitting `preload_loras` keeps the existing behavior: every declared source
1182+
loads at startup and remains pinned in SIE. An empty list defers every source;
1183+
a nonempty list preloads and pins only those served names. Names must be unique
1184+
members of the selected profile's map. Profile `loadtime` blocks use full
1185+
replacement when a child supplies one, so restate the source map and its other
1186+
options when changing only a child's preload selection.
1187+
1188+
Deferred adapters require the maximum rank and target modules at engine launch
1189+
because SGLang allocates its LoRA buffers then. LoRA profiles require an explicit
1190+
memory fraction. For profiles with deferred adapters, the resolved
1191+
`max_loras_per_model` engine setting (normally `SIE_MAX_LORAS_PER_MODEL`)
1192+
bounds resident adapters, including pinned preloads
1193+
and in-flight loads; `max_loras_per_batch` and the preload count cannot exceed
1194+
it. When full, SIE drains and evicts the least-recently-used unpinned adapter. An
1195+
adapter being loaded or drained retains its slot, and all-pinned/full capacity
1196+
returns a retry response. Unconfirmed attach rollback or unload retains the
1197+
slot and requires a model reload rather than risking unbounded residency.
1198+
Startup-only profiles retain every declared adapter: their effective capacity
1199+
is at least the pinned source count and `max_loras_per_batch`, even when the
1200+
configured runtime limit is lower.
1201+
1202+
Generation and chat requests select a selected-profile declared name with
1203+
`lora_adapter` (a served alias or its declared source id);
1204+
embedding requests use `options.lora` (or a declared source `options.lora_id`).
1205+
The first request for a cold adapter returns HTTP 503 `LORA_LOADING` with
1206+
`Retry-After`; retry after loading finishes. Source files are downloaded outside
1207+
the inference mutation lock, while attachment may briefly pause SGLang's
1208+
scheduler. Requests cannot supply a new repository or override its configured
1209+
revision. Runtime adapters must contain ordinary LoRA A/B safetensors, without
1210+
base-weight transforms, saved base modules, variable rank/alpha patterns,
1211+
rsLoRA, or DoRA.
1212+
1213+
Generation streams keep independent adapter leases and can run concurrently
1214+
through SGLang's continuous batcher. Eviction refuses new leases and waits for
1215+
active streams to finish. Embedding dispatch remains serialized by selected
1216+
LoRA; this change does not introduce a mixed-adapter embedding forward. Deferred
1217+
LoRA profiles require one tokenizer worker and one data-parallel worker;
1218+
LoRA generation on those profiles supports `n=1` and `best_of=1` because pinned
1219+
upstream versions have unsafe multi-candidate unload accounting. SGLang
1220+
0.5.10.post1 uses SIE's scoped GPU-slot release compatibility hook; 0.5.20
1221+
already implements that cleanup natively.
1222+
1223+
The single-server `/v1/models` response includes top-level `lora_adapters` with
1224+
ready served names, excluding adapters being loaded or evicted. Gateways omit
1225+
this worker-local snapshot. Their `capabilities.lora_adapters` continues to
1226+
advertise the declared allowlist, including cold adapters.
1227+
11621228
### Compute & precision
11631229

11641230
| Env var | Default | Effect |

‎packages/sie_server/openapi.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2141,6 +2141,13 @@
21412141
"title": "Profiles",
21422142
"default": {}
21432143
},
2144+
"lora_adapters": {
2145+
"items": {
2146+
"type": "string"
2147+
},
2148+
"type": "array",
2149+
"title": "Lora Adapters"
2150+
},
21442151
"revision": {
21452152
"anyOf": [
21462153
{

‎packages/sie_server/src/sie_server/adapters/_generation_base.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ def __init__(
125125
self.upstream_reason = upstream_reason
126126

127127

128+
class GenerationLoraLoadingError(GenerationCapacityError):
129+
"""A selected LoRA is loading or draining for eviction."""
130+
131+
code = "LORA_LOADING"
132+
133+
def __init__(self) -> None:
134+
super().__init__("The selected LoRA is loading; retry the request", retry_after_s=1)
135+
136+
128137
class GenerationDrainingError(GenerationCapacityError):
129138
"""The generation backend is draining and cannot accept new work."""
130139

‎packages/sie_server/src/sie_server/adapters/base.py‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sie_server.types.inputs import Item
1010

1111
if TYPE_CHECKING:
12-
from sie_server.adapters.lora import LoraCapability
12+
from sie_server.adapters.lora import LoraCapability, LoraRuntimeConfig
1313
from sie_server.core.inference_output import EncodeOutput, ExtractOutput, ScoreOutput
1414

1515
logger = logging.getLogger(__name__)
@@ -572,30 +572,27 @@ def lora_capability(self) -> "LoraCapability | None":
572572
LoRA, else ``None``.
573573
574574
One accessor callers use in place of checking :meth:`supports_lora` and
575-
then calling the individual hooks. PEFT (in-process) is the adapter
576-
behind this capability; an adapter that cannot load a LoRA at runtime
577-
returns ``None``.
575+
then calling the individual hooks. PEFT and SGLang implement this
576+
capability; adapters that cannot load a LoRA at runtime return ``None``.
578577
"""
579578
return self if self.supports_lora() else None
580579

581580
def supports_hot_lora_reload(self) -> bool:
582-
"""Return True if LoRAs can be loaded without blocking inference.
581+
"""Return True for background fetch and immediate ``LORA_LOADING`` retry.
583582
584-
- PEFT-based adapters: True (can load in thread pool)
585-
- SGLang adapters: False (HTTP API blocks during load)
586-
587-
This affects whether the server returns 503 LORA_LOADING during
588-
loading or blocks all requests until loading completes.
589-
590-
Returns:
591-
True for non-blocking LoRA loading, False for blocking.
583+
Attachment still runs under the adapter's mutation guard and can
584+
briefly pause inference. False waits for loading in the caller.
592585
"""
593586
return False
594587

588+
def lora_runtime_config(self) -> "LoraRuntimeConfig | None":
589+
"""Describe engine-owned startup LoRAs, or use the ordinary PEFT preload path."""
590+
return None
591+
595592
def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
596593
"""Bring a LoRA adapter's files to one local directory, outside inference.
597594
598-
Hot-reload adapters (PEFT) download and validate here, so that
595+
Runtime LoRA adapters download and validate here, so that
599596
:meth:`load_lora` only attaches from local files.
600597
601598
Args:
@@ -611,6 +608,9 @@ def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
611608
msg = f"{self.__class__.__name__} does not support LoRA (supports_lora=False)"
612609
raise NotImplementedError(msg)
613610

611+
def discard_lora_source(self, lora_path: str) -> None:
612+
"""Release an unused temporary source view; ordinary shared caches stay intact."""
613+
614614
def load_lora(self, lora_path: str, revision: str | None = None, *, source: str | None = None) -> int:
615615
"""Load a LoRA adapter.
616616

‎packages/sie_server/src/sie_server/adapters/lora.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,35 @@
33
Groups the LoRA lifecycle an adapter exposes when it supports LoRA into one
44
typed capability, so callers ask ``adapter.lora_capability()`` once and use the
55
returned handle instead of flag-checking ``supports_lora()`` and then calling
6-
the individual hooks. The PEFT mixin (in-process, hot-reloadable) implements
7-
this capability. An adapter whose engine loads its LoRAs at launch, such as the
8-
SGLang embedding adapter, selects them by served name and returns ``None``.
6+
the individual hooks. PEFT and SGLang generation/embedding implement this
7+
capability. SGLang additionally describes engine-owned aliases and pinned
8+
startup residency; adapters without runtime loading return ``None``.
99
"""
1010

1111
from __future__ import annotations
1212

13+
from dataclasses import dataclass
1314
from typing import Protocol
1415

1516

17+
@dataclass(frozen=True)
18+
class LoraRuntimeConfig:
19+
"""Engine-owned aliases and startup residency, keyed by declared source id."""
20+
21+
aliases: dict[str, str]
22+
preloaded: frozenset[str]
23+
pinned: frozenset[str]
24+
max_loaded: int
25+
26+
27+
class LoraStateUncertainError(RuntimeError):
28+
"""A mutation could not be rolled back; retain its capacity until model reload."""
29+
30+
31+
class LoraUnavailableError(ValueError):
32+
"""The declared adapter is absent or is draining for eviction."""
33+
34+
1635
class LoraCapability(Protocol):
1736
"""The LoRA lifecycle exposed by a LoRA-capable adapter.
1837
@@ -22,8 +41,11 @@ class LoraCapability(Protocol):
2241
"""
2342

2443
def supports_hot_lora_reload(self) -> bool:
25-
"""True if LoRAs can be loaded without blocking inference (PEFT), False
26-
if the load blocks all requests (SGLang HTTP).
44+
"""True when fetch can run in the background before a serialized attach.
45+
46+
Attach may still briefly pause an engine's scheduler. This flag enables
47+
the immediate LORA_LOADING response; it does not promise zero impact
48+
on concurrent inference.
2749
"""
2850

2951
def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
@@ -32,6 +54,9 @@ def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
3254
Hot-reload adapters do their network and disk I/O here, outside inference.
3355
"""
3456

57+
def discard_lora_source(self, lora_path: str) -> None:
58+
"""Release temporary fetched files only if no attached adapter can use them."""
59+
3560
def load_lora(self, lora_path: str, revision: str | None = None, *, source: str | None = None) -> int:
3661
"""Load a LoRA adapter (optionally at a pinned 40-hex commit SHA), from
3762
``source`` when :meth:`fetch_lora` already brought it local; return its

‎packages/sie_server/src/sie_server/adapters/peft_lora_mixin.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,9 @@ def unload_lora(self, lora_name: str) -> None:
443443
if self._active_lora == lora_name:
444444
self.set_active_lora(None)
445445

446+
# PEFT's get_base_model consults active_peft_config, which disappears
447+
# when delete_adapter removes the final adapter. Capture it first.
448+
base_model = self._peft_model.get_base_model()
446449
# Delete the adapter
447450
self._peft_model.delete_adapter(self._get_peft_adapter_name(lora_name))
448451
self._loaded_loras.discard(lora_name)
@@ -451,8 +454,7 @@ def unload_lora(self, lora_name: str) -> None:
451454
# If no LoRAs remain, unwrap the model
452455
if not self._loaded_loras:
453456
logger.debug("No LoRAs remaining, unwrapping PeftModel")
454-
# Get the base model back
455-
base_model = self._peft_model.get_base_model()
457+
_unwrap_lora_layers(base_model)
456458
self._model = base_model
457459
del self._peft_model
458460
self._peft_model = None
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Release LoRA GPU slots when the pinned SGLang 0.5.10 engine unloads an adapter.
2+
3+
Upstream issue https://github.com/sgl-project/sglang/issues/21380 describes the
4+
missing cleanup: CPU weights disappear, but the GPU pool keeps the old UID.
5+
Repeated load/unload cycles then exhaust the pool. The CUDA 13 bundle's
6+
SGLang 0.5.20 already implements this cleanup and must keep its native path.
7+
8+
Only the SGLang child opts into this hook. It waits for the engine's normal
9+
import rather than importing CUDA or SGLang during Python site initialization.
10+
The engine already drains requests before calling the method wrapped here.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import builtins
16+
import importlib.metadata
17+
import logging
18+
import sys
19+
from collections.abc import Mapping, Sequence
20+
from functools import wraps
21+
from types import ModuleType
22+
from typing import Any
23+
24+
_AFFECTED_VERSIONS = frozenset({"0.5.10", "0.5.10.post1"})
25+
_MANAGER_MODULE = "sglang.srt.lora.lora_manager"
26+
_POOL_MODULE = "sglang.srt.lora.mem_pool"
27+
_CLASS_MARKER = "_sie_lora_slot_release_compat"
28+
_IMPORT_MARKER = "_sie_lora_slot_release_deferred"
29+
logger = logging.getLogger(__name__)
30+
31+
32+
def _release_slot(pool: Any, lora_id: str, empty_slot: Any) -> None:
33+
"""Forget one drained adapter, preserving the base model's ``None`` UID."""
34+
if lora_id is None:
35+
raise ValueError("a LoRA unload must never release the base model slot")
36+
slot = pool.uid_to_buffer_id.get(lora_id)
37+
if slot is not None and pool.buffer_id_to_uid[slot] != lora_id:
38+
raise RuntimeError("LoRA GPU slot mapping is inconsistent")
39+
# The policy can remember an adapter even when it is not currently in a
40+
# GPU slot. remove() is idempotent in both pinned upstream policies.
41+
pool.eviction_policy.remove(lora_id)
42+
if slot is not None:
43+
pool.uid_to_buffer_id.pop(lora_id)
44+
pool.buffer_id_to_uid[slot] = empty_slot
45+
46+
47+
def _patch_manager_module(module: ModuleType) -> None:
48+
manager = getattr(module, "LoRAManager", None)
49+
if manager is None:
50+
raise RuntimeError("SGLang does not expose the expected LoRAManager")
51+
if getattr(manager, _CLASS_MARKER, False):
52+
return
53+
pool_module = sys.modules.get(_POOL_MODULE)
54+
if pool_module is None or not hasattr(pool_module, "EMPTY_SLOT"):
55+
raise RuntimeError("SGLang does not expose the expected LoRA pool sentinel")
56+
empty_slot = pool_module.EMPTY_SLOT
57+
original_unload = manager.unload_lora_adapter
58+
59+
@wraps(original_unload)
60+
def unload_with_slot_release(self: Any, lora_ref: Any) -> Any:
61+
# Do not alter a pool for an upstream operation that did not succeed.
62+
result = original_unload(self, lora_ref)
63+
if result.success:
64+
try:
65+
_release_slot(self.memory_pool, lora_ref.lora_id, empty_slot)
66+
except Exception:
67+
logger.exception("SIE could not release an unloaded LoRA GPU slot")
68+
return self.create_lora_update_result(
69+
success=False,
70+
error_message="LoRA GPU slot cleanup failed; reload this model before loading more adapters",
71+
)
72+
return result
73+
74+
manager.unload_lora_adapter = unload_with_slot_release
75+
setattr(manager, _CLASS_MARKER, True)
76+
77+
78+
def install_lora_compat() -> bool:
79+
"""Install the exact-version backport; return False for unaffected engines."""
80+
try:
81+
version = importlib.metadata.version("sglang")
82+
except importlib.metadata.PackageNotFoundError:
83+
return False
84+
if version not in _AFFECTED_VERSIONS:
85+
return False
86+
loaded = sys.modules.get(_MANAGER_MODULE)
87+
if isinstance(loaded, ModuleType) and hasattr(loaded, "LoRAManager"):
88+
_patch_manager_module(loaded)
89+
return True
90+
91+
current_import = builtins.__import__
92+
if getattr(current_import, _IMPORT_MARKER, False):
93+
return True
94+
95+
def deferred_import(
96+
name: str,
97+
globals: Mapping[str, object] | None = None,
98+
locals: Mapping[str, object] | None = None,
99+
fromlist: Sequence[str] | None = (),
100+
level: int = 0,
101+
) -> ModuleType:
102+
module = current_import(name, globals, locals, fromlist, level)
103+
loaded_module = sys.modules.get(_MANAGER_MODULE)
104+
if not isinstance(loaded_module, ModuleType) or not hasattr(loaded_module, "LoRAManager"):
105+
return module
106+
try:
107+
_patch_manager_module(loaded_module)
108+
finally:
109+
if builtins.__import__ is deferred_import:
110+
setattr(builtins, "__import__", current_import) # noqa: B010
111+
return module
112+
113+
setattr(deferred_import, _IMPORT_MARKER, True)
114+
setattr(builtins, "__import__", deferred_import) # noqa: B010
115+
return True

‎packages/sie_server/src/sie_server/adapters/sglang/_compat/sitecustomize.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
When the adapter sets ``SIE_SGLANG_JSON_NUMBER_MAX_DIGITS``, the hook also
2323
bounds the digit runs of numbers in XGrammar's JSON-schema grammars (see the
2424
section at the end of this module).
25+
26+
``SIE_SGLANG_LORA_COMPAT`` enables the exact-version LoRA GPU slot release
27+
backport needed for runtime eviction on the pinned SGLang 0.5.10 engine.
2528
"""
2629

2730
from __future__ import annotations
@@ -152,6 +155,9 @@ def deferred_import(
152155
if os.environ.get("SIE_SGLANG_MM_PROCESS_CONFIG_COMPAT") == "1":
153156
_install_mm_process_config_compat()
154157

158+
if os.environ.get("SIE_SGLANG_LORA_COMPAT") == "1":
159+
importlib.import_module("sie_lora_compat").install_lora_compat()
160+
155161

156162
# --- JSON number digit bound ---------------------------------------------
157163
#

0 commit comments

Comments
 (0)