Skip to content

Commit eef7060

Browse files
authored
[docs] Add IPC cost case study + DataLoader benchmark (#1594)
Add a new case study documenting the cost of inter-process communication in data loading, with a targeted benchmark that measures how long it takes to ship a dataset object to PyTorch DataLoader workers. The benchmark reproduces the 20+ second startup stall observed when wrapping TorchVision's ImageNet dataset (1.2M file paths) in a multi-worker DataLoader. It shows that building the dataset is cheap (~1s), but serializing and copying it to each worker scales with both payload size and worker count—reaching tens of seconds at larger configurations. Includes plotting utilities and integration into the docs.
1 parent e9f6214 commit eef7060

11 files changed

Lines changed: 604 additions & 7 deletions

File tree

70 KB
Loading

docs/source/async/sync.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Using ``SharedMemory`` for faster inter-process-communication
8585
-------------------------------------------------------------
8686

8787
The multi-processing does not have the GIL constraint, but it comes with the cost of memory copy between processes.
88+
(See :ref:`ipc-cost` for why this copy is expensive and how it scales with the payload.)
8889
Array formats like NumPy's NDArray and PyTorch's Tensor use shared memory to make this performant.
8990
If you need to pass a large data between processes
9091
(such as a dataset, though we don't recommend passing around dataset)

docs/source/case_studies/data_format.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ The multi-processing can improve the throughput to certain degree.
7777
Its peak is at 4 workers.
7878
As we see later, even when we use a function that is significantly
7979
faster, the throughput of multi-processing is similar.
80-
So the bottleneck likely is at the inter-process communication.
80+
So the bottleneck likely is at the inter-process communication
81+
(see :ref:`ipc-cost`).
8182

8283
With multi-threading, the throughput is highest when there is only one worker.
8384
The performance decreases as more workers are used.

docs/source/case_studies/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ we encountered while optimizing production pipelines.
99
.. toctree::
1010

1111
parallelism
12+
ipc
1213
shared_memory_arena
1314
data_format
1415
inference

docs/source/case_studies/ipc.rst

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
.. _ipc-cost:
2+
3+
The Cost of Inter-Process Communication
4+
=======================================
5+
6+
.. py:currentmodule:: spdl.pipeline
7+
8+
Whenever data crosses a process boundary — a stage dispatched to a
9+
:py:class:`~concurrent.futures.ProcessPoolExecutor`, a pipeline run with
10+
:py:func:`run_pipeline_in_subprocess`, or the workers of a
11+
:py:class:`torch.utils.data.DataLoader` — it is **copied** from one process to
12+
another. That copy is not free, and for large payloads it is easy to spend more
13+
time moving data between processes than doing useful work on it. This page
14+
explains where that cost comes from, measures it, and describes ways to avoid it.
15+
SPDL does not remove this cost automatically — it is something to be aware of and
16+
to design around.
17+
18+
An example: the PyTorch DataLoader
19+
----------------------------------
20+
21+
To make this concrete, let's take a conventional
22+
:py:class:`~torch.utils.data.DataLoader` built around a large in-memory dataset.
23+
Constructing TorchVision's ImageNet dataset object takes well under a second — it
24+
scans the directory tree and builds a list of roughly 1.2 million
25+
``(path, label)`` entries. But wrapping it in a ``DataLoader`` with a few workers
26+
stalls the first iteration for **20+ seconds** before a single batch appears,
27+
because the dataset object is **serialized and copied into every worker
28+
process**. Building the list is cheap; shipping it across a process boundary,
29+
once per worker, is not. The same copy is paid for any object sent across any
30+
process boundary — the DataLoader just makes it easy to observe. In production,
31+
dataloaders built without care for this have taken **more than 30 minutes** to
32+
initialize, as the per-worker copy compounds with dataset size and worker count.
33+
34+
The :py:mod:`benchmark_ipc_dataloader` example isolates the effect: a dataset of
35+
byte strings (standing in for an ``ImageFolder``'s paths) swept over total size
36+
and worker count, timing the dataset **build** and the **startup** cost (iterator
37+
creation through the first batch, when the workers are spawned and the dataset
38+
shipped to each).
39+
40+
.. image:: ../_static/data/example_benchmark_ipc_dataloader.png
41+
:width: 100%
42+
43+
Building the dataset is cheap and roughly flat — no data crosses a boundary.
44+
Startup cost grows with both payload size and worker count: each worker receives
45+
its own serialized copy, so the transfer work scales with (payload size) ×
46+
(worker count), reaching tens of seconds at the largest sizes and worker counts
47+
even though ``__getitem__`` here does essentially nothing.
48+
49+
Why copying between processes is expensive
50+
------------------------------------------
51+
52+
Two separate costs are hiding in that transfer.
53+
54+
**1. Serialization (pickling).** Processes do not share memory, so an arbitrary
55+
Python object cannot simply be handed to another process — it must be converted
56+
to a flat byte stream (:py:mod:`pickle`) on the sending side and reconstructed on
57+
the receiving side. Both halves cost CPU, and the reconstruction on the receiving
58+
side — allocating the objects again in the worker — is often the more expensive
59+
one. A structure of many small objects, such as a large list of ``(path, label)``
60+
entries, incurs this per object.
61+
62+
**2. Transport (the copy itself).** The resulting byte stream then has to travel
63+
from one address space to another. By default this goes through a **pipe** (or a
64+
socket-backed queue), and a pipe is a *stream*, not a shared region — so every
65+
byte is physically copied twice: once from the sender's buffer into the kernel,
66+
and once from the kernel into the receiver's buffer.
67+
68+
.. code-block:: text
69+
70+
producer process kernel worker process
71+
┌─────────────────┐ ┌──────────┐ ┌─────────────────┐
72+
│ pickled dataset │ write() │ pipe │ read() │ rebuilt copy │
73+
│ (~100 MB blob) │ ───────► │ buffer │ ─────────► │ (~100 MB blob) │
74+
└─────────────────┘ copy #1 └──────────┘ copy #2 └─────────────────┘
75+
user→kernel kernel→user
76+
77+
the bytes never "move" — they are memcpy'd through the kernel, twice,
78+
and a whole second copy is rebuilt in the worker's address space
79+
80+
Chunking: a big payload through a small pipe
81+
--------------------------------------------
82+
83+
The transport has a second cost, hidden below the API in how the operating
84+
system actually moves the bytes. A pipe has a fixed capacity — on Linux the
85+
default is **64 KiB** (16 pages of 4 KiB). It cannot hold a hundred-megabyte
86+
payload, so the transfer cannot happen in one shot. Instead the payload is pushed
87+
through the pipe **64 KiB at a time**: the writer fills the buffer, then *blocks*
88+
until the reader has drained it, then fills it again. Producer and consumer
89+
ping-pong like this until the whole payload is through.
90+
91+
.. code-block:: text
92+
93+
pickled buffer (e.g. 100 MB) pipe capacity = 64 KiB (16 × 4 KiB)
94+
┌────────────────────────────┐
95+
│████████████████████████████│
96+
└─────────────┬──────────────┘
97+
│ write() 64 KiB ───────────► [▓▓▓▓ pipe full ▓▓▓▓]
98+
│ │ read() drains 64 KiB ─► worker
99+
│ writer blocks until drained ◄───────┘
100+
│ write() next 64 KiB ──────► [▓▓▓▓ pipe full ▓▓▓▓]
101+
│ │ read() ─► worker
102+
▼ ▼
103+
... repeat ~1,600 times for 100 MB (100 MB ÷ 64 KiB) ...
104+
105+
every 64 KiB round-trip is a pair of read/write syscalls plus a
106+
block-and-wake — thousands of syscalls and context switches per payload
107+
108+
So moving one large payload through a pipe is not a single copy but *thousands*
109+
of small ones, each with its own system-call and scheduler overhead, gated by the
110+
64 KiB window. The two costs compound: pickling produces a large buffer, and the
111+
pipe then drips that buffer across the boundary in tiny, blocking increments — on
112+
both sides, once per worker.
113+
114+
.. _ipc-avoiding-the-cost:
115+
116+
Avoiding the cost
117+
-----------------
118+
119+
There are three broad ways to pay less:
120+
121+
- **Don't cross the boundary at all.** SPDL's default is multi-threaded, not
122+
multi-process: threads share one address space, so passing data between stages
123+
is by reference — no pickling, no copy. When the heavy stages release the GIL,
124+
this is both the simplest and the fastest option. See :ref:`pipeline-parallelism`
125+
and :ref:`execution-models`.
126+
- **Cross it fewer times.** When a process boundary is unavoidable (a GIL-bound
127+
stage, or isolating the loader from the training process), keep whole *regions*
128+
of the pipeline together in the workers rather than paying a round trip per
129+
stage, and hand only finished batches back. This is what the MP and MTP patterns
130+
do; see :ref:`execution-models` and the "cost of crossing a process boundary"
131+
discussion in :ref:`pipeline-parallelism`.
132+
- **Make each crossing cheaper.** For the payloads you *do* send across, prefer
133+
types that move through shared memory instead of the pickle/pipe path.
134+
:py:class:`torch.Tensor` already does this: its multiprocessing reducer moves
135+
the tensor's storage through shared memory and pickles only a small handle, so
136+
a tensor transfers far more cheaply than its size suggests. Python's native
137+
``bytes`` and NumPy arrays do **not** — they take the full pickle-and-copy path
138+
above — so a raw byte string is exactly the worst case, and the way to send one
139+
cheaply is to wrap it in a 1-D ``uint8`` tensor so it rides the tensor path. For
140+
the last bit of performance, :py:class:`SharedMemorySegmentPool` writes any
141+
payload into a pre-mapped shared-memory region so the bulk bytes never enter
142+
the pipe at all — see the :ref:`shared-memory arena case study
143+
<shared-memory-arena>`.
144+
145+
The common thread: a process boundary is a copy, and copies scale with what you
146+
push across them. Keep the boundary count low and the per-crossing payload light,
147+
and the IPC cost stops being the bottleneck.

docs/source/case_studies/shared_memory_arena.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ the bytes through a multiprocessing queue — work that is paid on *both* sides
1818
that grows with the payload size. For pipelines that move large payloads per item
1919
(NumPy arrays, Torch tensors, raw ``bytes``, or :py:class:`spdl.io.VideoPackets`),
2020
this transfer is itself a meaningful source of host CPU usage — the very thing we
21-
are trying to keep low.
21+
are trying to keep low. See :ref:`ipc-cost` for why crossing a process boundary
22+
is expensive in the first place.
2223

2324
The shared-memory arena removes most of that cost.
2425

docs/source/examples.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ Examples
2626
benchmark_video
2727
benchmark_thread_output_queue
2828
benchmark_arena_transport
29+
benchmark_ipc_dataloader

docs/source/getting_started/execution_models.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,11 @@ The region's inputs and outputs cross a process boundary, so they must be
285285
`picklable <https://docs.python.org/3/library/pickle.html#pickle-picklable>`_;
286286
values passed between stages *inside* the region do not.
287287
**Reach for MP when** a CPU-bound Python stage that does not release the GIL
288-
dominates. The cost is IPC (data is copied across the boundary) and higher
289-
memory (each worker is a full interpreter). See :ref:`pipeline-parallelism` for
290-
the region and per-stage mechanics (including how a region composes with
291-
``run_pipeline_in_subprocess``) and the picklability rules.
288+
dominates. The cost is IPC (data is copied across the boundary; see
289+
:ref:`ipc-cost`) and higher memory (each worker is a full interpreter). See
290+
:ref:`pipeline-parallelism` for the region and per-stage mechanics (including how
291+
a region composes with ``run_pipeline_in_subprocess``) and the picklability
292+
rules.
292293

293294
Choosing between them
294295
---------------------

docs/source/getting_started/parallelism.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,8 @@ The cost of crossing a process boundary
298298

299299
Every time a stage is dispatched to a subprocess, its input is pickled and copied
300300
into the worker, and the result is pickled and copied back. For a single heavy,
301-
GIL-holding stage this overhead is easily worth it.
301+
GIL-holding stage this overhead is easily worth it. See :ref:`ipc-cost` for why
302+
this pickle-and-copy is expensive and how it scales with the payload.
302303

303304
The *size* of what crosses the boundary matters too, since pickling and copying a
304305
large payload is costly and is paid on both sides. PyTorch tensors (and NumPy

0 commit comments

Comments
 (0)