Source code for genvarloader._torch

from __future__ import annotations

from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any, Literal, overload

import numpy as np
from loguru import logger
from numpy.typing import NDArray
from seqpro.rag import Ragged, is_rag_dtype

from ._types import AnnotatedHaps
from ._utils import lengths_to_offsets

try:
    import torch
    import torch.utils.data as td

    TORCH_AVAILABLE = True
except ImportError:
    TORCH_AVAILABLE = False


if TYPE_CHECKING:
    import torch
    import torch.utils.data as td

    from ._dataset._impl import Dataset


def no_torch_error(*args, **kwargs):
    raise ImportError(
        "PyTorch is not available. Please install PyTorch to use this function/class."
    )


def requires_torch(func):
    if TORCH_AVAILABLE:
        return func
    else:
        return no_torch_error


def _resolve_buffered_inputs(
    dataset,
    batch_size: int,
    shuffle: bool,
    drop_last: bool,
    sampler,
    generator,
    buffer_bytes: int,
    n_slots: int,
    include_offsets: bool = False,
):
    """Compute flat (r_idx, s_idx) epoch order, bytes_per_instance table, and slot_bytes.

    ``dataset`` must be a raw gvl Dataset (not a TorchDataset wrapper) so that
    ``_output_bytes_per_instance`` and ``.shape`` are available.

    ``include_offsets`` adds the serialized offset/lengths overhead to the
    byte table. Required for ``double_buffered``, whose fixed-size shm slots
    must hold the full serialized chunk (payload + offsets); ``buffered`` packs
    by payload alone since it never serializes into a fixed slot.
    """
    # 1) Resolve full epoch order from the BatchSampler.
    if sampler is None:
        sampler = get_sampler(
            len(dataset), batch_size, shuffle, drop_last, generator=generator
        )
    flat = []
    for batch in sampler:
        flat.extend(batch)
    flat = np.asarray(flat, dtype=np.int64)
    # Only drop the trailing partial batch when the caller asked for it. When
    # drop_last=False, keep it -- ChunkPlanner emits it as a partial batch.
    if drop_last:
        n_keep = (len(flat) // batch_size) * batch_size
        flat = flat[:n_keep]
    r_idx, s_idx = np.unravel_index(flat, dataset.shape)

    # 2) Pre-pass: exact bytes per instance for the entire (n_regions, n_samples) grid.
    # Pass None, None so parse_idx uses slice(None), slice(None) → "basic" indexing →
    # out_reshape=(n_regions, n_samples), and _output_bytes_per_instance returns a 2-D array.
    bpi = dataset._output_bytes_per_instance(
        None, None, include_offsets=include_offsets
    )
    # Ensure exactly (n_regions, n_samples) 2-D regardless of squeeze/reshape behavior.
    bpi = np.asarray(bpi, dtype=np.int64).reshape(dataset.shape)

    slot_bytes = buffer_bytes // n_slots
    return r_idx, s_idx, bpi, slot_bytes, sampler


@requires_torch
def get_dataloader(
    dataset,
    batch_size: int = 1,
    shuffle: bool = False,
    sampler: td.Sampler | Iterable | None = None,
    num_workers: int = 0,
    collate_fn: Callable | None = None,
    pin_memory: bool = False,
    drop_last: bool = False,
    timeout: float = 0,
    worker_init_fn: Callable | None = None,
    multiprocessing_context: Callable | None = None,
    generator: torch.Generator | None = None,
    *,
    prefetch_factor: int | None = None,
    persistent_workers: bool = False,
    pin_memory_device: str = "",
    mode: Literal["buffered", "double_buffered"] | None = None,
    buffer_bytes: int = 2 * 1024**3,
    copy: bool = True,
    heartbeat_seconds: float = 60.0,
):
    if mode is None:
        # Existing path unchanged.
        if num_workers > 1:
            logger.warning(
                "It is recommended to use num_workers <= 1 with GenVarLoader since it leverages"
                " multithreading which has lower overhead than multiprocessing."
            )

        if sampler is None:
            sampler = get_sampler(
                len(dataset),
                batch_size,
                shuffle,
                drop_last,
                generator=generator,
            )

        return td.DataLoader(
            dataset,
            batch_size=None,
            sampler=sampler,
            num_workers=num_workers,
            collate_fn=collate_fn,
            pin_memory=pin_memory,
            timeout=timeout,
            worker_init_fn=worker_init_fn,
            multiprocessing_context=multiprocessing_context,
            generator=generator,
            prefetch_factor=prefetch_factor,
            persistent_workers=persistent_workers,
            pin_memory_device=pin_memory_device,
        )

    if mode not in {"buffered", "double_buffered"}:
        raise ValueError(
            f"unknown mode={mode!r}; expected None, 'buffered', or 'double_buffered'"
        )
    if num_workers > 0:
        raise ValueError(
            f"mode={mode!r} is incompatible with num_workers>0; "
            "the loader IS the concurrency strategy"
        )

    # When the caller passes a BatchSampler directly, use its batch_size so the
    # buffered loader re-batches at the granularity the sampler intended. This
    # mirrors the mode=None path, where the sampler governs batching too.
    if isinstance(sampler, td.BatchSampler):
        if batch_size != 1 and batch_size != sampler.batch_size:
            logger.warning(
                f"Both a BatchSampler and an explicit batch_size={batch_size} "
                "were passed; the BatchSampler's batch_size="
                f"{sampler.batch_size} takes precedence."
            )
        batch_size = sampler.batch_size

    n_slots = 1 if mode == "buffered" else 2
    r_idx, s_idx, bpi, slot_bytes, _sampler = _resolve_buffered_inputs(
        dataset,
        batch_size,
        shuffle,
        drop_last,
        sampler,
        generator,
        buffer_bytes,
        n_slots,
        include_offsets=(mode == "double_buffered"),
    )

    if mode == "buffered":
        from ._buffered_loader import make_buffered_dataset

        inner_ds = make_buffered_dataset(
            dataset, batch_size, slot_bytes, bpi, r_idx, s_idx
        )
    else:
        from ._double_buffered_loader import make_double_buffered_dataset

        inner_ds = make_double_buffered_dataset(
            dataset,
            batch_size,
            slot_bytes,
            bpi,
            r_idx,
            s_idx,
            copy=copy,
            heartbeat_seconds=heartbeat_seconds,
        )

    return td.DataLoader(
        inner_ds,
        batch_size=None,
        num_workers=0,
        collate_fn=collate_fn,
        pin_memory=pin_memory,
        pin_memory_device=pin_memory_device,
    )


@requires_torch
def get_sampler(
    ds_len: int,
    batch_size: int,
    shuffle: bool = False,
    drop_last: bool = False,
    generator: torch.Generator | None = None,
):
    if shuffle:
        inner_sampler = td.RandomSampler(range(ds_len), generator=generator)
    else:
        inner_sampler = td.SequentialSampler(range(ds_len))

    return td.BatchSampler(inner_sampler, batch_size, drop_last)


@overload
def tensor_from_maybe_bytes(array: NDArray) -> torch.Tensor: ...
@overload
def tensor_from_maybe_bytes(array: AnnotatedHaps) -> dict[str, torch.Tensor]: ...
@requires_torch
def tensor_from_maybe_bytes(
    array: NDArray | AnnotatedHaps,
) -> torch.Tensor | dict[str, torch.Tensor]:
    if isinstance(array, AnnotatedHaps):
        return {
            "haps": tensor_from_maybe_bytes(array.haps),
            "var_idxs": tensor_from_maybe_bytes(array.var_idxs),
            "ref_coords": tensor_from_maybe_bytes(array.ref_coords),
        }
    else:
        if array.dtype.type == np.bytes_:
            array = array.view(np.uint8)
        return torch.from_numpy(array)


[docs] @requires_torch def to_nested_tensor(rag: Ragged) -> torch.Tensor: """Convert a Ragged array to a PyTorch `nested tensor <https://pytorch.org/docs/stable/nested.html>`_. Will cast byte arrays (dtype "S1") to uint8. Args: rag: Ragged array to convert. """ if is_rag_dtype(rag, np.bytes_): rag = rag.view(np.uint8) values = torch.from_numpy(rag.data) offsets = torch.from_numpy(lengths_to_offsets(rag.lengths, np.int32)) nt = torch.nested.nested_tensor_from_jagged( values, offsets, max_seqlen=rag.lengths.max() ) return nt
if TORCH_AVAILABLE: class TorchDataset(td.Dataset): dataset: Dataset include_indices: bool transform: Callable | None def __init__( self, dataset: Dataset, include_indices: bool, transform: Callable | None, ): self.dataset = dataset self.include_indices = include_indices self.transform = transform def __len__(self) -> int: return len(self.dataset) def __getitem__( self, idx: int | list[int] ) -> torch.Tensor | tuple[torch.Tensor, ...] | Any: r_idx, s_idx = np.unravel_index(idx, self.dataset.shape) batch = self.dataset[r_idx, s_idx] if not isinstance(batch, tuple): batch = (batch,) if self.include_indices: batch = (*batch, r_idx, s_idx) if self.transform is not None: batch = self.transform(*batch) elif len(batch) == 1: batch = batch[0] return batch class StratifiedSampler(td.Sampler[np.intp]): """Stratified sampler for GVL datasets. This ensures that each batch has the most diversity of samples possible. Args: n_regions (int): Number of regions. n_samples (int): Number of samples. shuffle (bool, optional): Whether to shuffle the dataset, by default False. seed (int, optional): Random seed, by default None. Examples: >>> n_regions = 10 >>> n_samples = 100 >>> batch_size = 7 >>> sampler = torch.utils.data.BatchSampler( gvl.StratifiedSampler(n_regions, n_samples), batch_size, drop_last=True, ) >>> dl = ds.to_dataloader(sampler=sampler) """ ds_idx: NDArray[np.intp] def __init__( self, n_regions: int, n_samples: int, shuffle: bool = False, seed: int | None = None, ): rng = np.random.default_rng(seed) if shuffle: r_idx = rng.permutation(n_regions) s_idx = rng.permutation(n_samples) else: r_idx = np.arange(n_regions) s_idx = np.arange(n_samples) self.ds_idx = np.ravel_multi_index( (r_idx[:, None], s_idx), (n_regions, n_samples) ).ravel() def __len__(self): return len(self.ds_idx) def __iter__(self): return iter(self.ds_idx) else: TorchDataset = no_torch_error StratifiedSampler = no_torch_error