LoRA 서빙
LoRA 서빙 (LoRA Serving)
SGLang은 base model과 함께 LoRA 어댑터를 사용할 수 있게 해줍니다. S-LoRA와 Punica의 기법을 통합해, 단일 입력 배치 내의 서로 다른 시퀀스에 여러 LoRA 어댑터를 효율적으로 지원합니다.
출처: 문서
본문
SGLang은 base model과 함께 LoRA 어댑터를 사용할 수 있게 합니다. S-LoRA와 Punica의 기법을 통합해, SGLang은 단일 입력 배치 내의 서로 다른 시퀀스에 대해 여러 LoRA 어댑터를 효율적으로 지원할 수 있습니다.
LoRA 서빙용 인자 (Arguments for LoRA Serving)
다음 서버 인자들이 multi-LoRA 서빙과 관련됩니다:
-
enable_lora: 모델에 LoRA 지원 활성화. 하위 호환을 위해--lora-paths가 제공되면 자동으로 True로 설정됩니다. -
enable_lora_overlap_loading: 비동기 LoRA 가중치 로딩을 활성화해 H2D 전송을 GPU 계산과 겹칩니다. LoRA 워크로드가 어댑터 가중치 로딩에 병목이 될 때(예: 대형 LoRA 어댑터를 자주 로드할 때) 활성화해야 합니다. -
lora_paths: 로드할 LoRA 어댑터 목록. 각 어댑터는 다음 형식 중 하나로 지정해야 합니다:<PATH> | <NAME>=<PATH> | 스키마{"lora_name":str,"lora_path":str,"pinned":bool}`의 JSON. -
max_loras_per_batch: 각 배치가 사용하는 어댑터 최대 수. 이 인자는 multi-LoRA 서빙을 위해 예약되는 GPU 메모리 양에 영향을 주므로, 메모리가 부족할 때는 더 작은 값으로 설정해야 합니다. 기본값 8. -
max_loaded_loras: 지정하면 CPU 메모리에 한 번에 로드되는 LoRA 어댑터 최대 수를 제한합니다. 값은max-loras-per-batch보다 크거나 같아야 합니다. -
lora_eviction_policy: GPU 메모리 풀이 가득 찼을 때의 LoRA 어댑터 축출 정책.lru: Least Recently Used (기본, 더 나은 캐시 효율).fifo: First-In-First-Out. -
lora_backend: LoRA 모듈의 GEMM 커널 실행 백엔드. 현재 Triton LoRA 백엔드(triton)와 Chunked SGMV 백엔드(csgmv)를 지원합니다. 향후 Cutlass 또는 Cuda 커널 기반의 더 빠른 백엔드가 추가될 예정입니다. -
max_lora_rank: 지원해야 하는 최대 LoRA rank. 지정하지 않으면--lora-paths에 제공된 어댑터에서 자동 추론됩니다. 서버 시작 후 더 큰 LoRA rank의 어댑터를 동적으로 로드할 계획이라면 이 인자가 필요합니다. -
lora_target_modules: LoRA가 적용되어야 하는 모든 대상 모듈의 합집합(예:q_proj,k_proj,gate_proj). 지정하지 않으면--lora-paths에 제공된 어댑터에서 자동 추론됩니다. 서버 시작 후 다른 대상 모듈의 어댑터를 동적으로 로드할 계획이라면 이 인자가 필요합니다.all로 설정해 모든 지원 모듈에 LoRA를 활성화할 수도 있습니다. 단, 추가 모듈에 LoRA를 활성화하면 약간의 성능 오버헤드가 있습니다. 애플리케이션이 성능에 민감하다면 어댑터를 로드할 모듈만 지정하는 것을 권장합니다. -
max_lora_chunk_size: ChunkedSGMV LoRA 백엔드의 최대 청크 크기.--lora-backend가 'csgmv'일 때만 사용됩니다. 더 큰 값을 고르면 성능이 향상될 수 있습니다. 하드웨어와 워크로드에 따라 이 값을 조정하세요. 기본값 16. -
lora_drain_wait_threshold: 어떤 LoRA 어댑터 요청이라도 이 임계값(초)보다 오래 기다리면, 스케줄러가 공간을 만들기 위해 실행 중인 어댑터 하나를 선택적으로 배출(drain)합니다. 이는 소수의 어댑터가 배치 슬롯을 독점하는 것을 막아 높거나 왜곡된 워크로드에서 극단적인 테일 지연을 완화합니다. 배출을 비활성화하려면 0으로 설정(기본). -
tp_size: SGLang은 Tensor Parallelism과 함께 LoRA 서빙을 지원합니다.tp_size는 텐서 병렬의 GPU 수를 제어합니다. 텐서 샤딩 전략의 자세한 내용은 S-Lora 논문에서 찾을 수 있습니다.
클라이언트 쪽에서는 입력 배치로 문자열 리스트를, 각 입력 시퀀스에 대응하는 어댑터 이름 리스트를 제공해야 합니다.
사용법 (Usage)
단일 어댑터 서빙 (Serving Single Adaptor)
참고: SGLang은 LoRA 어댑터를 두 가지 API로 지원합니다:
-
OpenAI 호환 API (
/v1/chat/completions,/v1/completions):model:adapter-name구문 사용. 예시는 OpenAI API with LoRA 참고. -
네이티브 API (
/generate): 요청 본문에lora_path전달(아래에 표시).
import json
import requests
from sglang.test.doc_patch import launch_server_cmd
from sglang.utils import wait_for_server, terminate_process
server_process, port = launch_server_cmd(
# Here we set max-loras-per-batch to 2: one slot for adaptor and another one for base model
"""
python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3.1-8B-Instruct \
--enable-lora \
--lora-paths lora0=algoprog/fact-generation-llama-3.1-8b-instruct-lora \
--max-loras-per-batch 2 \
--log-level warning \
"""
)
wait_for_server(f"http://localhost:{port}")
url = f"http://127.0.0.1:{port}"
json_data = {
"text": [
"List 3 countries and their capitals.",
"List 3 countries and their capitals.",
],
"sampling_params": {"max_new_tokens": 32, "temperature": 0},
# The first input uses lora0, and the second input uses the base model
"lora_path": ["lora0", None],
}
response = requests.post(
url + "/generate",
json=json_data,
)
print(f"Output 0: {response.json()[0]['text']}")
print(f"Output 1: {response.json()[1]['text']}")
terminate_process(server_process)
다중 어댑터 서빙 (Serving Multiple Adaptors)
server_process, port = launch_server_cmd(
"""
python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3.1-8B-Instruct \
--enable-lora \
--lora-paths lora0=algoprog/fact-generation-llama-3.1-8b-instruct-lora \
lora1=Nutanix/Meta-Llama-3.1-8B-Instruct_SFT_lora_4_alpha_16_humaneval_raw_json \
--max-loras-per-batch 2 \
--log-level warning \
"""
)
wait_for_server(f"http://localhost:{port}")
url = f"http://127.0.0.1:{port}"
json_data = {
"text": [
"List 3 countries and their capitals.",
"List 3 countries and their capitals.",
],
"sampling_params": {"max_new_tokens": 32, "temperature": 0},
# The first input uses lora0, and the second input uses lora1
"lora_path": ["lora0", "lora1"],
}
response = requests.post(
url + "/generate",
json=json_data,
)
print(f"Output 0: {response.json()[0]['text']}")
print(f"Output 1: {response.json()[1]['text']}")
terminate_process(server_process)
동적 LoRA 로딩 (Dynamic LoRA loading)
--lora-paths로 서버 시작 시 모든 어댑터를 지정하는 대신, /load_lora_adapter와 /unload_lora_adapter API로 LoRA 어댑터를 동적으로 로드·언로드할 수도 있습니다.
동적 LoRA 로딩을 사용할 때는 시작 시 --max-lora-rank와 --lora-target-modules를 명시적으로 지정하는 것이 권장됩니다. 하위 호환을 위해 SGLang은 명시적으로 제공되지 않으면 --lora-paths에서 이 값들을 추론합니다. 그러나 그 경우 모든 동적으로 로드되는 어댑터가 초기 --lora-paths의 어댑터와 같은 형태(rank와 target modules)를 공유하거나 strictly "smaller"임을 보장해야 합니다.
lora0 = "Nutanix/Meta-Llama-3.1-8B-Instruct_SFT_lora_4_alpha_16_humaneval_raw_json" # rank - 4, target modules - q_proj, k_proj, v_proj, o_proj, gate_proj
lora1 = "algoprog/fact-generation-llama-3.1-8b-instruct-lora" # rank - 64, target modules - q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj
lora0_new = "philschmid/code-llama-3-1-8b-text-to-sql-lora" # rank - 256, target modules - q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj
# The `--target-lora-modules` param below is technically not needed, as the server will infer it from lora0 which already has all the target modules specified.
# We are adding it here just to demonstrate usage.
server_process, port = launch_server_cmd(
"""
python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3.1-8B-Instruct \
--enable-lora \
--cuda-graph-max-bs-decode 2 \
--max-loras-per-batch 2 \
--max-lora-rank 256
--lora-target-modules all
--log-level warning
"""
)
url = f"http://127.0.0.1:{port}"
wait_for_server(url)
어댑터 lora0 로드
response = requests.post(
url + "/load_lora_adapter",
json={
"lora_name": "lora0",
"lora_path": lora0,
},
)
if response.status_code == 200:
print("LoRA adapter loaded successfully.", response.json())
else:
print("Failed to load LoRA adapter.", response.json())
어댑터 lora1 로드:
response = requests.post(
url + "/load_lora_adapter",
json={
"lora_name": "lora1",
"lora_path": lora1,
},
)
if response.status_code == 200:
print("LoRA adapter loaded successfully.", response.json())
else:
print("Failed to load LoRA adapter.", response.json())
추론 출력 확인:
url = f"http://127.0.0.1:{port}"
json_data = {
"text": [
"List 3 countries and their capitals.",
"List 3 countries and their capitals.",
],
"sampling_params": {"max_new_tokens": 32, "temperature": 0},
# The first input uses lora0, and the second input uses lora1
"lora_path": ["lora0", "lora1"],
}
response = requests.post(
url + "/generate",
json=json_data,
)
print(f"Output from lora0: \n{response.json()[0]['text']}\n")
print(f"Output from lora1 (updated): \n{response.json()[1]['text']}\n")
lora0 언로드 후 다른 어댑터로 교체:
response = requests.post(
url + "/unload_lora_adapter",
json={
"lora_name": "lora0",
},
)
response = requests.post(
url + "/load_lora_adapter",
json={
"lora_name": "lora0",
"lora_path": lora0_new,
},
)
if response.status_code == 200:
print("LoRA adapter loaded successfully.", response.json())
else:
print("Failed to load LoRA adapter.", response.json())
출력 다시 확인:
url = f"http://127.0.0.1:{port}"
json_data = {
"text": [
"List 3 countries and their capitals.",
"List 3 countries and their capitals.",
],
"sampling_params": {"max_new_tokens": 32, "temperature": 0},
# The first input uses lora0, and the second input uses lora1
"lora_path": ["lora0", "lora1"],
}
response = requests.post(
url + "/generate",
json=json_data,
)
print(f"Output from lora0: \n{response.json()[0]['text']}\n")
print(f"Output from lora1 (updated): \n{response.json()[1]['text']}\n")
terminate_process(server_process)
OpenAI 호환 API 사용 (OpenAI-compatible API usage)
OpenAI 호환 API에서도 model 필드에 base-model:adapter-name 구문(예: qwen/qwen2.5-0.5b-instruct:adapter_a)으로 어댑터를 지정해 LoRA 어댑터를 사용할 수 있습니다. 자세한 내용과 예시는 OpenAI API 문서의 "Using LoRA Adapters" 섹션을 참고하세요: openai_api_completions.
LoRA GPU 핀 (LoRA GPU Pinning)
또 다른 고급 옵션은 로딩 중 어댑터를 pinned로 지정하는 것입니다. 어댑터가 pin되면 사용 가능한 GPU 풀 슬롯(--max-loras-per-batch로 구성) 중 하나에 영구히 할당되어 런타임 중 GPU 메모리에서 축출되지 않습니다. 대신 명시적으로 언로드될 때까지 상주합니다.
이것은 같은 어댑터가 여러 요청에서 자주 사용되는 시나리오에서 반복되는 메모리 전송과 재초기화 오버헤드를 피해 성능을 개선할 수 있습니다. 그러나 GPU 풀 슬롯이 제한적이므로, 어댑터를 pin하면 다른 어댑터를 주문형으로 동적으로 로드하는 시스템 유연성이 줄어듭니다. 너무 많은 어댑터가 pin되면 성능 저하로 이어질 수 있고, 극단적인 경우(Number of pinned adapters == max-loras-per-batch) 모든 unpinned 요청이 중단될 수 있습니다. 따라서 SGLang은 현재 최대 pinned 어댑터 수를 max-loras-per-batch - 1로 제한해 예상치 못한 기아(starvation)를 방지합니다.
아래 예시에서 우리는 lora1을 pinned로, lora2와 lora3를 일반(unpinned) 어댑터로 로드해 서버를 시작합니다. lora2와 lora3를 의도적으로 서로 다른 두 형식으로 지정해 둘 다 지원됨을 보여줍니다.
server_process, port = launch_server_cmd(
"""
python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3.1-8B-Instruct \
--enable-lora \
--cuda-graph-max-bs-decode 8 \
--max-loras-per-batch 3 \
--max-lora-rank 256 \
--lora-target-modules all \
--lora-paths \
{"lora_name":"lora0","lora_path":"Nutanix/Meta-Llama-3.1-8B-Instruct_SFT_lora_4_alpha_16_humaneval_raw_json","pinned":true} \
{"lora_name":"lora1","lora_path":"algoprog/fact-generation-llama-3.1-8b-instruct-lora"} \
lora2=philschmid/code-llama-3-1-8b-text-to-sql-lora
--log-level warning
"""
)
url = f"http://127.0.0.1:{port}"
wait_for_server(url)
동적 어댑터 로딩 중에도 어댑터를 pinned로 지정할 수 있습니다. 아래 예시에서 lora2를 pinned 어댑터로 다시 로드합니다:
response = requests.post(
url + "/unload_lora_adapter",
json={
"lora_name": "lora1",
},
)
response = requests.post(
url + "/load_lora_adapter",
json={
"lora_name": "lora1",
"lora_path": "algoprog/fact-generation-llama-3.1-8b-instruct-lora",
"pinned": True, # Pin the adapter to GPU
},
)
결과가 예상과 같은지 확인:
url = f"http://127.0.0.1:{port}"
json_data = {
"text": [
"List 3 countries and their capitals.",
"List 3 countries and their capitals.",
"List 3 countries and their capitals.",
],
"sampling_params": {"max_new_tokens": 32, "temperature": 0},
# The first input uses lora0, and the second input uses lora1
"lora_path": ["lora0", "lora1", "lora2"],
}
response = requests.post(
url + "/generate",
json=json_data,
)
print(f"Output from lora0 (pinned): \n{response.json()[0]['text']}\n")
print(f"Output from lora1 (pinned): \n{response.json()[1]['text']}\n")
print(f"Output from lora2 (not pinned): \n{response.json()[2]['text']}\n")
terminate_process(server_process)
LoRA 백엔드 선택 (Choosing LoRA Backend)
SGLang은 --lora-backend 인자로 고를 수 있는 두 가지 LoRA 백엔드를 지원합니다:
triton: 기본 Triton 기반 백엔드.csgmv: 고동시성 시나리오에 최적화된 기본 chunked SGMV 백엔드.
csgmv 백엔드는 특히 고동시성 시나리오에서 성능을 개선하기 위해 최근 도입되었습니다. 우리 벤치마크에 따르면 기본 triton 백엔드보다 20%~80%의 지연 개선을 달성합니다.
server_process, port = launch_server_cmd(
"""
python3 -m sglang.launch_server \
--model-path meta-llama/Meta-Llama-3.1-8B-Instruct \
--enable-lora \
--lora-backend csgmv \
--max-loras-per-batch 16 \
--lora-paths lora1=path/to/lora1 lora2=path/to/lora2
"""
)
terminate_process(server_process)
LoRA 오버랩 로딩 (LoRA Overlap Loading)
--enable-lora-overlap-loading 서버 인자를 사용하면 SGLang 엔진이 LoRA 가중치 로딩을 prefill·decode 계산과 겹칠 수 있어, 본질적으로 LoRA 가중치의 데이터 이동을 GPU 계산 뒤에 숨깁니다. 벤치마크에 따르면 불리한 조건에서 이 기능을 활성화하면 중앙값 TTFT가 약 ~35% 감소합니다. (자세한 벤치마크는 LoRA overlap loading PR 참고).
lora0 = "Nutanix/Meta-Llama-3.1-8B-Instruct_SFT_lora_4_alpha_16_humaneval_raw_json"
lora1 = "algoprog/fact-generation-llama-3.1-8b-instruct-lora"
lora2 = "philschmid/code-llama-3-1-8b-text-to-sql-lora"
server_process, port = launch_server_cmd(
"""
python3 -m sglang.launch_server \
--model-path meta-llama/Meta-Llama-3.1-8B-Instruct \
--enable-lora \
--enable-lora-overlap-loading \
--lora-paths lora0=Nutanix/Meta-Llama-3.1-8B-Instruct_SFT_lora_4_alpha_16_humaneval_raw_json \
lora1=algoprog/fact-generation-llama-3.1-8b-instruct-lora \
lora2=philschmid/code-llama-3-1-8b-text-to-sql-lora \
--max-lora-rank 256 \
--max-loras-per-batch 2 \
--max-loaded-loras 4
"""
)
url = f"http://127.0.0.1:{port}"
wait_for_server(url)
json_data = {
"text": [
"Write a very long fairy-tale.",
"List 3 countries and their capitals.",
"List 3 countries and their capitals.",
],
"sampling_params": [
{"max_new_tokens": 1024, "temperature": 0},
{"max_new_tokens": 64, "temperature": 0},
{"max_new_tokens": 64, "temperature": 0},
],
"lora_path": ["lora0", "lora1", "lora2"],
}
# lora0 and lora1 will be loaded into the memory pool first, and because max_loras_per_batch = 2, lora2's request will remain in the queue.
# lora1's request will likely finish first, and once it does, lora2 will be loaded. With --enable-lora-overlap-loading, this loading will
# occur asynchronously and thus decoding for lora0's request won't be blocked.
response = requests.post(
url + "/generate",
json=json_data,
)
for i in range(3):
print(f"Output from lora{i}: \n{response.json()[i]['text']}\n")
terminate_process(server_process)
LoRA 오버랩 로딩의 한계 (Limitations of LoRA Overlap Loading)
단, LoRA 오버랩 로딩은 공짜가 아니며 두 가지 중요한 주의사항이 있습니다:
-
Pinned CPU 메모리 요구사항: 비동기 H2D 메모리 복사는 LoRA 가중치가 CPU 메모리에 pin된 상태여야 하며, 이는 유한한 시스템 자원입니다. 과도한 pinned 메모리 사용을 완화하기 위해 SGLang은 현재 LoRA 오버랩 로딩이 활성화되면
max_loaded_loras를 최대 2×max_loras_per_batch로 제한합니다. -
다중 어댑터 prefill 배칭 감소 (Reduced multi-adapter prefill batching): 오버랩 로딩에서는 각 어댑터가 비동기로 로드되므로 어댑터가 GPU에 서로 다른 시점에 이용 가능해집니다. 이는 현재 로드된 어댑터의 요청만 묶을 수 있으므로 스케줄러가 다중 어댑터 prefill 배치를 형성하는 능력을 줄일 수 있습니다. 결과적으로 서로 다른 어댑터의 요청은 별도(또는 더 작은) prefill 배치로 스케줄링되어, 어댑터 로드 시간이 prefill 계산 시간보다 짧을 때 TTFT를 증가시킬 수 있습니다. 이것이 LoRA 오버랩 로딩이 기본적으로 비활성인 이유입니다. 사용자가 LoRA 가중치 로딩이 병목이라고 판단했을 때만(높은 어댑터 변동, 무거운 어댑터 가중치, PCIe 병목 워크로드 등) 활성화해야 합니다.
오버랩 로딩이 더 높은 지연을 초래하는 예 (Example When Overlap Loading Results in Higher Latency)
예를 들어 네 개의 LoRA 어댑터 lora0, lora1, lora2, lora3가 있다고 하겠습니다. 어댑터 로딩은 2ms, 해당 어댑터에 대한 요청의 prefill 단계는 20ms가 걸린다고 합시다.
-
베이스라인 (Baseline): 엔진이 네 어댑터를 동기적으로 모두 로드한 뒤 결합된 prefill 배치 하나를 실행, 총 시간 ≈
2 * 4 + 20 = 28ms -
LoRA 오버랩 로딩 활성화 시: 엔진이
lora0로딩을 시작하고 준비되면lora1이 백그라운드에서 로드되는 동안lora0만 포함한 prefill 배치를 스케줄링합니다. 그런 다음lora2가 로드되는 동안lora1의 prefill을 스케줄링하는 식입니다. 최악의 경우(어댑터 간 prefill 배칭 불가) 총 시간은 ≈2 + 4 * 20 = 82ms
이 시나리오에서 오버랩 로딩은 어댑터 로드 오버헤드를 줄이지만, 다중 어댑터 prefill 배칭의 상실이 지배적이라 더 높은 TTFT로 이어집니다.
향후 작업 (Future Works)
LoRA 관련 기능의 개발 로드맵은 이 issue에서 확인할 수 있습니다. Embedding Layer, Unified Paging, Cutlass 백엔드 등 다른 기능들은 아직 개발 중입니다.