Skip to content

Commit 70be9d0

Browse files
authored
[autoresearch] add pipeline authoring guide + async/auto_stop doc fixes (#1578)
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 5e10b90 commit 70be9d0

5 files changed

Lines changed: 37 additions & 4 deletions

File tree

src/spdl/autoresearch/pipeline_optimization/prompts/apply_changes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ __PIPELINE_CODE__
7676
13. **TorchTNT scripts**: If the code uses TorchTNT (`torchtnt.framework.fit`, `train`, `AutoUnit`), the SPDL `Pipeline` is passed directly to TorchTNT as the `train_dataloader` (Pipeline is iterable — no wrapper class is required unless instrumentation was added). When applying changes:
7777
- **Pipeline construction changes** (concurrency, MTP, batch size): Modify the function that builds the `PipelineBuilder`, same as non-TorchTNT code. The `Pipeline` abstracts MTP vs pure multithreading, so the code passing Pipeline to TorchTNT does not change.
7878
- **Do NOT modify TorchTNT internals** (`fit()`, `train()`, `AutoUnit.train_step`). Only modify the pipeline construction.
79-
- The pipeline is built once and iterated many times. There is no need for `auto_stop()` or rebuilding per epoch.
79+
- The pipeline is built once and iterated many times. `auto_stop()` is obsolete — do not call it, and do not rebuild per epoch.
8080

8181
Output the modified file:
8282

src/spdl/autoresearch/pipeline_optimization/prompts/knowledge/knowledge.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ This wrapper is passed to TorchTNT's `fit()` or `train()` as the `train_dataload
268268
**Key facts about Pipeline:**
269269
- `Pipeline` is both iterable and iterator. It can technically be directly iterated, but the wrapper pattern above is recommended because it provides `get_iterator(timeout=...)` for timeout handling and `__len__` for epoch length.
270270
- **Preferred iteration**: Always use `pipeline.get_iterator(timeout=<seconds>)` to obtain an iterator with a timeout, so that jobs do not get stuck. Directly iterating with `for batch in pipeline:` or `iter(pipeline)` is discouraged because it lacks timeout handling.
271-
- The pipeline is built once and iterated many times. There is no need for `auto_stop()` or rebuilding per epoch.
271+
- The pipeline is built once and iterated many times. `auto_stop()` is obsolete — do not call it, and do not rebuild per epoch.
272272
- The `Pipeline` abstracts away whether it uses MTP (subprocess) or pure multithreading internally, so switching between them does not affect how the Pipeline is consumed.
273273
- TorchTNT calls `iter(dataloader)` at the start of each epoch, then `next(data_iter)` per step until `StopIteration`.
274274
- The pipeline's source controls how many items are produced per epoch. When the source is exhausted, the iterator raises `StopIteration`, ending the epoch in TorchTNT.

src/spdl/autoresearch/pipeline_optimization/prompts/knowledge/optimization_strategies.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,37 @@ pipeline = (
3333
| `buffer_size` | `.add_sink(N)` | 2-10; affects memory, NOT throughput |
3434
| `output_order` | `.pipe(fn, output_order=...)` | "completion" for I/O (avoids head-of-line blocking), "input" for determinism |
3535

36+
### Async Stage Functions: Pass Them As-Is
37+
38+
`.pipe()` accepts both regular (`def`) and asynchronous (`async def`) callables. **Pass an async function directly — do not wrap it in `asyncio.run()`:**
39+
40+
```python
41+
async def fetch(key):
42+
async with session.get(url(key)) as resp:
43+
return await resp.read()
44+
45+
pipeline = (
46+
PipelineBuilder()
47+
.add_source(source)
48+
.pipe(fetch, concurrency=32) # CORRECT: coroutine function passed as-is
49+
...
50+
)
51+
```
52+
53+
SPDL runs stages on its own event loop, so it awaits async stage functions natively and drives up to `concurrency` of them at once as cheap cooperative coroutines — ideal for I/O-bound work.
54+
55+
**Anti-pattern:** wrapping the coroutine so the stage becomes synchronous.
56+
57+
```python
58+
# WRONG — asyncio.run() spins up and tears down a brand-new event loop on
59+
# every single item. That per-item loop init/finalize is pure overhead, and
60+
# it forces the work onto pipeline worker threads instead of running as cheap
61+
# cooperative coroutines on SPDL's shared event loop.
62+
.pipe(lambda key: asyncio.run(fetch(key)), concurrency=32)
63+
```
64+
65+
If a stage is already synchronous, just pass it as a normal function — no event loop is involved either way.
66+
3667
## Recommended Architecture: Multi-Threading in Subprocess (MTP)
3768

3869
The production pattern runs the CPU-heavy pipeline in a **subprocess** and only does GPU transfer in the main process.

src/spdl/autoresearch/pipeline_optimization/prompts/plan_next.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Before you can consider stopping, ALL of the following must have been attempted
7777
- **Critical — understand the existing code first**: Inspect the pipeline builder function's return type and structure. If it calls `.build()` or `.get_iterator()` (returning a `Pipeline` or iterator), the MTP refactor must change it to return a `PipelineConfig` (via `.get_config()`) or an unbuilt `PipelineBuilder` instead. `run_pipeline_in_subprocess()` accepts ONLY `PipelineConfig` — NOT `Pipeline` objects or iterators. Check type annotations and return statements.
7878
- **Separate CPU and GPU stages**: The backend pipeline (subprocess) must contain ONLY CPU-bound stages (fetch, decode, aggregate, collate). GPU stages like `transfer_tensor` require a CUDA context and MUST be in the frontend pipeline (main process). If the existing code includes `transfer_tensor` or similar GPU ops in the pipeline, exclude them from the backend config.
7979
- **Write the `description` field to explicitly instruct the code modifier** about these structural requirements, e.g.: "Refactor `build_pipeline()` to NOT call `.build()`. Instead, build a `PipelineBuilder` with only CPU stages (no `transfer_tensor`), call `.get_config()`, pass the config to `run_pipeline_in_subprocess(config, num_threads=N)`, then create a frontend `PipelineBuilder` that takes the subprocess iterable as source and applies `transfer_tensor`."
80-
- **TorchTNT scripts**: If the code uses TorchTNT (`fit()`, `train()`, `AutoUnit`), the MTP refactor targets only the pipeline builder function — NOT TorchTNT internals. The `Pipeline` object abstracts away MTP vs pure multithreading, so the wrapper class that calls `get_iterator(timeout=...)` does not need to change. Do NOT use `auto_stop()` — the pipeline is built once and iterated many times.
80+
- **TorchTNT scripts**: If the code uses TorchTNT (`fit()`, `train()`, `AutoUnit`), the MTP refactor targets only the pipeline builder function — NOT TorchTNT internals. The `Pipeline` object abstracts away MTP vs pure multithreading, so the wrapper class that calls `get_iterator(timeout=...)` does not need to change. Do NOT use `auto_stop()` (obsolete) — the pipeline is built once and iterated many times.
8181
- Use the **two-tier approach** for pickling: first try module-level functions with `functools.partial` (Tier 1). If the subprocess crashes silently (0 batches), retry with picklable callable classes that pickle objects directly from the main process (Tier 2). See "Pickling Constraints" in the knowledge base.
8282
- **HuggingFace tokenizers are NOT thread-safe** — use thread-local storage (TLS) when tokenizing with concurrency > 1.
8383
- Wrap the sampler with **`spdl.source.utils.embed_shuffle()`** for correct sampling behavior.
@@ -204,6 +204,6 @@ Rules:
204204
- torchx entrypoint args use **underscores**, not dashes (e.g. `--num_threads`)
205205
- The orchestrator will call a separate Claude session to apply the code changes described in `description`, commit them, rebuild the image, and then launch. **Write the `description` field as precise instructions for what code to modify** — specify function names, SPDL API calls to add/change, and the exact transformation. Do not write vague descriptions like "enable MTP" — instead write "Refactor `build_pipeline()` to return a `PipelineBuilder` config (without `.build()`), wrap it with `spdl.pipeline.run_pipeline_in_subprocess(config, num_threads=16, mp_context='forkserver')`, and create an outer pipeline that takes the subprocess source and applies GPU transfer via `pipe(transfer_tensor, executor=ThreadPoolExecutor(1))`."
206206
- Each experiment should differ from the baseline in exactly one dimension (or a small, justified set of changes)
207-
- **TorchTNT scripts**: When writing `description` for rebuild experiments in TorchTNT code, specify changes to the pipeline builder function only. The `Pipeline` abstracts away MTP vs pure multithreading, so the wrapper class (which calls `get_iterator(timeout=...)`) and TorchTNT internals (`fit()`, `train()`, `AutoUnit`) do not change. Do NOT use `auto_stop()` — Pipeline supports multiple iterations without it.
207+
- **TorchTNT scripts**: When writing `description` for rebuild experiments in TorchTNT code, specify changes to the pipeline builder function only. The `Pipeline` abstracts away MTP vs pure multithreading, so the wrapper class (which calls `get_iterator(timeout=...)`) and TorchTNT internals (`fit()`, `train()`, `AutoUnit`) do not change. Do NOT use `auto_stop()` (obsolete) — Pipeline supports multiple iterations natively.
208208
- **`best_practices_tags`**: Tag each experiment with which best practices it covers from the valid tags list. This is how the orchestrator tracks progress. If an experiment covers multiple practices, include all relevant tags.
209209
- **`goto`** (per-experiment): The orchestrator checks out the instrumentation (anchor) commit before applying each experiment's code changes by default. This ensures every experiment starts from a clean slate. Set `goto` to `null` in most cases. Only set it to a specific commit hash if you want to stack changes on top of a previous successful experiment (e.g., adding batch size tuning on top of an MTP experiment that already improved metrics). Never stack incompatible changes (e.g., GPU decode on top of MTP — they are mutually exclusive). **The `goto` field also determines the experiment's parent in the hypothesis tree**: `null` means the experiment branches from baseline; a specific commit means it branches from the experiment that produced that commit. This allows a single planning round to propose experiments with different parents (e.g., NVDEC from baseline + batch_size from a successful MTP experiment).

tools/skills/migration/migrating_to_spdl_pipeline.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Or any iterable that yields keys/paths/metadata for downstream stages.
5959

6060
Map each classified operation to a `.pipe()` stage with appropriate concurrency. Separate operations of different natures into distinct stages — never bundle network I/O and CPU decode in one function.
6161

62+
`.pipe()` accepts both sync (`def`) and async (`async def`) callables. If the original loader used `async` I/O (e.g. `aiohttp`), **pass the coroutine function to `.pipe()` as-is** — SPDL awaits it on its own event loop. Do **not** wrap it as `asyncio.run(coro(x))`: that spins up and tears down a brand-new event loop on every item — pure per-item overhead — and forces the work onto worker threads instead of running as cheap cooperative coroutines on SPDL's shared event loop.
63+
6264
Use `PriorityThreadPoolExecutor` from `spdl.pipeline` as the shared executor. It prioritizes downstream stages over upstream ones — when the thread pool is contended, items closer to the pipeline output are processed first, reducing end-to-end latency and ensuring data flows through the pipeline faster.
6365

6466
```python

0 commit comments

Comments
 (0)