SPDL v0.6.0 Release Notes
Highlights
Declarative execution regions with PipelineBuilder.to(). You can now mark a region of the
pipeline to run together in a worker pool, instead of attaching an executor to individual stages.
Every stage between .to(ProcessPoolExecutorConfig(...)) and .to(MAIN_PROCESS) is fused into one
nested pipeline that runs inside a worker process (or, on Python 3.14+, a subinterpreter), so values
handed from one stage to the next stay in the worker — they are never copied back to the main
process and need not be picklable. Only the region's inputs and outputs cross the boundary. Unlike
per-stage executors, a region can also absorb aggregate, disaggregate, and path_variants
stages, and the target is a serializable spec rather than a live Executor, so a pipeline remains
expressible as static config
(#1584,
#1585,
#1586,
#1587).
from spdl.pipeline import PipelineBuilder
from spdl.pipeline.defs import MAIN_PROCESS, ProcessPoolExecutorConfig
pipeline = (
PipelineBuilder()
.add_source(src)
.to(ProcessPoolExecutorConfig(max_workers=4))
.pipe(op1, concurrency=2) # runs in a worker process
.aggregate(32) # runs in a worker process
.pipe(op2, concurrency=3) # runs in a worker process
.to(MAIN_PROCESS) # data returns to the main process
.add_sink(4)
.build(num_threads=4)
)Note that each stage's concurrency applies within each worker, so effective concurrency is
concurrency × max_workers — size the two together.
Reproducible randomness in worker-based execution. Switching a pipeline between in-process
multi-threading and worker-based execution used to silently change the behavior of the global
RNGs (random, numpy, torch) used inside preprocessing functions: with fork the worker
continued the parent's stream, with spawn/forkserver it seeded from OS entropy. SPDL now
captures the main process' global RNG state at build time and restores it in the worker before
iteration, with no user opt-in — matching the torch.utils.data.DataLoader contract.
numpy/torch state is captured only if the program already imported them, so spdl.pipeline
stays pure Python (#1556).
import random
random.seed(0)
# Draws inside the pipeline's ops now continue from the seeded main-process
# state, regardless of the multiprocessing start method.
for item in run_pipeline_in_subprocess(config, num_threads=4):
...For run_pipeline_in_subinterpreter, only the stdlib random state is copied — NumPy/PyTorch state
objects are not shareable across interpreters, and neither library can be imported in a
subinterpreter at all.
Pipeline iterators are now single-use (one epoch). Pipeline.get_iterator() has always been
documented as covering exactly one epoch, but the old iterator's reuse behavior depended on the
source: with a continuous source, reusing an exhausted iterator silently resumed into the next
epoch; with a finite source it yielded nothing. That mode-dependent behavior is gone — an exhausted
iterator stays exhausted in both modes. See BC-Breaking Changes below
(#1549).
Shared-memory arenas throttle the producer instead of failing it. SharedMemoryRingBuffer and
SharedMemorySegmentPool now park the producer on a process-shared condition variable when the
arena is full and wake it from the consumer's release path, rather than raising immediately (ring)
or busy-polling every 0.5 ms (pool). Measured with no throughput regression
(#1534).
BC-Breaking Changes
-
Pipeline.get_iterator()returns a single-use, one-epoch iterator
(#1549): once the iterator reaches the epoch
boundary it stays exhausted and yields nothing on reuse, regardless of whether the source is
continuous. Previously, code that reused one iterator instance across epochs with a continuous
source advanced into the next epoch. Callget_iterator()once per epoch (or just re-iterate the
pipeline).# Before (v0.5.0) — worked only with a continuous source it = pipeline.get_iterator() for epoch in range(num_epochs): for item in it: # silently resumed into the next epoch ... # After (v0.6.0) for epoch in range(num_epochs): for item in pipeline.get_iterator(): # or: for item in pipeline: ...
-
Shared-memory arenas block on a full arena instead of raising
(#1534):
SharedMemoryRingBuffer.write_binaryused to raiseBufferErrorthe moment an in-progress unit
did not fit. It now waits up to the newacquire_timeoutfor the consumer to free space. Pass
acquire_timeout=0to restore the previous raise-immediately behavior.from spdl.pipeline import SharedMemoryRingBuffer # Before (v0.5.0): full arena -> immediate BufferError ring = SharedMemoryRingBuffer(capacity=1 << 30) # After (v0.6.0): full arena -> block, then raise BufferError on timeout ring = SharedMemoryRingBuffer(capacity=1 << 30) # blocking (default) ring = SharedMemoryRingBuffer(capacity=1 << 30, acquire_timeout=0) # legacy behavior
Both arenas also gain a sticky shutdown flag:
shutdown_arena()flips it and broadcasts, so a
parked producer exits with a cleanBufferErrorat teardown instead of waiting out the full
timeout. Fast-fail guards remain for the two unsatisfiable cases (a single binary larger than
capacity, and an in-progress unit whose accumulated bytes exceed capacity).
New Features
-
Batched routing in
path_variants(#1595):
withbatched=True, the router receives a whole batch and returns one path index per element; the
batch is partitioned into per-path sub-batches (order preserved), each path processes its
sub-batch as a list, and the merge concatenates them back into one batch. This amortizes the
router and fan-out/fan-in cost over the batch instead of paying it per item — useful for bulk
cache hit/miss splits. Defaults toFalse, so existing behavior is byte-identical.from spdl.pipeline.defs import Aggregate, PathVariants, Pipe pipes = [ Aggregate(64), PathVariants( router=lambda batch: [0 if x in cache else 1 for x in batch], paths=[ [Pipe(load_batch_from_cache)], # path 0: cache hits [Pipe(load_batch_from_source)], # path 1: cache misses ], batched=True, ), ]
Each path op receives and returns a
list, may drop elements by returning a shorter list, and
must tolerate an empty input list. -
Stdlib executors are supported in
run_pipeline_in_subprocess
(#1548,
#1550): a pipe stage carrying a
ThreadPoolExecutor,ProcessPoolExecutor, or (Python 3.14+)InterpreterPoolExecutorcan now
be shipped to the pipeline subprocess. Thread/interpreter pools are lazily reconstructed inside
the subprocess; process pools are spawned in the main process and driven through a queue-backed
proxy, so their workers are siblings of the pipeline subprocess and can never be orphaned if it is
force-killed.from concurrent.futures import ProcessPoolExecutor from spdl.pipeline import run_pipeline_in_subprocess from spdl.pipeline.defs import Pipe, PipelineConfig, SinkConfig, SourceConfig config = PipelineConfig( src=SourceConfig(paths), pipes=[Pipe(decode, executor=ProcessPoolExecutor(max_workers=4))], sink=SinkConfig(buffer_size=8), ) for item in run_pipeline_in_subprocess(config, num_threads=4): ...
The executor must be freshly constructed — one that has already spawned workers or run work is
rejected withValueError.max_workers/initializer/initargsare preserved;mp_context
andmax_tasks_per_childare not honored on the hoisted process pool. -
acquire_timeoutonSharedMemoryRingBuffer
(#1534): bounds how long a producer waits
for the consumer to free space, guarding against a stalled or dead consumer. Already present on
SharedMemorySegmentPool.
Bug Fixes
- Fix a use-after-free in
spdl.io.load_npz:NpzFilestored only a raw pointer into the input
buffer, so the backing memory could be freed while entries were still being read. The archive is
now kept alive by theNpzFile, and uncompressed entries keep it alive for the array's own
lifetime (#1607). - Fix a hang at interpreter exit for programs that hold a
Pipelinereference and never call
stop(). The pipeline's non-daemon event-loop thread was joined by CPython before the
weakref.finalizecleanup ran; a shutdown hook registered viathreading._register_atexitnow
stops the pipeline first (#1591). - Fix a stage failure being silently swallowed when an external cancellation raced the stage's
finalization — the cancellation replaced the in-flight exception, so noPipelineFailurewas
raised. Stage-hook finalization is now shielded from cancellation
(#1572). - Fix a deadlock when
add_source(..., continuous=True)was combined with apath_variantsstage:
the epoch-end sentinel was passed to the user router and delivered to a single path, so the fan-in
barrier blocked forever. The sentinel is now broadcast to all paths and collapsed by the merge
(#1547). - Fix a Windows crash (
ModuleNotFoundError: No module named '_posixsubprocess') in shared-memory
arena workers, caused by an unconditionalresource_tracker.unregister()call on attach. This
also fixes the resulting parent-side hang
(#1544). - Stop logging an
AssertionErrorwhen a pipeline is built inside a daemon process:
ProcessGroupStatsMonitorcannot spawn its/procreader there, and a per-worker monitor would
be redundant since the main-process monitor already sums over the process group. It now logs a
single warning and returns (#1590).
Other Changes
- CUDA wheels are now built against CUDA 13 on Linux and Windows
(#1539,
#1540,
#1538). - Upgrade bundled
fmtto 11.0.2, fixing a build failure on recent AppleClang wherefmt10.1.1's
consteval format-string validation is rejected as non-constant
(#1562,
#1561). - Add a missing
<cstdint>include innumpy_support.h
(#1609).
Documentation
- New Execution Models guide, with the parallelism and GIL material reorganized around it
(#1593,
#1592). - New case study on the cost of inter-process communication in data loading, with a benchmark that
reproduces the 20+ second startup stall from shipping a 1.2M-path ImageNet dataset to PyTorch
DataLoaderworkers (#1594). - New from-scratch pipeline authoring guide (
tools/skills/authoring/building_pipelines.md), plus
clarification that async functions go straight to.pipe()and must not be wrapped in
asyncio.run(), and thatauto_stop()is obsolete
(#1583,
#1578). - Document that continuous sources have been supported in
run_pipeline_in_subprocesssince v0.4 —
the previously assumedcontinuous=Falseworkaround reintroduces a per-epoch rebuild gap
(#1546). - Add free-threaded Python (
3.14t) results to the WAV benchmark:soundfilescales with thread
count instead of degrading (>10x at 16 threads), whilespdl.io.load_audio, which already
releases the GIL, is essentially unchanged
(#1537).