본문으로 건너뛰기

[vllm] vLLM 성능 최적화: H2D 메모리 복사 병목 해결을 통한 추론 처리량 개선

PR 링크: vllm-project/vllm#38794 상태: Merged | 변경: +None / -None

들어가며

대규모 언어 모델(LLM) 추론 엔진인 vLLM의 성능 최적화 과정에서, 멀티모달 모델을 사용할 때 발생하는 예상치 못한 병목 지점이 발견되었습니다. 바로 Triton Attention 커널에서 사용되는 mm_prefix_range_tensor가 매 어텐션 호출마다 CPU에서 GPU로 반복적으로 전송(H2D Copy)되고 있었던 점입니다.

이러한 Pageable Memory Copy는 GPU 연산 사이에 파이프라인 버블(Pipeline Bubble)을 형성하여 전체적인 처리량(Throughput)을 저하시킵니다. 이번 PR은 이 데이터를 캐싱하고 전송 방식을 효율화하여 레이어 간 공백을 줄이는 최적화를 담고 있습니다.

코드 분석: 무엇이 바뀌었나?

1. TritonAttentionMetadata: 계산 로직의 분리와 효율화

기존에는 mm_prefix_range_tensor가 프로퍼티(Property)로 정의되어 있어, 해당 값에 접근할 때마다 매번 텐서를 생성하고 GPU로 전송했습니다. 이를 정적 메서드로 분리하고 CPU에서 미리 패딩을 완료한 뒤 한 번에 전송하도록 변경되었습니다.

Before:

@property
def mm_prefix_range_tensor(self) -> torch.Tensor | None:
    # ... 생략 ...
    # 매 호출마다 리스트 컴프리헨션으로 텐서 생성 및 개별 전송 발생
    range_tensors = [
        torch.tensor(r, dtype=torch.int32, device=device).view(-1, 2)
        for r in range_lists
    ]
    return torch.nested.nested_tensor(
        range_tensors, layout=torch.jagged
    ).to_padded_tensor(0)

After:

@staticmethod
def compute_mm_prefix_range_tensor(
    mm_prefix_range: dict[int, list[tuple[int, int]]] | None,
    num_seqs: int,
    device: torch.device,
) -> torch.Tensor | None:
    # ... 생략 ...
    # CPU에서 모든 패딩 작업을 마치고 단일 H2D 전송 수행
    max_ranges = max(len(r) for r in range_lists)
    padded = []
    for r in range_lists:
        padded_r = list(r) + [(0, 0)] * (max_ranges - len(r))
        padded.append(padded_r)
    
    return torch.tensor(padded, dtype=torch.int32, device=device).view(
        num_seqs, max_ranges, 2
    )

2. GPUModelRunner: 캐싱 및 공유 메커니즘 도입

가장 핵심적인 변화는 _set_mm_prefix_range_for_metadata 메서드의 도입입니다. 여러 어텐션 메타데이터 객체가 동일한 범위를 가질 때, 텐서를 한 번만 계산하여 공유하도록 수정되었습니다.

After (vllm/v1/worker/gpu_model_runner.py):

def _set_mm_prefix_range_for_metadata(
    self,
    attn_metadata: Any,
    req_doc_ranges: dict[int, list[tuple[int, int]]],
) -> None:
    # ... 메타데이터 리스트 추출 로직 ...
    shared_tensor = None
    for metadata in metadata_list:
        metadata.mm_prefix_range = req_doc_ranges
        if isinstance(metadata, TritonAttentionMetadata):
            if shared_tensor is None:
                # 최초 1회만 계산 및 GPU 전송
                shared_tensor = TritonAttentionMetadata.compute_mm_prefix_range_tensor(
                    req_doc_ranges,
                    metadata.seq_lens.shape[0],
                    metadata.seq_lens.device,
                )
            metadata.mm_prefix_range_tensor = shared_tensor

왜 이게 좋은 최적화인가?

1. 불필요한 H2D 전송 제거

기존 구조에서는 트랜스포머 블록(Layer)을 지날 때마다 동일한 데이터를 GPU로 다시 보냈습니다. 최적화 후에는 첫 번째 레이어에서 계산된 shared_tensor를 이후 모든 레이어의 메타데이터가 재사용하므로, 레이어 수만큼 비례해서 발생하던 오버헤드가 $O(N)$에서 $O(1)$로 줄어듭니다.

2. Pageable Memory Copy 최적화

리뷰어와 작업자 간의 논의에 따르면, Python의 dictlist를 매번 해싱(Hashing)하여 캐시 유효성을 검사하는 방식은 오히려 배치 사이즈가 커질수록 CPU 오버헤드를 유발했습니다. 대신 ModelRunner 단계에서 명시적으로 텐서를 생성하고 주입하는 방식을 택함으로써, 복잡한 캐시 로직 없이도 안전하고 빠른 성능 개선을 이뤄냈습니다.

3. 파이프라인 버블 감소

GPU는 연산 속도가 매우 빠르기 때문에, 아주 작은 데이터 전송이라도 동기적으로 발생하면 GPU가 노는 시간(Bubble)이 생깁니다. 특히 멀티모달 모델처럼 입력 데이터 처리가 복잡한 경우 이 효과는 극대화됩니다. 이번 변경을 통해 엔드-투-엔드 추론 처리량이 유의미하게 개선되었습니다.

결론

성능 최적화는 때로 거창한 알고리즘의 변경보다, **"당연히 캐싱되어야 할 것이 반복되고 있지는 않은가?"**라는 질문에서 시작됩니다. 이번 PR은 vLLM의 V1 아키텍처에서 메타데이터 관리 효율성을 높여 실제 서비스 환경에서의 지연 시간을 줄인 훌륭한 사례입니다.

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글