logic-engine / docs /pipeline /execution.md
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
|
Raw
History Blame Contribute Delete
8.48 kB
# Execution Model
"Async" means three different things in this framework. They operate at different levels and solve different problems. Keeping them separate is key to understanding the concurrency model.
---
## Three types of concurrency
| Type | Level | Problem it solves |
|------|-------|-------------------|
| **Async steps** | single step | Don't block the thread during I/O |
| **`async_boundary`** | across samples | Start the next sample before the current one finishes |
| **Branch parallelism** | within one sample | Run independent work simultaneously on the same data |
Each mechanism is independent. They compose freely β€” you can have async steps inside branches, behind an `async_boundary`, run with multiple workers.
---
## Entry points: `run()` and `run_async()`
=== "Sync"
```python
# For regular (non-async) callers
results = pipe.run(contexts, workers=4)
```
=== "Async"
```python
# For async callers (e.g. inside an async framework)
results = await pipe.run_async(contexts, workers=4)
```
=== "With cancellation"
```python
from pipeline import CancellationToken
token = CancellationToken()
results = pipe.run(contexts, workers=4, cancel_token=token)
# Call token.cancel() from another thread to stop processing
```
`run()` is a thin wrapper that calls `asyncio.run(self.run_async(...))`. Both accept the same parameters and return `list[SampleResult]`.
Optional parameters: `on_sample_done` (callback after each sample), `cancel_token` (stop between steps). See [API Reference](api-reference.md) for full signatures.
---
## Workers: sample-level parallelism
The `workers` parameter on `run()` / `run_async()` controls how many samples are processed through foreground steps simultaneously:
```python
import time
from pipeline import Pipeline, StepContext
class SlowStep:
requires = frozenset()
provides = frozenset({"result"})
def __call__(self, ctx: StepContext) -> StepContext:
time.sleep(0.1) # Simulate expensive work
return ctx.replace(
metadata=MappingProxyType({**ctx.metadata, "result": "done"})
)
pipe = Pipeline().then(SlowStep())
samples = [StepContext(sample=f"s{i}") for i in range(6)]
# Sequential: 6 Γ— 0.1s β‰ˆ 0.6s
results = pipe.run(samples, workers=1)
# Parallel: 0.1s (all 6 run at once)
results = pipe.run(samples, workers=6)
```
Under the hood, `workers` creates an `asyncio.Semaphore` β€” at most N samples flow through the foreground steps at any given time.
---
## Async steps β€” non-blocking I/O
A step that makes network calls (HTTP requests, API calls, subprocess) can be defined as a coroutine to avoid blocking the thread:
=== "Sync step"
```python
class FetchStep:
requires = frozenset()
provides = frozenset({"response"})
def __call__(self, ctx: StepContext) -> StepContext:
response = requests.get(ctx.sample) # blocks the thread
return ctx.replace(
metadata=MappingProxyType({**ctx.metadata, "response": response})
)
```
=== "Async step"
```python
class FetchStep:
requires = frozenset()
provides = frozenset({"response"})
async def __call__(self, ctx: StepContext) -> StepContext:
async with aiohttp.ClientSession() as session:
response = await session.get(ctx.sample) # yields the thread
return ctx.replace(
metadata=MappingProxyType({**ctx.metadata, "response": response})
)
```
The pipeline detects async steps automatically via `asyncio.iscoroutinefunction` and awaits them. Sync steps are wrapped with `asyncio.to_thread()` so they're safe in an async context too.
!!! note
Async steps are about **not blocking the thread**, not about parallelism. The pipeline is still sequential β€” it just yields the thread during I/O waits.
---
## Async boundary β€” fire-and-forget background
**Problem:** Some steps are slow (e.g. LLM calls for analysis). Waiting for them before starting the next sample hurts throughput.
**Solution:** A step declares `async_boundary = True`. Everything from that step onward runs in a background thread. The pipeline loop moves to the next sample immediately.
```python
class SlowScoreStep:
requires = frozenset({"tokens"})
provides = frozenset({"score"})
async_boundary = True # hand off to background from here
max_workers = 3 # up to 3 scoring threads in parallel
def __call__(self, ctx: StepContext) -> StepContext:
time.sleep(0.5) # Expensive scoring
score = len(ctx.metadata["tokens"]) * 10
return ctx.replace(
metadata=MappingProxyType({**ctx.metadata, "score": score})
)
```
```mermaid
graph LR
A1[Tokenize] --> B1[Uppercase] -->|async_boundary| C1[SlowScore]
style A1 fill:#6366f1,stroke:#4f46e5,color:#fff
style B1 fill:#6366f1,stroke:#4f46e5,color:#fff
style C1 fill:#3b82f6,stroke:#2563eb,color:#fff
```
> **Indigo** = foreground (returns immediately) Β· **Blue** = background (fire-and-forget)
Multiple samples flow through this simultaneously β€” sample 2 starts its foreground steps while sample 1's background steps are still running.
### Using the boundary
```python
pipe = Pipeline().then(Tokenize()).then(Uppercase()).then(SlowScoreStep())
# run() returns immediately after foreground steps (Tokenize + Uppercase)
results = pipe.run(samples, workers=4)
# Background scoring continues β€” results not yet populated
print(pipe.background_stats())
# {'active': 3, 'completed': 1}
# Block until all background work finishes
pipe.wait_for_background(timeout=30.0)
# Now all SampleResult.output fields are fully populated
for r in results:
print(r.output.metadata["score"])
```
### Background pool model
Each step **class** has a single shared `ThreadPoolExecutor`:
- `SlowScoreStep.max_workers = 3` means one pool of 3 threads for all `SlowScoreStep` instances, regardless of how many pipelines are running
- The pool is created lazily at first use and persists for the process lifetime
- If two users need different concurrency limits for the same step type, they should subclass
!!! warning "Boundary rules"
- **One boundary per pipeline.** If multiple steps declare `async_boundary = True`, the pipeline raises `PipelineConfigError` at construction time.
- **No boundary inside Branch children.** A boundary inside a branch child raises `PipelineConfigError`. Branch children always block until joined β€” detaching mid-branch is incoherent.
- **Nested pipeline boundary is ignored.** When a pipeline is used as a step inside another pipeline, `async_boundary` is warned and ignored β€” there is no "next sample" to move to from the outer pipeline's perspective.
---
## `workers` vs `max_workers` β€” independent pools
These two knobs control different thread pools and do not interact:
| Knob | Pool | Controls |
|------|------|----------|
| `pipe.run(contexts, workers=N)` | foreground pool | How many samples run through pre-boundary steps simultaneously |
| `step.max_workers = K` | background pool (per step class) | How many instances of that step run in the background simultaneously |
A sample leaves the foreground pool when it crosses the `async_boundary` and enters the background step's pool.
**Mental model:** `workers` controls throughput *into* the pipeline; `max_workers` controls throughput *through* each background step.
!!! warning "Rate limits"
`workers` and `max_workers` are independent pools, but total concurrent outbound calls = foreground calls + background calls. With `workers=4` and `max_workers=3`, up to 7 requests may be in-flight simultaneously. Account for this when configuring per-provider rate limits.
---
## Rule of thumb
| Question | Answer |
|----------|--------|
| Does the step wait on I/O? | `async def __call__` |
| Do I want to process more samples while previous ones are still in background steps? | `async_boundary = True` on the handoff step |
| Can two steps on the same sample run simultaneously? | [`Branch`](branching.md) |
| Do I want N samples going through the pipeline at the same time? | `workers=N` on `run()` |