Data Pipelines
Every training loop so far generated its batches on the fly. Real
datasets live in files, are bigger than memory, and need to be
decoded, shuffled, and batched while the GPU is busy with the
previous batch. Torch.Data is Hasktorch's answer: streaming data
loaders built on pipes,
which read in constant memory and prefetch on background threads.
import Control.Monad (foldM)
import Control.Monad.Cont (runContT)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Vector as V
import GHC.Generics (Generic)
import Pipes (ListT, enumerate)
import qualified Pipes.Prelude as P
import Pipes.Safe (SafeT, runSafeT)
import Torch
import Torch.Data.CsvDatastream
import Torch.Data.StreamedPipeline (datastreamOpts, streamFrom')
Two kinds of dataset
Torch.Data mirrors the two dataset notions of PyTorch. A
map-style dataset is indexed — it knows its keys and can fetch any
sample directly (PyTorch's Dataset):
class Ord k => Dataset m dataset k sample where
getItem :: dataset -> k -> m sample
keys :: dataset -> Set k
A datastream is only a stream — samples arrive in whatever order
the source produces them, which is all you can ask of a file being
read front to back, a network socket, or an infinite generator
(PyTorch's IterableDataset):
class Monad m => Datastream m seed dataset sample where
streamSamples :: dataset -> seed -> ListT m sample
Both are consumed the same way: a streamFrom function turns the
dataset into a ListT of samples — an ordinary pipes stream — and
the training loop is a fold over it. Nothing dataset-specific leaks
into the loop.
CSV files decode into records
The most common concrete datastream is CsvDatastream from
Torch.Data.CsvDatastream. It decodes rows with
cassava, so a row
schema is an ordinary Haskell record — the same move the named-tensor
chapter made for dimensions, applied to files:
data Point = Point
{ px :: Float,
py :: Float
}
deriving (Generic, Show, FromRecord)
Let's make ourselves a small CSV file with points on the line (y = 3x + 1):
writeFile "/tmp/points.csv" $
unlines [show x ++ "," ++ show (3 * x + 1) | x <- [0.0, 0.01 .. 2.0 :: Float]]
A datastream over it is a value describing how to read the file — nothing is opened yet:
stream :: CsvDatastream Point
stream = (csvDatastream "/tmp/points.csv") {batchSize = 16}
streamFrom' runs it. The stream lives in SafeT IO (from
pipes-safe) so the file handle is released even if the consumer
dies, and the result comes as a continuation (runContT) that scopes
the background prefetch thread. The stream yields batches — here
V.Vector Point of length 16:
batches <- runSafeT $
runContT (streamFrom' datastreamOpts stream [()]) $ \input ->
P.toListM (enumerate input)
length batches =>
12
V.toList (V.take 3 (head batches)) =>
[ Point {px = 0.0, py = 1.0}
, Point {px = 1.0e-2, py = 1.03}
, Point {px = 2.0e-2, py = 1.06}]
Training is a fold over the stream
An epoch folds a model over the batches; Pipes.Prelude.foldM is the
loop. Batches become tensors with plain list conversions:
toBatch :: V.Vector Point -> (Tensor, Tensor)
toBatch b = (asTensor (map ((: []) . px) l), asTensor (map py l))
where
l = V.toList b
trainEpoch :: Linear -> ListT (SafeT IO) (V.Vector Point) -> SafeT IO Linear
trainEpoch model input = P.foldM step (pure model) pure (enumerate input)
where
step m batch = do
let (xs, ys) = toBatch batch
loss = mseLoss ys (squeezeAll (linear m xs))
fst <$> liftIO (runStep m GD loss 1e-1)
Epochs re-run the stream from the top — the datastream value is
reusable, so an epoch loop is just foldM over epoch numbers:
model0 <- sample (LinearSpec 1 1)
trained <- runSafeT $
foldM
(\m _epoch -> runContT (streamFrom' datastreamOpts stream [()]) (trainEpoch m))
model0
[1 .. 20 :: Int]
The file said (y = 3x + 1); the model should agree (Torch.NN
uses duplicate record fields across its layer types, so we match the
Linear constructor positionally instead of using the weight and
bias accessors):
let Linear w b = trained
toDependent w =>
Tensor Float [1,1] [[ 2.9987 ]]
toDependent b =>
Tensor Float [1] [ 1.0024 ]
Shuffling, prefetch, and parallelism
Everything above streamed sequentially. The knobs:
- Shuffling.
stream {bufferedShuffle = Just 1000}keeps a buffer of 1000 records, yielding random elements from it as it refills — the streaming compromise, since a true shuffle would need the whole file. (Our file is sorted byx, so for real training you would want this; a buffer at least the file's size gives a full shuffle.) Map-style datasets can do better:streamFromMaptakes a random generator and visits keys in shuffled order. - Prefetch.
datastreamOptshas abufferSize(default 4): batches are produced on a background thread into a bounded queue, so decoding the next batch overlaps with training on the current one. - Sharding. The
[()]we passed is the list of seeds. Each seed starts its own concurrent copy of the stream, and the streams are interleaved — seeds can be file shards, worker ids, or random generators. For CSV there is one file, so one unit seed. - Parallel preprocessing.
Torch.Data.Utils.pmapmaps a function over a stream on its own thread (the iris example uses it to move record-to-tensor conversion off the training thread), andcollateregroups sample streams into batches.
dropLast (default True) discards a final ragged batch, which is
why 201 rows became 12 batches of 16 rather than 12 and a half.
Where to look next
The iris-classification
example is this chapter on a real file; the
static-mnist
examples stream MNIST through the same interface with the typed API,
using the loader from Torch.Vision. The next chapter looks at what
the fold does with each batch: the optimizers.