Skip to content

Stream package extraction to bound peak memory usage - #410

Merged
zooba merged 2 commits into
python:mainfrom
joaogabriel15:fix/stream-package-extraction
Sep 10, 2026
Merged

Stream package extraction to bound peak memory usage#410
zooba merged 2 commits into
python:mainfrom
joaogabriel15:fix/stream-package-extraction

Conversation

@joaogabriel15

Copy link
Copy Markdown
Contributor

Fixes #409.

Extract ZIP members in 1 MiB chunks instead of materializing each uncompressed member in memory. Destination validation, repair behavior, overwrite warnings, and progress reporting are unchanged.

Adds regression coverage for ZIP and NuGet archives, multi-chunk files, empty files, existing files, and repair. The four new cases fail against the original extraction function and pass with the change.

Validation on Windows, CPython 3.14.3:

  • Installation tests: 28 passed.
  • Full suite: 433 passed, 28 failed. The same 28 verification/download tests also fail with the original extraction function in this environment.
  • Synthetic 64 MiB deflated member: tracemalloc peak decreased from 141.74 MiB to 3.42 MiB. This measures Python allocations, not process RSS or real-package installation throughput.

@python-cla-bot

python-cla-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

Comment thread src/manage/install_command.py Outdated
with open(dest, "wb") as f:
f.write(zf.read(member))
with zf.open(member) as source, open(dest, "wb") as f:
shutil.copyfileobj(source, f, length=1024 * 1024)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How badly does it affect the maximum memory usage if we make this use 10MB instead of 1MB? I'd like our standard Python distributions to still only use one call per file, and the biggest file we include is (currently) around 7MB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I measured this on Windows 11 with CPython 3.14.3, using the PR's actual extract_package function and changing only the copyfileobj buffer size in an in-memory benchmark variant.

For synthetic ZIP_DEFLATED archives containing one member each, the median tracemalloc peak across five extractions was:

Uncompressed member size 1 MiB buffer 10 MiB buffer Increase
7 MiB 3.34 MiB 17.32 MiB 13.98 MiB
64 MiB 3.78 MiB 30.67 MiB 26.90 MiB

Archive creation was outside the measured region. Each extraction used a fresh destination, and I verified the extracted contents afterward. The payload was a repeating bytes(range(256)) pattern, so these are highly compressible synthetic cases, not measurements of standard runtime packages. These figures measure peak traced Python allocations during extraction, not total process RSS; they include zipfile/decompression allocations as well as the copy buffer.

The 10 MiB buffer therefore costs more than just the extra 9 MiB of buffer capacity, but still bounds memory for oversized members. A 7 MiB file fits in one data-bearing read/write with that buffer (copyfileobj also performs a final EOF read).

Punisheroot has also posted complementary measurements using real 3.12/3.13/3.14 packages here: #409 (comment) . Their reported results likewise support 10 MiB as a compromise: single data-bearing reads for normal distribution files, with bounded memory for oversized files.

Based on these results, 10 MiB looks reasonable to me for the goal you described.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up with measurements using real official runtime packages rather than synthetic files.

I tested the AMD64 ZIP distributions for Python 3.12.10, 3.13.15, and 3.14.7 from the official Windows install-manager index, verifying each download against its published SHA-256. Together they contain 10,241 files and 376,099,043 uncompressed bytes; the largest member is 6,945,272 bytes.

Setup: Windows 11 (build 26200), CPython 3.14.3 AMD64. Each run used a fresh process and destination and extracted all three packages sequentially using this PR's extract_package function. The variants changed only the copy operation: original zf.read(), 1 MiB copyfileobj, or 10 MiB copyfileobj. There was one warm-up per variant, then five measured runs per variant in rotating order.

Variant Median extraction time Time range Median tracemalloc peak Median peak process working set
Original whole-member read 9.50 s 9.23–14.09 s 25.05 MiB 50.66 MiB
1 MiB buffer 11.91 s 9.67–15.57 s 7.33 MiB 40.86 MiB
10 MiB buffer 10.23 s 9.78–20.11 s 21.70 MiB 50.75 MiB

Moving from 1 MiB to 10 MiB increased the median traced peak by 14.38 MiB and peak working set by 9.88 MiB. Compared with the original, 10 MiB reduced traced allocations somewhat, but the process working-set peak was essentially unchanged on these normal-sized packages.

A separate instrumented extraction confirmed that all 10,164 non-empty files completed in exactly one data-bearing read with 10 MiB. There were 20,405 reads including EOF reads across all 10,241 files. This diagnostic run was excluded from the measurements above.

SHA-256 output manifests, including relative paths and per-file digests, matched across all 18 benchmark executions and the separate read-count run.

Limitations: downloads, imports, integrity hashing, and cleanup were excluded from extraction timing. tracemalloc was enabled during all timed runs, so timings include its overhead. Windows working-set peaks were read with GetProcessMemoryInfo immediately after extraction, before hashing, and include the interpreter/tracing machinery. OS caches were not flushed and background activity was not controlled. The timing ranges overlap substantially, so I would not claim a reliable speed improvement from this run.

This confirms that 10 MiB meets the single-data-read goal for these distributions, at the cost of giving up much of the normal-package memory saving of 1 MiB. Oversized members remain a separate case, covered by the earlier synthetic measurements. No production code has been changed for this benchmark.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make it 10MB then. In practice, I think this will help most with actual "bad" cases such as a custom package that contains a 2GB file in it, which will no longer attempt to allocate all of that in one go.

@zooba
zooba merged commit fe0d239 into python:main Sep 10, 2026
6 checks passed
@zooba

zooba commented Sep 10, 2026

Copy link
Copy Markdown
Member

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reduce peak memory usage during package extraction

2 participants