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.)
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
- 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,
runstimes.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.
- write_csv(rows: list[Row], path: str) None[source]¶
Write benchmark rows to
pathas CSV (one column perRowfield).
Classes
- class ByteStringDataset(num_strings: int, string_bytes: int)[source]¶
A dataset of
num_stringsdistinct byte strings ofstring_byteseach.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.