|
| 1 | +.. _autoresearch-example-v2: |
| 2 | + |
| 3 | +Example: Video Classification (MTP + Concurrency Tuning) |
| 4 | +========================================================= |
| 5 | + |
| 6 | +This page describes the second autoresearch run on the video classification |
| 7 | +example pipeline. The first run (:ref:`autoresearch-example`) discovered |
| 8 | +GPU NVDEC decode, split demux/decode, and subclip windowing — reducing |
| 9 | +step time from 426ms to 153ms (2.78×). This run starts from that |
| 10 | +optimized baseline and pushes further by introducing subprocess isolation |
| 11 | +(MTP) and concurrency tuning, achieving a **7.0× throughput improvement** |
| 12 | +over the new baseline. |
| 13 | + |
| 14 | +Setup |
| 15 | +----- |
| 16 | + |
| 17 | +**Starting point**: The pipeline from the first autoresearch run, which |
| 18 | +uses GPU NVDEC decode with a split demux/decode architecture, subclip |
| 19 | +2s windowing, dedicated thread executors, and bf16 autocast. |
| 20 | + |
| 21 | +**Pipeline**: |
| 22 | + |
| 23 | +.. code-block:: text |
| 24 | +
|
| 25 | + Sampling → fetch (c=8) → disaggregate → demux (c=8) → NvdecDecode (c=16) → aggregate → collate |
| 26 | +
|
| 27 | +**Hardware**: 1×8 H100 GPUs (grandteton). |
| 28 | + |
| 29 | +**Engine configuration**: |
| 30 | + |
| 31 | +.. code-block:: bash |
| 32 | +
|
| 33 | + spdl autoresearch engine \ |
| 34 | + --workflow spdl.autoresearch.pipeline_optimization:create_workflow \ |
| 35 | + --workdir /data/users/moto/autoresearch/video_classification_opt_v5 \ |
| 36 | + --max-concurrency 3 --platform auto \ |
| 37 | + -- \ |
| 38 | + --pipeline-script video_classification.py \ |
| 39 | + --source-dir spdl/examples/video_classification \ |
| 40 | + --build-command '...' \ |
| 41 | + --base-launch-command 'torchx run ...' \ |
| 42 | + --max-iterations 20 --patience 5 --job-timeout 600 |
| 43 | +
|
| 44 | +The engine ran with up to 3 concurrent experiments and a 10-minute timeout |
| 45 | +per job. |
| 46 | + |
| 47 | + |
| 48 | +Baseline |
| 49 | +-------- |
| 50 | + |
| 51 | +The starting pipeline produced **195 samples/s** (3,120 fps) with 14.7% |
| 52 | +steady-state GPU SM utilization. The headspace analysis (CacheDataLoader) |
| 53 | +measured a compute floor of **37.6ms/step** — 94% of step time was spent |
| 54 | +waiting for data. |
| 55 | + |
| 56 | +Unlike the first run, MTP (subprocess pipeline) succeeded this time because |
| 57 | +packet serialization support was added to SPDL, enabling demuxed |
| 58 | +``VideoPackets`` to be pickled across the process boundary. The MTP seed |
| 59 | +experiment provided a modest +4% improvement (202.8 sps) by isolating CPU |
| 60 | +data loading threads from CUDA kernel scheduling. |
| 61 | + |
| 62 | + |
| 63 | +Results |
| 64 | +------- |
| 65 | + |
| 66 | +The best configuration (``gc_disabled_buffer_5``) achieved **1,368 samples/s** |
| 67 | +(21,890 fps), a **7.0× improvement** from the baseline of 195 sps (3,120 fps). |
| 68 | +The optimized pipeline is available in the repository at `commit fa5e934 |
| 69 | +<https://github.com/facebookresearch/spdl/commit/fa5e934c5c783444bffb06f3a73ac27051ed4878>`_. |
| 70 | + |
| 71 | +.. list-table:: Optimization Breakdown |
| 72 | + :header-rows: 1 |
| 73 | + :widths: 45 15 15 15 |
| 74 | + |
| 75 | + * - Optimization |
| 76 | + - Throughput |
| 77 | + - Step Time |
| 78 | + - Cumulative Improvement |
| 79 | + * - **Baseline** (single process, demux c=8, NVDEC c=16) |
| 80 | + - 195 sps |
| 81 | + - 632ms |
| 82 | + - — |
| 83 | + * - \+ MTP subprocess isolation |
| 84 | + - 203 sps |
| 85 | + - 573ms |
| 86 | + - ↑4% |
| 87 | + * - \+ Subclip 0.5s (from 2.0s) |
| 88 | + - 317 sps |
| 89 | + - 395ms |
| 90 | + - ↑63% |
| 91 | + * - \+ NVDEC concurrency 7 (from 16) |
| 92 | + - 353 sps |
| 93 | + - 360ms |
| 94 | + - ↑81% |
| 95 | + * - \+ Subclip 0.5s + NVDEC c=7 combined |
| 96 | + - 386 sps |
| 97 | + - 332ms |
| 98 | + - ↑98% |
| 99 | + * - \+ Demux concurrency 4 (from 8) |
| 100 | + - 1,126 sps |
| 101 | + - 113ms |
| 102 | + - ↑478% |
| 103 | + * - \+ Demux concurrency 3 |
| 104 | + - 1,294 sps |
| 105 | + - 93ms |
| 106 | + - ↑564% |
| 107 | + * - \+ GC disabled + frontend buffer=5 |
| 108 | + - **1,368 sps** |
| 109 | + - **85ms** |
| 110 | + - **↑601%** |
| 111 | + |
| 112 | + |
| 113 | +Key Discoveries |
| 114 | +--------------- |
| 115 | + |
| 116 | +What worked |
| 117 | +~~~~~~~~~~~ |
| 118 | + |
| 119 | +1. **Reducing demux concurrency from 8 to 3** — The single biggest lever |
| 120 | + and the most counter-intuitive finding. Demuxing was assumed to be a |
| 121 | + near-trivial operation (milliseconds per item vs hundreds of |
| 122 | + milliseconds for decoding), so its concurrency seemed unimportant. |
| 123 | + In reality, demux threads contend severely at modest concurrency |
| 124 | + levels, and per-item latency rises sharply: |
| 125 | + |
| 126 | + .. note:: |
| 127 | + |
| 128 | + A follow-up investigation traced the bottleneck to FFmpeg's |
| 129 | + ``codec_mutex`` — a process-wide lock in `libavcodec/avcodec.c |
| 130 | + <https://ffmpeg.org/doxygen/8.0/avcodec_8c_source.html>`_ |
| 131 | + that serializes ``codec->init()`` for any decoder flagged |
| 132 | + ``FF_CODEC_CAP_NOT_INIT_THREADSAFE`` (applied to codecs backed |
| 133 | + by external libraries whose thread-safety status is unknown to |
| 134 | + FFmpeg). Every ``Demuxer`` construction calls |
| 135 | + ``avformat_find_stream_info``, which internally opens decoders |
| 136 | + to probe stream parameters, hitting this lock once per stream. |
| 137 | + |
| 138 | + .. list-table:: Demux Contention Curve |
| 139 | + :header-rows: 1 |
| 140 | + :widths: 20 25 25 |
| 141 | + |
| 142 | + * - Concurrency |
| 143 | + - Per-item Latency (p50) |
| 144 | + - Throughput |
| 145 | + * - c=2 |
| 146 | + - ~0.010s |
| 147 | + - 1,261 sps |
| 148 | + * - **c=3** |
| 149 | + - **0.013s** |
| 150 | + - **1,294 sps** |
| 151 | + * - c=4 |
| 152 | + - 0.023s |
| 153 | + - 1,126 sps |
| 154 | + * - c=6 |
| 155 | + - 0.067s |
| 156 | + - 580 sps |
| 157 | + * - c=8 (default) |
| 158 | + - 0.150s |
| 159 | + - 386 sps |
| 160 | + |
| 161 | + Going from c=8 to c=3 reduced per-item demux latency by **11.5×** |
| 162 | + (0.150s → 0.013s). The agent mapped this curve methodically and |
| 163 | + pinpointed c=3 as the sweet spot. |
| 164 | + |
| 165 | +2. **Shorter subclip duration (0.5s vs 2.0s)** — The first run found |
| 166 | + 2.0s optimal. With MTP subprocess isolation and the improved pipeline |
| 167 | + architecture, 0.5s became viable — the demux seeking overhead that |
| 168 | + penalized short clips in the first run was eliminated by running demux |
| 169 | + in a separate process. |
| 170 | + |
| 171 | +3. **MTP subprocess isolation** — Running demux in a subprocess via |
| 172 | + ``spdl.pipeline.run_pipeline_in_subprocess`` isolates CPU-intensive |
| 173 | + FFmpeg container parsing from CUDA kernel launch scheduling. This |
| 174 | + required packet serialization support (pickle for ``VideoPackets``). |
| 175 | + |
| 176 | +4. **GC management** — Disabling automatic garbage collection during |
| 177 | + training steps and running ``gc.collect()`` between epochs eliminated |
| 178 | + periodic latency spikes (~30ms every 50 steps). |
| 179 | + |
| 180 | +5. **Frontend buffer size** — Increasing the frontend sink buffer from |
| 181 | + 3 to 5 smoothed NVDEC timing jitter, providing a marginal but |
| 182 | + consistent improvement. |
| 183 | + |
| 184 | +What did not work |
| 185 | +~~~~~~~~~~~~~~~~~ |
| 186 | + |
| 187 | +1. **Increasing demux concurrency (c=12, c=16)** — Every experiment |
| 188 | + with higher demux concurrency was worse than baseline. The intuition |
| 189 | + "more threads = more throughput" pointed in the wrong direction for |
| 190 | + this memory-bandwidth-bound stage. |
| 191 | + |
| 192 | +2. **Larger batch sizes (alone)** — Batch=32 without other optimizations |
| 193 | + only yielded +5%. However, batch=32 combined with the full MTP + |
| 194 | + concurrency tuning stack was competitive (1,301 sps). |
| 195 | + |
| 196 | +3. **CPU decode** — CPU-based FFmpeg decode with demux c=4 reached |
| 197 | + ~1,060 sps — competitive but ~20% below NVDEC configurations. |
| 198 | + |
| 199 | +4. **torch.compile** — Added compilation warmup with no steady-state |
| 200 | + improvement. The model (R3D-18 at 112×112) is too compute-light for |
| 201 | + compilation to matter. |
| 202 | + |
| 203 | +5. **Streaming demux** — Both streaming demux variants crashed or stalled. |
| 204 | + |
| 205 | +6. **Priority executor** — Provided no improvement over standard |
| 206 | + ThreadPoolExecutor configurations. |
| 207 | + |
| 208 | + |
| 209 | +Pipeline Architecture: Before and After |
| 210 | +---------------------------------------- |
| 211 | + |
| 212 | +**Before** (single process, 195 sps): |
| 213 | + |
| 214 | +.. code-block:: python |
| 215 | +
|
| 216 | + pipeline = ( |
| 217 | + PipelineBuilder() |
| 218 | + .add_source(source, continuous=True) |
| 219 | + .pipe(dataset.__getitem__, concurrency=8, executor=fetch_executor) |
| 220 | + .disaggregate() |
| 221 | + .pipe(Demux(...), concurrency=8, executor=demux_executor) |
| 222 | + .pipe(nvdec_decode, concurrency=16, executor=decode_executor) |
| 223 | + .aggregate(batch_size, drop_last=True) |
| 224 | + .pipe(collate) |
| 225 | + .add_sink(buffer_size=3) |
| 226 | + .build(num_threads=2) |
| 227 | + ) |
| 228 | +
|
| 229 | +**After** (MTP subprocess split, 1,368 sps): |
| 230 | + |
| 231 | +.. code-block:: python |
| 232 | +
|
| 233 | + # Backend (subprocess) — CPU-only: fetch → disaggregate → demux |
| 234 | + backend = ( |
| 235 | + PipelineBuilder() |
| 236 | + .add_source(source, continuous=True) |
| 237 | + .pipe(partial(_fetch_sample, dataset=dataset), concurrency=num_fetch_threads) |
| 238 | + .disaggregate() |
| 239 | + .pipe( |
| 240 | + partial(_demux_sample, label_to_index=label_to_index, ...), |
| 241 | + concurrency=3, # not 8 — memory-bandwidth contention |
| 242 | + ) |
| 243 | + .add_sink(buffer_size=3) |
| 244 | + ) |
| 245 | +
|
| 246 | + source2 = spdl.pipeline.run_pipeline_in_subprocess( |
| 247 | + backend.get_config(), |
| 248 | + num_threads=max(num_fetch_threads, num_demux_threads), |
| 249 | + mp_context="forkserver", |
| 250 | + ) |
| 251 | +
|
| 252 | + # Frontend (main process) — GPU NVDEC decode → aggregate → collate |
| 253 | + frontend = ( |
| 254 | + PipelineBuilder() |
| 255 | + .add_source(source2, continuous=True) |
| 256 | + .pipe(nvdec_decode, concurrency=7, executor=decode_executor) # match H100 HW slots |
| 257 | + .aggregate(batch_size, drop_last=True) |
| 258 | + .pipe(collate) |
| 259 | + .add_sink(buffer_size=5) |
| 260 | + ) |
| 261 | + pipeline = frontend.build(num_threads=2) |
| 262 | +
|
| 263 | +
|
| 264 | +Progress |
| 265 | +-------- |
| 266 | + |
| 267 | +The following plot shows throughput, step time, SM utilization, job |
| 268 | +duration, and raw SM utilization across all 111 experiments. Green dots |
| 269 | +mark improvements over the running best. The dashed blue line shows the |
| 270 | +headspace ceiling (3,405 samples/s steady-state compute floor). |
| 271 | + |
| 272 | +.. image:: /_static/data/autoresearch_video_classification_v2_progress.png |
| 273 | + :alt: Autoresearch progress over 111 experiments |
| 274 | + :width: 100% |
| 275 | + |
| 276 | +Throughput climbed steadily through the first 30 experiments as the |
| 277 | +engine discovered subclipping and NVDEC concurrency tuning. The |
| 278 | +breakthrough at experiment ~30 (reducing demux concurrency to c=4) caused |
| 279 | +a sharp jump past 1,000 sps. Subsequent experiments refined the demux |
| 280 | +sweet spot (c=3 vs c=2 vs c=4) and stacked GC/buffer tuning for the |
| 281 | +final result. |
| 282 | + |
| 283 | + |
| 284 | +Hypothesis Tree |
| 285 | +--------------- |
| 286 | + |
| 287 | +.. image:: /_static/data/autoresearch_video_classification_v2_hypothesis_tree.png |
| 288 | + :alt: Hypothesis tree for 111 experiments |
| 289 | + :width: 100% |
| 290 | + |
| 291 | +The tree shows the engine exploring three main branches from the seed |
| 292 | +experiments: subclipping (the dominant branch), decode concurrency |
| 293 | +tuning, and batch size. The breakthrough path runs through |
| 294 | +subclip 0.5s → NVDEC c=7 → demux c=4 → demux c=3 → GC+buffer tuning. |
| 295 | +Failed experiments (red nodes) include high demux concurrency, streaming |
| 296 | +demux, and torch.compile — each failure narrowed the search space and |
| 297 | +confirmed that concurrency reduction, not increase, was the right |
| 298 | +direction. |
| 299 | + |
| 300 | + |
| 301 | +Remaining Headspace |
| 302 | +------------------- |
| 303 | + |
| 304 | +.. list-table:: |
| 305 | + :widths: 40 20 |
| 306 | + |
| 307 | + * - Compute floor (CacheDataLoader) |
| 308 | + - 37.6ms (3,405 sps) |
| 309 | + * - Best achieved |
| 310 | + - 85ms (1,368 sps) |
| 311 | + * - Remaining headspace |
| 312 | + - ~60% |
| 313 | + * - Bottleneck |
| 314 | + - NVDEC hardware decode rate (7 slots) |
| 315 | + |
| 316 | +The best result is now limited by NVDEC frontend throughput — the 7 |
| 317 | +hardware decoder slots on H100 are the binding constraint. Further |
| 318 | +improvement would likely require IPC optimizations (e.g. memory arenas |
| 319 | +to reduce pickle/unpickle overhead across the subprocess boundary) or |
| 320 | +pre-decoded datasets. |
| 321 | + |
| 322 | + |
| 323 | +Experiment Statistics |
| 324 | +--------------------- |
| 325 | + |
| 326 | +The engine ran more than 100 experiments over approximately 20 hours |
| 327 | +with up to 3 concurrent jobs. About a quarter failed at runtime — |
| 328 | +mostly aggressive configurations (high demux concurrency, streaming |
| 329 | +demux, ``torch.compile``, exotic GC settings) that the engine tried |
| 330 | +knowing some would fail. Of those that completed, 12 produced kept |
| 331 | +improvements. |
0 commit comments