본문으로 건너뛰기

[ray] [Ray Data] Wide Schema에서 10배 성능 향상을 이끌어낸 한 줄의 설정: Parquet pre_buffer의 마법

PR 링크: ray-project/ray#63466 상태: Merged | 변경: +19 / -14

들어가며

분산 컴퓨팅 프레임워크인 Ray의 데이터 처리 라이브러리, Ray Data는 최근 V2 엔진으로의 전환을 진행하고 있습니다. 하지만 이 과정에서 흥미로운 성능 퇴행(Regression) 보고가 있었습니다. 수천 개의 컬럼을 가진 'Wide Schema' Parquet 파일을 읽을 때, 기존 V1 엔진에서는 16초면 끝나던 작업이 V2에서는 154초로 무려 10배 가까이 느려진 것입니다.

분석 결과, 원인은 CPU 연산이나 메모리 부족이 아닌 '너무 많은 작은 I/O 요청(Many small I/O requests)'에 있었습니다. 이번 포스트에서는 단 한 줄의 설정을 변경하여 이 문제를 해결한 PR을 통해, 클라우드 스토리지 환경에서 Parquet I/O 최적화가 얼마나 중요한지 살펴보겠습니다.

문제의 현상: 96%의 Blocked Time

문제가 발생한 wide_schema_pipeline_primitives 테스트의 통계(ds.stats())를 보면 상황이 명확해집니다.

Operator 2 ReadFilesParquetV2: 27 tasks executed, 40 blocks produced in 143.19s
  Remote wall time:  61.73ms min, 141.62s max, 88.67s mean
  Remote cpu time:   62.07ms min, 4.76s max,   2.96s mean

작업 시간의 대부분(약 143초)이 Wall time에 쏠려 있고, 실제 CPU 사용 시간은 3초 미만입니다. 이는 태스크가 연산을 수행하는 시간보다 S3로부터 데이터를 기다리는 I/O Wait 상태에 머물러 있었다는 것을 의미합니다. 특히 5,000개의 컬럼을 가진 데이터셋에서 이런 현상이 두드러졌는데, 이는 전형적인 I/O 증폭 현상의 징후입니다.

코드 분석: 왜 V2만 느렸을까?

원인은 ParquetFileReader가 PyArrow의 스캐너를 생성할 때 사용하는 옵션에 있었습니다.

Before: 명시적으로 비활성화된 pre_buffer

기존 V2 코드에서는 메모리 관리를 위해 pre_buffer=False를 강제하고 있었습니다.

# [Before] python/ray/data/_internal/datasource_v2/readers/parquet_file_reader.py

kwargs: dict = {
    "fragment_scan_options": pds.ParquetFragmentScanOptions(
        pre_buffer=False,  # 이 부분이 문제의 핵심
        use_buffered_stream=True,
        buffer_size=_PARQUET_FRAGMENT_BUFFER_SIZE,
    ),
}

After: 기본값(True) 사용으로 복구

수정된 코드에서는 pre_buffer=False 설정을 제거하여 PyArrow의 기본값인 True가 적용되도록 했습니다.

# [After] python/ray/data/_internal/datasource_v2/readers/parquet_file_reader.py

@override
def _arrow_scanner_kwargs(self) -> dict:
    # ``pre_buffer`` is left at pyarrow's default (``True``).
    # ... (상세 설명 주석 추가) ...
    kwargs: dict = {
        "fragment_scan_options": pds.ParquetFragmentScanOptions(
            # pre_buffer=False 가 제거됨
            use_buffered_stream=True,
            buffer_size=_PARQUET_FRAGMENT_BUFFER_SIZE,
        ),
    }

왜 이게 좋은 최적화인가?

1. Coalesced I/O vs. Lazy Range Requests

pre_buffer 옵션은 PyArrow가 Parquet 파일을 읽을 때 I/O를 어떻게 스케줄링할지 결정합니다.

  • pre_buffer=True (개선 후): PyArrow는 해당 Fragment에서 필요한 모든 컬럼 청크(Column Chunks)를 파악한 뒤, 인접한 범위를 하나로 묶어(Coalesce) 단일 혹은 최소한의 I/O 요청으로 S3에 보냅니다. 데이터를 메모리에 미리 버퍼링한 뒤 디코딩을 시작합니다.
  • pre_buffer=False (개선 전): 각 컬럼별로 스트림을 열고, 데이터가 필요할 때마다 지연(Lazy) 요청을 보냅니다.

5,000개의 컬럼이 있는 Wide Schema의 경우, False 설정은 하나의 파일을 읽기 위해 5,000번의 S3 GET 요청을 발생시킵니다. S3와 같은 클라우드 스토리지는 요청당 레이턴시(TTFB)가 존재하므로, 수천 번의 작은 요청은 전체 처리 시간을 기하급수적으로 늘립니다. 반면 True로 설정하면 이 요청들이 병합되어 단 몇 번의 큰 요청으로 끝나게 됩니다.

2. 과거의 제약 사항 해결

원래 V2에서 pre_buffer=False를 설정했던 이유는 Apache Arrow #39808 이슈 때문이었습니다. 여러 Fragment를 읽을 때 메모리가 누적되는 현상을 방지하기 위함이었죠.

하지만 Ray Data V2의 아키텍처는 이미 Fragment 단위로 스캐너를 생성하고 파괴하도록 설계되어 있습니다. 즉, Arrow 레벨에서 걱정하던 'Fragment 간 메모리 누적' 문제가 구조적으로 발생하지 않는 환경이 된 것입니다. 따라서 더 이상 성능을 희생하며 pre_buffer를 끌 이유가 없어졌습니다.

3. 합리적인 버퍼 사이즈 설정

PR에서는 buffer_size를 8 MiB로 유지하고 있는데, 이는 PyArrow의 기본값(8 KiB)보다 훨씬 큽니다. S3 환경에서는 한 번의 왕복(Round-trip)에서 충분한 양의 데이터를 가져와야 레이턴시를 상쇄할 수 있기 때문에, 이 설정은 pre_buffer와 시너지를 내어 처리량을 극대화합니다.

일반적인 교훈

  1. I/O 패턴의 이해: 분산 시스템에서 성능 병목은 연산보다 I/O에서 자주 발생합니다. 특히 클라우드 스토리지(S3, GCS 등)를 사용할 때는 요청 횟수를 줄이는 'Batching'과 'Coalescing'이 최우선 최적화 대상입니다.
  2. 맥락에 따른 최적화: 특정 라이브러리(Arrow)의 권장 사항이나 과거의 해결책이 현재의 시스템 아키텍처(Ray Data V2)에서도 유효한지 항상 재검토해야 합니다.
  3. 기본값의 가치: 때로는 복잡한 튜닝보다 라이브러리 제작자가 의도한 기본값(Default)을 믿는 것이 가장 좋은 성능을 낼 때가 있습니다.

이번 변경은 단 한 줄의 삭제였지만, 시스템의 전체적인 I/O 동작 방식을 이해하고 아키텍처 변화에 맞춰 불필요한 제약을 제거함으로써 10배의 성능 향상을 이끌어낸 훌륭한 사례입니다.

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글