본문으로 건너뛰기

[vllm] vLLM의 /derender 엔드포인트 성능 개선: CPU 작업 오프로딩으로 이벤트 루프 차단 문제 해결

PR 링크: vllm-project/vllm#49396 상태: Merged | 변경: +24 / -0

들어가며

vLLM은 대규모 언어 모델(LLM) 서빙을 위한 고성능 추론 엔진입니다. 최근 vLLM 프로젝트에서는 /derender 엔드포인트의 성능 병목 현상을 해결하기 위한 중요한 개선이 이루어졌습니다. 이 PR은 /derender 엔드포인트가 동기적으로 CPU 집약적인 작업을 처리하면서 비동기 이벤트 루프를 차단하는 문제를 해결합니다. 이를 통해 동시 요청 처리 능력을 향상시키고 전반적인 응답성을 개선하는 것을 목표로 합니다.

기존 방식에서는 /derender 엔드포인트가 요청 처리 과정에서 디토큰화(detokenization) 및 로그프로브(logprobs) 해석과 같은 CPU 바운드 작업을 이벤트 루프 스레드에서 직접 수행했습니다. 이는 특히 요청량이 많거나 처리해야 할 토큰 수가 많을 때 이벤트 루프를 장시간 차단하여, 다른 동시 요청들의 처리를 지연시키는 심각한 성능 저하를 야기했습니다. 본 PR은 이러한 CPU 바운드 작업을 vLLM의 렌더러가 관리하는 공유 스레드 풀로 오프로딩함으로써 이 문제를 해결합니다.

코드 분석

vllm/renderers/online_derenderer.py 변경사항

이번 PR의 핵심 변경 사항은 OnlineDerenderer 클래스의 __init__, derender_chat, derender_completion 메서드에 집중되어 있습니다. 주요 목표는 CPU 집약적인 _derender_chat_derender_completion 메서드를 비동기적으로 실행 가능하도록 만들고, 이를 렌더러의 공유 스레드 풀에서 실행하는 것입니다.

1. __init__ 메서드: 비동기 래퍼 생성

기존 __init__ 메서드는 초기화 로직만 담당했지만, 변경 후에는 CPU 바운드 작업을 위한 비동기 래퍼 함수를 생성합니다.

Before:

--- a/vllm/renderers/online_derenderer.py
+++ b/vllm/renderers/online_derenderer.py
@@ -24,6 +24,7 @@
 from vllm.renderers import BaseRenderer
 from vllm.tokenizers import TokenizerLike
 from vllm.utils import random_uuid
+from vllm.utils.async_utils import make_async
 
 logger = init_logger(__name__)
 

After:

@@ -73,10 +74,26 @@ def __init__(
         self.supports_browsing = False
         self.supports_code_interpreter = False
 
+        # Detokenization, logprob resolution and parsing are CPU-bound;
+        # offload them in one hop to keep the event loop responsive.
+        self._derender_chat_async = make_async(
+            self._derender_chat, executor=renderer._executor
+        )
+        self._derender_completion_async = make_async(
+            self._derender_completion, executor=renderer._executor
+        )
+
     async def derender_chat(
         self, 
         generate_response: GenerateResponse,
         chat_request: ChatCompletionRequest | None = None,
+    ) -> list[ChatCompletionResponseChoice]:
+        return await self._derender_chat_async(generate_response, chat_request)
+
+    def _derender_chat(
+        self, 
+        generate_response: GenerateResponse,
+        chat_request: ChatCompletionRequest | None = None,
     ) -> list[ChatCompletionResponseChoice]:
         tokenizer = self.renderer.get_tokenizer()
         choices: list[ChatCompletionResponseChoice] = []

__init__ 메서드에서 vllm.utils.async_utils.make_async 함수를 사용하여 _derender_chat_derender_completion 메서드를 감싸는 비동기 버전(_derender_chat_async, _derender_completion_async)을 생성합니다. 이때 executor=renderer._executor를 통해 렌더러의 공유 스레드 풀을 사용하도록 지정합니다. 이 스레드 풀은 이미 다른 CPU 집약적인 작업들을 처리하기 위해 구성되어 있으므로, 추가적인 스레드 생성 없이 효율적으로 작업을 분산시킬 수 있습니다.

2. derender_chat 메서드: 비동기 호출로 변경

derender_chat 메서드는 외부에서 호출되는 비동기 API입니다. 변경 후에는 내부적으로 위에서 생성한 _derender_chat_async를 호출하여 실제 CPU 작업을 비동기적으로 위임합니다.

Before: (기존에는 async def였으나, 내부 로직이 동기적이었음)

After:

@@ -73,10 +74,26 @@ def __init__(
         self.supports_browsing = False
         self.supports_code_interpreter = False
 
+        # Detokenization, logprob resolution and parsing are CPU-bound;
+        # offload them in one hop to keep the event loop responsive.
+        self._derender_chat_async = make_async(
+            self._derender_chat, executor=renderer._executor
+        )
+        self._derender_completion_async = make_async(
+            self._derender_completion, executor=renderer._executor
+        )
+
     async def derender_chat(
         self, 
         generate_response: GenerateResponse,
         chat_request: ChatCompletionRequest | None = None,
+    ) -> list[ChatCompletionResponseChoice]:
+        return await self._derender_chat_async(generate_response, chat_request)
+
+    def _derender_chat(
+        self, 
+        generate_response: GenerateResponse,
+        chat_request: ChatCompletionRequest | None = None,
     ) -> list[ChatCompletionResponseChoice]:
         tokenizer = self.renderer.get_tokenizer()
         choices: list[ChatCompletionResponseChoice] = []

async def derender_chat(...)는 이제 await self._derender_chat_async(...)를 호출하여 실제 작업이 백그라운드 스레드 풀에서 실행되도록 합니다. 이로써 derender_chat 메서드는 CPU 작업이 완료될 때까지 기다리지 않고 즉시 제어권을 이벤트 루프에 반환하게 됩니다.

3. _derender_chat 메서드: 동기 작업 로직

이 메서드는 실제 디토큰화, 로그프로브 해석 등의 CPU 집약적인 작업을 수행합니다. make_async에 의해 래핑되어 스레드 풀에서 실행됩니다.

Before: (기존 derender_chat의 내부 로직)

After: (동일한 로직이 _derender_chat으로 분리됨)

@@ -172,6 +189,13 @@ async def derender_completion(
         self, 
         generate_responses: list[GenerateResponse],
         prompt_tokens: list[int] | None = None,
+    ) -> tuple[list[CompletionResponseChoice], int, int]:
+        return await self._derender_completion_async(generate_responses, prompt_tokens)
+
+    def _derender_completion(
+        self, 
+        generate_responses: list[GenerateResponse],
+        prompt_tokens: list[int] | None = None,
     ) -> tuple[list[CompletionResponseChoice], int, int]:
         n = len(generate_responses)
         prompt_tokens_list: list[int] = ( 

이 메서드 자체는 동기적으로 동작하지만, make_async를 통해 스레드 풀에서 실행되도록 예약됩니다. 따라서 이 메서드 내부의 모든 연산은 이벤트 루프 스레드를 차단하지 않습니다.

4. derender_completion_derender_completion 메서드

derender_chat과 유사하게, derender_completion 역시 비동기 API로서 _derender_completion_async를 호출하고, 실제 CPU 작업은 _derender_completion 메서드에서 수행되며 스레드 풀로 오프로딩됩니다. 이 변경은 /derender 엔드포인트의 두 가지 주요 기능 모두에 적용되어 일관된 성능 개선을 제공합니다.

왜 이게 좋은가?

성능 향상

PR 설명에 포함된 테스트 결과는 이 변경이 가져온 성능 향상을 명확하게 보여줍니다.

tokens probe p50 (ms) base → fix probe p99 (ms) base → fix heavy avg (ms) base / fix
500 125 → 12 365 → 27 143 / 140
1,000 237 → 12 355 → 33 257 / 236
2,000 491 → 12 563 → 62 507 / 478
4,000 908 → 12 976 → 124 925 / 936
  • Probe Latency (p50, p99): 특히 tokens 수가 증가함에 따라 기존 방식에서는 Probe Latency가 선형적으로 증가했지만, 수정 후에는 p50이 12ms 수준으로 매우 낮게 유지됩니다. 이는 /derender 요청이 이벤트 루프를 거의 차단하지 않음을 의미합니다. p99 역시 크게 개선되었습니다.
  • Heavy Request Throughput: 무거운 요청(heavy request)의 평균 처리 시간도 기존 대비 소폭 개선되거나 비슷한 수준을 유지하여, 오프로딩으로 인한 오버헤드가 크지 않음을 보여줍니다.

이러한 결과는 CPU 바운드 작업을 별도의 스레드 풀로 옮기는 것이 비동기 애플리케이션의 전반적인 응답성과 처리량에 얼마나 큰 영향을 미칠 수 있는지를 잘 보여줍니다.

일반적인 교훈

  1. CPU 바운드 작업과 I/O 바운드 작업 분리: 비동기 프레임워크(예: Python의 asyncio)는 I/O 바운드 작업에 최적화되어 있습니다. CPU 바운드 작업이 이벤트 루프 스레드에서 실행되면 전체 시스템의 성능을 저하시킬 수 있습니다. 이러한 작업은 별도의 스레드 풀(예: concurrent.futures.ThreadPoolExecutor)이나 프로세스 풀로 오프로딩해야 합니다.
  2. asyncio와 스레드 풀의 조화: Python의 asyncioloop.run_in_executor() (또는 async_utils.make_async와 같은 유틸리티)를 통해 스레드 풀 작업을 쉽게 통합할 수 있는 메커니즘을 제공합니다. 이를 통해 비동기 코드의 장점과 멀티스레딩의 이점을 결합할 수 있습니다.
  3. 병목 지점 식별 및 해결: 성능 프로파일링을 통해 병목 지점을 정확히 식별하는 것이 중요합니다. 이 PR에서는 /derender 엔드포인트의 동기적 CPU 작업이 병목임을 파악하고 이를 해결했습니다.
  4. 테스트의 중요성: PR 설명에 포함된 상세한 테스트 계획과 결과는 변경의 효과를 객관적으로 입증합니다. 특히 부하 테스트와 A/B 테스트는 실제 운영 환경에서의 성능을 예측하는 데 필수적입니다.

리뷰 피드백

주요 리뷰어인 guan404ming님은 간단히 "Thanks!"라고 코멘트를 남겼습니다. 이는 코드 변경이 명확하고 문제 해결에 효과적이라는 긍정적인 신호로 해석될 수 있습니다. 복잡한 코드 변경이 아니었기에 추가적인 기술적 논의는 필요 없었던 것으로 보입니다.

결론

이번 vLLM의 /derender 엔드포인트 성능 개선 PR은 CPU 집약적인 작업을 비동기적으로 처리하여 이벤트 루프의 차단을 방지하는 모범적인 사례입니다. make_async 유틸리티를 활용하여 기존 동기 함수를 효율적으로 스레드 풀로 오프로딩함으로써, vLLM은 더 높은 동시성 처리 능력과 향상된 응답성을 제공하게 되었습니다. 이는 대규모 언어 모델 서빙 시스템에서 안정적이고 빠른 성능을 유지하는 데 중요한 기여를 합니다.

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글