Skip to content

Commit fa5e934

Browse files
authored
Switch video classification example to MTP pipeline with optimized concurrency (#1494)
Convert `build_pipeline` from single-process to Multi-Threading in subprocess (MTP) architecture, based on configurations discovered by autoresearch (78 experiments, 12 hours of automated optimization). <img width="2349" height="3679" alt="image" src="https://github.com/user-attachments/assets/b89fa087-43d3-4bdb-954d-bb02639d2138" /> **Architecture change:** - Split pipeline into backend subprocess (fetch → disaggregate → demux) and frontend main process (NVDEC decode → aggregate → collate) - Demuxed packets are serialized across the process boundary via pickle, isolating CPU-intensive demux work from CUDA kernel scheduling in the training process **Key parameter changes:** - Add `--num-demux-threads` argument to control demux concurrency independently from decode - Increase frontend sink buffer from 3 to 5 for smoother NVDEC timing jitter absorption - Disable automatic GC during training steps; run `gc.collect()` between epochs instead **Results:** The winning autoresearch configuration (`--subclip-duration 0.5 --num-decode-threads 7 --num-demux-threads 3`) achieved **6.6x throughput improvement** on Kinetics-400 with R3D-18 (1x8 H100 grandteton): 195 → 1,294 samples/s (3,120 → 20,704 fps). The most impactful finding was that reducing demux concurrency from 8 to 3 threads yielded a 3.4x throughput jump due to memory-bandwidth contention at higher thread counts.
1 parent d720e9e commit fa5e934

2 files changed

Lines changed: 104 additions & 56 deletions

File tree

examples/video_classification/utils/pipeline.py

Lines changed: 88 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@
99
"""SPDL pipeline construction for video classification.
1010
1111
Provides ``build_pipeline`` which assembles a GPU NVDEC-accelerated SPDL
12-
pipeline with a split demux/decode architecture::
12+
pipeline using Multi-Threading in subprocess (MTP)::
1313
14-
sample → fetch → disaggregate → demux (CPU) → decode (NVDEC) → aggregate → collate
14+
Backend (subprocess): sample → fetch → disaggregate → demux (CPU)
15+
Frontend (main process): GPU decode (NVDEC) → aggregate → collate
16+
17+
Demuxed packets are serialized across the process boundary, isolating
18+
CPU-intensive demux work from CUDA kernel scheduling in the training
19+
process.
1520
1621
The pipeline is dataset-agnostic: any dataset whose ``__getitem__`` returns
1722
``list[{"video_bytes": bytes, "label": str}]`` can be used.
@@ -20,17 +25,22 @@
2025
from __future__ import annotations
2126

2227
import argparse
28+
import logging
2329
import os
2430
from collections.abc import Iterator
2531
from concurrent.futures import ThreadPoolExecutor
32+
from functools import partial
2633
from typing import Protocol
2734

2835
import spdl.io
36+
import spdl.pipeline
2937
import spdl.source.utils
3038
import torch
3139
from spdl.pipeline import PipelineBuilder
3240
from spdl.source import DistributedRandomSampler
3341

42+
_LG: logging.Logger = logging.getLogger(__name__)
43+
3444
type _TBatch = dict[str, torch.Tensor]
3545

3646

@@ -39,24 +49,36 @@ def __len__(self) -> int: ...
3949
def __getitem__(self, index: int) -> list[dict[str, object]]: ...
4050

4151

42-
class Demux:
43-
"""Demux video bytes into packets and resolve label to index."""
52+
# ---------------------------------------------------------------------------
53+
# Backend (subprocess) stage functions — must be picklable.
54+
# Using module-level functions + functools.partial.
55+
# ---------------------------------------------------------------------------
4456

45-
def __init__(
46-
self, label_to_index: dict[str, int], subclip_duration: float | None = None
47-
) -> None:
48-
self.label_to_index = label_to_index
49-
self.subclip_duration = subclip_duration
5057

51-
def __call__(self, sample: dict[str, object]) -> dict[str, object] | None:
52-
video_bytes = sample["video_bytes"]
53-
try:
54-
timestamp = (0.0, self.subclip_duration) if self.subclip_duration else None
55-
packets = spdl.io.demux_video(video_bytes, timestamp=timestamp)
56-
except RuntimeError:
57-
return None
58-
label = self.label_to_index[sample["label"]] # pyre-ignore[6]
59-
return {"packets": packets, "label": label}
58+
def _fetch_sample(index: int, *, dataset: object) -> list[dict[str, object]]:
59+
return dataset[index] # pyre-ignore[16]
60+
61+
62+
def _demux_sample(
63+
sample: dict[str, object],
64+
*,
65+
label_to_index: dict[str, int],
66+
subclip_duration: float | None = None,
67+
) -> dict[str, object] | None:
68+
video_bytes = sample["video_bytes"]
69+
try:
70+
timestamp = (0.0, subclip_duration) if subclip_duration else None
71+
packets = spdl.io.demux_video(video_bytes, timestamp=timestamp)
72+
except RuntimeError as e:
73+
_LG.warning("Demux failed: %s", e)
74+
return None
75+
label = label_to_index[sample["label"]] # pyre-ignore[6]
76+
return {"packets": packets, "label": label}
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# Frontend (main process) — GPU decode, runs in the training process.
81+
# ---------------------------------------------------------------------------
6082

6183

6284
class NvdecDecode:
@@ -92,7 +114,8 @@ def __call__(
92114
pix_fmt="rgb",
93115
)
94116
tensor = spdl.io.to_torch(buffer) # [T, C, H, W], already on GPU
95-
except RuntimeError:
117+
except RuntimeError as e:
118+
_LG.warning("NVDEC decode failed: %s", e)
96119
return None
97120

98121
# Frame sampling
@@ -130,20 +153,22 @@ def build_pipeline(
130153
rank: int,
131154
world_size: int,
132155
) -> Iterator[_TBatch]:
133-
"""Build a multithreaded SPDL pipeline with GPU NVDEC video decoding.
156+
"""Build an MTP pipeline with subprocess demux and GPU NVDEC decode.
134157
135-
Uses the GPU's dedicated NVDEC hardware decoders instead of CPU FFmpeg,
136-
with a split demux/decode architecture. Each stage runs on a dedicated
137-
thread executor to eliminate pool contention.
158+
Splits the pipeline into a backend subprocess (CPU-only: fetch,
159+
disaggregate, demux) and a frontend main process (GPU NVDEC decode,
160+
aggregate, collate). Demuxed packets are serialized across the
161+
process boundary, isolating CPU work from CUDA kernel scheduling.
138162
139163
Pipeline stages::
140164
141-
sample → fetch → disaggregate → demux (CPU) → decode (NVDEC) → aggregate → collate
165+
Backend (subprocess): sample → fetch → disaggregate → demux
166+
Frontend (main process): NVDEC decode → aggregate → collate
142167
143168
Args:
144169
args: CLI args providing ``num_fetch_threads``, ``num_decode_threads``,
145-
``num_frames``, ``frame_width``, ``frame_height``, ``batch_size``,
146-
and ``subclip_duration``.
170+
``num_demux_threads``, ``num_frames``, ``frame_width``,
171+
``frame_height``, ``batch_size``, and ``subclip_duration``.
147172
dataset: Any object implementing the ``VideoDataset`` protocol.
148173
label_to_index: Mapping from label string to class index.
149174
rank: Current distributed rank.
@@ -165,17 +190,44 @@ def build_pipeline(
165190

166191
num_fetch_threads = args.num_fetch_threads
167192
num_decode_threads = args.num_decode_threads
193+
num_demux_threads = args.num_demux_threads
168194

169195
source = spdl.source.utils.embed_shuffle(
170196
DistributedRandomSampler(
171-
len(dataset), rank=rank, world_size=world_size
172-
) # pyre-ignore[6]
197+
len(dataset),
198+
rank=rank,
199+
world_size=world_size, # pyre-ignore[6]
200+
)
173201
)
174202

175-
fetch_executor = ThreadPoolExecutor(max_workers=num_fetch_threads)
176-
demux_executor = ThreadPoolExecutor(max_workers=8)
177-
decode_executor = ThreadPoolExecutor(max_workers=num_decode_threads)
203+
# Backend (subprocess) — CPU-only stages: fetch → disaggregate → demux
204+
backend = (
205+
PipelineBuilder()
206+
.add_source(source, continuous=True)
207+
.pipe(
208+
partial(_fetch_sample, dataset=dataset),
209+
concurrency=num_fetch_threads,
210+
)
211+
.disaggregate()
212+
.pipe(
213+
partial(
214+
_demux_sample,
215+
label_to_index=label_to_index,
216+
subclip_duration=args.subclip_duration,
217+
),
218+
concurrency=num_demux_threads,
219+
)
220+
.add_sink(buffer_size=3)
221+
)
178222

223+
source2 = spdl.pipeline.run_pipeline_in_subprocess(
224+
backend.get_config(),
225+
num_threads=max(num_fetch_threads, num_demux_threads),
226+
mp_context="forkserver",
227+
)
228+
229+
# Frontend (main process) — GPU NVDEC decode → aggregate → collate
230+
decode_executor = ThreadPoolExecutor(max_workers=num_decode_threads)
179231
nvdec_decode = NvdecDecode(
180232
num_frames=args.num_frames,
181233
cuda_cfg=cuda_cfg,
@@ -184,29 +236,13 @@ def build_pipeline(
184236
device=device,
185237
)
186238

187-
pipeline = (
239+
frontend = (
188240
PipelineBuilder()
189-
.add_source(source, continuous=True)
190-
.pipe(
191-
dataset.__getitem__,
192-
concurrency=num_fetch_threads,
193-
executor=fetch_executor,
194-
) # pyre-ignore[6]
195-
.disaggregate()
196-
.pipe(
197-
Demux(label_to_index, subclip_duration=args.subclip_duration),
198-
concurrency=8,
199-
executor=demux_executor,
200-
)
201-
.pipe(
202-
nvdec_decode,
203-
concurrency=num_decode_threads,
204-
executor=decode_executor,
205-
)
241+
.add_source(source2, continuous=True)
242+
.pipe(nvdec_decode, concurrency=num_decode_threads, executor=decode_executor)
206243
.aggregate(args.batch_size, drop_last=True)
207244
.pipe(collate)
208-
.add_sink(buffer_size=3)
209-
.build(num_threads=2)
245+
.add_sink(buffer_size=5)
210246
)
211-
247+
pipeline = frontend.build(num_threads=2)
212248
return pipeline.get_iterator(timeout=300)

examples/video_classification/video_classification.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
SPDL Data Pipeline
1616
^^^^^^^^^^^^^^^^^^
1717
18-
Uses GPU NVDEC hardware decoding in a multithreaded pipeline:
18+
Uses Multi-Threading in subprocess (MTP) with GPU NVDEC hardware decoding:
1919
20-
Sampling → fetch → demux → GPU decode (NVDEC) → aggregate → collate.
20+
Backend (subprocess): Sampling → fetch → disaggregate → demux (CPU)
21+
Frontend (main process): GPU decode (NVDEC) → aggregate → collate
2122
2223
The data source is pluggable: local video files (OSS) or WarmStorage
2324
via BulkDataset (Meta-internal), selected automatically via the
@@ -38,6 +39,7 @@
3839
from __future__ import annotations
3940

4041
import argparse
42+
import gc
4143
import logging
4244
import os
4345
import time
@@ -93,6 +95,8 @@ def train(
9395

9496
_LG.info("Rank %d/%d on device %s", rank, world_size, device)
9597

98+
gc.disable()
99+
96100
# --- Model ---
97101
_LG.info("Building R3D-18 model with %d classes", num_classes)
98102
model = r3d_18(num_classes=num_classes).to(device=device, dtype=torch.float32)
@@ -149,6 +153,8 @@ def train(
149153
num_batches * batch_size * world_size / elapsed,
150154
)
151155

156+
gc.collect()
157+
152158
elapsed = time.monotonic() - t0
153159
if rank == 0:
154160
avg_loss = epoch_loss / max(num_batches, 1)
@@ -179,7 +185,7 @@ def parse_args() -> argparse.Namespace:
179185
parser.add_argument(
180186
"--subclip-duration",
181187
type=float,
182-
default=None,
188+
default=0.5,
183189
help="Temporal subclip duration in seconds. If set, only the first N seconds of each video are demuxed and decoded.",
184190
)
185191
# Training
@@ -205,9 +211,15 @@ def parse_args() -> argparse.Namespace:
205211
parser.add_argument(
206212
"--num-decode-threads",
207213
type=int,
208-
default=16,
214+
default=7,
209215
help="Concurrent video decode threads",
210216
)
217+
parser.add_argument(
218+
"--num-demux-threads",
219+
type=int,
220+
default=3,
221+
help="Concurrent demux threads in the backend subprocess",
222+
)
211223
# Dataset-specific args (OSS or FB, depending on which module is available)
212224
add_dataset_args(parser)
213225
return parser.parse_args()

0 commit comments

Comments
 (0)