[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() 호출을 줄여줍니다.
왜 이게 좋은가
이 버그의 메커니즘을 정리하면:
- 요청 전송 →
on_send_request()→ 캐시 +1 - 요청 완료 → (감소 없음) → 캐시 값 유지
- 반복 후 캐시 값 ==
max_ongoing_requests - 라우터가 캐시만 보고 "모든 replica 포화"로 판단
- 매 라우팅마다 blocking probe RPC 발생 → P99 급등
max_ongoing_requests가 1인 경우(기본값), 단 한 번의 요청만으로도 캐시가 포화 상태에 빠집니다. 수정 후에는 요청 완료 시 캐시가 정확히 감소하므로, 캐시 기반 빠른 라우팅이 정상 동작합니다.
정리
- 증가만 있고 감소가 없는 캐시는 반드시 포화한다: 캐시 증감 로직은 항상 쌍으로 구현해야 합니다. 코드 리뷰 시 "이 값이 감소하는 경로가 있는가?"를 반드시 확인하십시오.
- 콜백의 스레드 안전성을 확인하라: 분산 시스템에서 콜백은 예상치 못한 스레드에서 호출될 수 있습니다.
call_soon_threadsafe로 이벤트 루프에 안전하게 전달해야 합니다. - O(n) 재생성 vs 재사용: 업데이트 주기가 빈번한 경우, 기존 객체를 재사용하고 변경된 필드만 갱신하는 것이 훨씬 효율적입니다.
참고 자료
- ray-project/ray#61755 — PR 전체 diff 및 테스트 코드
- Ray Serve Architecture — Ray Serve 라우팅 아키텍처 문서
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
- [Ray Data] RAPIDS MPF 기반 GPU 셔플 지원으로 GPU 데이터 처리 파이프라인 가속
- [Ray Serve] Pack 스케줄링 최적화: O(replicas x total_replicas)에서 O(replicas x nodes)로
- [Ray Serve] ClusterNodeInfoCache 정렬 버그 수정 및 중복 GCS RPC 제거로 캐시 갱신 최적화
- [Open WebUI] 채팅 제목 업데이트 시 DB 컨텍스트를 단일 세션으로 통합하여 역직렬화 2회 제거
- [Ray] 외부 소비자의 Object Store 사용량을 Resource Manager 예산에 반영
PR Analysis 의 다른글
- 이전글 [pytest] request.getfixturevalue()의 dirty optimization 제거
- 현재글 : [ray] Ray Serve P99 레이턴시 회귀 수정 — 큐 길이 캐시 미감소 버그
- 다음글 [llm-compressor] Intermediates Cache Prefetch - 중간 결과 프리페칭
댓글