Benchmark tarfile

Benchmark script for spdl.io.iter_tarfile() function.

This script benchmarks the performance of iter_tarfile() against Python’s built-in tarfile module using multi-threading. Two types of inputs are tested for iter_tarfile(). Byte string and a file-like object returns byte string by chunk.

The benchmark:

  1. Creates test tar archives with various numbers of files

  2. Runs both implementations with different thread counts

  3. Measures queries per second (QPS) for each configuration

  4. Plots the results comparing the three implementations

Example

$ numactl --membind 0 --cpubind 0 python benchmark_tarfile.py --output results.csv
# Plot results
$ python benchmark_tarfile_plot.py --input results.csv --output wav_benchmark_plot.png
# Plot results without load_wav
$ python benchmark_tarfile_plot.py --input results.csv --output wav_benchmark_plot_2.png \
  --filter '4. SPDL iter_tarfile (bytes w/o convert)'

Result

The following plot shows the QPS (measured by the number of files processed) of each functions with different file size.

../_static/data/example_benchmark_tarfile.png ../_static/data/example_benchmark_tarfile_2.png

The spdl.io.iter_tarfile() function processes data fastest when the input is a byte string. Its performance is consistent across different file sizes. This is because, when the entire TAR file is loaded into memory as a contiguous array, the function only needs to read the header and return the address of the corresponding data (note that iter_tarfile() returns a memory view when the input is a byte string). Since reading the header is very fast, most of the time is spent creating memory view objects while holding the GIL (Global Interpreter Lock). As a result, the speed of loading files decreases as more threads are used.

When the input data type is switched from a byte string to a file-like object, the performance of spdl.io.iter_tarfile() is also affected by the size of the input data. This is because data is processed incrementally, and for each file in the TAR archive, a new byte string object is created. The implementation tries to request the exact amount of bytes needed, but file-like objects do not guarantee that they return the requested length, instead, they return at most the requested number of bytes. Therefore, many intermediate byte string objects must be created. As the file size grows, it takes longer to process the data. Since the GIL must be locked while byte strings are created, performance degrades as more threads are used. At some point, the performance becomes similar to Python’s built-in tarfile module, which is a pure-Python implementation and thus holds the GIL almost entirely.

Source

Source

Click here to see the source.
  1#!/usr/bin/env python3
  2# Copyright (c) Meta Platforms, Inc. and affiliates.
  3# All rights reserved.
  4#
  5# This source code is licensed under the BSD-style license found in the
  6# LICENSE file in the root directory of this source tree.
  7
  8
  9"""Benchmark script for :py:func:`spdl.io.iter_tarfile` function.
 10
 11This script benchmarks the performance of :py:func:`~spdl.io.iter_tarfile` against
 12Python's built-in :py:mod:`tarfile` module using multi-threading.
 13Two types of inputs are tested for :py:func:`~spdl.io.iter_tarfile`.
 14Byte string and a file-like object returns byte string by chunk.
 15
 16The benchmark:
 17
 181. Creates test tar archives with various numbers of files
 192. Runs both implementations with different thread counts
 203. Measures queries per second (QPS) for each configuration
 214. Plots the results comparing the three implementations
 22
 23**Example**
 24
 25.. code-block:: shell
 26
 27   $ numactl --membind 0 --cpubind 0 python benchmark_tarfile.py --output results.csv
 28   # Plot results
 29   $ python benchmark_tarfile_plot.py --input results.csv --output wav_benchmark_plot.png
 30   # Plot results without load_wav
 31   $ python benchmark_tarfile_plot.py --input results.csv --output wav_benchmark_plot_2.png \\
 32     --filter '4. SPDL iter_tarfile (bytes w/o convert)'
 33
 34**Result**
 35
 36The following plot shows the QPS (measured by the number of files processed) of each
 37functions with different file size.
 38
 39.. image:: ../../_static/data/example_benchmark_tarfile.png
 40
 41.. image:: ../../_static/data/example_benchmark_tarfile_2.png
 42
 43The :py:func:`spdl.io.iter_tarfile` function processes data fastest when the input is a byte
 44string.
 45Its performance is consistent across different file sizes.
 46This is because, when the entire TAR file is loaded into memory as a contiguous array,
 47the function only needs to read the header and return the address of the corresponding data
 48(note that :py:func:`~spdl.io.iter_tarfile` returns a memory view when the input is a byte
 49string).
 50Since reading the header is very fast, most of the time is spent creating memory view objects
 51while holding the GIL (Global Interpreter Lock).
 52As a result, the speed of loading files decreases as more threads are used.
 53
 54When the input data type is switched from a byte string to a file-like object,
 55the performance of :py:func:`spdl.io.iter_tarfile` is also affected by the size of
 56the input data.
 57This is because data is processed incrementally, and for each file in the TAR archive,
 58a new byte string object is created.
 59The implementation tries to request the exact amount of bytes needed, but file-like objects
 60do not guarantee that they return the requested length,
 61instead, they return at most the requested number of bytes.
 62Therefore, many intermediate byte string objects must be created.
 63As the file size grows, it takes longer to process the data.
 64Since the GIL must be locked while byte strings are created,
 65performance degrades as more threads are used.
 66At some point, the performance becomes similar to Python's built-in ``tarfile`` module,
 67which is a pure-Python implementation and thus holds the GIL almost entirely.
 68"""
 69
 70__all__ = [
 71    "BenchmarkConfig",
 72    "create_test_tar",
 73    "iter_tarfile_builtin",
 74    "main",
 75    "process_tar_builtin",
 76    "process_tar_spdl",
 77    "process_tar_spdl_filelike",
 78]
 79
 80import argparse
 81import io
 82import os
 83import tarfile
 84from collections.abc import Callable, Iterator
 85from dataclasses import dataclass
 86from functools import partial
 87
 88import spdl.io
 89
 90try:
 91    from examples.benchmark_utils import (  # pyre-ignore[21]
 92        BenchmarkResult,
 93        BenchmarkRunner,
 94        ExecutorType,
 95        get_default_result_path,
 96        save_results_to_csv,
 97    )
 98except ImportError:
 99    from spdl.examples.benchmark_utils import (
100        BenchmarkResult,
101        BenchmarkRunner,
102        ExecutorType,
103        get_default_result_path,
104        save_results_to_csv,
105    )
106
107
108DEFAULT_RESULT_PATH: str = get_default_result_path(__file__)
109
110
111@dataclass
112class BenchmarkConfig:
113    """BenchmarkConfig()
114
115    Configuration for a single TAR benchmark run."""
116
117    function_name: str
118    """Name of the function being tested"""
119
120    tar_size: int
121    """Total size of the TAR archive in bytes"""
122
123    file_size: int
124    """Size of each file in the TAR archive in bytes"""
125
126    num_files: int
127    """Number of files in the TAR archive"""
128
129    num_threads: int
130    """Number of concurrent threads"""
131
132    num_iterations: int
133    """Number of iterations per run"""
134
135    total_files_processed: int
136    """Total number of files processed across all iterations"""
137
138
139def iter_tarfile_builtin(tar_data: bytes) -> Iterator[tuple[str, bytes]]:
140    """Iterate over TAR file using Python's built-in ``tarfile`` module.
141
142    Args:
143        tar_data: TAR archive as bytes.
144
145    Yields:
146        Tuple of ``(filename, content)`` for each file in the archive.
147    """
148    with tarfile.open(fileobj=io.BytesIO(tar_data), mode="r") as tar:
149        for member in tar.getmembers():
150            if member.isfile():
151                file_obj = tar.extractfile(member)
152                if file_obj:
153                    content = file_obj.read()
154                    yield member.name, content
155
156
157def process_tar_spdl(tar_data: bytes, convert: bool) -> int:
158    """Process TAR archive using :py:func:`spdl.io.iter_tarfile`.
159
160    Args:
161        tar_data: TAR archive as bytes.
162
163    Returns:
164        Number of files processed.
165    """
166    count = 0
167    if convert:
168        for _, content in spdl.io.iter_tarfile(tar_data):
169            bytes(content)
170            count += 1
171        return count
172    else:
173        for _ in spdl.io.iter_tarfile(tar_data):
174            count += 1
175        return count
176
177
178def process_tar_builtin(tar_data: bytes) -> int:
179    """Process TAR archive using Python's built-in ``tarfile`` module.
180
181    Args:
182        tar_data: TAR archive as bytes.
183
184    Returns:
185        Number of files processed.
186    """
187    count = 0
188    for _ in iter_tarfile_builtin(tar_data):
189        count += 1
190    return count
191
192
193def process_tar_spdl_filelike(tar_data: bytes) -> int:
194    """Process TAR archive using :py:func:`spdl.io.iter_tarfile` with file-like object.
195
196    Args:
197        tar_data: TAR archive as bytes.
198
199    Returns:
200        Number of files processed.
201    """
202    count = 0
203    file_like = io.BytesIO(tar_data)
204    for _ in spdl.io.iter_tarfile(file_like):  # pyre-ignore[6]
205        count += 1
206    return count
207
208
209def _size_str(n: int) -> str:
210    if n < 1024:
211        return f"{n} B"
212    if n < 1024 * 1024:
213        return f"{n / 1024: .2f} kB"
214    if n < 1024 * 1024 * 1024:
215        return f"{n / (1024 * 1024): .2f} MB"
216    return f"{n / (1024 * 1024 * 1024): .2f} GB"
217
218
219def create_test_tar(num_files: int, file_size: int) -> bytes:
220    """Create a TAR archive in memory with specified number of files.
221
222    Args:
223        num_files: Number of files to include in the archive.
224        file_size: Size of each file in bytes.
225
226    Returns:
227        TAR archive as bytes.
228    """
229    tar_buffer = io.BytesIO()
230    with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
231        for i in range(num_files):
232            filename = f"file_{i:06d}.txt"
233            content = b"1" * file_size
234            info = tarfile.TarInfo(name=filename)
235            info.size = len(content)
236            tar.addfile(info, io.BytesIO(content))
237    tar_buffer.seek(0)
238    return tar_buffer.getvalue()
239
240
241def _parse_args() -> argparse.Namespace:
242    """Parse command line arguments.
243
244    Returns:
245        Parsed arguments.
246    """
247    parser = argparse.ArgumentParser(
248        description="Benchmark iter_tarfile performance with multi-threading"
249    )
250    parser.add_argument(
251        "--num-files",
252        type=int,
253        default=100,
254        help="Number of files in the test TAR archive",
255    )
256    parser.add_argument(
257        "--num-iterations",
258        type=int,
259        default=100,
260        help="Number of iterations for each thread count",
261    )
262    parser.add_argument(
263        "--output",
264        type=lambda p: os.path.realpath(p),
265        default=DEFAULT_RESULT_PATH,
266        help="Output path for the results",
267    )
268
269    return parser.parse_args()
270
271
272def main() -> None:
273    """Main entry point for the benchmark script.
274
275    Parses command-line arguments, runs benchmarks, and generates plots.
276    """
277
278    args = _parse_args()
279
280    # Define explicit configuration lists
281    thread_counts = [1, 4, 8, 16, 32]
282    file_sizes = [2**8, 2**12, 2**16, 2**20]
283
284    # Define benchmark function configurations
285    # (function_name, function)
286    benchmark_functions: list[tuple[str, Callable[[bytes], int]]] = [
287        ("1. Python tarfile", process_tar_builtin),
288        ("2. SPDL iter_tarfile (file-like)", process_tar_spdl_filelike),
289        (
290            "3. SPDL iter_tarfile (bytes w/ convert)",
291            partial(process_tar_spdl, convert=True),
292        ),
293        (
294            "4. SPDL iter_tarfile (bytes w/o convert)",
295            partial(process_tar_spdl, convert=False),
296        ),
297    ]
298
299    print("Starting benchmark with configuration:")
300    print(f"  Number of files: {args.num_files}")
301    print(f"  File sizes: {file_sizes} bytes")
302    print(f"  Iterations per thread count: {args.num_iterations}")
303    print(f"  Thread counts: {thread_counts}")
304
305    results: list[BenchmarkResult[BenchmarkConfig]] = []
306    num_runs = 5
307
308    for num_threads in thread_counts:
309        with BenchmarkRunner(
310            executor_type=ExecutorType.THREAD,
311            num_workers=num_threads,
312            warmup_iterations=10 * num_threads,
313        ) as runner:
314            for file_size in file_sizes:
315                tar_data = create_test_tar(args.num_files, file_size)
316                for func_name, func in benchmark_functions:
317                    print(
318                        f"TAR size: {_size_str(len(tar_data))} "
319                        f"({args.num_files} x {_size_str(file_size)}), "
320                        f"'{func_name}', {num_threads} threads"
321                    )
322
323                    total_files_processed = args.num_files * args.num_iterations
324
325                    config = BenchmarkConfig(
326                        function_name=func_name,
327                        tar_size=len(tar_data),
328                        file_size=file_size,
329                        num_files=args.num_files,
330                        num_threads=num_threads,
331                        num_iterations=args.num_iterations,
332                        total_files_processed=total_files_processed,
333                    )
334
335                    result, _ = runner.run(
336                        config,
337                        partial(func, tar_data),
338                        args.num_iterations,
339                        num_runs=num_runs,
340                    )
341
342                    margin = (result.ci_upper - result.ci_lower) / 2
343                    print(
344                        f"  QPS: {result.qps:8.2f} ± {margin:.2f}  "
345                        f"({result.ci_lower:.2f}-{result.ci_upper:.2f}, "
346                        f"{num_runs} runs, {total_files_processed} files)"
347                    )
348
349                    results.append(result)
350
351    # Save results to CSV
352    save_results_to_csv(results, args.output)
353
354    print(
355        f"Benchmark complete. To generate plots, run: "
356        f"python benchmark_tarfile_plot.py --input {args.output} "
357        f"--output {args.output.replace('.csv', '.png')}"
358    )
359
360
361if __name__ == "__main__":
362    main()

API Reference

Functions

create_test_tar(num_files: int, file_size: int) bytes[source]

Create a TAR archive in memory with specified number of files.

Parameters:
  • num_files – Number of files to include in the archive.

  • file_size – Size of each file in bytes.

Returns:

TAR archive as bytes.

iter_tarfile_builtin(tar_data: bytes) Iterator[tuple[str, bytes]][source]

Iterate over TAR file using Python’s built-in tarfile module.

Parameters:

tar_data – TAR archive as bytes.

Yields:

Tuple of (filename, content) for each file in the archive.

main() None[source]

Main entry point for the benchmark script.

Parses command-line arguments, runs benchmarks, and generates plots.

process_tar_builtin(tar_data: bytes) int[source]

Process TAR archive using Python’s built-in tarfile module.

Parameters:

tar_data – TAR archive as bytes.

Returns:

Number of files processed.

process_tar_spdl(tar_data: bytes, convert: bool) int[source]

Process TAR archive using spdl.io.iter_tarfile().

Parameters:

tar_data – TAR archive as bytes.

Returns:

Number of files processed.

process_tar_spdl_filelike(tar_data: bytes) int[source]

Process TAR archive using spdl.io.iter_tarfile() with file-like object.

Parameters:

tar_data – TAR archive as bytes.

Returns:

Number of files processed.

Classes

class BenchmarkConfig[source]

Configuration for a single TAR benchmark run.

file_size: int

Size of each file in the TAR archive in bytes

function_name: str

Name of the function being tested

num_files: int

Number of files in the TAR archive

num_iterations: int

Number of iterations per run

num_threads: int

Number of concurrent threads

tar_size: int

Total size of the TAR archive in bytes

total_files_processed: int

Total number of files processed across all iterations