Skip to content

Commit daa90c4

Browse files
authored
[pipeline] add pipeline authoring guide + async/auto_stop doc fixes (#1583)
(Followup of #1578, which did not include the main guideline file.) Add a from-scratch SPDL pipeline authoring guide (`tools/skills/authoring/building_pipelines.md`) covering stage decomposition by operation nature, concurrency budgeting, `spdl.io` for media, and multi-threading-in-subprocess for production. Its minimal example shows the build-once / re-iterate-per-epoch pattern. Clarify async stage handling in the pipeline reference docs: async functions can be passed to `.pipe()` directly and must not be wrapped in `asyncio.run()`, which only adds per-item event-loop init/finalize overhead and forces the work onto worker threads instead of running as cheap cooperative coroutines on the shared event loop. Mark `auto_stop()` as obsolete in the optimization reference docs — a `Pipeline` is built once and re-iterated each epoch without it.
1 parent ede2326 commit daa90c4

1 file changed

Lines changed: 153 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Building an Efficient SPDL Pipeline From Scratch
2+
3+
Instructions for authoring a **new** SPDL data pipeline (greenfield) that keeps the GPU fed without starving it or triggering the noisy-neighbour effect. If you are converting an *existing* PyTorch `DataLoader`/`Dataset`, start from `migrating_to_spdl_pipeline.md` instead — it covers the same construction patterns plus how to decompose existing `__getitem__`/iterator code. This guide is for when there is no prior loader to migrate.
4+
5+
## Mental Model
6+
7+
An SPDL pipeline is a series of stages connected by queues. Each stage runs concurrently and applies one transformation to items flowing through it:
8+
9+
```
10+
source → pipe(fetch) → pipe(decode) → aggregate(batch) → pipe(collate) → pipe(transfer) → sink
11+
```
12+
13+
The core idea for efficiency: **classify every operation by its nature and give each its own stage with independent concurrency.** Never bundle operations of different natures (e.g. network fetch + CPU decode) into one function — they need different concurrency and would block each other.
14+
15+
| Nature | Examples | Stage design |
16+
|---|---|---|
17+
| Network I/O | HTTP fetch, blob store read, DB query | `.pipe(fn, concurrency=16-32, output_order="completion")` |
18+
| Disk I/O | local file read, mmap | `.pipe(fn, concurrency=8-16)` |
19+
| CPU, GIL-free | media decode (`spdl.io`), tiktoken, NumPy/Torch ops | `.pipe(fn, concurrency=4-8)` |
20+
| CPU, GIL-holding | pure-Python transforms | `.pipe(fn, concurrency=1)` — minimize or replace |
21+
| Batching | collation / stacking | `.aggregate(batch_size)` then `.pipe(collate_fn)` |
22+
| GPU transfer | move batch to device | `spdl.io.transfer_tensor` in a dedicated 1-worker executor |
23+
24+
## Minimal End-to-End Example
25+
26+
```python
27+
from spdl.pipeline import PipelineBuilder
28+
from spdl.source import DistributedRandomSampler
29+
30+
source = DistributedRandomSampler(num_samples, rank=rank, world_size=world_size)
31+
32+
pipeline = (
33+
PipelineBuilder()
34+
.add_source(source, continuous=True)
35+
.pipe(fetch, concurrency=24) # network I/O
36+
.pipe(decode, concurrency=8) # CPU, GIL-free (use spdl.io)
37+
.aggregate(batch_size, drop_last=True)
38+
.pipe(collate)
39+
.add_sink(buffer_size=3)
40+
.build(num_threads=16)
41+
)
42+
43+
# Build the pipeline once, then re-iterate it every epoch — a Pipeline is
44+
# re-iterable, so call get_iterator() afresh each epoch (no rebuild needed).
45+
for epoch in range(num_epochs):
46+
for batch in pipeline.get_iterator(timeout=900):
47+
train_step(batch)
48+
```
49+
50+
## Efficiency Principles
51+
52+
1. **One nature per stage.** Split I/O, CPU, and GPU work into separate `.pipe()` calls with independent `concurrency`. This is the single biggest lever.
53+
2. **Respect the CPU budget.** Keep total CPU utilization ≤ 40% — above that, the OS can't schedule GPU kernel launches promptly (the noisy-neighbour effect) and the GPU idles even with data ready. Do not set concurrency higher than `num_CPU_cores / 8`. Using all CPUs for data loading is an anti-pattern.
54+
3. **Use `spdl.io` for media.** Replace Pillow/TorchVision/torchaudio with `spdl.io` for image/video/audio — it releases the GIL, keeps data in native format until batch creation, and converts zero-copy into one contiguous batch tensor.
55+
4. **Async stage functions: pass them as-is.** `.pipe()` accepts both sync (`def`) and async (`async def`) callables. Pass a coroutine function **directly** — SPDL awaits it on its own event loop and drives up to `concurrency` of them at once on a single thread, which is ideal for I/O-bound work.
56+
57+
```python
58+
async def fetch(key):
59+
async with session.get(url(key)) as resp:
60+
return await resp.read()
61+
62+
.pipe(fetch, concurrency=32) # CORRECT — coroutine function passed as-is
63+
```
64+
65+
**Do not** wrap it as `asyncio.run(fetch(x))`: that spins up and tears down a brand-new event loop on every single item — pure per-item overhead — and forces the work onto pipeline worker threads instead of running as cheap cooperative coroutines on SPDL's shared event loop.
66+
5. **`transfer_tensor` for GPU.** Use `spdl.io.transfer_tensor` in a dedicated `ThreadPoolExecutor(max_workers=1)` so it gets its own CUDA stream and overlaps transfer with compute.
67+
6. **Batching is two steps.** `.aggregate(batch_size, drop_last=True)` then a `.pipe(collate)` stage. `drop_last=True` avoids partial-batch shape mismatches under DDP.
68+
7. **Iterate with a timeout.** Prefer `pipeline.get_iterator(timeout=<seconds>)` over `for batch in pipeline` / manual `next()` — it prevents jobs from hanging forever on a stall.
69+
70+
## Going to Production: Multi-Threading in Subprocess (MTP)
71+
72+
For production training, isolate the CPU-heavy stages in a subprocess and keep only GPU transfer in the main process — this removes GIL contention between data loading and the training loop. Build the CPU stages with `PipelineBuilder`, obtain a `PipelineConfig` via `.get_config()`, hand it to `run_pipeline_in_subprocess()`, then build a small frontend pipeline that only does `transfer_tensor`. Stage functions must then be **picklable** (module-level functions with `functools.partial`, or callable classes — never lambdas or nested functions).
73+
74+
See `migrating_to_spdl_pipeline.md` for the full MTP construction pattern, pickling/thread-safety constraints, and media (`spdl.io`) recipes — they apply identically to a from-scratch build. See `optimization_strategies.md` for deep tuning: concurrency search, subprocess IPC / shared-memory arena, GPU (NVDEC) video decode, decoder-thread tuning, GC-stall mitigation, and headspace analysis.
75+
76+
## Structuring the Construction Code
77+
78+
When code builds a `Pipeline` (or `PipelineConfig`) in more than one shape — e.g. a script that supports several execution modes, or a loader with optional stages — keep **each pipeline's construction in one place**. The maintainability win is being able to read a single fluent chain top-to-bottom and see the whole pipeline; it is lost when the construction is scattered across helpers that each tack on a few stages.
79+
80+
**Route at the top, then let each builder own its whole chain.** Dispatch on the mode/variant first, and have each branch build its pipeline as a single uninterrupted `PipelineBuilder().add_source(...)...add_sink(...).build()` chain:
81+
82+
```python
83+
def build_pipeline_for_mode(mode, ...):
84+
match mode:
85+
case "mt":
86+
return _build_mt_pipeline(...)
87+
case "mp":
88+
return _build_mp_pipeline(...)
89+
case "mtp":
90+
return _build_mtp_pipeline(...)
91+
case _:
92+
raise ValueError(f"unknown mode: {mode!r}")
93+
94+
def _build_mt_pipeline(...):
95+
return (
96+
PipelineBuilder()
97+
.add_source(...)
98+
.pipe(...)
99+
.aggregate(...)
100+
.add_sink(...)
101+
.build(num_threads=...)
102+
)
103+
```
104+
105+
**Extract a shared builder only when the variants differ purely by argument.** When two modes really do produce the identical chain except for one value (say an executor or a buffer size), factor out *the entire chain* into a helper parameterized by that value — never a helper that builds only part of it:
106+
107+
```python
108+
# Two modes that are the SAME stage graph, differing only by the executor:
109+
def _build_thread_pipeline(args):
110+
return _build_local_pipeline(args, ThreadPoolExecutor(max_workers=args.n))
111+
112+
def _build_interp_pipeline(args):
113+
return _build_local_pipeline(args, InterpreterPoolExecutor(max_workers=args.n))
114+
115+
def _build_local_pipeline(args, executor: Executor):
116+
return (
117+
PipelineBuilder()
118+
.add_source(...)
119+
.aggregate(...)
120+
.pipe(decode, executor=executor)
121+
.add_sink(...)
122+
.build(num_threads=args.n, fuse_subprocess_stages=True)
123+
)
124+
```
125+
126+
Apply that test honestly — it holds only when the chains are otherwise identical. A variant that changes the *stage graph* does **not** qualify, even if it superficially sounds like "the same thing with a different executor." For example, a PyTorch-`DataLoader`-style mode that aggregates the source into batches *first* and hands each whole batch to a process worker (download + decode + transform fused into one stage) is not "the thread mode with a process executor" — it has a different topology and gets its own complete builder.
127+
128+
**Anti-pattern — partial-construction helpers.** Do **not** write helpers that take a half-built `PipelineBuilder`, append a few stages, and return it for the caller to continue chaining. This splits one pipeline's definition across several functions and forces the reader to jump between them to reconstruct the shape:
129+
130+
```python
131+
# DON'T: the pipeline's stages are scattered across _add_cache_variants and the caller
132+
builder = (
133+
_add_cache_variants(builder, cache_dir=cd) # appends some stages, returns builder
134+
.aggregate(batch_size, drop_last=True) # caller appends more
135+
.pipe(decode, executor=executor)
136+
)
137+
```
138+
139+
When variants genuinely differ in topology (not just an argument), inline each one as its own complete chain in a branch, even at the cost of repeating the common `add_source`/`add_sink`/`build` boilerplate — one readable chain per variant beats a deduplicated tangle.
140+
141+
**A `PipelineConfig` is a complete artifact, not a partial build.** Returning `builder...add_sink(...).get_config()` from a helper is fine: a config is a finished, picklable pipeline spec. It is the right tool when the *same* topology must be materialized more than one way — e.g. `build_pipeline(config, ...)` to run in-process versus `run_pipeline_in_subprocess(config, ...)` for an MTP-style subprocess stage — letting one topology definition serve every mode without splitting its construction. Note that a config sent to `run_pipeline_in_subprocess` is pickled: every stage op must be picklable (module-level functions / `functools.partial`, not closures), and a live `ProcessPoolExecutor` can't ride along — use `PriorityProcessPoolExecutor(...).get_executor()` for a process-pool stage that must survive into the subprocess.
142+
143+
## Checklist
144+
145+
- [ ] Every operation classified by nature and given its own stage
146+
- [ ] I/O stages high concurrency (16-32); CPU stages moderate (4-8); GPU transfer isolated (1 worker)
147+
- [ ] Media decoding uses `spdl.io` (not Pillow/TorchVision) where applicable
148+
- [ ] Async functions passed to `.pipe()` as-is (no `asyncio.run()` wrapping)
149+
- [ ] Batching uses `.aggregate()` + an explicit collate stage
150+
- [ ] Total concurrency respects the CPU budget (≤ 40% utilization)
151+
- [ ] Production build uses MTP with picklable stage functions
152+
- [ ] Iterated via `get_iterator(timeout=...)`
153+
- [ ] Each pipeline shape is a single readable builder chain (no partial-construction helpers)

0 commit comments

Comments
 (0)