Skip to content

Add io_whole_file benchmark and an opt-in io group - #498

Open
jayzuccarelli wants to merge 2 commits into
python:mainfrom
jayzuccarelli:io-whole-file
Open

jayzuccarelli wants to merge 2 commits into
python:mainfrom
jayzuccarelli:io-whole-file

Conversation

@jayzuccarelli

@jayzuccarelli jayzuccarelli commented Oct 27, 2026 •

Copy link
Copy Markdown

First of three PRs for #399, one benchmark each as suggested there. This one adds the shared piece (an io group that is not part of default) together with the first benchmark that uses it.

What it measures

io_whole_file reads and writes a whole file in a single call, which is the pattern gh-120754 optimized:

Each result measures a single API:

  • read_bytes_{4k,1m}: open(path, "rb").read()
  • write_bytes_{4k,1m}: open(path, "wb").write(data)
  • read_text_1m / write_text_1m: Path.read_text() / Path.write_text(), utf-8, ASCII content

Path.read_bytes() / write_bytes() are thin wrappers over the same open() calls, so they are not measured separately.

6 results. 4 KiB is where per-call overhead and the number of system calls dominate; 1 MiB is where buffer allocation and copying dominate. I had an 8 MiB size too, but it scaled linearly from 1 MiB and sits closer to dirty page throttling, so I dropped it to keep the result count small.

Everything runs in a fresh temp dir with a warm page cache and no fsync, so it measures Python's I/O stack and syscall overhead rather than the disk. Writes always create a new file (unlinked outside the timed region): truncating and rewriting an existing file on ext4 triggers writeback on close (auto_da_alloc), which made a 16 MiB rewrite take ~490 ms +- 220 ms instead of ~30 ms. The run records io.DEFAULT_BUFFER_SIZE in the metadata.

Setup allocates and frees one 8 MiB block before anything is measured. On glibc that raises malloc's dynamic mmap/trim thresholds above the 1 MiB working set; without it read_text_1m (which frees two 1 MiB blocks per call) sat right at the trim threshold and, depending on heap history, flipped between ~430 us and ~1.3 ms on the same interpreter because the heap top was handed back to the kernel and faulted in again every call.

Why opt-in

Per the issue, I/O benchmarks shouldn't run by default. The benchmark is tagged io, [group io] picks it up through the tag, and [group default] excludes it by name (a -io group exclusion is silently ignored by the manifest parser, so it has to name the benchmark). pyperformance list is unchanged at 100 benchmarks; -b io selects it.

Sample timings (pyperformance run -b io_whole_file --fast, one untuned Linux box, ext4 /tmp)

result 3.13.13 3.14.4
io_whole_file_read_bytes_4k 15.6 us 13.8 us
io_whole_file_write_bytes_4k 34.0 us 34.2 us
io_whole_file_read_bytes_1m 153 us 148 us
io_whole_file_write_bytes_1m 912 us 916 us
io_whole_file_read_text_1m 437 us 501 us
io_whole_file_write_text_1m 1.15 ms 1.12 ms

How to run

python -m pyperformance run -b io
python -m pyperformance run -b io_whole_file

Tests

  • python -m pyperformance.tests passes (it runs -b all, so the io benchmarks execute there too)
  • pyperformance list unchanged at 100, list -b io is just io_whole_file
  • flake8 with the tox config is clean on the new file
  • No version bump: 1.14.0 is already tagged, so the changelog line goes under a new 1.15.0 heading. Happy to bump __version__ too if you want that in the same PR, as Add bm_base64 covering common base64 module APIs #447 did.

Part of #399.

AI disclosure: I used Claude Code to write the benchmark and docs; I reviewed the code and ran it.

Add an "io" benchmark group, resolved through the "io" tag and excluded
from the default group, so I/O benchmarks only run with "-b io".

io_whole_file reads and writes a whole file in a single call:
open().read()/write() in binary mode plus Path.read_bytes/write_bytes at
4 KiB and 1 MiB, and Path.read_text/write_text at 1 MiB (6 results).

Part of python#399.
for _ in range_it:
with open(path, "rb") as f:
f.read()
path.read_bytes()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It doesn't make sense to call f.read and path.read_bytes in the same benchmark. IIUC, @cmaloney asked for separate benchmarks: one for f.read and one for path.read_bytes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point, mixing them made the numbers hard to attribute. Each result now measures a single API: read_bytes/write_bytes are plain open().read() / open().write(), and read_text/write_text are Path.read_text() / Path.write_text(). I dropped the Path.read_bytes/write_bytes variants since they are thin wrappers over the same open() calls; happy to add them back as separate results if you'd rather have them.

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.

2 participants