Training with Grain¶
Grain’s
random-access source contract
requires two methods: __len__() and integer __getitem__(). SDS datasets provide both. Use an
application adapter to convert generic records into the array tree expected by a model.
Install Grain in the training application; it is intentionally not an SDS dependency:
uv add 'grain>=0.2.17,<0.3'
import threading
import grain
import numpy as np
import signal_dataset as sds
class SDSSource(grain.sources.RandomAccessDataSource):
def __init__(self, uri: str) -> None:
self._uri = uri
self._dataset = None
self._lock = threading.Lock()
def _open(self) -> sds.Dataset:
# Double-checked: the lock guards the one-time open, not the read.
if self._dataset is None:
with self._lock:
if self._dataset is None:
self._dataset = sds.open(self._uri)
return self._dataset
def __len__(self) -> int:
return len(self._open())
def __getitem__(self, index: int) -> dict[str, np.ndarray]:
record = self._open()[index]
return {
"inputs": record["samples"].data,
"target": np.asarray(record.metadata["label"], dtype=np.int32),
}
def __getstate__(self) -> dict[str, object]:
return {"_uri": self._uri}
def __setstate__(self, state: dict[str, object]) -> None:
self._uri = str(state["_uri"])
self._dataset = None
self._lock = threading.Lock()
def __repr__(self) -> str:
return f"SDSSource(uri={self._uri!r})"
Build the training pipeline with Grain-owned transforms:
def train_step(inputs: np.ndarray, target: np.ndarray) -> None:
"""Stand-in for the real optimizer step."""
assert inputs.shape[0] == target.shape[0]
source = SDSSource("captures.sds")
pipeline = (
grain.MapDataset.source(source)
.shuffle(seed=20260831)
.batch(2, drop_remainder=True)
.repeat(num_epochs=1)
.to_iter_dataset(
read_options=grain.ReadOptions(num_threads=16, prefetch_buffer_size=2)
)
)
for batch in pipeline:
train_step(batch["inputs"], batch["target"])
Grain provides shuffle, sharding, mapping, random transforms, batching, repetition, prefetch, and
iterator checkpointing. SDS remains responsible only for immutable indexed records. Keep model
conversion in the adapter; do not pass an SDS Record directly to Grain batching.
The lock guards the one-time open, and nothing else. Holding it across the read instead would
serialize every one of Grain’s reader threads – ReadOptions.num_threads defaults to 16 – and make
that setting inert. Grain asks that __getitem__ be thread-safe and deterministic, meaning a given
index returns the same value; concurrent reads of distinct indices from an immutable dataset already
satisfy that.
Pickling drops the live SDS reader and lock; each worker process reopens the same immutable dataset
URI. The stable __repr__
supports Grain DataLoader checkpoint validation.
For annotations, pin the set once in __init__ or _open, then read the signal record
and annotation at the same ordinal. Grain slicing can shard a source without copying SDS data:
worker_index, worker_count = 0, 2
worker_source = grain.MapDataset.source(source)[worker_index::worker_count]