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