[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.py의 LoRAMemoryPool에서 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 의 다른글
- 이전글 [SGLang] LoRA + MoE 융합: 어댑터와 전문가 혼합의 통합
- 현재글 : [SGLang] LoRA Eviction: 어댑터 캐시 관리와 퇴거 정책
- 다음글 [SGLang] Sampler: logits에서 토큰까지의 샘플링 파이프라인
댓글