Skip to main content
LanceDB provides an excellent data backend for training machine learning models. For basic use cases, a Table can be fed directly into a data loader. For a more complete solution, a StreamingDataset adds prefetching, elastic determinism, resumability, multithreaded transformations, and more.

Basic Data Loading

Most model training frameworks feed data into a model by sequencing through successive batches, a process often referred to as data loading. The simplest way to perform data loading is to iterate through a LanceDB table in a loop.

Advanced Data Loading

Advanced data loading scenarios can be handled by the StreamingDataset. This object allows you to apply preprocessing before training, pipelined reads, and more. Use this when the training data does not fit in memory, or when you need deterministic global batches across cluster sizes.
The StreamingDataset wraps a LanceDB Table and yields plain Python dictionaries by default. Whether iterating directly or through a PyTorch DataLoader, only one iterator can be simultaneously active on a StreamingDataset instance.
StreamingDataset is built on the permutation API and works with both LanceDB OSS and LanceDB Enterprise. The underlying table data can live on local disk or object storage.
To understand the wide range of scenarios handled by StreamingDataset, consider a model trained with stochastic gradient descent (SGD) and distributed data parallelism (DDP). In this example, we need to load batches onto multiple GPUs across multiple servers. After each batch is processed, the GPUs exchange weights and the next batch is loaded. We fix the following terminology:
  • World size - The number of GPUs being loaded simultaneously. For example, if we have 22 servers with 44 GPUs each, the world size is 88.
  • Rank - The identifier of an individual GPU. It is an integer in the range [0, world_size). Each rank is allocated a disjoint portion of the source data.
  • Batch size - The number of rows processed by one GPU in a single training step. For example, 128128.
  • Global batch size - The total number of rows processed across all GPUs in a training step (sometimes called a “minibatch” in machine learning literature). For example, if we have 88 GPUs with batch size 128128, the global batch size is 10241024.
Other concepts, such as read batch size and num_workers, are introduced in the relevant sections below.

Filtering

The StreamingDataset object allows you to train models on only the subset of rows that satisfy a user-defined filter, specified using the filter parameter. This can be useful for removing large rows, badly-sourced data, and a wide range of other applications. You can also use the columns parameter to load only a subset of columns.

Shuffling Rows

In model training, model quality can be hurt by unintentionally learning patterns in the ordering of the source data. Shuffling the dataset before training alleviates this problem. However, fetching rows with scattered sparse reads across object storage can be dramatically slower than fetching contiguous batches. StreamingDataset offers two different implementations to read data in shuffled order, toggled by the presence of the block_size parameter.
StreamingDataset shuffle seeds combine shuffle_seed with epoch; each epoch produces a different permutation while runs with the same inputs remain reproducible. Set shuffle_seed = None to generate a random seed upon pipeline initialization.

One-Phase Shuffle

Setting block_size = None corresponds to the naive 11-phase shuffled read: the rows of the source dataset are shuffled according to a pseudorandom permutation, then fed in batches to the accelerator. This gives the best possible randomness, but can exhibit low throughput.

Two-Phase Shuffle

Setting block_size to an integer corresponds to selecting 22-phase shuffled read. In this implementation, the source dataset is first partitioned into fixed-size blocks, which are then assigned randomly to splits. Within each split, rows are then further shuffled, with each row moving at most max_shuffle_distance distance away from its original position. Reading blocks allows storage I/O to constitute mostly large, contiguous reads rather than single row fetches. This yields much higher throughput while slightly compromising randomness.

Parallelism & Elasticity

When loading data, StreamingDataset partitions the the source dataset among disjoint parallel threads called splits. Each rank is allocated a contiguous group of splits, and each DataLoader worker associated to a rank gets a contiguous subgroup of its rank’s splits. If using shuffle = false, data is allocated to splits naively in ordered contiguous blocks of equal size. If shuffle = true, in one-phase shuffling, the rows of the source dataset are distributed randomly among splits such that each split receives the same number of rows. In two-phase shuffling, the dataset is first partitioned into blocks, which are then distributed randomly among splits such that each split receives the same number of blocks. The number of splits defaults to world_size if num_splits is omitted during initialization. This is not recommended, as a later run using a different world size will then construct a different layout and structure of global batches. To get elastic determinism, choose a fixed num_splits that is compatible with every topology you plan to use. For example, a run with num_splits=8 and 44 GPUs assigns 22 splits to each rank. The ranks pull from their splits in round-robin order, producing the same global batches as a run with 88 GPUs and the same num_splits, shuffle_seed, and epoch. The following constraints apply:
  • num_splits must be divisible by world_size.
  • With DataLoader worker processes, num_splits must be divisible by world_size * num_workers.
  • For the same samples to form each global training step across topologies, global_batch_size must be a multiple of num_splits.
  • The filtered row count must be at least num_splits. If it is not evenly divisible by num_splits, up to num_splits - 1 surplus rows are dropped so that every split has the same length.
For example, to switch between 88 and 66 GPUs, use a common multiple such as 2424 for num_splits. Highly composite values can support many layouts; for example, num_splits = 48 is compatible with 1010 distinct possible GPU counts. Remember to include num_workers when checking divisibility.

Fetching

Traditional datasets have often been designed to yield a single sample at a time upon iteration. However, in modern use cases, accessing data on object storage one row at a time introduces too much per-call overhead. Thus, StreamingDataset fetches data in read batches, whose size is controlled by the read_batch_size parameter (defaults = 64). In addition to batching requests, the prefetching mechanism reads ahead in the background. While one batch is being processed, StreamingDataset reads several subsequent batches. The prefetch_batches parameter specifies the number of batches simultaneously kept in flight per split (default = 4). A larger value provides more buffering against jittery workloads, but also increases memory use and I/O concurrency.

Transforming

Many model training workloads require performing a transformation to the data after it is loaded from object storage and before it is passed to the GPU. For example, we may need to decode images, tokenize text, or normalize vectors. A transformation function can be provided using the transform parameter. A transformation function must receive a PyArrow RecordBatch and return an iterable. For example, the default transform function returns a list of Python dictionaries. Furthermore, the input expected by PyTorch must match the collation function used in the PyTorch data loader. PyTorch’s default collation function accepts a Python dictionary and several other types, and is thus compatible with the default StreamingDataset transform function. If your custom transform function returns something different (for example, Arrow record batches), you may need to provide a compatible PyTorch collation function.
StreamingDataset manages expensive transforms through a ThreadPoolExecutor whose worker count equals the number of available CPUs. Furthermore, transformations are applied to batches (instead of individual samples) to amortize per-batch overhead.
DataLoader Workers
The thread-based transformation model that StreamingDataset uses by default is only effective when the transform function releases the GIL. This is true for many Python scientific libraries, including NumPy, PyArrow, and TorchVision, but not for pure-Python transforms. PyTorch can launch multiple DataLoader worker processes per rank, and StreamingDataset uses get_worker_info() to give each worker a non-overlapping group of splits. Multiprocessing adds pickling and transfer overhead and increases memory use. Start with num_workers=0, which keeps loading in the rank’s main process, and add workers only after confirming an unavoidable GIL bottleneck. If you use workers, num_splits must be divisible by world_size * num_workers. Use the forkserver or spawn multiprocessing context because LanceDB uses internal threads.
Python
By default, StreamingDataset serializes enough table state to reopen the table in each worker. For custom connection setup, pass a picklable connection_factory callable that accepts a table name and returns an open table. This avoids serializing connection credentials into worker state.

Observability

Optimizing data loader performance can be tricky, as runtime bottlenecks are often difficult to locate across various stages of the I/O and CPU pipelines. To help distinguish bottlenecks, StreamingDataset exposes several pipeline counters: raw_queue_depth shows the number of loaded rows waiting to be transformed, and prefetch_queue_depth is the number of transformed rows ready to be consumed. unscanned_rows and consumed_rows show how far the current iterator has progressed. If the prefetch_queue_depth is consistently zero but the raw_queue_depth is not, then you likely have a CPU transformation bottleneck. You should investigate GIL bottlenecks or look for ways to optimize your transformation. This can often be done by batching the compute work. If both prefetch_queue_depth and raw_queue_depth are consistently zero while the consumer is waiting, I/O is the likely bottleneck. A larger read batch size or clumped shuffling could help.
bytes_loaded measures raw Arrow buffer bytes before transformation. bytes_loaded, fetch_time, and transform_time are cumulative across iterations of the same dataset instance; because work runs concurrently, the summed stage times can exceed wall-clock time. The queue depths report rows currently waiting in the pipeline, and the progress counters reflect the latest iterator snapshot.

Checkpointing

StreamingDataset provides checkpointing in case your model training fails partway through a run. Use StreamingDataset.state_dict() to capture the current number of samples consumed from each split in a Python dictionary, and load_state_dict() to restore the last saved position. A stored StreamingDataset state reflects only round-robin cycles that have been completed across all splits; samples completed partway through a cycle at the time of saving can be replayed. To ensure efficient saving, make sure global_batch_size is a multiple of num_splits, as in the example below.
load_state_dict() rejects a checkpoint whose num_splits or shuffle_seed differs from the new dataset. For an exact resume, also use the same table snapshot, epoch, shuffle, filter, and other data-selection settings. The world_size and number of DataLoader workers may change as long as the split and batch-size divisibility constraints still hold.
Checkpointing with Multiple DataLoader Workers
Python’s built-in torch.utils.data.DataLoader produces a safe checkpoint when num_workers = 0. With num_workers > 0, calling state_dict() often raises RuntimeError. In this case, use StreamingDataLoader for multi-worker prefetch and exact resumability. It carries a state snapshot alongside every internal batch and commits it to the parent StreamingDataset only when that batch is returned to the trainer. The trainer receives the same collated batch it would receive from a standard DataLoader.
StreamingDataLoader accepts the same arguments as torch.utils.data.DataLoader, with a few restrictions:
  • dataset must be a StreamingDataset (subclasses that override __iter__ are not supported).
  • in_order = True is required so that consumer-committed checkpoints stay deterministic.
  • persistent_workers = True is not supported, because prefetched worker copies cannot be restored from parent-committed state.
  • drop_last = True is not supported, because incomplete tails discarded by worker replicas cannot be checkpointed topology-independently.
With more than one worker, call state_dict() at a complete logical step boundary where every split assigned to the rank has the same consumed-sample count. Calling it mid-step raises RuntimeError asking you to consume more batches first.
Resuming Across Different Topologies
When training across ranks, each rank owns its own subset of splits and only its own splits have exact progress. To resume on a different world_size, collect the state_dict() from every rank of the previous run and merge them with StreamingDataset.merge_state_dicts before calling load_state_dict() on the new run. The merge is topology-agnostic: pass the full list of per-rank states in, and hand the identical merged dict to every rank of the resumed job, regardless of whether the topology grew, shrank, or stayed the same.

Permutations

In specific use cases, you may need the flexibility to shuffle, split, and select data without using the full StreamingDataset object. In these cases, use Permutation, the lower-level functionality on which StreamingDataset is built. A Permutation defines a customizable row ordering, and supports map-style access through __getitem__() and batched access through __getitems__().
Version Pinning
A Permutation is pinned to the version of its base table at the time it was constructed, and every read (including future reads from a StreamingDataset that wraps it, and every DataLoader worker after a fork) resolves against that pinned version. This makes iteration deterministic across compactions and worker forks: the permutation only addresses rows that existed when it was built, and rows appended to the base table afterwards are not visible through the existing permutation. To include newly appended rows, build a new Permutation (and any StreamingDataset that depends on it) against the updated table. Permutations written before this behavior shipped continue to read as they did before.