Benchmark ipc dataloader

Targeted benchmark: the cost of shipping a dataset to DataLoader workers.

Motivating observation: constructing TorchVision’s ImageNet dataset object takes under a second, yet the first time a torch.utils.data.DataLoader with workers is iterated there is a multi-second stall before any batch appears. That stall is inter-process communication (IPC): the dataset — roughly 1.2 million (path, label) entries — is pickled and copied to every worker process. This benchmark reproduces that effect in isolation and shows how it scales.

A ByteStringDataset holds a list of num_strings byte strings whose total size is the sweep knob (mirroring the many small path strings in an ImageFolder-style dataset). We measure two things per configuration:

  • build — time to instantiate the dataset object (build the Python list). This is cheap and roughly flat: no data crosses a process boundary.

  • startup — time from creating the DataLoader iterator (which spawns the workers and ships the dataset to each) to receiving the first batch. This is the IPC cost, and it grows with both the payload size and the worker count.

__getitem__ returns a single int so the per-item transfer back from the workers is negligible; the measured startup time is dominated by pickling and copying the dataset out to the workers.

Important

The effect is only visible under the spawn (or forkserver) start method, where the dataset is pickled and streamed to each worker through a pipe. Under fork (the Linux default) the workers inherit the parent’s memory copy-on-write and the dataset is not re-serialized, so startup stays flat — the benchmark defaults to spawn to expose the IPC cost, which is also the start method used with CUDA. See the The Cost of Inter-Process Communication case study.

Results

From --sizes 16 32 64 128 --workers 1 2 4 8 --runs 3 on a CPU-only host (spawn, 64-byte strings). Building the dataset stays well under a second and barely moves with size, while startup climbs with both the payload size and the worker count — every worker gets its own serialized copy:

payload   workers   build s   startup s
----------------------------------------
 16 MiB         1      0.09         2.9
128 MiB         1      0.65         3.8
 16 MiB         8      0.08        22.4
128 MiB         8      0.65        28.3
(startup = create iterator -> first batch; grows with size x workers, while
build = dataset instantiation stays flat. See the plot for the full sweep.)
../_static/data/example_benchmark_ipc_dataloader.png

Example

$ python benchmark_ipc_dataloader.py --sizes 16 32 64 128 --output ipc.csv
$ python benchmark_ipc_dataloader_plot.py --input ipc.csv --output ipc.png

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"""Targeted benchmark: the cost of shipping a dataset to DataLoader workers.
 10
 11Motivating observation: constructing TorchVision's ImageNet dataset object takes
 12under a second, yet the first time a :py:class:`torch.utils.data.DataLoader` with
 13workers is iterated there is a multi-second stall before any batch appears. That
 14stall is **inter-process communication (IPC)**: the dataset — roughly 1.2 million
 15``(path, label)`` entries — is pickled and copied to every worker process. This
 16benchmark reproduces that effect in isolation and shows how it scales.
 17
 18A :py:class:`ByteStringDataset` holds a list of ``num_strings`` byte strings whose
 19total size is the sweep knob (mirroring the many small path strings in an
 20``ImageFolder``-style dataset). We measure two things per configuration:
 21
 22- **build** — time to *instantiate* the dataset object (build the Python list).
 23  This is cheap and roughly flat: no data crosses a process boundary.
 24- **startup** — time from creating the DataLoader iterator (which spawns the
 25  workers and ships the dataset to each) to receiving the first batch. This is
 26  the IPC cost, and it grows with both the payload size and the worker count.
 27
 28``__getitem__`` returns a single ``int`` so the per-item transfer *back* from the
 29workers is negligible; the measured startup time is dominated by pickling and
 30copying the dataset *out* to the workers.
 31
 32.. important::
 33
 34   The effect is only visible under the ``spawn`` (or ``forkserver``) start
 35   method, where the dataset is pickled and streamed to each worker through a
 36   pipe. Under ``fork`` (the Linux default) the workers inherit the parent's
 37   memory copy-on-write and the dataset is *not* re-serialized, so startup stays
 38   flat — the benchmark defaults to ``spawn`` to expose the IPC cost, which is
 39   also the start method used with CUDA. See the :ref:`ipc-cost` case study.
 40
 41**Results**
 42
 43From ``--sizes 16 32 64 128 --workers 1 2 4 8 --runs 3`` on a CPU-only host
 44(``spawn``, 64-byte strings). Building the dataset stays well under a second and
 45barely moves with size, while startup climbs with both the payload size and the
 46worker count — every worker gets its own serialized copy:
 47
 48.. code-block:: text
 49
 50   payload   workers   build s   startup s
 51   ----------------------------------------
 52    16 MiB         1      0.09         2.9
 53   128 MiB         1      0.65         3.8
 54    16 MiB         8      0.08        22.4
 55   128 MiB         8      0.65        28.3
 56   (startup = create iterator -> first batch; grows with size x workers, while
 57   build = dataset instantiation stays flat. See the plot for the full sweep.)
 58
 59.. image:: ../../_static/data/example_benchmark_ipc_dataloader.png
 60
 61**Example**
 62
 63.. code-block:: shell
 64
 65   $ python benchmark_ipc_dataloader.py --sizes 16 32 64 128 --output ipc.csv
 66   $ python benchmark_ipc_dataloader_plot.py --input ipc.csv --output ipc.png
 67"""
 68
 69from __future__ import annotations
 70
 71__all__ = [
 72    "ByteStringDataset",
 73    "Row",
 74    "main",
 75    "measure_startup",
 76    "read_csv",
 77    "write_csv",
 78]
 79
 80import argparse
 81import csv
 82import gc
 83import multiprocessing as mp
 84import statistics
 85import time
 86from dataclasses import asdict, dataclass, fields
 87from itertools import product
 88
 89from torch.utils.data import DataLoader, Dataset
 90
 91_MiB = 1 << 20
 92
 93
 94class ByteStringDataset(Dataset):
 95    """A dataset of ``num_strings`` distinct byte strings of ``string_bytes`` each.
 96
 97    The strings are path-like and vary by index, so they are genuinely distinct
 98    objects (as real file paths are) and pickle cannot dedupe them — every one is
 99    traversed and copied when the dataset is shipped to a worker. The total
100    payload is ``num_strings * string_bytes``.
101
102    ``__getitem__`` returns only the length of an entry, not the entry itself, so
103    the cost measured by the benchmark is the *outbound* transfer of the dataset
104    to the workers rather than any per-item work.
105    """
106
107    def __init__(self, num_strings: int, string_bytes: int) -> None:
108        self.data: list[bytes] = [
109            _make_entry(i, string_bytes) for i in range(num_strings)
110        ]
111
112    def __len__(self) -> int:
113        return len(self.data)
114
115    def __getitem__(self, index: int) -> int:
116        return len(self.data[index])
117
118
119def _make_entry(index: int, string_bytes: int) -> bytes:
120    """A distinct, path-like byte string padded / trimmed to ``string_bytes``."""
121    s = b"/data/imagenet/train/img_%012d.JPEG" % index
122    if len(s) < string_bytes:
123        return s + b"\0" * (string_bytes - len(s))
124    return s[:string_bytes]
125
126
127@dataclass(frozen=True)
128class Row:
129    """Row()
130
131    One measurement: dataset build + DataLoader startup for a (size, workers)."""
132
133    total_mb: float
134    """Total payload size shipped to each worker, in MiB (the sweep knob)."""
135
136    num_strings: int
137    """Number of byte strings in the dataset (``total_mb`` / ``string_bytes``)."""
138
139    string_bytes: int
140    """Size of each byte string, in bytes."""
141
142    num_workers: int
143    """Number of DataLoader worker processes."""
144
145    start_method: str
146    """Multiprocessing start method (``"spawn"``, ``"fork"``, ``"forkserver"``)."""
147
148    build_sec: float
149    """Time to instantiate the dataset object (build the list). Cheap and flat."""
150
151    startup_sec: float
152    """Mean time from creating the iterator to the first batch — the IPC cost."""
153
154    startup_sec_lo: float
155    """Lower bound of the ~95% confidence interval of ``startup_sec``."""
156
157    startup_sec_hi: float
158    """Upper bound of the ~95% confidence interval of ``startup_sec``."""
159
160
161def measure_startup(
162    dataset: ByteStringDataset,
163    num_workers: int,
164    *,
165    mp_ctx: "mp.context.BaseContext",
166    batch_size: int,
167    runs: int,
168) -> list[float]:
169    """Time creating the DataLoader iterator through the first batch, ``runs`` times.
170
171    Creating the iterator spawns the workers and ships the dataset to each; the
172    first :py:func:`next` blocks until a worker has received the dataset and
173    produced a batch, so the interval captures the outbound transfer. One warmup
174    pass is discarded. The iterator is fully drained and dropped between passes so
175    the workers are torn down and re-spawned each time.
176
177    Returns:
178        One startup sample (seconds) per timed pass.
179    """
180    samples: list[float] = []
181    for pass_i in range(runs + 1):  # one warmup, then ``runs`` timed passes
182        loader = DataLoader(
183            dataset,
184            batch_size=batch_size,
185            num_workers=num_workers,
186            multiprocessing_context=mp_ctx,
187            persistent_workers=False,
188        )
189        t0 = time.perf_counter()
190        it = iter(loader)  # spawn workers + ship the dataset to each
191        next(it)  # block until the first batch crosses back
192        elapsed = time.perf_counter() - t0
193        for _ in it:  # drain so the workers exit cleanly
194            pass
195        del it, loader
196        gc.collect()
197        if pass_i:  # skip the warmup pass
198            samples.append(elapsed)
199    return samples
200
201
202def _confidence_interval(samples: list[float]) -> tuple[float, float]:
203    """~95% confidence interval of the mean (normal approximation).
204
205    Degenerate cases (a single pass) return ``(mean, mean)``.
206    """
207    mean = statistics.mean(samples)
208    if len(samples) < 2:
209        return mean, mean
210    half = 1.96 * statistics.stdev(samples) / (len(samples) ** 0.5)
211    return mean - half, mean + half
212
213
214def _run_config(
215    size_mb: int,
216    num_workers: int,
217    args: argparse.Namespace,
218    mp_ctx: "mp.context.BaseContext",
219) -> Row:
220    """Benchmark one (size, workers): time the build once and the startup ``runs`` times."""
221    num_strings = (size_mb * _MiB) // args.string_bytes
222    t0 = time.perf_counter()
223    dataset = ByteStringDataset(num_strings, args.string_bytes)
224    build_sec = time.perf_counter() - t0
225    samples = measure_startup(
226        dataset,
227        num_workers,
228        mp_ctx=mp_ctx,
229        batch_size=args.batch_size,
230        runs=args.runs,
231    )
232    lo, hi = _confidence_interval(samples)
233    return Row(
234        total_mb=num_strings * args.string_bytes / _MiB,
235        num_strings=num_strings,
236        string_bytes=args.string_bytes,
237        num_workers=num_workers,
238        start_method=args.start_method,
239        build_sec=build_sec,
240        startup_sec=statistics.mean(samples),
241        startup_sec_lo=lo,
242        startup_sec_hi=hi,
243    )
244
245
246def _print_table(rows: list[Row]) -> None:
247    """Print one row per (size, workers): dataset build vs DataLoader startup."""
248    header = (
249        f"{'size':>7} {'strings':>10} {'workers':>7} {'build s':>8} {'startup s':>10}"
250    )
251    print(header)
252    print("-" * len(header))
253    for r in sorted(rows, key=lambda r: (r.num_workers, r.total_mb)):
254        print(
255            f"{r.total_mb:>6.0f}M {r.num_strings:>10} {r.num_workers:>7} "
256            f"{r.build_sec:>8.2f} {r.startup_sec:>10.2f}"
257        )
258    print(
259        "(build = dataset instantiation, flat; startup = iterator -> first batch, "
260        "the IPC cost, which grows with size and worker count)"
261    )
262
263
264def write_csv(rows: list[Row], path: str) -> None:
265    """Write benchmark rows to ``path`` as CSV (one column per :class:`Row` field)."""
266    names = [f.name for f in fields(Row)]
267    with open(path, "w", newline="") as f:
268        writer = csv.DictWriter(f, fieldnames=names)
269        writer.writeheader()
270        writer.writerows(asdict(r) for r in rows)
271    print(f"wrote {len(rows)} rows to {path}")
272
273
274def read_csv(path: str) -> list[Row]:
275    """Read benchmark rows written by :py:func:`write_csv`."""
276    with open(path, newline="") as f:
277        return [
278            Row(
279                total_mb=float(d["total_mb"]),
280                num_strings=int(d["num_strings"]),
281                string_bytes=int(d["string_bytes"]),
282                num_workers=int(d["num_workers"]),
283                start_method=d["start_method"],
284                build_sec=float(d["build_sec"]),
285                startup_sec=float(d["startup_sec"]),
286                startup_sec_lo=float(d["startup_sec_lo"]),
287                startup_sec_hi=float(d["startup_sec_hi"]),
288            )
289            for d in csv.DictReader(f)
290        ]
291
292
293def _parse_args() -> argparse.Namespace:
294    parser = argparse.ArgumentParser(
295        description="Cost of shipping a dataset to DataLoader workers over IPC"
296    )
297    parser.add_argument(
298        "--sizes",
299        type=int,
300        nargs="+",
301        default=[16, 32, 64, 128],
302        help="payload sizes (MiB)",
303    )
304    parser.add_argument(
305        "--workers", type=int, nargs="+", default=[1, 2, 4, 8], help="worker counts"
306    )
307    parser.add_argument(
308        "--string-bytes", type=int, default=64, help="size of each byte string"
309    )
310    parser.add_argument("--batch-size", type=int, default=256)
311    parser.add_argument("--runs", type=int, default=5)
312    parser.add_argument(
313        "--start-method",
314        default="spawn",
315        choices=["spawn", "fork", "forkserver"],
316        help="start method; fork inherits memory (no IPC copy) so shows the flat baseline",
317    )
318    parser.add_argument("--output", help="optional path to write results as CSV")
319    return parser.parse_args()
320
321
322def main() -> None:
323    """Sweep payload sizes x worker counts; print a table and optionally a CSV."""
324    args = _parse_args()
325    mp_ctx = mp.get_context(args.start_method)
326    rows = [
327        _run_config(size_mb, num_workers, args, mp_ctx)
328        for size_mb, num_workers in product(args.sizes, args.workers)
329    ]
330    _print_table(rows)
331    if args.output:
332        write_csv(rows, args.output)
333
334
335if __name__ == "__main__":
336    main()

API Reference

Functions

main() None[source]

Sweep payload sizes x worker counts; print a table and optionally a CSV.

measure_startup(dataset: ByteStringDataset, num_workers: int, *, mp_ctx: BaseContext, batch_size: int, runs: int) list[float][source]

Time creating the DataLoader iterator through the first batch, runs times.

Creating the iterator spawns the workers and ships the dataset to each; the first next() blocks until a worker has received the dataset and produced a batch, so the interval captures the outbound transfer. One warmup pass is discarded. The iterator is fully drained and dropped between passes so the workers are torn down and re-spawned each time.

Returns:

One startup sample (seconds) per timed pass.

read_csv(path: str) list[Row][source]

Read benchmark rows written by write_csv().

write_csv(rows: list[Row], path: str) None[source]

Write benchmark rows to path as CSV (one column per Row field).

Classes

class ByteStringDataset(num_strings: int, string_bytes: int)[source]

A dataset of num_strings distinct byte strings of string_bytes each.

The strings are path-like and vary by index, so they are genuinely distinct objects (as real file paths are) and pickle cannot dedupe them — every one is traversed and copied when the dataset is shipped to a worker. The total payload is num_strings * string_bytes.

__getitem__ returns only the length of an entry, not the entry itself, so the cost measured by the benchmark is the outbound transfer of the dataset to the workers rather than any per-item work.

__getitem__(index: int) int[source]
__len__() int[source]
class Row[source]

One measurement: dataset build + DataLoader startup for a (size, workers).

build_sec: float

Time to instantiate the dataset object (build the list). Cheap and flat.

num_strings: int

Number of byte strings in the dataset (total_mb / string_bytes).

num_workers: int

Number of DataLoader worker processes.

start_method: str

Multiprocessing start method ("spawn", "fork", "forkserver").

startup_sec: float

Mean time from creating the iterator to the first batch — the IPC cost.

startup_sec_hi: float

Upper bound of the ~95% confidence interval of startup_sec.

startup_sec_lo: float

Lower bound of the ~95% confidence interval of startup_sec.

string_bytes: int

Size of each byte string, in bytes.

total_mb: float

Total payload size shipped to each worker, in MiB (the sweep knob).