본문으로 건너뛰기

[cpython] Python statistics.fmean() 성능 최적화: itertools.compress를 활용한 오버헤드 제거

PR 링크: python/cpython#148875 상태: Merged | 변경: +None / -None

들어가며

Python의 statistics.fmean() 함수는 부동 소수점 산술 평균을 계산할 때 math.fsum()을 사용하여 정밀도를 유지하면서도 빠른 속도를 제공하는 유용한 도구입니다. 하지만 최근 CPython 메인 레포지토리에 이 함수의 성능을 한 단계 더 끌어올리는 흥미로운 PR이 머지되었습니다.

이 PR의 핵심은 데이터의 개수를 세는 방식(Counting strategy)의 개선입니다. 특히 길이를 미리 알 수 없는 이터레이터(Iterator)가 입력으로 들어왔을 때, 기존 방식이 가졌던 불필요한 객체 생성 오버헤드를 어떻게 제거했는지 상세히 살펴보겠습니다.

문제 상황: 이터레이터의 길이를 재는 비용

fmean은 평균을 구하기 위해 합계(total) / 개수(n)를 계산해야 합니다. 입력 데이터가 리스트처럼 __len__을 구현하고 있다면 len(data)로 즉시 개수를 알 수 있지만, 제너레이터나 이터레이터의 경우 끝까지 순회해보기 전에는 그 개수를 알 수 없습니다.

기존의 statistics.fmean은 이 문제를 해결하기 위해 다음과 같은 트릭을 사용했습니다.

Before (기존 코드)

# Lib/statistics.py

try:
    n = len(data)
except TypeError:
    # Handle iterators that do not define __len__().
    counter = count()
    total = fsum(map(itemgetter(0), zip(data, counter)))
    n = next(counter)

위 코드의 흐름을 분석해봅시다.

  1. zip(data, counter)를 호출하여 데이터와 숫자를 묶습니다. 예: (val1, 0), (val2, 1), ...
  2. map(itemgetter(0), ...)를 통해 다시 값만 추출합니다.
  3. fsum이 이 값을 소비하면서 합계를 구합니다.
  4. 마지막에 next(counter)를 호출하여 총 몇 개의 아이템이 지나갔는지 확인합니다.

이 방식은 영리해 보이지만, 치명적인 단점이 있습니다. 매 루프마다 zip에 의해 새로운 튜플 객체가 생성되고, itemgetter에 의해 인덱싱이 발생하며, 다시 튜플이 파괴되는 과정이 반복됩니다. 이는 순수하게 합계만 구하면 되는 작업에 비해 상당한 CPU 사이클을 낭비하게 만듭니다.

개선된 방식: itertools.compress의 재발견

이번 PR에서는 itertools.compress를 활용하여 이 오버헤드를 획기적으로 줄였습니다.

After (변경 후 코드)

# Lib/statistics.py

# ... (생략) ...
from itertools import compress, count, groupby, repeat
# ... (생략) ...

try:
    n = len(data)
except TypeError:
    # Handle iterators that do not define __len__().
    counter = count(1)
    total = fsum(compress(data, counter))
    n = next(counter) - 1

무엇이 달라졌나요?

  1. itertools.compress 사용: compress(data, selectors)selectors의 요소가 참(True)인 경우에만 data의 요소를 반환합니다. 여기서 countercount(1)로 시작하므로 모든 숫자가 참(True)으로 취급됩니다. 즉, data의 모든 요소를 그대로 통과시킵니다.
  2. 객체 생성 오버헤드 제거: zip과 달리 compress는 내부적으로 튜플을 생성하지 않습니다. 단순히 두 이터레이터를 동시에 소비하면서 조건에 맞는 값만 넘겨줄 뿐입니다.
  3. 인덱싱 제거: itemgetter(0)와 같은 추가적인 함수 호출이나 인덱싱 과정이 사라졌습니다.

리뷰어의 피드백: 코드 스타일 유지

이 PR의 리뷰 과정에서 Shrey-N은 기술적인 로직 외에도 코드의 가독성과 유지보수성을 위한 피드백을 남겼습니다.

"Hiya, Keeping the imports in alphabetical order would be better..."

기존 코드에서 compress를 추가할 때 알파벳 순서를 고려하지 않았던 점을 지적했고, 최종 코드는 compress, count, groupby, repeat 순으로 정렬되어 반영되었습니다. 이는 거대한 오픈소스 프로젝트에서 일관된 스타일을 유지하는 것이 얼마나 중요한지 보여주는 대목입니다.

왜 이게 좋은 최적화인가?

1. 성능 수치 (Benchmark)

PR 작성자가 제시한 벤치마크 결과는 놀랍습니다.

  • Before: 18.7 usec per loop
  • After: 11.4 usec per loop
  • 성능 향상: 약 39% 속도 개선

단순히 라이브러리 함수 하나를 바꿨을 뿐인데, 이터레이터를 처리하는 속도가 비약적으로 상승했습니다.

2. 일반적인 교훈

이 최적화는 파이썬 고성능 코딩의 핵심 원칙을 잘 보여줍니다.

  • Avoid Tuple Packing/Unpacking in Loops: 루프 내부에서 발생하는 암묵적인 객체 생성은 파이썬에서 가장 큰 성능 저하 원인 중 하나입니다.
  • Leverage C-implemented Itertools: itertools 모듈의 함수들은 C 언어로 고도로 최적화되어 있습니다. 파이썬 레벨에서 zip + map을 조합하는 것보다 전용 도구인 compress를 사용하는 것이 훨씬 빠릅니다.

마치며

이번 statistics.fmean()의 개선 사례는 "동작하는 코드"를 넘어 "효율적인 코드"로 가는 길을 잘 보여줍니다. 우리가 무심코 사용하는 zip이나 map이 때로는 불필요한 비용을 발생시킬 수 있다는 점을 인지하고, 표준 라이브러리가 제공하는 다양한 도구들을 적재적소에 활용하는 안목을 길러야겠습니다.

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글