diff --git a/doc/benchmarks.rst b/doc/benchmarks.rst
index 7701d512..fc698834 100644
--- a/doc/benchmarks.rst
+++ b/doc/benchmarks.rst
@@ -18,6 +18,8 @@ Available benchmark groups:
* ``all``: Group including all benchmarks
* ``apps``: "High-level" applicative benchmarks (2to3, Chameleon, Tornado HTTP)
* ``default``: Group of benchmarks run by default by the ``run`` command
+* ``io``: File and stream I/O benchmarks. Not part of ``default``: run them
+ explicitly with ``-b io``.
* ``math``: Float and integers
* ``regex``: Collection of regular expression benchmarks
* ``serialize``: Benchmarks on ``pickle`` and ``json`` modules
@@ -306,6 +308,23 @@ rendered):
See the `html5lib project `_.
+io
+--
+
+I/O benchmarks (``io`` group, not run by default). They stay at the binary
+(bytes) I/O layer where possible and use a fresh temporary directory per run.
+Files stay in the OS page cache and writes are not fsync'ed, so they measure
+Python's I/O stack and system call overhead rather than the storage device.
+Results still depend on the operating system and file system of the temporary
+directory.
+
+* ``io_whole_file``: read or write a whole file in a single call, one API
+ per result: ``open().read()`` / ``open().write()`` in binary mode at 4 KiB
+ and 1 MiB (``io_whole_file_read_bytes_4k``,
+ ``io_whole_file_write_bytes_4k``, ...), and ``pathlib.Path.read_text()`` /
+ ``write_text()`` at 1 MiB.
+
+
json_dumps, json_loads
----------------------
diff --git a/doc/changelog.rst b/doc/changelog.rst
index 8735b3bb..762a6c13 100644
--- a/doc/changelog.rst
+++ b/doc/changelog.rst
@@ -1,6 +1,10 @@
Changelog
=========
+Version 1.15.0
+--------------
+* Add opt-in ``io`` benchmark group with the ``io_whole_file`` benchmark
+
Version 1.14.0
--------------
* Bump ``pyperf`` to 2.10.0
diff --git a/pyperformance/data-files/benchmarks/MANIFEST b/pyperformance/data-files/benchmarks/MANIFEST
index 35777453..6fee5d6b 100644
--- a/pyperformance/data-files/benchmarks/MANIFEST
+++ b/pyperformance/data-files/benchmarks/MANIFEST
@@ -54,6 +54,7 @@ hexiom
# Unable to get the program 'hg' from the virtual environment
#hg_startup
html5lib
+io_whole_file
json_dumps
json_loads
logging
@@ -107,6 +108,8 @@ xml_etree
[group default]
+# I/O benchmarks are opt-in: run them with "-b io".
+-io_whole_file
[group asyncio]
[group startup]
[group regex]
@@ -114,3 +117,4 @@ xml_etree
[group apps]
[group math]
[group template]
+[group io]
diff --git a/pyperformance/data-files/benchmarks/bm_io_whole_file/pyproject.toml b/pyperformance/data-files/benchmarks/bm_io_whole_file/pyproject.toml
new file mode 100644
index 00000000..84f03a99
--- /dev/null
+++ b/pyperformance/data-files/benchmarks/bm_io_whole_file/pyproject.toml
@@ -0,0 +1,10 @@
+[project]
+name = "pyperformance_bm_io_whole_file"
+requires-python = ">=3.8"
+dependencies = ["pyperf"]
+urls = {repository = "https://github.com/python/pyperformance"}
+dynamic = ["version"]
+
+[tool.pyperformance]
+name = "io_whole_file"
+tags = "io"
diff --git a/pyperformance/data-files/benchmarks/bm_io_whole_file/run_benchmark.py b/pyperformance/data-files/benchmarks/bm_io_whole_file/run_benchmark.py
new file mode 100644
index 00000000..0f67206b
--- /dev/null
+++ b/pyperformance/data-files/benchmarks/bm_io_whole_file/run_benchmark.py
@@ -0,0 +1,135 @@
+"""
+Benchmark reading and writing a whole file in a single call.
+
+Each result measures a single API. The binary pattern
+``open(path, "rb").read()`` / ``open(path, "wb").write(data)`` is measured at
+4 KiB, where per-call overhead and the number of system calls dominate, and
+at 1 MiB, where buffer allocation and copying dominate.
+``Path.read_text`` / ``Path.write_text`` are measured at 1 MiB.
+``Path.read_bytes`` / ``Path.write_bytes`` are thin wrappers over the same
+``open()`` calls, so they are not measured separately.
+
+The file lives in a fresh temporary directory and stays in the OS page cache,
+so this measures Python's I/O stack and system call overhead, not the storage
+device. Writes are not fsync'ed and always create a new file: the file is
+removed outside the timed region, because truncating and rewriting an
+existing file can make the filesystem start writeback on close (ext4
+``auto_da_alloc``), which would measure the disk. Larger sizes are left out
+on purpose: they scale linearly from 1 MiB and get closer to dirty page
+throttling (``vm.dirty_*``), which measures the kernel's writeback policy
+rather than Python.
+
+Before the benchmarks run, an 8 MiB block is allocated and freed once. On
+glibc this raises malloc's dynamic mmap and trim thresholds well above the
+1 MiB buffers used here; otherwise ``read_text_1m``, which frees two 1 MiB
+blocks per call, can sit right at the trim threshold and, depending on
+earlier heap history, give the memory back to the kernel and fault it in
+again on every call (~3x slower).
+"""
+
+import io
+import os
+import pathlib
+import shutil
+import tempfile
+
+import pyperf
+
+
+SIZES = {
+ "4k": 4 * 1024,
+ "1m": 1024 * 1024,
+}
+TEXT_SIZE = "1m"
+
+
+def make_bytes(size):
+ return (bytes(range(256)) * (size // 256 + 1))[:size]
+
+
+def make_text(size):
+ line = "abcdefghijklmnopqrstuvwxyz0123456789" * 2 + "\n"
+ return (line * (size // len(line) + 1))[:size]
+
+
+def bench_read_bytes(loops, path):
+ range_it = range(loops)
+ t0 = pyperf.perf_counter()
+ for _ in range_it:
+ with open(path, "rb") as f:
+ f.read()
+ return pyperf.perf_counter() - t0
+
+
+def bench_write_bytes(loops, path, data):
+ elapsed = 0
+ for _ in range(loops):
+ t0 = pyperf.perf_counter()
+ with open(path, "wb") as f:
+ f.write(data)
+ elapsed += pyperf.perf_counter() - t0
+ os.unlink(path)
+ return elapsed
+
+
+def bench_read_text(loops, path):
+ range_it = range(loops)
+ t0 = pyperf.perf_counter()
+ for _ in range_it:
+ path.read_text(encoding="utf-8")
+ return pyperf.perf_counter() - t0
+
+
+def bench_write_text(loops, path, text):
+ elapsed = 0
+ for _ in range(loops):
+ t0 = pyperf.perf_counter()
+ path.write_text(text, encoding="utf-8")
+ elapsed += pyperf.perf_counter() - t0
+ os.unlink(path)
+ return elapsed
+
+
+if __name__ == "__main__":
+ runner = pyperf.Runner()
+ runner.metadata["description"] = (
+ "Read and write a whole file in a single call (page cache warm)"
+ )
+ runner.metadata["io_default_buffer_size"] = io.DEFAULT_BUFFER_SIZE
+
+ # Pin glibc's dynamic mmap/trim thresholds above the working set, so the
+ # results do not depend on heap history (see the module docstring).
+ bytes(8 * 1024 * 1024)
+
+ tmp_dir = tempfile.mkdtemp()
+ try:
+ tmp_path = pathlib.Path(tmp_dir)
+ for name, size in SIZES.items():
+ data = make_bytes(size)
+ path = tmp_path / f"read_{name}.bin"
+ path.write_bytes(data)
+ runner.bench_time_func(
+ f"io_whole_file_read_bytes_{name}", bench_read_bytes, path
+ )
+ runner.bench_time_func(
+ f"io_whole_file_write_bytes_{name}",
+ bench_write_bytes,
+ tmp_path / f"write_{name}.bin",
+ data,
+ )
+ os.unlink(path)
+
+ text = make_text(SIZES[TEXT_SIZE])
+ path = tmp_path / f"read_{TEXT_SIZE}.txt"
+ path.write_text(text, encoding="utf-8")
+ runner.bench_time_func(
+ f"io_whole_file_read_text_{TEXT_SIZE}", bench_read_text, path
+ )
+ runner.bench_time_func(
+ f"io_whole_file_write_text_{TEXT_SIZE}",
+ bench_write_text,
+ tmp_path / f"write_{TEXT_SIZE}.txt",
+ text,
+ )
+ finally:
+ shutil.rmtree(tmp_dir)