feat: add record-mode to Store for DPO/GRPO

- Store gains fetch_record/num_records alongside stream fetch/__len__
- save_bin/load_bin support per-record offsets via record_keys param
- H5Store/MmapStore/JsonlStore all support dual stream+record access
- DPODataset/GRPODataset use fetch_record, no cross-record concat
- dpo_collate_fn + collate_fn wired through TrainConfig
- fixes attention context leakage in DPO from windowed concatenation
This commit is contained in:
2026-07-18 21:02:29 +08:00
parent 28886e4241
commit a74e5b91a3
12 changed files with 334 additions and 92 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ from astrai.config import (
from astrai.dataset import ( from astrai.dataset import (
BaseDataset, BaseDataset,
DatasetFactory, DatasetFactory,
ResumableDistributedSampler, RDSampler,
Store, Store,
StoreFactory, StoreFactory,
) )
@@ -77,7 +77,7 @@ __all__ = [
"Pipeline", "Pipeline",
"PipelineConfig", "PipelineConfig",
"ProtocolHandler", "ProtocolHandler",
"ResumableDistributedSampler", "RDSampler",
"SamplingPipeline", "SamplingPipeline",
"SchedulerFactory", "SchedulerFactory",
"Store", "Store",
+4
View File
@@ -87,6 +87,10 @@ class TrainConfig(BaseConfig):
pin_memory: bool = field( pin_memory: bool = field(
default=False, metadata={"help": "Pin memory for dataloader."} default=False, metadata={"help": "Pin memory for dataloader."}
) )
collate_fn: Optional[Callable[[List[Any]], Any]] = field(
default=None,
metadata={"help": "Collate function for dataloader (e.g. dpo_collate_fn)."},
)
# distributed training # distributed training
nprocs: int = field( nprocs: int = field(
+4 -2
View File
@@ -1,9 +1,10 @@
from astrai.dataset.dataset import ( from astrai.dataset.dataset import (
BaseDataset, BaseDataset,
DatasetFactory, DatasetFactory,
dpo_collate_fn,
grpo_collate_fn, grpo_collate_fn,
) )
from astrai.dataset.sampler import ResumableDistributedSampler from astrai.dataset.sampler import RDSampler
from astrai.dataset.storage import ( from astrai.dataset.storage import (
H5Store, H5Store,
JsonlStore, JsonlStore,
@@ -22,6 +23,7 @@ from astrai.serialization import (
__all__ = [ __all__ = [
"BaseDataset", "BaseDataset",
"DatasetFactory", "DatasetFactory",
"dpo_collate_fn",
"grpo_collate_fn", "grpo_collate_fn",
"Store", "Store",
"StoreFactory", "StoreFactory",
@@ -33,5 +35,5 @@ __all__ = [
"load_h5", "load_h5",
"save_bin", "save_bin",
"load_bin", "load_bin",
"ResumableDistributedSampler", "RDSampler",
] ]
+96 -52
View File
@@ -15,6 +15,46 @@ from astrai.dataset.storage import (
from astrai.factory import BaseFactory from astrai.factory import BaseFactory
def dpo_collate_fn(batch: List[Dict[str, Tensor]]) -> Dict[str, Tensor]:
"""Collate variable-length DPO samples into padded 2-D tensors.
Input: list of dicts, each with:
- chosen: [C_i]
- rejected: [R_i]
- chosen_mask: [C_i]
- rejected_mask: [R_i]
Output (padded to the max length across chosen/rejected within the batch):
- chosen: [B, S_max]
- rejected: [B, S_max]
- chosen_mask: [B, S_max]
- rejected_mask: [B, S_max]
"""
B = len(batch)
S_max = max(b["chosen"].size(0) for b in batch)
S_max = max(S_max, max(b["rejected"].size(0) for b in batch))
chosen = torch.zeros(B, S_max, dtype=torch.long)
rejected = torch.zeros(B, S_max, dtype=torch.long)
chosen_mask = torch.zeros(B, S_max, dtype=torch.bool)
rejected_mask = torch.zeros(B, S_max, dtype=torch.bool)
for i, b in enumerate(batch):
c_len = b["chosen"].size(0)
r_len = b["rejected"].size(0)
chosen[i, :c_len] = b["chosen"]
rejected[i, :r_len] = b["rejected"]
chosen_mask[i, :c_len] = b["chosen_mask"]
rejected_mask[i, :r_len] = b["rejected_mask"]
return {
"chosen": chosen,
"rejected": rejected,
"chosen_mask": chosen_mask,
"rejected_mask": rejected_mask,
}
def grpo_collate_fn(batch: List[Dict[str, Tensor]]) -> Dict[str, Tensor]: def grpo_collate_fn(batch: List[Dict[str, Tensor]]) -> Dict[str, Tensor]:
"""Collate variable-length GRPO samples into padded 3-D tensors. """Collate variable-length GRPO samples into padded 3-D tensors.
@@ -262,32 +302,61 @@ class SFTDataset(BaseDataset):
@DatasetFactory.register("dpo") @DatasetFactory.register("dpo")
class DPODataset(BaseDataset): class DPODataset(BaseDataset):
"""Dataset for Direct Preference Optimization training.""" """Record-structured dataset for Direct Preference Optimization.
Each sample is one preference pair (chosen + rejected) and is an
independent training unit — no windowing, stride, or cross-record
concatenation. This keeps each sequence self-contained so attention
never leaks across preference pairs.
Delegates record access to ``Store.fetch_record``, which works with
any storage backend (H5 per-record datasets, bin+offsets memmap, or
JSONL on-the-fly tokenization).
"""
def __init__(self, window_size: int = 0, stride: int = 0, **kwargs):
super().__init__(window_size=window_size, stride=stride or window_size)
@property @property
def required_keys(self) -> List[str]: def required_keys(self) -> List[str]:
return ["chosen", "rejected", "chosen_mask", "rejected_mask"] return ["chosen", "rejected", "chosen_mask", "rejected_mask"]
def _fetch_data(self, begin_idx: int, end_idx: int, key: str) -> Tensor: def load(self, load_path: str, storage_type: Optional[str] = None, **kwargs):
return self.storage.fetch(begin_idx, end_idx, key) if storage_type is None:
storage_type = detect_format(load_path)
self.storage = StoreFactory.create(storage_type, **kwargs)
self._load_path = load_path
self.storage.load(load_path, **kwargs)
self._validate_keys()
def __getitem__(self, index: int): def _validate_keys(self):
begin_idx, end_idx = self.get_index(index) actual_keys = set(self.storage.keys)
missing = [k for k in self.required_keys if k not in actual_keys]
if missing:
raise KeyError(
f"DPODataset requires keys {self.required_keys}, "
f"but storage only has {sorted(actual_keys)}. Missing: {missing}"
)
chosen = self._fetch_data(begin_idx, end_idx, "chosen").to(dtype=torch.long) @property
rejected = self._fetch_data(begin_idx, end_idx, "rejected").to(dtype=torch.long) def count(self) -> int:
chosen_mask = self._fetch_data(begin_idx, end_idx, "chosen_mask").to( return self.storage.num_records
dtype=torch.bool
)
rejected_mask = self._fetch_data(begin_idx, end_idx, "rejected_mask").to(
dtype=torch.bool
)
def __len__(self) -> int:
return self.storage.num_records
def __getitem__(self, index: int) -> Dict[str, Tensor]:
return { return {
"chosen": chosen, "chosen": self.storage.fetch_record(index, "chosen").to(dtype=torch.long),
"rejected": rejected, "rejected": self.storage.fetch_record(index, "rejected").to(
"chosen_mask": chosen_mask, dtype=torch.long
"rejected_mask": rejected_mask, ),
"chosen_mask": self.storage.fetch_record(index, "chosen_mask").to(
dtype=torch.bool
),
"rejected_mask": self.storage.fetch_record(index, "rejected_mask").to(
dtype=torch.bool
),
} }
@@ -310,7 +379,6 @@ class GRPODataset(BaseDataset):
def __init__(self, window_size: int = 0, stride: int = 0, **kwargs): def __init__(self, window_size: int = 0, stride: int = 0, **kwargs):
super().__init__(window_size=window_size, stride=stride or window_size) super().__init__(window_size=window_size, stride=stride or window_size)
self._records: List[dict] = []
@property @property
def required_keys(self) -> List[str]: def required_keys(self) -> List[str]:
@@ -323,7 +391,6 @@ class GRPODataset(BaseDataset):
self._load_path = load_path self._load_path = load_path
self.storage.load(load_path, **kwargs) self.storage.load(load_path, **kwargs)
self._validate_keys() self._validate_keys()
self._build_records()
def _validate_keys(self): def _validate_keys(self):
actual_keys = set(self.storage.keys) actual_keys = set(self.storage.keys)
@@ -334,44 +401,21 @@ class GRPODataset(BaseDataset):
f"but storage only has {sorted(actual_keys)}. Missing: {missing}" f"but storage only has {sorted(actual_keys)}. Missing: {missing}"
) )
def _build_records(self):
"""Unfold segmented storage into per-record lists.
``prompts`` is a flat list of 1-D tensors (one per record).
``responses`` / ``masks`` are nested lists (G tensors per record).
``rewards`` is a flat list of 1-D tensors (len G per record).
"""
prompt_segs = self.storage._data.get("prompts", [])
response_segs = self.storage._data.get("responses", [])
mask_segs = self.storage._data.get("masks", [])
reward_segs = self.storage._data.get("rewards", [])
n_records = len(prompt_segs)
self._records = []
for i in range(n_records):
self._records.append(
{
"prompts": prompt_segs[i],
"responses": response_segs[i] if i < len(response_segs) else [],
"masks": mask_segs[i] if i < len(mask_segs) else [],
"rewards": reward_segs[i]
if i < len(reward_segs)
else torch.tensor([]),
}
)
@property @property
def count(self) -> int: def count(self) -> int:
return len(self._records) return self.storage.num_records
def __len__(self) -> int: def __len__(self) -> int:
return len(self._records) return self.storage.num_records
def __getitem__(self, index: int) -> Dict[str, Tensor]: def __getitem__(self, index: int) -> Dict[str, Tensor]:
rec = self._records[index] prompts = self.storage.fetch_record(index, "prompts")
responses = self.storage.fetch_record(index, "responses")
masks = self.storage.fetch_record(index, "masks")
rewards = self.storage.fetch_record(index, "rewards")
return { return {
"prompts": rec["prompts"].to(dtype=torch.long), "prompts": prompts.to(dtype=torch.long),
"responses": [r.to(dtype=torch.long) for r in rec["responses"]], "responses": [r.to(dtype=torch.long) for r in responses],
"masks": [m.to(dtype=torch.bool) for m in rec["masks"]], "masks": [m.to(dtype=torch.bool) for m in masks],
"rewards": rec["rewards"].to(dtype=torch.float32), "rewards": rewards.to(dtype=torch.float32),
} }
+9 -1
View File
@@ -5,7 +5,15 @@ import torch.distributed as dist
from torch.utils.data import Dataset, Sampler from torch.utils.data import Dataset, Sampler
class ResumableDistributedSampler(Sampler[int]): class RDSampler(Sampler[int]):
"""Resumable Distributed Sampler.
A distributed sampler that supports checkpoint-based resume: iteration
state (epoch, position) is tracked so training can continue from the
exact sample after a restart. Shards the dataset across
``dist.world_size`` replicas with optional shuffling.
"""
def __init__( def __init__(
self, self,
data_source: Dataset, data_source: Dataset,
+135 -19
View File
@@ -23,7 +23,7 @@ import json
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Dict, List, Union from typing import Dict, List, Optional, Union
import torch import torch
from torch import Tensor from torch import Tensor
@@ -34,6 +34,7 @@ from astrai.preprocessing.builder import MaskBuilderFactory
from astrai.preprocessing.position_id import PositionIdStrategyFactory from astrai.preprocessing.position_id import PositionIdStrategyFactory
from astrai.serialization import ( from astrai.serialization import (
load_bin, load_bin,
load_bin_offsets,
load_h5, load_h5,
) )
from astrai.tokenize import AutoTokenizer from astrai.tokenize import AutoTokenizer
@@ -90,11 +91,18 @@ def detect_format(load_path: str) -> str:
class Store(ABC): class Store(ABC):
"""String keys -> segmented tensors with ``fetch(begin, end, keys)``. """String keys -> segmented tensors with two access modes.
Each key maps to one or more tensor segments (no forced concatenation). Stream mode (SEQ/SFT):
``len(store)`` returns ``self._length`` (explicit, O(1)), the minimum ``fetch(begin, end, keys)`` slices across concatenated segments,
total element count across all keys. transparently ``torch.cat``-ing across segment boundaries.
``len(store)`` returns total token count.
Record mode (DPO/GRPO):
``fetch_record(index, keys)`` returns the i-th record without
cross-record concatenation. ``num_records`` returns the record
count. Backed by either per-record segment lists (H5/JSONL) or
a single concatenated segment plus per-record offsets (bin).
Subclasses fill ``self._data`` and ``self._cum`` during ``load()`` Subclasses fill ``self._data`` and ``self._cum`` during ``load()``
via ``_normalize()``. via ``_normalize()``.
@@ -103,7 +111,9 @@ class Store(ABC):
def __init__(self): def __init__(self):
self._data: Dict[str, List[Tensor]] = {} self._data: Dict[str, List[Tensor]] = {}
self._cum: Dict[str, List[int]] = {} self._cum: Dict[str, List[int]] = {}
self._offsets: Dict[str, List[int]] = {}
self._length: int = 0 self._length: int = 0
self._num_records: int = 0
@abstractmethod @abstractmethod
def load(self, path: str) -> None: def load(self, path: str) -> None:
@@ -148,17 +158,71 @@ class Store(ABC):
return results[0] if len(results) == 1 else torch.cat(results, dim=0) return results[0] if len(results) == 1 else torch.cat(results, dim=0)
def _normalize(self, raw: Dict[str, list]): @property
"""Register segments and pre-compute cumulative lengths. def num_records(self) -> int:
return self._num_records
Does NOT concatenate — segments are kept as-is to avoid OOM on def fetch_record(
large datasets. Sets ``self._length`` to the minimum total self,
element count across all flat-tensor keys. index: int,
keys: Union[str, List[str]],
):
"""Fetch the *index*-th record without cross-record concatenation.
For GRPO multi-response keys, values may be ``List[List[Tensor]]`` Returns a tensor (flat key) or ``List[Tensor]`` (nested key such as
(one list of G tensors per record). These are stored as-is and GRPO ``responses``).
excluded from the cumulative-length bookkeeping since they are """
accessed record-by-record via ``_data`` rather than via ``fetch``. if not self._data:
raise RuntimeError("Store not loaded")
if not 0 <= index < self._num_records:
raise ValueError(
f"Record index out of bounds: {index}, num_records={self._num_records}"
)
if isinstance(keys, str):
return self._fetch_record_key(keys, index)
return {k: self._fetch_record_key(k, index) for k in keys}
def _fetch_record_key(self, key: str, index: int):
"""Return the *index*-th record for *key*.
Two storage layouts are supported:
- **bin + offsets**: ``_data[key]`` is ``[single_long_segment]``;
``_offsets[key]`` holds cumulative per-record offsets. The record
is sliced as ``segment[offsets[i]:offsets[i+1]]``.
- **h5 / jsonl**: ``_data[key]`` is ``[t0, t1, ...]`` with one tensor
(or nested list of tensors for GRPO) per record. Direct indexing.
"""
offsets = self._offsets.get(key)
if offsets:
start = offsets[index]
end = (
offsets[index + 1]
if index + 1 < len(offsets)
else self._data[key][0].shape[0]
)
return self._data[key][0][start:end]
return self._data[key][index]
def _normalize(
self,
raw: Dict[str, list],
offsets: Optional[Dict[str, List[int]]] = None,
per_record: bool = False,
):
"""Register segments and pre-compute indices for both access modes.
Stream mode: ``_cum[key]`` accumulates per-segment lengths so
``_fetch_key`` can bisect across segments without concatenation.
Record mode: if *offsets* is provided (bin layout), ``_offsets[key]``
stores cumulative per-record offsets into the single concatenated
segment. Otherwise (h5/jsonl layout), ``_data[key]`` is already a
per-record list and ``fetch_record`` indexes it directly.
Nested keys (GRPO ``responses``/``masks`` as ``List[List[Tensor]]``)
are stored as-is and excluded from both cumulative bookkeepings —
they are only accessed record-by-record.
""" """
flat_lengths = [] flat_lengths = []
for key, tensors in raw.items(): for key, tensors in raw.items():
@@ -180,6 +244,42 @@ class Store(ABC):
flat_lengths.append(cum[-1] if cum else 0) flat_lengths.append(cum[-1] if cum else 0)
self._length = min(flat_lengths) if flat_lengths else 0 self._length = min(flat_lengths) if flat_lengths else 0
# Record-mode offsets (bin layout). Only valid when each key is a
# single concatenated segment — multi-shard bin + offsets is not
# supported (merge shards or use H5/JSONL instead).
valid_offsets: Dict[str, List[int]] = {}
if offsets:
for key, off in offsets.items():
segs = self._data.get(key, [])
if len(segs) == 1 and len(off) > 1:
valid_offsets[key] = off
elif len(segs) > 1:
logger.warning(
"Key '%s' has %d segments with offsets — record mode "
"disabled for this key (multi-shard bin+offsets not "
"supported). Merge shards or use H5/JSONL.",
key,
len(segs),
)
self._offsets = valid_offsets
if valid_offsets:
record_counts = [len(v) - 1 for v in valid_offsets.values()]
self._num_records = min(record_counts) if record_counts else 0
elif per_record:
# H5/JSONL layout: _data[key] is a per-record list where each
# segment is one record. Even a single segment counts as one
# record.
per_record_counts = []
for key, tensors in self._data.items():
if not tensors or isinstance(tensors[0], list):
continue
per_record_counts.append(len(tensors))
self._num_records = min(per_record_counts) if per_record_counts else 0
else:
# bin layout without offsets: _data[key] is [concatenated_stream].
# Cannot determine record boundaries — stream mode only.
self._num_records = 0
class StoreFactory(BaseFactory["Store"]): class StoreFactory(BaseFactory["Store"]):
"""Factory for creating Store instances by type name. """Factory for creating Store instances by type name.
@@ -194,10 +294,15 @@ class StoreFactory(BaseFactory["Store"]):
@StoreFactory.register("h5") @StoreFactory.register("h5")
class H5Store(Store): class H5Store(Store):
"""HDF5-based storage backend (pre-tokenized data).""" """HDF5-based storage backend (pre-tokenized data).
Each key is stored as a group of per-record datasets (``data_0``,
``data_1``, …), so record mode indexes ``_data[key]`` directly.
Stream mode concatenates across records via ``_cum``.
"""
def load(self, path: str): def load(self, path: str):
self._normalize(load_h5(path)) self._normalize(load_h5(path), per_record=True)
@StoreFactory.register("bin") @StoreFactory.register("bin")
@@ -208,10 +313,15 @@ class MmapStore(Store):
No per-process memory duplication — all DataLoader workers share the No per-process memory duplication — all DataLoader workers share the
same OS page-cache pages. same OS page-cache pages.
When ``meta.json`` contains per-record ``offsets`` for a key (written
via ``save_bin(..., record_keys=...)``), record-mode access slices
individual records from the concatenated memmap. Legacy bin files
without offsets only support stream mode.
Format on disk:: Format on disk::
data_root/ data_root/
meta.json # {key: {shape, dtype}, ...} meta.json # {key: {shape, dtype, offsets?}, ...}
<key>.bin # raw numpy array, one per key <key>.bin # raw numpy array, one per key
""" """
@@ -219,18 +329,24 @@ class MmapStore(Store):
self._mmap_refs = [] self._mmap_refs = []
root = Path(path) root = Path(path)
all_raw: Dict[str, List[Tensor]] = {} all_raw: Dict[str, List[Tensor]] = {}
all_offsets: Dict[str, List[int]] = {}
meta_paths = [ meta_paths = [
Path(p) for p in glob.glob(str(root / "**" / "meta.json"), recursive=True) Path(p) for p in glob.glob(str(root / "**" / "meta.json"), recursive=True)
] ]
for meta_path in meta_paths: for meta_path in meta_paths:
raw = load_bin(str(meta_path.parent)) raw = load_bin(str(meta_path.parent))
off = load_bin_offsets(str(meta_path.parent))
for key, tensors in raw.items(): for key, tensors in raw.items():
if key not in all_raw: if key not in all_raw:
all_raw[key] = [] all_raw[key] = []
all_raw[key].extend(tensors) all_raw[key].extend(tensors)
for key, o in off.items():
if key not in all_offsets:
all_offsets[key] = []
all_offsets[key].extend(o)
if not meta_paths: if not meta_paths:
raise FileNotFoundError(f"No meta.json found under {path}") raise FileNotFoundError(f"No meta.json found under {path}")
self._normalize(all_raw) self._normalize(all_raw, offsets=all_offsets or None)
for tensors in self._data.values(): for tensors in self._data.values():
self._mmap_refs.extend(tensors) self._mmap_refs.extend(tensors)
@@ -327,7 +443,7 @@ class JsonlStore(Store):
if pos_ids: if pos_ids:
raw["position_ids"] = [torch.tensor(pos_ids, dtype=torch.int32)] raw["position_ids"] = [torch.tensor(pos_ids, dtype=torch.int32)]
self._normalize(raw) self._normalize(raw, per_record=True)
@staticmethod @staticmethod
def _primary_ids(result: dict) -> List[int]: def _primary_ids(result: dict) -> List[int]:
+2
View File
@@ -19,6 +19,7 @@ from astrai.serialization.checkpoint import (
) )
from astrai.serialization.dataset import ( from astrai.serialization.dataset import (
load_bin, load_bin,
load_bin_offsets,
load_h5, load_h5,
save_bin, save_bin,
save_h5, save_h5,
@@ -37,6 +38,7 @@ __all__ = [
"save_safetensors", "save_safetensors",
"save_torch", "save_torch",
"load_bin", "load_bin",
"load_bin_offsets",
"load_h5", "load_h5",
"save_bin", "save_bin",
"save_h5", "save_h5",
+50 -3
View File
@@ -3,7 +3,7 @@
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Any, Dict, List, Optional
import h5py import h5py
import numpy as np import numpy as np
@@ -50,12 +50,43 @@ def load_h5(file_path: str, share_memory=True) -> Dict[str, List[Tensor]]:
return tensor_group return tensor_group
def save_bin(file_path: str, tensor_group: Dict[str, List[Tensor]]): def save_bin(
file_path: str,
tensor_group: Dict[str, List[Tensor]],
record_keys: Optional[List[str]] = None,
):
"""Save tensors as memory-mapped binary files.
When *record_keys* is provided, those keys are written with per-record
cumulative offsets in ``meta.json`` so that ``MmapStore.fetch_record``
can slice individual records from the concatenated binary without
cross-record concatenation. Keys not in *record_keys* (e.g. SEQ
``sequence``) are written as a single contiguous stream without
offsets, preserving backward compatibility.
Nested keys (``List[List[Tensor]]`` such as GRPO ``responses``) are
not supported in bin format — use H5 for those.
"""
os.makedirs(file_path, exist_ok=True) os.makedirs(file_path, exist_ok=True)
record_keys = set(record_keys or [])
meta = {} meta = {}
for key, tensors in tensor_group.items(): for key, tensors in tensor_group.items():
if tensors and isinstance(tensors[0], list):
raise ValueError(
f"Nested key '{key}' (List[List[Tensor]]) is not supported "
f"in bin format. Use H5 or JSONL storage instead."
)
cat = torch.cat(tensors, dim=0) cat = torch.cat(tensors, dim=0)
meta[key] = {"shape": list(cat.shape), "dtype": str(cat.dtype).split(".")[-1]} entry: Dict[str, Any] = {
"shape": list(cat.shape),
"dtype": str(cat.dtype).split(".")[-1],
}
if key in record_keys:
offsets = [0]
for t in tensors:
offsets.append(offsets[-1] + t.shape[0])
entry["offsets"] = offsets
meta[key] = entry
np.asarray(cat.cpu().numpy()).tofile(os.path.join(file_path, f"{key}.bin")) np.asarray(cat.cpu().numpy()).tofile(os.path.join(file_path, f"{key}.bin"))
with open(os.path.join(file_path, "meta.json"), "w") as f: with open(os.path.join(file_path, "meta.json"), "w") as f:
json.dump(meta, f) json.dump(meta, f)
@@ -74,3 +105,19 @@ def load_bin(file_path: str) -> Dict[str, List[Tensor]]:
) )
segments[key] = [torch.from_numpy(arr)] segments[key] = [torch.from_numpy(arr)]
return segments return segments
def load_bin_offsets(file_path: str) -> Dict[str, List[int]]:
"""Read per-record cumulative offsets from ``meta.json``.
Returns an empty dict when no key has offsets (legacy bin files),
in which case record-mode access falls back to per-record segment
indexing (H5/JSONL layout).
"""
with open(os.path.join(file_path, "meta.json"), "r") as f:
meta = json.load(f)
offsets: Dict[str, List[int]] = {}
for key, info in meta.items():
if "offsets" in info:
offsets[key] = info["offsets"]
return offsets
+5 -3
View File
@@ -7,7 +7,7 @@ import torch.nn as nn
from torch.utils.data import DataLoader, random_split from torch.utils.data import DataLoader, random_split
from astrai.config.train_config import TrainConfig from astrai.config.train_config import TrainConfig
from astrai.dataset import ResumableDistributedSampler from astrai.dataset import RDSampler
from astrai.model.components.lora import inject_lora from astrai.model.components.lora import inject_lora
from astrai.parallel.executor import BaseExecutor, ExecutorFactory from astrai.parallel.executor import BaseExecutor, ExecutorFactory
from astrai.parallel.setup import get_current_device, get_rank, get_world_size from astrai.parallel.setup import get_current_device, get_rank, get_world_size
@@ -141,7 +141,7 @@ class TrainContextBuilder:
) )
sampler_offset = context.consumed_samples // context.world_size sampler_offset = context.consumed_samples // context.world_size
sampler = ResumableDistributedSampler( sampler = RDSampler(
data_source=train_dataset, data_source=train_dataset,
start_epoch=context.epoch, start_epoch=context.epoch,
start_iter=sampler_offset, start_iter=sampler_offset,
@@ -154,10 +154,11 @@ class TrainContextBuilder:
num_workers=cfg.num_workers, num_workers=cfg.num_workers,
pin_memory=cfg.pin_memory, pin_memory=cfg.pin_memory,
prefetch_factor=cfg.prefetch_factor, prefetch_factor=cfg.prefetch_factor,
collate_fn=cfg.collate_fn,
) )
if val_dataset is not None: if val_dataset is not None:
val_sampler = ResumableDistributedSampler( val_sampler = RDSampler(
data_source=val_dataset, data_source=val_dataset,
start_epoch=0, start_epoch=0,
start_iter=0, start_iter=0,
@@ -171,6 +172,7 @@ class TrainContextBuilder:
num_workers=cfg.num_workers, num_workers=cfg.num_workers,
pin_memory=cfg.pin_memory, pin_memory=cfg.pin_memory,
prefetch_factor=cfg.prefetch_factor, prefetch_factor=cfg.prefetch_factor,
collate_fn=cfg.collate_fn,
) )
context.model, context.optimizer, context.dataloader, context.scheduler = ( context.model, context.optimizer, context.dataloader, context.scheduler = (
+8 -1
View File
@@ -8,7 +8,7 @@ import torch.optim as optim
from torch import Tensor, nn from torch import Tensor, nn
from astrai.config import AutoRegressiveLMConfig, TrainConfig from astrai.config import AutoRegressiveLMConfig, TrainConfig
from astrai.dataset import DatasetFactory from astrai.dataset import DatasetFactory, dpo_collate_fn, grpo_collate_fn
from astrai.model import AutoRegressiveLM from astrai.model import AutoRegressiveLM
from astrai.model.components.decoder_block import DecoderBlock from astrai.model.components.decoder_block import DecoderBlock
from astrai.trainer import SchedulerFactory, Trainer from astrai.trainer import SchedulerFactory, Trainer
@@ -504,6 +504,12 @@ def train(
grad_ckpt_modules = [DecoderBlock] if gradient_checkpointing else [] grad_ckpt_modules = [DecoderBlock] if gradient_checkpointing else []
collate_fn = None
if train_type == "dpo":
collate_fn = dpo_collate_fn
elif train_type == "grpo":
collate_fn = grpo_collate_fn
train_config = TrainConfig( train_config = TrainConfig(
model_fn=model_fn, model_fn=model_fn,
strategy=train_type, strategy=train_type,
@@ -536,6 +542,7 @@ def train(
executor_kwargs=executor_kwargs, executor_kwargs=executor_kwargs,
extra_kwargs=strategy_kwargs, extra_kwargs=strategy_kwargs,
neftune_alpha=neftune_alpha, neftune_alpha=neftune_alpha,
collate_fn=collate_fn,
) )
trainer = Trainer(train_config) trainer = Trainer(train_config)
+13 -3
View File
@@ -56,6 +56,13 @@ def _write_jsonl_dataset(test_dir, tokenizer_path, records, config_overrides=Non
return data_dir return data_dir
def _fake_fetch_record(self, idx, keys):
"""FakeStore.fetch_record matching real Store semantics."""
if isinstance(keys, str):
return self._data[keys][idx]
return {k: self._data[k][idx] for k in keys}
def _make_seq_dataset( def _make_seq_dataset(
test_dir, name="data", seq_length=200, train_type="seq", data=None, **load_kwargs test_dir, name="data", seq_length=200, train_type="seq", data=None, **load_kwargs
): ):
@@ -338,6 +345,7 @@ def test_grpo_dataset_dtype(base_test_env):
(), (),
{ {
"keys": ["prompts", "responses", "masks", "rewards"], "keys": ["prompts", "responses", "masks", "rewards"],
"num_records": 1,
"_data": { "_data": {
"prompts": [torch.randint(0, 100, (10,), dtype=torch.int32)], "prompts": [torch.randint(0, 100, (10,), dtype=torch.int32)],
"responses": [ "responses": [
@@ -346,9 +354,9 @@ def test_grpo_dataset_dtype(base_test_env):
"masks": [[torch.ones(5, dtype=torch.int32) for _ in range(G)]], "masks": [[torch.ones(5, dtype=torch.int32) for _ in range(G)]],
"rewards": [torch.rand(G, dtype=torch.float32)], "rewards": [torch.rand(G, dtype=torch.float32)],
}, },
"fetch_record": _fake_fetch_record,
}, },
)() )()
dataset._build_records()
item = dataset[0] item = dataset[0]
assert item["prompts"].dtype == torch.long assert item["prompts"].dtype == torch.long
@@ -371,15 +379,16 @@ def test_grpo_dataset_load(base_test_env):
(), (),
{ {
"keys": ["prompts", "responses", "masks", "rewards"], "keys": ["prompts", "responses", "masks", "rewards"],
"num_records": 1,
"_data": { "_data": {
"prompts": [torch.randint(0, 100, (prompt_len,))], "prompts": [torch.randint(0, 100, (prompt_len,))],
"responses": [[torch.randint(0, 100, (rl,)) for rl in resp_lens]], "responses": [[torch.randint(0, 100, (rl,)) for rl in resp_lens]],
"masks": [[torch.ones(rl, dtype=torch.int64) for rl in resp_lens]], "masks": [[torch.ones(rl, dtype=torch.int64) for rl in resp_lens]],
"rewards": [torch.tensor([0.9, 0.3, 0.7], dtype=torch.float32)], "rewards": [torch.tensor([0.9, 0.3, 0.7], dtype=torch.float32)],
}, },
"fetch_record": _fake_fetch_record,
}, },
)() )()
dataset._build_records()
assert len(dataset) == 1 assert len(dataset) == 1
item = dataset[0] item = dataset[0]
@@ -864,6 +873,7 @@ def test_grpo_multiple_records(base_test_env):
(), (),
{ {
"keys": ["prompts", "responses", "masks", "rewards"], "keys": ["prompts", "responses", "masks", "rewards"],
"num_records": n_records,
"_data": { "_data": {
"prompts": [torch.randint(0, 100, (10,)) for _ in range(n_records)], "prompts": [torch.randint(0, 100, (10,)) for _ in range(n_records)],
"responses": dummy_responses, "responses": dummy_responses,
@@ -875,9 +885,9 @@ def test_grpo_multiple_records(base_test_env):
torch.rand(G, dtype=torch.float32) for _ in range(n_records) torch.rand(G, dtype=torch.float32) for _ in range(n_records)
], ],
}, },
"fetch_record": _fake_fetch_record,
}, },
)() )()
dataset._build_records()
assert len(dataset) == n_records assert len(dataset) == n_records
+6 -6
View File
@@ -1,4 +1,4 @@
from astrai.dataset import ResumableDistributedSampler from astrai.dataset import RDSampler
def test_random_sampler_consistency(random_dataset): def test_random_sampler_consistency(random_dataset):
@@ -6,8 +6,8 @@ def test_random_sampler_consistency(random_dataset):
dataset = random_dataset dataset = random_dataset
# Create two samplers with same seed # Create two samplers with same seed
sampler1 = ResumableDistributedSampler(dataset, seed=42) sampler1 = RDSampler(dataset, seed=42)
sampler2 = ResumableDistributedSampler(dataset, seed=42) sampler2 = RDSampler(dataset, seed=42)
indices1 = list(iter(sampler1)) indices1 = list(iter(sampler1))
indices2 = list(iter(sampler2)) indices2 = list(iter(sampler2))
@@ -20,8 +20,8 @@ def test_random_sampler_different_seeds(random_dataset):
dataset = random_dataset dataset = random_dataset
# Create two samplers with different seeds # Create two samplers with different seeds
sampler1 = ResumableDistributedSampler(dataset, seed=42) sampler1 = RDSampler(dataset, seed=42)
sampler2 = ResumableDistributedSampler(dataset, seed=123) sampler2 = RDSampler(dataset, seed=123)
indices1 = list(iter(sampler1)) indices1 = list(iter(sampler1))
indices2 = list(iter(sampler2)) indices2 = list(iter(sampler2))
@@ -35,7 +35,7 @@ def test_sampler_across_epochs(random_dataset):
dataset = random_dataset dataset = random_dataset
n = len(dataset) n = len(dataset)
sampler = ResumableDistributedSampler(dataset, seed=42) sampler = RDSampler(dataset, seed=42)
# Get indices for first epoch # Get indices for first epoch
epoch1_indices = list(iter(sampler)) epoch1_indices = list(iter(sampler))