[cpython] tarfile 스트리밍 모드(r|*) 성능 개선: 파이썬 압축 파일 처리의 숨겨진 병목 제거
PR 링크: python/cpython#121296 상태: Merged | 변경: +39 / -22
tarfile 스트리밍 모드(r|*) 성능 개선: 파이썬 압축 파일 처리의 숨겨진 병목 제거
들어가며
파이썬의 tarfile 모듈은 .tar, .tar.gz, .tar.bz2, .tar.xz 등 다양한 압축 형식의 아카이브 파일을 다루는 데 필수적인 도구입니다. 이 모듈은 파일을 직접 열어 처리하는 r:* (direct mode)와 파이프를 통해 스트리밍 방식으로 처리하는 r|* (streaming mode) 두 가지 주요 읽기 모드를 제공합니다. r|* 모드는 특히 표준 입력에서 압축된 데이터를 읽거나, subprocess 모듈과 연동하여 외부 프로세스의 출력을 직접 처리할 때 유용하게 사용됩니다.
하지만 최근 CPython GitHub 저장소의 gh-121109 이슈에서 tarfile의 r|* 스트리밍 모드가 특정 상황, 특히 많은 수의 작은 파일로 구성된 아카이브를 처리할 때 r:* 모드에 비해 "수십 배" 느려지는 심각한 성능 문제가 보고되었습니다. 이 문제는 비효율적인 내부 버퍼링 로직에서 비롯되었으며, TomiBelan 님의 PR (gh-121109: Fix performance of tarfile reading with "r|*")을 통해 성공적으로 해결되었습니다. 이 글에서는 해당 PR의 코드 변경사항을 깊이 있게 분석하여, 무엇이 왜 좋은 최적화/개선인지 설명하고자 합니다.
문제의 원인: 비효율적인 버퍼링
이전 tarfile._Stream 클래스의 _read 메서드는 압축 해제된 데이터를 self.dbuf라는 인스턴스 변수에 계속해서 누적하는 방식으로 동작했습니다. 파이썬의 bytes 객체는 불변(immutable)하기 때문에, self.dbuf += new_data와 같은 연산은 매번 새로운 bytes 객체를 생성하고 이전 데이터를 복사하는 오버헤드를 발생시킵니다. 특히 _read가 작은 size로 자주 호출될 때, self.dbuf의 크기가 커질수록 이 복사 비용은 기하급수적으로 증가하여 O(N^2)에 가까운 성능 저하를 일으켰습니다.
# Before: Lib/tarfile.py (excerpt from _Stream.__init__ and _init_read_gz)
# In __init__ for bz2, lzma, zstd
if mode == "r":
self.dbuf = b""
self.cmp = bz2.BZ2Decompressor()
self.exception = OSError
# In _init_read_gz
self.cmp = self.zlib.decompressobj(-self.zlib.MAX_WBITS)
self.dbuf = b""
위 코드에서 볼 수 있듯이, self.dbuf는 각 압축 해제기 초기화 시 빈 bytes 객체로 초기화됩니다. 이 dbuf는 _read 메서드 내에서 다음과 같이 사용되었습니다.
# Before: Lib/tarfile.py (excerpt from _Stream._read)
c = len(self.dbuf)
t = [self.dbuf]
while c < size:
# Skip underlying buffer to avoid unaligned double buffering.
if self.buf:
buf = self.buf
self.buf = b""
else:
buf = self.fileobj.read(self.bufsize)
if not buf:
break
try:
buf = self.cmp.decompress(buf)
except self.exception as e:
raise ReadError("invalid compressed data") from e
t.append(buf)
c += len(buf)
t = b"".join(t)
self.dbuf = t[size:]
return t[:size]
이전 코드에서는 self.dbuf에 이미 누적된 데이터를 t 리스트에 넣고, 새로 압축 해제된 buf를 t에 추가한 뒤 b"".join(t)로 한 번에 합치는 방식을 사용했습니다. 문제는 self.dbuf = t[size:] 라인에서 발생합니다. _read(size)가 요청한 size만큼의 데이터를 반환한 후, 남은 데이터를 다시 self.dbuf에 저장하는데, 이때 t[size:]는 t 전체를 복사하는 새로운 bytes 객체를 생성합니다. 이 self.dbuf가 다음 _read 호출 시 t의 첫 요소로 다시 들어가면서, 매번 불필요한 복사가 반복되어 성능 저하의 주범이 되었습니다.
코드 분석: Lib/tarfile.py의 핵심 변경사항
이 PR은 self.dbuf의 비효율적인 사용을 제거하고, 각 압축 해제기의 특성을 고려하여 _read 메서드의 로직을 재구성했습니다.
1. self.dbuf 초기화 제거
가장 먼저 눈에 띄는 변화는 _Stream 클래스의 생성자(__init__)와 _init_read_gz 메서드에서 self.dbuf = b"" 라인이 제거된 것입니다. 이는 self.dbuf가 더 이상 누적 버퍼로 사용되지 않음을 의미합니다.
diff --git a/Lib/tarfile.py b/Lib/tarfile.py
index b5b28cff419a712..9fc53b949ea373c 100644
--- a/Lib/tarfile.py
+++ b/Lib/tarfile.py
@@ -380,7 +380,6 @@ def __init__(self, name, mode, comptype, fileobj, bufsize,
except ImportError:
raise CompressionError("bz2 module is not available") from None
if mode == "r":
- self.dbuf = b""
self.cmp = bz2.BZ2Decompressor()
self.exception = OSError
else:
@@ -392,7 +391,6 @@ def __init__(self, name, mode, comptype, fileobj, bufsize,
except ImportError:
raise CompressionError("lzma module is not available") from None
if mode == "r":
- self.dbuf = b""
self.cmp = lzma.LZMADecompressor()
self.exception = lzma.LZMAError
else:
@@ -403,7 +401,6 @@ def __init__(self, name, mode, comptype, fileobj, bufsize,
except ImportError:
raise CompressionError("compression.zstd module is not available") from None
if mode == "r":
- self.dbuf = b""
self.cmp = zstd.ZstdDecompressor()
self.exception = zstd.ZstdError
else:
@@ -485,7 +482,6 @@ def _init_read_gz(self):
"""Initialize for reading a gzip compressed fileobj.
"""
self.cmp = self.zlib.decompressobj(-self.zlib.MAX_WBITS)
- self.dbuf = b""
# taken from gzip.GzipFile with some alterations
if self.__read(2) != b"\037\213":
2. _read 메서드의 로직 재구성
가장 중요한 변경은 _read 메서드 내부에서 발생했습니다. gzip과 다른 압축 방식(bz2, lzma, zstd)의 압축 해제 인터페이스가 다르다는 점을 고려하여 로직을 분리하고, self.dbuf를 사용하지 않는 방식으로 변경되었습니다.
diff --git a/Lib/tarfile.py b/Lib/tarfile.py
index b5b28cff419a712..9fc53b949ea373c 100644
--- a/Lib/tarfile.py
+++ b/Lib/tarfile.py
@@ -543,26 +539,44 @@ def _read(self, size):
if self.comptype == "tar":
return self.__read(size)
- c = len(self.dbuf)
- t = [self.dbuf]
+ c = 0
+ t = []
while c < size:
- # Skip underlying buffer to avoid unaligned double buffering.
- if self.buf:
- buf = self.buf
- self.buf = b""
+ if self.comptype == "gz":
+ # zlib interface is different than others.
+ # It returns data in unconsumed_tail.
+ if self.buf:
+ cbuf = self.buf
+ self.buf = b""
+ else:
+ cbuf = self.fileobj.read(self.bufsize)
+ if not cbuf:
+ break
+
+ try:
+ dbuf = self.cmp.decompress(cbuf, size - c)
+ self.buf = self.cmp.unconsumed_tail
+ except self.exception as e:
+ raise ReadError("invalid compressed data") from e
else:
- buf = self.fileobj.read(self.bufsize)
- if not buf:
- break
- try:
- buf = self.cmp.decompress(buf)
- except self.exception as e:
- raise ReadError("invalid compressed data") from e
- t.append(buf)
- c += len(buf)
- t = b"".join(t)
- self.dbuf = t[size:]
- return t[:size]
+ # Other decompressors have needs_input.
+ # decompress() can buffer data internally.
+ if self.cmp.needs_input:
+ cbuf = self.fileobj.read(self.bufsize)
+ if not cbuf:
+ break
+ else:
+ cbuf = b""
+
+ try:
+ dbuf = self.cmp.decompress(cbuf, size - c)
+ except self.exception as e:
+ raise ReadError("invalid compressed data") from e
+
+ t.append(dbuf)
+ c += len(dbuf)
+
+ return b"".join(t)
주요 변경 사항 분석:
self.dbuf제거: 이전 코드에서self.dbuf를t리스트에 넣고 남은 데이터를 다시self.dbuf에 할당하는 비효율적인 로직이 완전히 사라졌습니다. 이제t는 현재_read호출에서 필요한 데이터만 임시로 저장하는 역할을 합니다.gzip특화 처리:zlib.decompressobj는unconsumed_tail속성을 통해 압축 해제되지 않은 나머지 데이터를 반환합니다. 이 PR은 이unconsumed_tail을self.buf에 저장하여 다음_read호출 시 재사용하도록 했습니다. 이는gzip압축 해제기의 특성을 정확히 반영한 효율적인 처리 방식입니다.- 다른 압축 방식 처리:
bz2,lzma,zstd와 같은 다른 압축 해제기들은needs_input속성을 통해 추가 입력이 필요한지 여부를 알려줍니다. 이 PR은needs_input이True일 때만fileobj에서 데이터를 읽어decompress메서드에 전달합니다. 이는 압축 해제기가 내부적으로 데이터를 버퍼링할 수 있는 경우 불필요한 파일 I/O를 줄여줍니다. b"".join(t)의 효율적인 사용: 모든 압축 해제된 청크(dbuf)는t리스트에 추가되고, 최종적으로b"".join(t)를 통해 한 번에 하나의bytes객체로 결합됩니다. 이는bytes객체를 반복적으로 연결하는 것보다 훨씬 효율적인 파이썬의 표준 패턴입니다.
리뷰 과정에서 TomiBelan 님은 원래 패치가 이해하기 어렵다고 판단하여 gzip과 비-gzip 케이스를 완전히 분리하여 코드를 재작성했습니다. 이로 인해 코드 길이는 다소 늘어났지만, 각 압축 방식의 특성을 명시적으로 처리하여 가독성과 유지보수성이 향상되었습니다. 이는 "명시적인 것이 암시적인 것보다 낫다"는 파이썬의 철학을 잘 따르는 개선입니다.
왜 이 최적화가 좋은가?
이 PR은 tarfile 모듈의 r|* 스트리밍 모드에서 발생하던 심각한 성능 병목을 해결하고, 전반적인 효율성을 크게 향상시켰습니다.
1. 획기적인 성능 향상
가장 큰 장점은 r|* 모드의 성능이 r:* 모드와 거의 동일한 수준으로 개선되었다는 점입니다. PR 설명에 포함된 초기 벤치마크 결과는 다음과 같습니다.
| filename | mode | time with PR | Before PR (estimated) |
|---|---|---|---|
test.tar.gz |
`r | *` | 0.812s |
test.tar.xz |
`r | *` | 1.053s |
test.tar.bz2 |
`r | *` | 0.896s |
이 벤치마크는 tf.list()를 사용한 것으로, r|* 모드가 r:* 모드와 거의 동일한 속도를 보임을 보여줍니다. 특히, TomiBelan 님이 제공한 "Many small files" 벤치마크 결과는 이 최적화의 진정한 가치를 보여줍니다.
small25M.tar.bz2 (20000개 이상의 작은 파일, 총 25MB) 읽기 벤치마크:
| Mode | Before PR (origin/main) | After PR |
|---|---|---|
| `r | *` | 110.935s |
r:* |
2.053s | 2.077s |
r|* 모드에서 bz2 압축 파일을 읽는 데 걸리는 시간이 110초에서 2초 미만으로 단축되었습니다. 이는 약 55배의 성능 향상이며, r:* 모드와 거의 동일한 수준으로 빨라진 것입니다. gz, xz, zst 압축 파일에서도 유사하게 r|* 모드의 성능이 r:* 모드와 동등하게 개선되었습니다. 이는 self.dbuf의 비효율적인 복사로 인한 O(N^2) 시간 복잡도 문제가 O(N)으로 개선되었음을 명확히 보여줍니다.
또한, 압축률이 낮은 무작위 데이터(random300M.tar.*)에 대한 벤치마크에서도 r|* 모드의 성능이 r:* 모드와 비슷하거나 약간 더 빨라지는 경향을 보여, 이 최적화가 특정 시나리오에만 국한되지 않고 전반적인 효율성을 높였음을 알 수 있습니다.
2. 메모리 효율성 향상
이전 코드에서는 self.dbuf가 압축 해제된 데이터를 계속 누적하여 메모리 사용량이 무한정 커질 수 있었습니다. 이 PR은 self.dbuf를 제거하고 필요한 데이터 청크만 t 리스트에 임시로 저장한 후 한 번에 결합하는 방식으로 변경하여, 메모리 사용량을 최적화했습니다. 이는 특히 대용량 파일을 처리할 때 시스템 자원 소모를 줄이는 데 기여합니다.
3. 코드 명확성 및 유지보수성
gzip과 다른 압축 해제기의 인터페이스 차이를 명시적으로 분리하여 처리함으로써, 코드의 가독성이 향상되었습니다. 각 압축 해제기의 특성에 맞는 최적의 로직을 적용할 수 있게 되어, 향후 유지보수 및 확장이 용이해졌습니다.
4. 일반적인 교훈
이 최적화는 파이썬에서 bytes 객체를 다룰 때의 중요한 교훈을 제공합니다. bytes 객체는 불변이므로, 반복적인 += 연산이나 슬라이싱을 통한 재할당은 성능 저하를 일으킬 수 있습니다. 대신, list에 청크를 모아둔 다음 b"".join(list_of_bytes)를 사용하여 한 번에 결합하는 것이 훨씬 효율적입니다. 또한, 스트리밍 및 버퍼링 로직을 설계할 때는 사용하려는 라이브러리(여기서는 압축 해제기)의 인터페이스(unconsumed_tail, needs_input 등)를 정확히 이해하고 활용하는 것이 중요합니다.
향후 개선 방향
PR의 저자인 TomiBelan 님은 tarfile._Stream 클래스를 완전히 제거하고, compression._common._streams.DecompressReader와 같은 표준 라이브러리의 다른 스트림 처리 클래스를 활용하는 "겸손한 제안(modest proposal)"을 언급했습니다. 이는 gzip.GzipFile, bz2.BZ2File 등과 같이 이미 최적화된 파일 객체를 tarfile이 직접 활용하도록 하여 코드 중복을 줄이고, 더욱 견고하고 효율적인 스트림 처리를 구현할 수 있는 장기적인 리팩토링 방향을 제시합니다. 물론 이는 이 PR의 범위를 넘어서는 큰 변경이지만, tarfile 모듈의 미래를 위한 흥미로운 제안입니다.
결론
이 PR은 tarfile 모듈의 r|* 스트리밍 모드에서 발생하던 치명적인 성능 문제를 해결하여, 파이썬의 압축 파일 처리 능력을 한 단계 끌어올렸습니다. 비효율적인 bytes 버퍼링 로직을 제거하고, 각 압축 해제기의 특성을 고려한 효율적인 스트림 처리 방식을 도입함으로써, r|* 모드의 성능을 r:* 모드와 동등한 수준으로 끌어올렸습니다. 이는 CPython 개발자들이 사용자 경험과 성능 개선을 위해 얼마나 끊임없이 노력하는지를 보여주는 좋은 사례입니다. 이 최적화는 파이썬을 사용하는 많은 개발자에게 더 빠르고 안정적인 압축 파일 처리 경험을 제공할 것입니다.
참고 자료
- https://docs.python.org/3/library/tarfile.html
- https://docs.python.org/3/library/zlib.html
- https://docs.python.org/3/library/bz2.html
- https://docs.python.org/3/library/lzma.html
- https://github.com/python/cpython/issues/121109
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
PR Analysis 의 다른글
- 이전글 [sglang] SGLang 스케줄러 최적화: input_ids H2D 지연 처리 및 FutureMap 통합
- 현재글 : [cpython] tarfile 스트리밍 모드(r|*) 성능 개선: 파이썬 압축 파일 처리의 숨겨진 병목 제거
- 다음글 [sglang] SGLang의 KV-Canary JIT 커널 도입: 효율적인 KV 캐시 검증 최적화
댓글