Skip to content

Commit 3e7eef8

Browse files
committed
feat: support in-place LoRA updates through sidecar
1 parent 517260c commit 3e7eef8

6 files changed

Lines changed: 174 additions & 39 deletions

File tree

lib/backend-common/src/engine.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,13 @@ pub trait LLMEngine: Send + Sync + 'static {
260260
Err(unsupported_lora())
261261
}
262262

263+
/// Replace one LoRA while preserving its public name and engine ID.
264+
/// The default preserves compatibility for engines that only implement
265+
/// the original load operation.
266+
async fn load_lora_inplace(&self, adapter: LoraAdapter) -> Result<LoraAdapter, DynamoError> {
267+
self.load_lora(adapter).await
268+
}
269+
263270
/// Unload one logical LoRA adapter by name.
264271
async fn unload_lora(&self, _name: &str) -> Result<LoraAdapter, DynamoError> {
265272
Err(unsupported_lora())

lib/backend-common/src/lora.rs

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ impl LoraController {
188188
.and_then(Value::as_str)
189189
.filter(|uri| !uri.trim().is_empty())
190190
.ok_or_else(|| "'source.uri' is required in request".to_string())?;
191+
let load_inplace = match request.get("load_inplace") {
192+
None => false,
193+
Some(value) => value
194+
.as_bool()
195+
.ok_or_else(|| "'load_inplace' must be a boolean".to_string())?,
196+
};
191197
validate_adapter_name(&self.reserved_names, &name)?;
192198

193199
let operation = self.operation_lock(&name);
@@ -214,7 +220,8 @@ impl LoraController {
214220
name: name.clone(),
215221
path: path.to_string_lossy().into_owned(),
216222
};
217-
if let Some(existing) = self.loaded.lock().await.get(&name).cloned() {
223+
let existing = { self.loaded.lock().await.get(&name).cloned() };
224+
if let Some(existing) = existing {
218225
if existing.adapter == adapter {
219226
if !existing.published {
220227
self.discovery
@@ -230,9 +237,41 @@ impl LoraController {
230237
}
231238
return Ok(success(&existing.adapter, "already loaded"));
232239
}
233-
return Err(format!(
234-
"LoRA adapter '{name}' conflicts with the loaded source path"
235-
));
240+
if !load_inplace {
241+
return Err(format!(
242+
"LoRA adapter '{name}' conflicts with the loaded source path"
243+
));
244+
}
245+
let adapter = self
246+
.engine
247+
.load_lora_inplace(adapter)
248+
.await
249+
.map_err(|error| error.to_string())?;
250+
let published = if existing.published {
251+
true
252+
} else {
253+
match self.discovery.attach(&adapter).await {
254+
Ok(()) => true,
255+
Err(error) => {
256+
self.loaded.lock().await.insert(
257+
name,
258+
ManagedLora {
259+
adapter,
260+
published: false,
261+
},
262+
);
263+
return Err(format!("failed to register replaced LoRA model: {error}"));
264+
}
265+
}
266+
};
267+
self.loaded.lock().await.insert(
268+
name,
269+
ManagedLora {
270+
adapter: adapter.clone(),
271+
published,
272+
},
273+
);
274+
return Ok(success(&adapter, "replaced successfully"));
236275
}
237276

238277
let adapter = self
@@ -488,6 +527,13 @@ mod tests {
488527
Ok(adapter)
489528
}
490529

530+
async fn load_lora_inplace(
531+
&self,
532+
adapter: LoraAdapter,
533+
) -> Result<LoraAdapter, DynamoError> {
534+
self.load_lora(adapter).await
535+
}
536+
491537
async fn unload_lora(&self, name: &str) -> Result<LoraAdapter, DynamoError> {
492538
self.events.lock().await.push(format!("unload:{name}"));
493539
if self.fail_unload.load(Ordering::SeqCst) {
@@ -664,6 +710,42 @@ mod tests {
664710
);
665711
}
666712

713+
#[tokio::test]
714+
async fn load_inplace_replaces_a_stable_name_with_a_new_source() {
715+
let root = tempfile::tempdir().unwrap();
716+
let first = root.path().join("step-1");
717+
let second = root.path().join("step-2");
718+
std::fs::create_dir(&first).unwrap();
719+
std::fs::create_dir(&second).unwrap();
720+
let engine = MockEngine::new(Vec::new());
721+
let discovery = Arc::new(MockDiscovery::default());
722+
let controller = LoraController::new_with(
723+
engine.clone(),
724+
discovery.clone(),
725+
Some(downloader(&root.path().join("cache"))),
726+
HashSet::new(),
727+
)
728+
.await
729+
.unwrap();
730+
731+
for (path, load_inplace) in [(&first, false), (&second, true)] {
732+
controller
733+
.load(json!({
734+
"lora_name": "adapter-a",
735+
"source": { "uri": format!("file://{}", path.display()) },
736+
"load_inplace": load_inplace,
737+
}))
738+
.await
739+
.unwrap();
740+
}
741+
742+
assert_eq!(
743+
engine.loaded.lock().await["adapter-a"].path,
744+
std::fs::canonicalize(second).unwrap().to_string_lossy()
745+
);
746+
assert_eq!(discovery.attached.lock().await.as_slice(), ["adapter-a"]);
747+
}
748+
667749
#[tokio::test]
668750
async fn load_rejects_base_model_name_before_engine_mutation() {
669751
let dir = tempfile::tempdir().unwrap();

lib/backend-common/src/rl.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@ pub(crate) fn serve_endpoint(
4444
}
4545
let endpoint_name = resolve_endpoint_name(&primary.id().name)?;
4646
let endpoint = primary.component().endpoint(endpoint_name);
47+
let system_url = self_host_base_url(primary.drt())?;
4748
let handler = Arc::new(RlRouteHandler {
4849
routes: primary.drt().engine_routes().clone(),
4950
metadata,
51+
system_url,
5052
});
5153
let ingress = Ingress::for_engine(handler.clone())?;
5254
let future = endpoint
@@ -61,6 +63,20 @@ pub(crate) fn serve_endpoint(
6163
})
6264
}
6365

66+
fn self_host_base_url(drt: &dynamo_runtime::DistributedRuntime) -> anyhow::Result<Option<String>> {
67+
let Some(info) = drt.system_status_server_info() else {
68+
return Ok(None);
69+
};
70+
let configured = dynamo_runtime::RuntimeConfig::from_settings()
71+
.unwrap_or_default()
72+
.system_host;
73+
let host = match configured.as_str() {
74+
"0.0.0.0" | "::" | "[::]" => dynamo_runtime::utils::local_ip_for_advertise(),
75+
_ => configured,
76+
};
77+
Ok(Some(format!("http://{host}:{}", info.port())))
78+
}
79+
6480
fn resolve_endpoint_name(primary_name: &str) -> anyhow::Result<String> {
6581
let endpoint_name =
6682
std::env::var("DYN_RL_ENDPOINT").unwrap_or_else(|_| DEFAULT_RL_ENDPOINT.into());
@@ -82,6 +98,7 @@ fn validate_endpoint_name(endpoint_name: &str, primary_name: &str) -> anyhow::Re
8298
struct RlRouteHandler {
8399
routes: EngineRouteRegistry,
84100
metadata: RlWorkerMetadata,
101+
system_url: Option<String>,
85102
}
86103

87104
impl RlRouteHandler {
@@ -104,17 +121,20 @@ impl RlRouteHandler {
104121
});
105122
}
106123

107-
let mut routes = self
108-
.routes
109-
.routes()
110-
.into_iter()
111-
.filter(|route| !route.contains('/'))
112-
.collect::<Vec<_>>();
124+
let mut routes = self.routes.routes().into_iter().collect::<Vec<_>>();
125+
routes.retain(|route| {
126+
!route.contains('/')
127+
|| matches!(
128+
route.as_str(),
129+
"update/load_lora" | "update/unload_lora" | "update/list_loras"
130+
)
131+
});
113132
routes.sort();
114133
routes.dedup();
115134
json!({
116135
"status": "ok",
117136
"routes": routes,
137+
"system_url": self.system_url,
118138
"admin_base_url": self.metadata.admin_base_url,
119139
"world_size": self.metadata.world_size,
120140
})
@@ -138,12 +158,18 @@ mod tests {
138158
use super::*;
139159

140160
fn handler() -> RlRouteHandler {
161+
let routes = EngineRouteRegistry::new();
162+
routes.register(
163+
"update/load_lora",
164+
Arc::new(|_| Box::pin(async { Ok(json!({"status": "ok"})) })),
165+
);
141166
RlRouteHandler {
142-
routes: EngineRouteRegistry::new(),
167+
routes,
143168
metadata: RlWorkerMetadata {
144169
admin_base_url: "http://worker:8120".to_string(),
145170
world_size: 2,
146171
},
172+
system_url: Some("http://worker:8181".to_string()),
147173
}
148174
}
149175

@@ -153,7 +179,8 @@ mod tests {
153179
handler().dispatch(&json!({"method": "routes"})),
154180
json!({
155181
"status": "ok",
156-
"routes": [],
182+
"routes": ["update/load_lora"],
183+
"system_url": "http://worker:8181",
157184
"admin_base_url": "http://worker:8120",
158185
"world_size": 2,
159186
})

lib/runtime/src/system_status_server.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ impl SystemStatusState {
104104
pub struct LoadLoraRequest {
105105
pub lora_name: String,
106106
pub source: LoraSource,
107+
#[serde(default)]
108+
pub load_inplace: bool,
107109
}
108110

109111
/// Source information for loading a LoRA
@@ -373,6 +375,7 @@ async fn load_lora_handler(
373375
"source": {
374376
"uri": request.source.uri
375377
},
378+
"load_inplace": request.load_inplace,
376379
}),
377380
)
378381
.await

lib/vllm-sidecar/proto/vllm_grpc.proto

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,10 @@ message LoraAdapter {
308308
string source_path = 3;
309309
}
310310

311-
message LoadLoraRequest { LoraAdapter adapter = 1; }
311+
message LoadLoraRequest {
312+
LoraAdapter adapter = 1;
313+
bool load_inplace = 2;
314+
}
312315
message LoadLoraResponse {
313316
LoraAdapter adapter = 1;
314317
bool already_loaded = 2;

lib/vllm-sidecar/src/engine.rs

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,40 @@ impl VllmSidecarEngine {
120120
}
121121
}
122122

123+
async fn load_lora_with_mode(
124+
&self,
125+
adapter: LoraAdapter,
126+
load_inplace: bool,
127+
) -> Result<LoraAdapter, DynamoError> {
128+
let mut client = self
129+
.pool
130+
.get()
131+
.map(Pool::control_client)
132+
.ok_or_else(|| client::engine_shutdown("load_lora called before start"))?;
133+
let requested = adapter.clone();
134+
let response = client
135+
.load_lora(pb::LoadLoraRequest {
136+
adapter: Some(pb::LoraAdapter {
137+
lora_id: adapter.id,
138+
lora_name: adapter.name,
139+
source_path: adapter.path,
140+
}),
141+
load_inplace,
142+
})
143+
.await
144+
.map_err(|status| client::status_to_dynamo("LoadLora", status))?
145+
.into_inner()
146+
.adapter
147+
.ok_or_else(|| client::engine_shutdown("LoadLora returned no adapter"))?;
148+
let loaded = lora_from_proto(response)?;
149+
if loaded != requested {
150+
return Err(client::engine_shutdown(
151+
"LoadLora returned an adapter identity different from the request",
152+
));
153+
}
154+
Ok(loaded)
155+
}
156+
123157
/// Parse CLI args, bootstrap-discover the engine role + model, and build the
124158
/// pair `run()` consumes.
125159
pub fn from_args(argv: Option<Vec<String>>) -> Result<(Self, WorkerConfig), DynamoError> {
@@ -448,32 +482,11 @@ impl LLMEngine for VllmSidecarEngine {
448482
}
449483

450484
async fn load_lora(&self, adapter: LoraAdapter) -> Result<LoraAdapter, DynamoError> {
451-
let mut client = self
452-
.pool
453-
.get()
454-
.map(Pool::control_client)
455-
.ok_or_else(|| client::engine_shutdown("load_lora called before start"))?;
456-
let requested = adapter.clone();
457-
let response = client
458-
.load_lora(pb::LoadLoraRequest {
459-
adapter: Some(pb::LoraAdapter {
460-
lora_id: adapter.id,
461-
lora_name: adapter.name,
462-
source_path: adapter.path,
463-
}),
464-
})
465-
.await
466-
.map_err(|status| client::status_to_dynamo("LoadLora", status))?
467-
.into_inner()
468-
.adapter
469-
.ok_or_else(|| client::engine_shutdown("LoadLora returned no adapter"))?;
470-
let loaded = lora_from_proto(response)?;
471-
if loaded != requested {
472-
return Err(client::engine_shutdown(
473-
"LoadLora returned an adapter identity different from the request",
474-
));
475-
}
476-
Ok(loaded)
485+
self.load_lora_with_mode(adapter, false).await
486+
}
487+
488+
async fn load_lora_inplace(&self, adapter: LoraAdapter) -> Result<LoraAdapter, DynamoError> {
489+
self.load_lora_with_mode(adapter, true).await
477490
}
478491

479492
async fn unload_lora(&self, name: &str) -> Result<LoraAdapter, DynamoError> {

0 commit comments

Comments
 (0)