I/O는 더 이상 병목이 아니다
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
프로그래머를 면접할 때 저는 종종 텍스트 파일에서 단어 빈도를 세는 간단한 프로그램을 코드로 작성해 보라고 합니다. 여러 역량을 테스트할 수 있는 좋은 문제이고, 후속 질문을 몇 가지 덧붙이면 놀랍도록 깊이 있게 파고들 수 있습니다.
제가 하는 후속 질문 중 하나는 “프로그램에서 성능 병목은 어디인가요?”입니다. 대부분 “입력 파일을 읽는 부분”이라고 답합니다.
사실 이 글을 쓰게 된 계기는 Gopher Slack에서 누군가에게 답글을 달다가였습니다. 그는 이렇게 말했습니다. “여기서 전체 라인을 split하는 등 추가 작업이 많이 일어나고 있다는 점도 알고 있어요. 다만 보통은 이 모든 게 I/O보다 훨씬 빨라서 신경 쓰지 않죠.”
그분을 탓하려는 건 아닙니다 … 저도 count-words 문제의 성능을 분석하기 전에는 똑같이 생각했습니다. 우리 모두 그렇게 배워왔잖아요? “I/O는 느리다”라고요.
이제는 아닙니다! 10년이나 20년 전에는 디스크 I/O가 느렸을지 몰라도, 2022년 지금은 디스크에서 파일을 순차적으로 읽는 속도는 매우 빠릅니다.
얼마나 빠를까요? 이 방법을 사용해 제 개발용 노트북의 읽기 및 쓰기 속도를 테스트했지만, count=4096으로 설정해 4GB를 읽고 썼습니다. 다음은 Samsung PM9A1 NVMe 드라이브를 탑재하고 Ubuntu 22.04를 실행하는 2022년형 Dell XPS 13 Plus에서의 결과입니다:
| I/O 유형 | 속도 (GB/s) |
|---|---|
| 읽기 (캐시 미사용) | 1.7 |
| 읽기 (캐시 사용) | 10.8 |
| 쓰기 (sync 시간 포함) | 1.2 |
| 쓰기 (sync 시간 미포함) | 1.6 |
물론 시스템 콜은 상대적으로 느리지만, 순차적으로 읽거나 쓸 때는 버퍼 크기에 따라 4KB나 64KB마다 한 번씩만 syscall을 하면 됩니다. 그리고 네트워크를 통한 I/O는 여전히 느리며, 특히 원격 네트워크에서는 더욱 그렇습니다.
그렇다면 위와 같은 단어 빈도를 세는 프로그램에서 병목은 도대체 무엇일까요? 입력에 대한 처리 혹은 파싱과 그에 수반되는 메모리 할당입니다. 입력을 단어로 나누고, 소문자로 변환하고, 해시 테이블로 빈도를 세는 과정이죠.
저는 Python과 Go로 작성한 count-words 프로그램을 수정해 과정의 각 단계에 걸리는 시간을 기록했습니다. 입력 읽기, 처리(가장 느린 부분), 빈도순 정렬, 그리고 출력입니다. 413MB짜리 텍스트 파일로 실행했는데, 꽤 많은 양입니다(킹 제임스 성경 텍스트 100개를 이어 붙인 파일입니다).
아래는 3번 실행 중 가장 좋은 기록을 초 단위로 나타낸 결과입니다:
| 단계 | Python | Go (단순 버전) | Go (최적화 버전) |
|---|---|---|---|
| 읽기 | 0.384 | 0.499 | 0.154 |
| 처리 | 7.980 | 3.492 | 2.249 |
| 정렬 | 0.005 | 0.002 | 0.002 |
| 출력 | 0.010 | 0.009 | 0.010 |
| 합계 | 8.386 | 4.000 | 2.414 |
여기서 정렬과 출력은 무시해도 될 정도입니다. 입력이 100개의 복사본으로 이루어져 있어 고유 단어 수가 상대적으로 적기 때문입니다. 여담이지만, 이것도 면접에서 흥미로운 후속 질문이 됩니다. 일부 지원자는 정렬이 O(N log N)이고 입력 처리는 O(N)이므로 정렬이 병목이 될 거라고 말합니다. 하지만 여기서 N이 두 종류로 다르다는 점을 쉽게 잊습니다. 파일 전체의 단어 수와 고유 단어 수가 각각 다른 N인 셈이죠.
Python 버전의 핵심은 몇 줄의 코드로 요약됩니다:
content = sys.stdin.read()
counts = collections.Counter(content.lower().split())
most_common = counts.most_common()
for word, count in most_common:
print(word, count)Python에서는 한 줄씩 읽는 것도 쉽지만 조금 더 느리기 때문에, 여기서는 파일 전체를 메모리에 한 번에 읽어 들여 처리했습니다.
단순 Go 버전도 같은 방식을 사용합니다. 다만 Go 표준 라이브러리에는 collections.Counter가 없으므로, “most common” 정렬을 직접 구현해야 합니다.
최적화된 Go 버전은 훨씬 빠르지만, 그만큼 훨씬 더 복잡합니다. 소문자로 변환하고 단어 경계를 기준으로 나누는 작업을 제자리에서 수행해 대부분의 메모리 할당을 피했습니다. CPU 바운드 코드를 최적화할 때 유용한 경험칙입니다. 메모리 할당을 줄이라는 것이죠. 프로파일링 방법은 제 count-words 최적화 글을 참고하세요.
최적화된 Python 버전을 보여드리지 않은 이유는 Python을 더 최적화하기가 어렵기 때문입니다! (시간을 8.4초에서 7.5초까지 줄이는 것이 고작이었습니다). 그나마 이 정도 속도가 나오는 이유는 핵심 연산이 C 코드에서 실행되기 때문입니다. 그래서 “Python은 느리다”는 말이 그토록 자주 무의미해지는 것이죠.
보시다시피 단순 Go 버전에서 디스크 I/O는 전체 실행 시간의 14%에 불과합니다. 최적화 버전에서는 읽기와 처리 모두를 더 빠르게 만들었고, 디스크 I/O는 전체의 7%만을 차지합니다.
제 결론은 이렇습니다. “빅데이터”를 처리하고 있다면 디스크 I/O는 아마 병목이 아닐 겁니다. 조금만 측정해 봐도 병목이 파싱과 메모리 할당에 있다는 것을 알게 될 가능성이 큽니다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기