Repository navigation
Speed up tarfile.calc_chksums() #160991
Description
Feature or enhancement
Proposal:
tarfile.calc_chksums() runs once per header block, every time a header is read
(TarInfo.frombuf) or written (TarInfo._create_header). It currently builds
two 504-element tuples with struct.unpack_from (one unsigned, one signed) and
sums each one in Python:
unsigned_chksum = 256 + sum(struct.unpack_from("148B8x356B", buf))
signed_chksum = 256 + sum(struct.unpack_from("148b8x356b", buf))
return unsigned_chksum, signed_chksumBoth totals can be had from one C-level sum(buf): a byte b >= 128 adds b
to the unsigned sum and b - 256 to the signed one, so
signed == unsigned - 256 * (number of bytes >= 128). Almost every real header
is pure ASCII, in which case the two sums are equal and bytes.isascii()
detects that cheaply. Otherwise the high bytes are counted with
bytes.translate(None, ...).
The patch (+13 lines, no deletions) keeps the original code verbatim as the
fallback, and only takes the fast path when type(buf) is bytes and len(buf) == BLOCKSIZE, which is the only thing tarfile itself ever passes.
Any other input (memoryview, array, bytearray, other lengths) behaves exactly as
before, including raising struct.error below 512 bytes.
+_HIGH_BYTES = bytes(range(128, 256))
+
def calc_chksums(buf):
...
+ if type(buf) is bytes and len(buf) == BLOCKSIZE:
+ # Fast path for the only input tarfile itself passes. The chksum
+ # field (buf[148:156]) counts as eight spaces, i.e. 256. A byte with
+ # the high bit set adds b to the unsigned sum and b - 256 to the
+ # signed one, so signed == unsigned - 256 * (number of such bytes).
+ unsigned_chksum = 256 + sum(buf) - sum(buf[148:156])
+ if buf.isascii():
+ return unsigned_chksum, unsigned_chksum
+ head = buf[:148] + buf[156:]
+ high = BLOCKSIZE - 8 - len(head.translate(None, _HIGH_BYTES))
+ return unsigned_chksum, unsigned_chksum - 256 * high
unsigned_chksum = 256 + sum(struct.unpack_from("148B8x356B", buf))
signed_chksum = 256 + sum(struct.unpack_from("148b8x356b", buf))
return unsigned_chksum, signed_chksumBenchmarks
The absolute gain per archive is small (milliseconds), so I'm not claiming
this fixes a bottleneck. It is a modest, free speedup on a function called
about once or twice per archive member (PAX archives carry an extra x
header per member).
calc_chksums alone, ASCII header, ns per call (stock / patched):
| Python | stock | patched |
|---|---|---|
| 3.11.10 | 4877 | 1027 |
| 3.14.10 | 3008 | 826 |
| 3.15.2 | 3474 | 827 |
Headers containing high bytes are about 2.6-3.3x faster.
End to end (tarfile.open + iterate + read every member, CPU time via
time.process_time, 25 ABBA-interleaved rounds, geometric mean of the
per-workload median speedups, stock / patched):
| Python | read | list (getmembers) | stream (r|) |
write |
|---|---|---|---|---|
| 3.9 | 1.35x | 1.51x | 1.07x | 1.40x |
| 3.11 | 1.52x | 1.79x | 1.08x | 1.63x |
| 3.12 | 1.34x | 1.52x | 1.05x | 1.38x |
| 3.13 | 1.36x | 1.54x | 1.05x | 1.38x |
| 3.14 | 1.32x | 1.50x | 1.05x | 1.33x |
| 3.15 | 1.40x | 1.61x | 1.39x | 1.38x |
| 3.16.0a1+dev (built from main, 4a9b981) | 1.34x | 1.55x | 1.34x | 1.35x |
Each cell aggregates 20 synthetic workload/operation rows: many small members,
few large members, ustar/gnu/pax formats, high-bit names, and plain/gz/bz2/xz
variants. No row's 95% bootstrap confidence interval reaches 1.0; the smallest
gain is about 1.02x (bz2 stream, where decompression dominates) and the
largest about 2.0x.
For scale, on a synthetic archive of 20,000 x 100-byte members, reading every
member went from 199 ms to 134 ms on the 3.16 build (1.48x), and getmembers()
from 158 ms to 94 ms. Archives with only a few hundred members save a few
milliseconds. Peak memory (tracemalloc) is unchanged to within 3 KB on 3.11,
3.15 and 3.16.
Verification
- Exhaustive equivalence against the original function on 1,118,530 headers
per interpreter (every byte value at every position of 7 base blocks, plus
random, clustered-high-byte and k-high-byte buffers): 0 mismatches on 3.9,
3.11, 3.12, 3.13, 3.14 and 3.15. - The full
test_tarfilesuite passes identically with and without the patch
(3.11.10: 591 tests, 3.12.15: 633, 3.13.16: 666, 3.14.10: 768, and 795 on a
3.16.0a1+dev build of main, with_lzmaand_zstdavailable).test_shutil
also passes identically on that build. - Differential run of stock vs patched
tarfileover about 4.1 million cases
(about 120 base archives including the test data, ustar/gnu/pax, Sun-style
signed-checksum headers; every single-bit flip in the first headers,
truncation at every length, header mutations,r:*,r:,r|,
gz/bz2/xz,extractall): 0 differences. Archives written by the patched
tarfileare byte-identical in every format. The corpus exercises the
unsigned-match, signed-only-match and bad-checksum branches
(about 2.09M, 3,238 and 200k headers in one run). - To check the harness could actually fail, I injected six deliberate bugs
into the patch (signed == unsigned, checksum field not excluded, off-by-one
high-byte count, wrong high-byte threshold, wrong head slice, inverted
isascii); all six were caught.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsNo status
Activity
Anurag-S1ngh commentedon Oct 31, 2026
Hi, I'd like to take this issue. Could you assign it to me?
picnixz commentedon Oct 31, 2026
We don't assign issues and it's polite to ask the OP first. I believe they would be willing to submit a patch because they already have one in mind. Nonetheless, I want to know more ab out the benchmarks first:
bedevere-app commentedon Oct 31, 2026
Thank you for taking the time to open this issue. Python is a community-driven project, and we want it to be a welcoming place to learn and collaborate.
This issue appears to have been written with AI assistance. Please explain your issue in your own words and describe how you checked the information you provided. This helps us understand your issue and leaves more time to address other issues.
Please read our guidelines for using AI tools.
turtle-byte-17 commentedon Oct 31, 2026
Hello,
Benchmark scripts and results
All numbers below are for CPython
3.16.0a1+devbuilt frommainat4a9b98115f(-O3 -DNDEBUG, no PGO/LTO; full configuration below), on an Apple M5 Pro (arm64) macOS laptop that was not isolated (pyperf system tuneis Linux-only). "stock" ismain's unmodifiedLib/tarfile.py; "patched" is the same file with thecalc_chksumschange from this issue. The archives are all synthetic. Earlier results for 3.9, 3.11-3.15 are in the issue description.Build configuration
Out-of-tree build of
mainat4a9b98115fec4d93c32c554aa541d6c3fc4c3b27, macOS 26.7.1 (arm64, Apple M5 Pro), Apple clang 21.0.0 (CC=gccis the Apple clang shim on macOS):No
--enable-optimizations, no--with-lto, no--with-pydebug, so this is a release build without profile-guided or link-time optimization. TheLIBLZMA_*/LIBZSTD_*variables are only there becausepkg-configis not installed on this machine; without them_lzmaand_zstdare not built.Resulting flags (
sysconfig):1.
calc_chksumsmicrobenchmarkTwo versions: a plain
time.perf_counterloop and apyperfone.bench_calc_chksums.py(median of 15 rounds x 20000 calls):bench_calc_chksums_pyperf.py(pyperf2.10.0,--processes 30 --values 5, 31 runs each):2. End to end (
bench_tarfile.py)CPU time (
time.process_time), 25 rounds, stock/patched interleaved ABBA, ratio is the median of the per-round paired ratios, CI is a bootstrap 95% interval on that median.readiterates the archive and reads every regular member,listisgetmembers(),streamis the same asreadinr|mode,writeadds 3000 members.Geometric mean of the median speedups:
{'read': 1.357, 'list': 1.565, 'stream': 1.361, 'write': 1.372}3. Scripts
Run everything with
./run.sh /path/to/python. It takestarfile.pyfrom that interpreter's own (unpatched) stdlib, so use a stock interpreter, or edit thebuild_variants.pycall to point atgit show main:Lib/tarfile.py. For a quick smoke test:BENCH_ARGS="-n 5 --quick" ./run.sh /path/to/python. Thepyperfversion needspyperfinstalled (python -m pip install pyperf).run.shbuild_variants.py(writes mods//stock|patched/tarfile.py)bench_calc_chksums.pybench_calc_chksums_pyperf.pybench_tarfile.pypicnixz commentedon Oct 31, 2026
For the sake of keep a simple code, I don't think I'm going to actually accept this change considering that, as you observed, the overall E2E operation isn't really affected.
What I find odd is that 3.15+ has a larger speed-up for the stream mode as if we were somehow slower. The speed-ups on previous versions are less pronounced.
That being said, I find it attractive as an overall speed-up but I'm not confident in accepting (I still need to convince myself that the checksum computation is equivalent which I hadn't check yet, even if it's valid with your assertions I want to convince myself of the equal arithmetic). I also wonder how it fares on real data (say, compressed Python sources) or when it comes to images.
Also, why limiting yourself to bytes of size BLOCKSIZE only? wouldn't it work for any bytes-like object (memoryviews, etc)? the only thing you need is
len(head.translate(None, _HIGH_BYTES))to count the number of occurrences of high bytes right? this can be achieved in a more generic way for sequences (or simply converting to bytes for the method).Alternatively, since calc_chksums() is only used with a blocks of size 512, let's have a private
_calc_chksums512()which would be your optimization. The public (albeit undocumented function) could still be left as is (the fast path looks weird when you check the code).cc @encukou @StanFromIreland
turtle-byte-17 commentedon Oct 31, 2026
@picnixz
I don't think speed up is indicitive that 3.15 is slower, what I measured was roughly a 55ms gain, however the baseline was different
On 3.14 and earlier, stream mode is about 3x slower than read, so a fixed ~55 ms saving shows as only 1.05–1.1x. On 3.15+ the stream path is fast, so the same saving shows as 1.39-1.41x.
I agree with you that we should try to keep it simple and extract it into a private function, here's the diff I ran with initially, as you'll see it introduced a regression in the reading of non-ASCII header, signed checksum and the corrupt checksum cases, which my original proposal did not. Below this section I'm leaning towards
Revised proposal: private
_calc_chksum512()calc_chksums()is left as is. The optimisation is a private helper used by_create_headerand as a quick first check in_frombuf:pyperfcomparison (plain build)bench_chksum_check_pyperf.py,pyperf2.10.0,--processes 20 --values 3, mean ± std dev. It times the checksum check_frombufdoes per header (and the one_create_headerdoes for writes) for stock vs the proposal:_create_header)Normal archives, including non-ASCII headers, get about 3.3-3.6x faster. The cost is on the rarely used path: a header whose stored checksum is not the unsigned one (signed checksums written by old Sun/NeXT
tar, or a corrupt header) pays for one extrasum()before the original function runs.Real data (
bench_tarfile.py --lib-root, 25 rounds, CPU time)Every
Lib/**/*.pyfile of the checkout (2082 files, 44.9 MB tar, 8.3 MB.gz) and the 65 image files in it (4.5 MB tar),readiterates and reads every member,listisgetmembers(). stock → new, median of 25 paired rounds, the ratio is stock/new:-O3The gain shrinks when decompression dominates (
.xz, gzip of already-compressed images). Worst case, an archive where every header carries only the signed checksum:-O3So I think best of both worlds is to keep the private function but with the original proposal for the checksum calculation
Revised proposal: private
_calc_chksums512()calc_chksums()is left as is. The optimisation is a private helper that returns both checksums for the 512-byte header block, and_create_header/_frombufuse it instead ofcalc_chksums():Both callers only ever pass exactly
BLOCKSIZEbytes (_frombufraisesTruncatedHeaderErrorfor any other length before it gets to the checksum, and_create_headerbuilds its buffer withstruct.pack("%ds" % BLOCKSIZE, ...)), so the helper has no type or length checks. There is no fallback path, so every header gets the fast computation: ASCII headers need onesum()and anisascii(), non-ASCII ones also count their high bytes.calc_chksums()is no longer called bytarfileitself and is left in place for compatibility.Numbers are for CPython
3.16.0a1+devbuilt frommainat4a9b98115fon an Apple M5 Pro (arm64, macOS), not isolated, so compare the ratios and not the absolute times between tables. "plain" is the-O3 -DNDEBUGbuild, "PGO+LTO" is--enable-optimizations --with-lto(that tree also had unrelated uncommitted edits toLib/csv.pyandLib/gettext.py, which don't touchtarfile).pyperfcomparison (plain build)pyperf2.10.0,--processes 20 --values 3, mean ± std dev. It times the checksum check_frombufdoes per header (and the one_create_headerdoes for writes), stock vs the proposal:_create_header)Every case is faster (2.7x-3.5x), including non-ASCII headers carrying the signed checksum that old Sun/NeXT
tarwrote and headers with a corrupt checksum. No case regresses.Real data (
bench_tarfile.py --lib-root, 25 rounds, CPU time)Every
Lib/**/*.pyfile of the checkout (2082 files, 44.9 MB tar, 8.3 MB.gz) and the 65 image files in it (4.5 MB tar).readiterates and reads every member,listisgetmembers(). Stock → new, median of 25 paired rounds, the ratio is stock/new:-O3The gain shrinks when decompression dominates (
.xz, gzip of already-compressed images). An archive where every header carries only the signed checksum:-O3Geometric mean of the median speedups over every row of the run (synthetic, real-data and signed-checksum archives, plus streaming and write): plain
{read: 1.326, list: 1.468, stream: 1.224, write: 1.382}, PGO+LTO{read: 1.381, list: 1.525, stream: 1.245, write: 1.431}.Checking equivalence
_calc_chksums512(buf)returns exactlycalc_chksums(buf)on 1,118,530 buffers per interpreter (every byte value at every position of 7 base blocks, plus random, clustered-high-byte and k-high-byte buffers): 0 mismatches on 3.9, 3.11, 3.12, 3.13, 3.14, 3.15 and 3.16.0a1+dev.chksum in _calc_chksums512(buf)equals the originalchksum in calc_chksums(buf)for stored checksums in {unsigned, signed, unsigned+1, signed-1, unsigned+256, 0, random}: 1,808,590 decisions per interpreter, 0 mismatches.tarfileover about 667-671 thousand archive cases (ustar/gnu/pax, signed-checksum headers, bit flips, truncations, header mutations,r:*/r:/r|, gz/bz2/xz,extractall) found 0 differences on all seven interpreters, and archives written by the patchedtarfileare byte-identical to stock.picnixz commentedon Oct 31, 2026
I wasn't thinking of that. I was thinking of
_calc_chksum512doing exactly your fast path you suggested. And please, don't post very large LLM answers like that, or at loeast change the tone of the text because I'm really annoyed by how AI write in general.I wanted a mathematical proof, not a check like that. Equivalence through examples, unless the entire space is checked is not releavnt.
For the E2E numbers: I wanted to know how long a single compress/decompress operation would take. If we are optimizing a cold path, it doesn't matter IMO. But if it's a hot path and the overall time decreases significantly and is visible, then it's ok to optimize.