LoRA 서빙

LoRA 서빙 (LoRA Serving)

SGLang은 base model과 함께 LoRA 어댑터를 사용할 수 있게 해줍니다. S-LoRAPunica의 기법을 통합해, 단일 입력 배치 내의 서로 다른 시퀀스에 여러 LoRA 어댑터를 효율적으로 지원합니다.

출처: 문서

본문

SGLang은 base model과 함께 LoRA 어댑터를 사용할 수 있게 합니다. S-LoRAPunica의 기법을 통합해, 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로 지원합니다:

  1. OpenAI 호환 API (/v1/chat/completions, /v1/completions): model:adapter-name 구문 사용. 예시는 OpenAI API with LoRA 참고.

  2. 네이티브 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로, lora2lora3를 일반(unpinned) 어댑터로 로드해 서버를 시작합니다. lora2lora3를 의도적으로 서로 다른 두 형식으로 지정해 둘 다 지원됨을 보여줍니다.

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 오버랩 로딩은 공짜가 아니며 두 가지 중요한 주의사항이 있습니다:

  1. Pinned CPU 메모리 요구사항: 비동기 H2D 메모리 복사는 LoRA 가중치가 CPU 메모리에 pin된 상태여야 하며, 이는 유한한 시스템 자원입니다. 과도한 pinned 메모리 사용을 완화하기 위해 SGLang은 현재 LoRA 오버랩 로딩이 활성화되면 max_loaded_loras를 최대 2× max_loras_per_batch로 제한합니다.

  2. 다중 어댑터 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가 걸린다고 합시다.

  1. 베이스라인 (Baseline): 엔진이 네 어댑터를 동기적으로 모두 로드한 뒤 결합된 prefill 배치 하나를 실행, 총 시간 ≈ 2 * 4 + 20 = 28ms

  2. LoRA 오버랩 로딩 활성화 시: 엔진이 lora0 로딩을 시작하고 준비되면 lora1이 백그라운드에서 로드되는 동안 lora0만 포함한 prefill 배치를 스케줄링합니다. 그런 다음 lora2가 로드되는 동안 lora1의 prefill을 스케줄링하는 식입니다. 최악의 경우(어댑터 간 prefill 배칭 불가) 총 시간은 ≈ 2 + 4 * 20 = 82ms

이 시나리오에서 오버랩 로딩은 어댑터 로드 오버헤드를 줄이지만, 다중 어댑터 prefill 배칭의 상실이 지배적이라 더 높은 TTFT로 이어집니다.

향후 작업 (Future Works)

LoRA 관련 기능의 개발 로드맵은 이 issue에서 확인할 수 있습니다. Embedding Layer, Unified Paging, Cutlass 백엔드 등 다른 기능들은 아직 개발 중입니다.

더 알아보기 (Learn more)