본문으로 건너뛰기

[ray] Ray Serve P99 레이턴시 회귀 수정 — 큐 길이 캐시 미감소 버그

PR 링크: ray-project/ray#61755 상태: Merged | 변경: +259 / -16

들어가며

Ray Serve는 ML 모델 서빙을 위한 분산 프레임워크로, 요청을 여러 replica에 분배하는 request router가 핵심입니다. 라우터는 각 replica의 큐 길이를 캐싱하여 빠른 라우팅 결정을 내리는데, 캐시가 요청 전송 시 증가만 하고 완료 시 감소하지 않는 버그가 있었습니다.

이 버그로 인해 캐시된 큐 길이가 max_ongoing_requests에 도달하면, 라우터가 모든 replica를 "바쁨"으로 판단하여 매번 blocking probe RPC를 보내게 됩니다. 이것이 P99 레이턴시 회귀의 원인이었습니다.

핵심 코드 분석

1. decrement_queue_len_cache() 추가

기존 코드에는 on_send_request()만 있어 캐시를 증가시키는 경로만 존재했습니다.

Before (request_router.py):

def on_send_request(self, replica_id: ReplicaID):
    if self._use_replica_queue_len_cache:
        current = self._replica_queue_len_cache.get(replica_id)
        if current is not None:
            new_queue_len = current + 1
            self._replica_queue_len_cache.update(replica_id, new_queue_len)
            self._update_router_queue_len_gauge(replica_id, new_queue_len)

After:

def decrement_queue_len_cache(self, replica_id: ReplicaID):
    """Decrement the queue length cache for a replica.

    Called via add_done_callback when a request finishes on a replica,
    regardless of outcome (success, failure, or cancellation).
    """
    if self._use_replica_queue_len_cache:
        current = self._replica_queue_len_cache.get(replica_id)
        if current is not None:
            new_queue_len = max(0, current - 1)
            self._replica_queue_len_cache.update(replica_id, new_queue_len)
            self._update_router_queue_len_gauge(replica_id, new_queue_len)

max(0, current - 1)로 음수 방지를 하면서, 캐시 만료 시(get() returns None) 새로운 엔트리를 생성하지 않도록 current is not None 검사를 수행합니다.

2. 요청 완료 시 콜백 등록

After (router.py):

if not with_rejection:
    result.add_done_callback(
        lambda _: self._event_loop.call_soon_threadsafe(
            self.request_router.decrement_queue_len_cache,
            replica.replica_id,
        )
    )
    return result

# rejection 모드에서도 accept된 경우
if queue_info.accepted:
    self.request_router.on_request_routed(pr, replica.replica_id, result)
    result.add_done_callback(
        lambda _: self._event_loop.call_soon_threadsafe(
            self.request_router.decrement_queue_len_cache,
            replica.replica_id,
        )
    )
    return result

add_done_callback은 요청이 성공/실패/취소 어떤 이유로든 완료되면 호출됩니다. 이것이 올바른 선택인 이유는, 요청이 전송된 이상 큐 슬롯을 차지했고, 완료되면 어떤 결과든 슬롯이 해제되기 때문입니다.

3. Thread Safety — call_soon_threadsafe

result.add_done_callback(
    lambda _, cb=callback: self._event_loop.call_soon_threadsafe(cb, _)
)

add_done_callback은 C++ worker 스레드나 gRPC 콜백 스레드에서 호출될 수 있습니다. _replica_queue_len_cache는 thread-safe하지 않으므로, call_soon_threadsafe로 라우터의 이벤트 루프에 스케줄링하여 안전하게 접근합니다.

4. Replica Wrapper 재사용

Before:

def _update_running_replicas(self, running_replicas):
    replica_wrappers = []
    for r in running_replicas:
        try:
            replica_wrappers.append(self.create_replica_wrapper(r))
        except ValueError:
            logger.warning(...)

After:

def _update_running_replicas(self, running_replicas):
    replica_wrappers = []
    for r in running_replicas:
        if r.replica_id in self._replicas:
            wrapper = self._replicas[r.replica_id]
            wrapper.update_replica_info(r)
            replica_wrappers.append(wrapper)
        else:
            try:
                replica_wrappers.append(self.create_replica_wrapper(r))
            except ValueError:
                logger.warning(...)

매 업데이트마다 모든 replica wrapper를 O(n)으로 새로 생성하던 것을 기존 wrapper를 재사용하도록 변경했습니다. update_replica_info()_replica_info_multiplexed_model_ids만 갱신하고, actor handle 같은 identity 필드는 유지합니다. 이는 scaling storm(빈번한 스케일링) 상황에서 불필요한 ray.get_actor() 호출을 줄여줍니다.

왜 이게 좋은가

이 버그의 메커니즘을 정리하면:

  1. 요청 전송 → on_send_request() → 캐시 +1
  2. 요청 완료 → (감소 없음) → 캐시 값 유지
  3. 반복 후 캐시 값 == max_ongoing_requests
  4. 라우터가 캐시만 보고 "모든 replica 포화"로 판단
  5. 매 라우팅마다 blocking probe RPC 발생 → P99 급등

max_ongoing_requests가 1인 경우(기본값), 단 한 번의 요청만으로도 캐시가 포화 상태에 빠집니다. 수정 후에는 요청 완료 시 캐시가 정확히 감소하므로, 캐시 기반 빠른 라우팅이 정상 동작합니다.

정리

  • 증가만 있고 감소가 없는 캐시는 반드시 포화한다: 캐시 증감 로직은 항상 쌍으로 구현해야 합니다. 코드 리뷰 시 "이 값이 감소하는 경로가 있는가?"를 반드시 확인하십시오.
  • 콜백의 스레드 안전성을 확인하라: 분산 시스템에서 콜백은 예상치 못한 스레드에서 호출될 수 있습니다. call_soon_threadsafe로 이벤트 루프에 안전하게 전달해야 합니다.
  • O(n) 재생성 vs 재사용: 업데이트 주기가 빈번한 경우, 기존 객체를 재사용하고 변경된 필드만 갱신하는 것이 훨씬 효율적입니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글