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
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
- 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.