본문으로 건너뛰기

[SGLang] LoRA Eviction: 어댑터 캐시 관리와 퇴거 정책

들어가며

멀티 LoRA 서빙에서 GPU 메모리는 한정되어 있으므로, 동시에 모든 LoRA 어댑터를 메모리에 유지할 수 없다. SGLang은 고정 크기 메모리 풀에 max_loras_per_batch개의 슬롯을 할당하고, 슬롯이 부족하면 Eviction(퇴거) 정책에 따라 사용 빈도가 낮은 어댑터를 교체한다. 현재 LRU와 FIFO 두 가지 정책을 지원한다.

구조도

┌────────────────────────────────────────────┐
│             LoRAMemoryPool                  │
│                                            │
│  Slot 0: [LoRA-A weights] ← pinned         │
│  Slot 1: [LoRA-B weights] ← evictable      │
│  Slot 2: [LoRA-C weights] ← evictable      │
│  Slot 3: [  base model  ] ← evictable      │
│           (rank=0, no-op)                   │
│                                            │
│  ┌──────────────────────────────────────┐  │
│  │      Eviction Policy                 │  │
│  │  ┌─────┐        ┌──────┐            │  │
│  │  │ LRU │   or   │ FIFO │            │  │
│  │  └─────┘        └──────┘            │  │
│  │  access_order:                       │  │
│  │    LoRA-C → LoRA-B → LoRA-A         │  │
│  │    (oldest)         (newest)         │  │
│  └──────────────────────────────────────┘  │
└────────────────────────────────────────────┘

배치 도착 시:
  필요: {LoRA-A, LoRA-D}
  현재: {LoRA-A(pinned), LoRA-B, LoRA-C, base}
  → LoRA-C 퇴거 (LRU victim) → LoRA-D 로드

핵심 코드 분석

EvictionPolicy: 추상 인터페이스

python/sglang/srt/lora/eviction_policy.py에서 모든 퇴거 정책이 구현해야 하는 인터페이스를 정의한다.

class EvictionPolicy(ABC):
    @abstractmethod
    def mark_used(self, uid: Optional[str]) -> None:
        """어댑터를 사용됨으로 표시한다."""
        pass

    @abstractmethod
    def select_victim(self, candidates: Set[Optional[str]]) -> Optional[str]:
        """후보 중에서 퇴거할 어댑터를 선택한다."""
        pass

    @abstractmethod
    def remove(self, uid: Optional[str]) -> None:
        """어댑터를 추적에서 제거한다."""
        pass

LRUEvictionPolicy: 최근 최소 사용 정책

OrderedDict를 사용하여 접근 순서를 추적한다. 가장 오래전에 사용된 어댑터가 퇴거 대상이 된다.

class LRUEvictionPolicy(EvictionPolicy):
    def __init__(self):
        self.access_order = OrderedDict()
        self.total_accesses = 0
        self.eviction_count = 0

    def mark_used(self, uid: Optional[str]) -> None:
        if uid is not None:
            current_time = time.monotonic()
            self.access_order.pop(uid, None)
            self.access_order[uid] = current_time
            self.total_accesses += 1

pop 후 재삽입으로 해당 항목을 OrderedDict의 끝(가장 최근)으로 이동시킨다.

LRU Victim 선택

후보 집합에서 가장 오래된 항목을 선택한다.

    def select_victim(self, candidates: Set[Optional[str]]) -> Optional[str]:
        for uid in list(self.access_order.keys()):
            if uid in candidates:
                self.eviction_count += 1
                return uid
        if None in candidates:
            self.eviction_count += 1
            return None
        assert False, f"Failed to select LRU victim from candidates: {candidates}"

access_order를 순서대로 순회하므로 가장 처음 나오는(가장 오래된) 후보가 선택된다. None(base model 슬롯)도 퇴거 대상이 될 수 있는데, 이는 배치 전체가 LoRA 요청일 때 발생한다.

FIFOEvictionPolicy: 선입선출 정책

FIFO는 어댑터가 처음 로드된 순서만 추적한다. 접근 시간을 갱신하지 않으므로 가장 오래전에 로드된 어댑터가 퇴거된다.

class FIFOEvictionPolicy(EvictionPolicy):
    def __init__(self):
        self.insertion_order = OrderedDict()
        self.eviction_count = 0

    def mark_used(self, uid: Optional[str]) -> None:
        if uid is not None and uid not in self.insertion_order:
            self.insertion_order[uid] = True  # 최초 삽입만 기록

    def select_victim(self, candidates: Set[Optional[str]]) -> Optional[str]:
        for uid in list(self.insertion_order.keys()):
            if uid in candidates:
                self.eviction_count += 1
                return uid

팩토리 함수

설정 문자열로 정책을 생성한다.

def get_eviction_policy(policy_name: str) -> EvictionPolicy:
    policies = {
        "fifo": FIFOEvictionPolicy,
        "lru": LRUEvictionPolicy,
    }
    if policy_name not in policies:
        raise ValueError(f"Unknown eviction policy: {policy_name}")
    return policies[policy_name]()

LoRAMemoryPool에서의 사용

python/sglang/srt/lora/mem_pool.pyLoRAMemoryPool에서 eviction 정책을 초기화한다.

class LoRAMemoryPool:
    def __init__(self, base_hf_config, max_loras_per_batch, dtype,
                 tp_size, tp_rank, max_lora_rank, target_modules,
                 base_model, eviction_policy, ...):
        self.eviction_policy = get_eviction_policy(eviction_policy)
        self.uid_to_buffer_id: Dict[Optional[str], int] = {}
        self.A_buffer: Dict[str, List[torch.Tensor]] = {}
        self.B_buffer: Dict[str, List[torch.Tensor]] = {}

배치 준비 시 Eviction 트리거

새 배치에 필요한 LoRA가 메모리 풀에 없으면 eviction이 발생한다. prepare_lora_batch에서 호출되는 fetch_new_loras가 이를 처리한다.

# LoRAManager에서
def fetch_new_loras(self, new_loras, running_loras=set()):
    cur_uids = new_loras | running_loras
    assert len(cur_uids) <= self.max_loras_per_batch
    self.memory_pool.prepare_lora_batch(
        cur_uids=cur_uids,
        lora_adapters=self.loras,
        lora_modules=self.lora_modules,
        lora_refs=self.lora_refs.copy(),
        lora_embed_tokens_module=self.embed_tokens_module,
        lora_lm_head_module=self.lm_head_module,
    )

Pinned LoRA와 Eviction

Pinned 어댑터는 eviction 후보에서 제외된다. LoRAManager는 pinned LoRA가 모든 슬롯을 차지하지 않도록 검증한다.

# LoRAManager.validate_new_adapter에서
if lora_ref.pinned and self.num_pinned_loras >= self.max_loras_per_batch - 1:
    raise ValueError(
        "Not allowed to pin all slots to avoid starvation for unpinned adapters")

배치 검증과 슬롯 계산

배치의 LoRA 수가 가용 슬롯(전체 - pinned)을 초과하지 않는지 확인한다.

def validate_lora_batch(self, lora_ids):
    if len(lora_ids) > self.max_loras_per_batch:
        return False
    pinned_loras_in_batch = sum(
        int(self.lora_refs[lid].pinned) for lid in lora_ids if lid is not None)
    required_slots = len(lora_ids) - pinned_loras_in_batch
    mem_pool_vacancy = self.memory_pool.max_loras_per_batch - self.num_pinned_loras
    return required_slots <= mem_pool_vacancy

LRU vs FIFO 비교

┌──────────┬──────────────────────┬───────────────────────┐
│          │        LRU           │         FIFO          │
├──────────┼──────────────────────┼───────────────────────┤
│ 추적대상  │ 마지막 접근 시간       │ 최초 삽입 시간          │
│ 갱신     │ 매 사용 시            │ 최초 로드 시에만        │
│ 적합상황  │ 접근 패턴에 지역성 있음 │ 균등 접근 패턴          │
│ 오버헤드  │ OrderedDict 재삽입    │ 단순 삽입 확인          │
│ 기본값   │ LRU (권장)            │ 하위 호환용            │
└──────────┴──────────────────────┴───────────────────────┘

설계 근거

None 슬롯의 의미

메모리 풀에서 uid=None은 base model(LoRA 없음)을 의미한다. 이 슬롯의 가중치는 모두 0으로 설정되어 rank=0 역할을 한다. 모든 요청이 LoRA를 사용하는 배치에서는 None 슬롯이 eviction 대상이 될 수 있다.

Eviction Count 메트릭

두 정책 모두 eviction_count를 추적하여 어댑터 교체 빈도를 모니터링할 수 있다. 교체가 빈번하면 max_loras_per_batch를 늘리거나 pinned LoRA 전략을 재고해야 한다.

monotonic 시간 사용

LRU 정책은 time.monotonic()을 사용하여 시스템 시간 변경에 영향받지 않는 안정적인 접근 시간을 기록한다.

관련 포스트

참고

댓글

관련 포스트

SGLang 의 다른글