본문으로 건너뛰기

[vllm] vLLM, 캐싱 비활성화 시 불필요한 LRU 해시 분할 제거로 디코드 처리량 3.5% 향상

PR 링크: vllm-project/vllm#48017 상태: Merged | 변경: +5 / -3

들어가며

vLLM은 LLM 추론 속도를 높이기 위한 혁신적인 라이브러리로, 특히 KV 캐시 관리에 있어 효율적인 메모리 관리가 핵심입니다. 최근 vLLM 프로젝트의 GitHub PR에서는 BlockPool.free_blocks() 함수에서 Prefix Caching이 비활성화되었을 때 발생하는 불필요한 연산을 제거하여 디코드 처리량을 개선하는 변경이 있었습니다. 이 PR은 기존 코드에서 Prefix Caching이 꺼져 있을 때 전혀 사용되지 않는 LRU(Least Recently Used) 해시 분할 로직을 건너뛰도록 하여, 짧은 디코드 단계에서 발생하는 고정 오버헤드를 줄이는 것을 목표로 합니다.

이번 글에서는 해당 PR의 코드 변경 내용을 상세히 분석하고, 왜 이러한 변경이 성능 향상으로 이어졌는지, 그리고 이 최적화가 주는 일반적인 교훈은 무엇인지 살펴보겠습니다.

코드 분석

vllm/v1/core/block_pool.py

이번 PR의 핵심 변경은 BlockPool.free_blocks() 함수의 로직 수정에 있습니다. 기존에는 Prefix Caching 활성화 여부와 관계없이 항상 해시가 있는 블록(blocks_with_hash)과 없는 블록(blocks_without_hash)으로 나누는 과정이 수행되었습니다. 하지만 Prefix Caching이 비활성화된 경우(enable_caching=False), 어떤 블록도 해시를 가지지 않으므로 blocks_with_hash는 항상 비어있게 됩니다. 이 경우, 블록을 두 개의 리스트로 나누는 과정 자체가 불필요한 CPU 작업이 됩니다.

Before:

-        # Identify blocks with hash (LRU cache) and without it (will never match in APC)
+        # Identify blocks with hash (LRU cache) and without it (never match APC)
         blocks_with_hash = []
         blocks_without_hash = []
         for block in ordered_blocks:
             block.ref_cnt -= 1
             if block.ref_cnt == 0 and not block.is_null:
-                if block.block_hash is None:
+                if block.block_hash is None and self.enable_caching:
                     blocks_without_hash.append(block)
                 else:
                     blocks_with_hash.append(block)
 
-        # Blocks without hash always get evicted first - prepend them last to the tail
+        # Blocks without hash get evicted first - prepend them last to the tail
         self.free_block_queue.prepend_n(blocks_without_hash)
         self.free_block_queue.append_n(blocks_with_hash)

After:

-        # Identify blocks with hash (LRU cache) and without it (will never match in APC)
+        # Identify blocks with hash (LRU cache) and without it (never match APC)
         blocks_with_hash = []
         blocks_without_hash = []
         for block in ordered_blocks:
             block.ref_cnt -= 1
             if block.ref_cnt == 0 and not block.is_null:
-                if block.block_hash is None:
+                if block.block_hash is None and self.enable_caching:
                     blocks_without_hash.append(block)
                 else:
                     blocks_with_hash.append(block)
 
-        # Blocks without hash always get evicted first - prepend them last to the tail
+        # Blocks without hash get evicted first - prepend them last to the tail
         self.free_block_queue.prepend_n(blocks_without_hash)
         self.free_block_queue.append_n(blocks_with_hash)

PR 설명에 따르면, 이전 커밋(#42656)에서 free_blocks() 함수는 항상 두 개의 리스트(blocks_with_hash, blocks_without_hash)를 만들고, 각 블록마다 block.block_hash is None 조건을 확인하는 분기 처리를 수행했습니다. 또한, prepend_nappend_n 두 번의 큐 연산을 발생시켰습니다. Prefix Caching이 비활성화된 경우, block.block_hash는 항상 None이므로 blocks_with_hash는 비어있고, 이 모든 과정은 실제로는 아무런 효과 없이 CPU 작업만 증가시키는 결과를 낳았습니다. 특히 모델 추론 시간이 짧은 짧은 디코드 단계에서는 이 고정 오버헤드가 전체 처리 시간에 상당한 비율을 차지하게 되어 디코드 처리량을 감소시켰습니다.

이번 PR에서는 block.block_hash is None 조건에 and self.enable_caching 조건을 추가하여, Prefix Caching이 비활성화된 경우에는 해시 분할 로직을 건너뛰도록 했습니다. 즉, enable_caching=False일 때는 모든 블록이 blocks_without_hash에 포함되고, prepend_n 연산만 수행하게 됩니다. 이는 #42656 이전의 동작 방식으로 복귀하는 것입니다. Prefix Caching이 활성화된 경우에는 기존 로직이 그대로 유지됩니다.

리뷰어 njhill의 지적에 따라, 초기에는 별도의 빠른 경로(early-return fast path)를 추가하는 방안도 고려되었으나, 최종적으로는 조건문 하나를 수정하는 것으로 코드를 더 간결하게 유지하면서 동일한 효과를 얻도록 변경되었습니다. 리뷰어는 이 변경이 CPU 작업 증가가 아니라, 최근 사용된 블록을 재사용함으로써 GPU 캐시 지역성(locality)을 향상시키는 효과가 있다고 언급했습니다.

왜 이게 좋은가?

성능 향상

PR 설명과 테스트 결과에 따르면, Prefix Caching이 꺼진 디코드 중심(decode-bound)의 벤치마크 환경에서 다음과 같은 성능 향상이 관찰되었습니다:

  • 처리량(Throughput) 약 3.5% 향상: 초당 처리하는 토큰 수가 증가했습니다. (예: ~734 tok/s -> ~760 tok/s)
  • TPOT (Time Per Output Token) 감소: 토큰 하나를 생성하는 데 걸리는 평균 시간이 줄었습니다. (예: ~10.5 ms -> ~10.1 ms)

이러한 성능 향상은 불필요한 조건 분기 및 리스트 분할 로직 제거를 통해 고정 오버헤드를 줄였기 때문에 가능했습니다. 특히 짧은 디코드 단계에서는 이 오버헤드가 차지하는 비중이 크므로, 제거 시 성능 개선 효과가 두드러집니다.

일반적인 교훈

  1. 조건부 로직의 비용 인지: 기능이 활성화되었을 때만 필요한 로직은 비활성화되었을 때 건너뛸 수 있도록 설계해야 합니다. 특히 자주 호출되는 함수(hot path)에서는 작은 오버헤드도 누적되어 성능에 큰 영향을 미칠 수 있습니다.
  2. 프로파일링의 중요성: 이 PR은 #42656 커밋 이후 발생한 성능 저하를 bisecting을 통해 정확히 찾아내고 원인을 분석했습니다. 성능 최적화는 측정과 분석을 기반으로 이루어져야 합니다.
  3. 간결함과 효율성의 균형: 리뷰어의 피드백처럼, 때로는 복잡한 빠른 경로 추가보다 기존 로직의 조건만 수정하는 것이 더 간결하고 유지보수하기 좋은 해결책이 될 수 있습니다.
  4. GPU 캐시 지역성 고려: 리뷰어의 코멘트처럼, 메모리 할당 및 해제 로직은 단순히 CPU 오버헤드뿐만 아니라 GPU 캐시 효율성에도 영향을 미칠 수 있습니다. 최근 사용된 메모리 블록을 재사용 가능한 형태로 유지하는 것이 성능에 긍정적인 영향을 줄 수 있습니다.

결론

vLLM의 이번 PR은 Prefix Caching이 비활성화된 특정 시나리오에서 불필요한 연산을 제거함으로써 디코드 처리량을 효과적으로 개선했습니다. 이는 LLM 추론 시스템의 성능을 최적화하는 데 있어 세밀한 로직 최적화가 얼마나 중요한지를 보여주는 좋은 사례입니다. 앞으로도 vLLM 커뮤니티의 지속적인 최적화 노력을 기대합니다.

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글