Async on-the-fly training data generation pipeline for PyTorch
Project description
softs
A broker-based data pipeline for distributed teacher-student training in PyTorch.
Overview
softs provides a data-agnostic message routing system for teacher-student workflows:
- Broker: Routes messages between students and workers. Knows nothing about the data.
- Workers: Generate samples on-demand, write raw bytes to student-owned memory.
- Students: Own memory slots, request samples, read and decode bytes.
The library only moves bytes. What those bytes represent is entirely up to your application. Use BatchConfig for PyTorch tensor encoding/decoding.
Key Features
- Zero-copy transfer: Workers write directly to student shared memory
- Async pipeline: Students train while workers generate the next batch
- Model switching: Change teacher models mid-training (e.g., layer-by-layer distillation)
- Fault tolerance: Workers/students can crash and restart independently
- DDP compatible: Works with PyTorch's DistributedDataParallel
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ BROKER │
│ (message router, data-agnostic) │
│ │
│ Frontend Backend Control ControlPub │
│ (ROUTER) (ROUTER) (ROUTER) (PUB) │
│ ▲ ▲ ▲ │ │
└──────┼───────────────┼──────────────┼───────────────┼────────────┘
│ │ │ │
┌────┴────┐ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐
│ Student │ │ Worker │ │Student 0│ │ Workers │
│DataLoader │ (teacher) │ │(leader) │ │ (SUB) │
│ workers │ │ │ │ │ │ │
└─────────┘ └───────────┘ └─────────┘ └─────────┘
Message Flow
- Student creates shared memory slots and sends
REQUESTwith{token, shm_name, slot_offset} - Broker queues the request, assigns it to an available Worker via
WORK - Worker generates sample bytes using your
generator_fn, writes directly to shared memory - Worker sends
DONEto broker, broker sendsCOMPLETEto student - Student reads bytes from shared memory, decodes tensors, trains
Model Switching
Student rank 0 (leader) can change the model at any time:
client.set_model("layer_5") # Workers now generate for layer_5
This:
- Increments a generation counter
- Broadcasts new model to all workers via PUB/SUB
- Discards any pending work from the old generation
- Workers start generating for the new model immediately
Installation
pip install softs
# or
poetry add softs
Dependencies: pyzmq, torch, numpy
Quick Start
1. Define your data format with BatchConfig
from softs import BatchConfig, TensorSpec
config = BatchConfig([
TensorSpec("x", (3, 224, 224), "float32"),
TensorSpec("y", (1000,), "float32"),
])
2. Start the broker
from softs import Broker, setup_logging
setup_logging("INFO")
Broker().run()
3. Start worker(s)
import torch
from softs import Worker, setup_logging
setup_logging("INFO")
def generate_sample(model_id: str, model_cfg: dict | None) -> bytes:
# Your generation logic - runs once per sample
x = torch.randn(3, 224, 224)
y = torch.randn(1000)
return config.encode(x=x, y=y)
Worker(
generator_fn=generate_sample,
slot_size=config.nbytes(),
).run()
4. Run student training
from softs import StudentClient, DistillIterableDataset, setup_logging
setup_logging("INFO")
client = StudentClient(
student_rank=0,
slot_count=16,
batch_config=config,
)
client.hello()
client.set_model("my_model")
dataset = DistillIterableDataset(
student_rank=0,
generation_value=client.generation_value,
slot_count=8,
batch_config=config,
)
for batch in dataset:
x, y = batch["x"], batch["y"]
# Training loop...
client.close()
BatchConfig API
BatchConfig describes tensors and handles encoding/decoding:
from softs import BatchConfig, TensorSpec
# Define specs
config = BatchConfig([
TensorSpec("hidden", (512, 768), "bfloat16"),
TensorSpec("labels", (512,), "int64"),
])
# Total bytes
config.nbytes() # -> 790528
# Encode tensors to bytes
data = config.encode(hidden=hidden_tensor, labels=label_tensor)
# Decode bytes to dict of tensors
tensors = config.decode(data)
# Decode a single tensor
hidden = config.decode_single(data, "hidden")
# Properties
config.tensor_names # ['hidden', 'labels']
config.get_spec("hidden") # TensorSpec object
Supported dtypes: float64, float32, float16, bfloat16, int64, int32, int16, int8, uint8, bool
Loading from YAML
config = BatchConfig.from_yaml("config.yaml")
# Or from dict
config = BatchConfig.from_dict({
"specs": [
{"name": "x", "shape": [512, 768], "dtype": "bfloat16"},
{"name": "y", "shape": [512, 768], "dtype": "bfloat16"},
]
})
Hydra Integration
# config.yaml
batch_config:
_target_: softs.BatchConfig
specs:
- name: x
shape: [512, 768]
dtype: bfloat16
from hydra.utils import instantiate
config = instantiate(cfg.batch_config)
Transfer Mediums
By default, softs uses POSIX shared memory for zero-copy data transfer. The architecture supports other mediums through the Medium protocol.
How Mediums Work
- Students create and own the medium (e.g., shared memory segment)
- Broker routes opaque addressing info (
shm_name,slot_offset) to workers - Workers write directly to the medium using the addressing info
- Students read from the medium after receiving completion notification
The broker never touches the actual data - it only routes metadata.
Default: SharedMemoryManager
from softs.mediums import SharedMemoryManager
# Students create shm (read_only=True = create owner)
shm = SharedMemoryManager(slot_count=16, slot_stride=1024, read_only=True)
# Workers attach by name (read_only=False = attach)
shm = SharedMemoryManager(slot_count=16, slot_stride=1024, read_only=False, run_id=run_id)
Custom Mediums
Implement the Medium protocol or extend MediumBase:
from softs.mediums import MediumBase
class FileMedium(MediumBase):
"""File-based medium (example for network filesystems)."""
def __init__(self, slot_count: int, slot_stride: int, path: str):
self._path = path
self._slot_count = slot_count
self._slot_stride = slot_stride
self._file = open(path, 'w+b')
self._file.truncate(slot_count * slot_stride)
@property
def buf_name(self) -> str:
return self._path
@property
def slot_count(self) -> int:
return self._slot_count
@property
def slot_stride(self) -> int:
return self._slot_stride
def read_slot_tensors(self, slot_id: int) -> bytes:
offset = slot_id * self._slot_stride
self._file.seek(offset)
return self._file.read(self._slot_stride)
def close(self) -> None:
self._file.close()
def unlink(self) -> None:
import os
os.unlink(self._path)
Potential medium implementations:
- GPU Direct: Use CUDA IPC for GPU-to-GPU transfer
- Network: Use RDMA or TCP for multi-node setups
- Memory-mapped files: For persistence or network filesystems
Running with DDP
# Terminal 1: Broker
python -c "from softs import Broker; Broker().run()"
# Terminal 2: Worker(s) - can run multiple
python worker.py
# Terminal 3: DDP students
torchrun --nproc_per_node=2 student.py
For multi-GPU students:
- Only rank 0 creates the main
StudentClientand callsset_model() - Other ranks listen for model changes via PUB/SUB
if rank == 0:
client.set_model("layer_0")
else:
client.start_sub_listener()
Example: Layer-by-Layer LLM Distillation
See examples/distill_llm.py for a complete example that:
- Loads a teacher LLM
- Distills layer-by-layer (switches model per layer)
- Uses Hydra for configuration
- Supports DDP training
# Start broker
python distill_llm.py mode=broker
# Start worker (loads teacher model)
python distill_llm.py mode=worker device.worker_gpu=0
# Start student training (DDP)
torchrun --nproc_per_node=2 distill_llm.py mode=student
API Reference
setup_logging
setup_logging(level: int | str = "INFO") -> None
Configure logging for all softs modules.
Broker
Broker(
frontend_endpoint: str = "ipc:///tmp/softs_frontend.sock",
backend_endpoint: str = "ipc:///tmp/softs_backend.sock",
control_endpoint: str = "ipc:///tmp/softs_control.sock",
control_pub_endpoint: str = "ipc:///tmp/softs_control_pub.sock",
)
broker.run() # Blocking
broker.start() # Non-blocking (background thread)
broker.stop()
broker.stats # BrokerStats with metrics
Worker
Worker(
generator_fn: Callable[[str, dict | None], bytes], # model_id, model_cfg -> bytes
slot_size: int, # Expected bytes per sample
backend_endpoint: str = ...,
control_pub_endpoint: str = ...,
worker_id: int | None = None, # Defaults to PID
)
worker.run() # Blocking
worker.start() # Non-blocking
worker.stop()
worker.generation # Current generation counter
worker.model_id # Current model ID
StudentClient
StudentClient(
student_rank: int, # 0 = leader
slot_count: int, # Shared memory slots
batch_config: BatchConfig,
frontend_endpoint: str = ...,
control_endpoint: str = ...,
control_pub_endpoint: str = ...,
)
client.hello() -> dict # Register with broker
client.set_model(model_id, model_cfg=None) -> int # Set model (leader only), returns generation
client.request_sample(timeout_ms=1000) -> SampleRef | None
client.release_slot(slot_id) # Return slot to pool
client.start_sub_listener() # Listen for model changes (non-leader)
client.generation # Current generation
client.generation_value # multiprocessing.Value for sharing with dataset
client.close()
DistillIterableDataset
DistillIterableDataset(
student_rank: int,
generation_value: Value, # From client.generation_value
slot_count: int,
batch_config: BatchConfig,
frontend_endpoint: str = ...,
max_retries: int = 10,
retry_delay: float = 0.01,
)
Infinite IterableDataset yielding dict[str, Tensor].
BatchConfig / TensorSpec
TensorSpec(name: str, shape: tuple[int, ...], dtype: str)
spec.nbytes # Bytes for this tensor
spec.torch_dtype # torch.dtype
BatchConfig(specs: list[TensorSpec])
config.nbytes() -> int
config.encode(**tensors) -> bytes
config.decode(data: bytes) -> dict[str, Tensor]
config.decode_single(data: bytes, name: str) -> Tensor
config.tensor_names -> list[str]
config.get_spec(name) -> TensorSpec
Protocol Details
The broker uses ZeroMQ with four sockets:
| Socket | Type | Purpose |
|---|---|---|
| Frontend | ROUTER | Student requests (REQUEST, HELLO, STATS) |
| Backend | ROUTER | Worker communication (READY, WORK, DONE) |
| Control | ROUTER | Leader commands (SET_MODEL, STOP) |
| ControlPub | PUB | Broadcasts (MODEL changes, STOP) |
Commands:
HELLO: Register student/workerREQUEST: Student requests a sample slot to be filledREADY: Worker is available for workWORK: Broker assigns work to workerDONE: Worker completed writing to slotCOMPLETE: Broker notifies student slot is readySET_MODEL: Leader sets new modelSTOP: Shutdown workers
Troubleshooting
"Shared memory name too long" (macOS)
macOS limits shared memory names to 31 characters. The library uses short prefixes (sl_).
Stale samples after model switch
The generation counter ensures stale samples are discarded. If you see stale data, ensure:
- Dataset checks
generation_valuebefore yielding - You're using
client.generation_value(shared with dataset)
Worker not receiving work
Check:
- Broker is running
- Worker called
hello()and is in main loop - Student has called
set_model()(workers wait for a model)
Memory not released
Call client.close() to properly unlink shared memory. Use context managers:
with StudentClient(...) as client:
# ...
# Automatically closes and unlinks
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file softs-0.1.0.tar.gz.
File metadata
- Download URL: softs-0.1.0.tar.gz
- Upload date:
- Size: 21.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/2.3.2 CPython/3.13.11 Linux/6.12.74-gentoo-x86_64
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50e4beaa84f3d7d42ac1276549503735f9a7a74cf1d2534d78e5fcac851c2e52
|
|
| MD5 |
bfaedc4bbd4c91fe988c160abea5fd41
|
|
| BLAKE2b-256 |
9b020f51dabeef04c6da07b6bd81101da7513ce20b78a6b12ee8d2277afcd862
|
File details
Details for the file softs-0.1.0-py3-none-any.whl.
File metadata
- Download URL: softs-0.1.0-py3-none-any.whl
- Upload date:
- Size: 23.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/2.3.2 CPython/3.13.11 Linux/6.12.74-gentoo-x86_64
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
311235eb83c4d53b557cd0d6b019d3cfea4c7273737ab32bd2b66c0d4afd66de
|
|
| MD5 |
132e0c894581591146402fefeb589bdb
|
|
| BLAKE2b-256 |
3e517ca2864fd45dc1362b41a4d5ea22c9c32c402aad909ef7168fca9d4a504a
|