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)