Skip to content

Commit 0701689

Browse files
committed
feat: implement dataset preparation and streaming
This commit implements the dataset preparation phase of the roadmap. It includes a script to download and tokenize datasets (FineWeb-Edu and GSM8K) into efficient binary format, and a new Dataset class to stream this data during training. This fulfills the requirement to design and prepare a dataset for training. It provides: 1. `scripts/data/prepare_dataset.py`: A script to download Hugging Face datasets (FineWeb-Edu, GSM8K), tokenize them, and save them as efficient `uint16` binary files (saving storage). 2. `crsm/dataset.py`: A new `PretokenizedDataset` class that streams these binary files using `numpy.memmap` (saving memory) and correctly stitches data across file boundaries to prevent token loss during streaming.
1 parent 4829787 commit 0701689

7 files changed

Lines changed: 387 additions & 7 deletions

File tree

crsm/dataset.py

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
"""
44
import gc
55
from collections import deque
6-
from typing import Iterator, Optional
6+
from typing import Iterator, Optional, List, Tuple, Iterable
77
import torch
88
from torch.utils.data import Dataset, IterableDataset
99
from pathlib import Path
1010
from collections import Counter
11-
from typing import List, Tuple, Iterable
11+
import numpy as np
1212

1313
from .tokenizer import Tokenizer
1414

@@ -45,6 +45,132 @@ def encode(self, tokens: List[str]) -> List[int]:
4545
return [self.stoi.get(t, self.stoi['<unk>']) for t in tokens]
4646

4747

48+
class PretokenizedDataset(IterableDataset):
49+
"""
50+
Dataset that streams tokens from pre-tokenized binary files.
51+
"""
52+
def __init__(self, data_dir: str, seq_len: int = 1024, split: str = "train",
53+
vocab_size: int = 50257):
54+
self.data_dir = Path(data_dir)
55+
self.seq_len = seq_len
56+
self.vocab_size = vocab_size
57+
58+
# Check vocab size safety
59+
if self.vocab_size > 65535:
60+
raise ValueError(f"Vocab size {self.vocab_size} too large for uint16 storage used by PretokenizedDataset")
61+
62+
# Find all bin files matching the split
63+
self.files = sorted(list(self.data_dir.glob(f"*{split}*.bin")))
64+
if not self.files:
65+
raise FileNotFoundError(f"No bin files found in {data_dir} for split '{split}'. Did you run prepare_dataset.py?")
66+
67+
print(f"Found {len(self.files)} bin files in {data_dir} for split {split}")
68+
69+
def __iter__(self):
70+
worker_info = torch.utils.data.get_worker_info()
71+
if worker_info:
72+
# Shard files across workers
73+
# Simple interleaving
74+
files = self.files[worker_info.id::worker_info.num_workers]
75+
else:
76+
files = self.files
77+
78+
# Buffer to hold tokens from multiple files to ensure continuity
79+
buffer = np.array([], dtype=np.uint16)
80+
chunk_len = self.seq_len + 1
81+
82+
for path in files:
83+
# Read file as numpy array
84+
try:
85+
# Use memmap for efficiency
86+
new_data = np.memmap(path, dtype=np.uint16, mode='r')
87+
88+
# Append to buffer (we have to copy here, but buffer is small)
89+
# Actually, appending memmap to numpy array forces a read.
90+
# To be efficient, we should process the buffer as much as possible first.
91+
92+
# However, for continuity, we need to stitch the end of the previous file
93+
# with the start of the new file.
94+
95+
# Since we are iterating, we can yield chunks from `new_data`
96+
# but we need to handle the "leftover" from previous file.
97+
98+
# If buffer is not empty (from previous iteration), prepend it
99+
if len(buffer) > 0:
100+
# This forces a read of the whole file if we use np.concatenate
101+
# Ideally we only read the beginning.
102+
103+
# Optimization: Only concat the leftovers with the start of new file?
104+
# No, memmap is array-like.
105+
106+
# Let's just process the memmap directly and keep the tail in buffer.
107+
pass
108+
109+
# We can't easily concatenate memmap without reading it.
110+
# Strategy:
111+
# 1. Take leftovers from buffer.
112+
# 2. Iterate through memmap.
113+
# 3. If we have enough for a chunk using leftovers + start of memmap, yield it.
114+
# 4. Then yield chunks from memmap.
115+
# 5. Save tail of memmap to buffer.
116+
117+
current_idx = 0
118+
total_len = len(new_data)
119+
120+
# Handle leftovers
121+
if len(buffer) > 0:
122+
needed = chunk_len - len(buffer)
123+
if total_len >= needed:
124+
# Take 'needed' from new_data
125+
part = new_data[:needed] # This reads from disk
126+
full_chunk = np.concatenate([buffer, part])
127+
128+
x = torch.from_numpy(full_chunk[:-1].astype(np.int64))
129+
y = torch.from_numpy(full_chunk[1:].astype(np.int64))
130+
yield x, y
131+
132+
current_idx = needed
133+
buffer = np.array([], dtype=np.uint16)
134+
else:
135+
# File is too small to complete the buffer
136+
# Append all of it to buffer and continue to next file
137+
part = new_data[:]
138+
buffer = np.concatenate([buffer, part])
139+
continue
140+
141+
# Process main body of file
142+
# We can compute how many chunks fit
143+
remaining = total_len - current_idx
144+
num_chunks = remaining // chunk_len
145+
146+
if num_chunks > 0:
147+
# Create a view or slice
148+
# Note: slicing memmap returns memmap, which is good.
149+
# We can iterate over the slices.
150+
151+
# But constructing individual tensors from memmap slices is fine.
152+
for i in range(num_chunks):
153+
start = current_idx + i * chunk_len
154+
end = start + chunk_len
155+
chunk = new_data[start:end]
156+
157+
# Copy to memory and convert
158+
chunk_arr = np.array(chunk, dtype=np.int64)
159+
x = torch.from_numpy(chunk_arr[:-1])
160+
y = torch.from_numpy(chunk_arr[1:])
161+
yield x, y
162+
163+
current_idx += num_chunks * chunk_len
164+
165+
# Save leftovers
166+
if current_idx < total_len:
167+
buffer = np.array(new_data[current_idx:], dtype=np.uint16)
168+
169+
except Exception as e:
170+
print(f"Error reading {path}: {e}")
171+
continue
172+
173+
48174
class RealTextDataset(Dataset):
49175
"""Loads text files from a directory, builds a simple token-level vocab,
50176
and yields sequences for next-token prediction.

notebooks/cloud_training/1_train_backbone.ipynb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"source": [
3737
"!git clone https://github.com/Pomilon-Intelligence-Lab/crsm.git\n",
3838
"%cd crsm\n",
39-
"!pip install -r requirements.txt"
39+
"!pip install -r requirements.txt\n",
40+
"!pip install -e ."
4041
]
4142
},
4243
{
@@ -60,7 +61,7 @@
6061
],
6162
"metadata": {
6263
"kernelspec": {
63-
"display_name": "Python 3",
64+
"display_name": "Python 3 (ipykernel)",
6465
"language": "python",
6566
"name": "python3"
6667
},
@@ -74,7 +75,7 @@
7475
"name": "python",
7576
"nbconvert_exporter": "python",
7677
"pygments_lexer": "ipython3",
77-
"version": "3.8.10"
78+
"version": "3.12.12"
7879
}
7980
},
8081
"nbformat": 4,

notebooks/cloud_training/2_distill_dynamics.ipynb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"source": [
3737
"!git clone https://github.com/Pomilon-Intelligence-Lab/crsm.git\n",
3838
"%cd crsm\n",
39-
"!pip install -r requirements.txt"
39+
"!pip install -r requirements.txt\n",
40+
"!pip install -e ."
4041
]
4142
},
4243
{

notebooks/cloud_training/3_train_judgment.ipynb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"source": [
3737
"!git clone https://github.com/Pomilon-Intelligence-Lab/crsm.git\n",
3838
"%cd crsm\n",
39-
"!pip install -r requirements.txt"
39+
"!pip install -r requirements.txt\n",
40+
"!pip install -e ."
4041
]
4142
},
4243
{

scripts/data/prepare_dataset.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
Prepare Dataset Script
3+
----------------------
4+
Downloads and tokenizes datasets (FineWeb-Edu, GSM8K) into efficient binary format (uint16).
5+
This allows for fast streaming during training with minimal memory overhead.
6+
7+
Usage:
8+
python scripts/data/prepare_dataset.py --dataset fineweb --output_dir data/fineweb
9+
python scripts/data/prepare_dataset.py --dataset gsm8k --output_dir data/gsm8k
10+
"""
11+
import os
12+
import sys
13+
import argparse
14+
import numpy as np
15+
from pathlib import Path
16+
from tqdm import tqdm
17+
from datasets import load_dataset
18+
from transformers import AutoTokenizer
19+
import multiprocessing as mp
20+
21+
# Ensure the package is in the path
22+
sys.path.insert(0, '.')
23+
24+
def get_tokenizer(model_name="gpt2"):
25+
return AutoTokenizer.from_pretrained(model_name)
26+
27+
def process_fineweb(example, tokenizer):
28+
ids = tokenizer.encode(example['text'])
29+
ids.append(tokenizer.eos_token_id)
30+
return ids
31+
32+
def process_gsm8k(example, tokenizer):
33+
# Format: Question: ... Answer: ...
34+
text = f"Question: {example['question']}\nAnswer: {example['answer']}"
35+
ids = tokenizer.encode(text)
36+
ids.append(tokenizer.eos_token_id)
37+
return ids
38+
39+
def write_to_bin(tokens, output_file):
40+
# Convert to uint16 (ensure vocab < 65535)
41+
# GPT2 vocab is 50257, so it fits.
42+
arr = np.array(tokens, dtype=np.uint16)
43+
with open(output_file, "wb") as f:
44+
f.write(arr.tobytes())
45+
46+
def main():
47+
parser = argparse.ArgumentParser()
48+
parser.add_argument('--dataset', type=str, required=True, choices=['fineweb', 'gsm8k'], help="Dataset to prepare")
49+
parser.add_argument('--output-dir', type=str, required=True, help="Directory to save binary files")
50+
parser.add_argument('--tokenizer', type=str, default="gpt2", help="Tokenizer name")
51+
parser.add_argument('--shard-size', type=int, default=100_000_000, help="Tokens per shard")
52+
parser.add_argument('--subset', type=str, default="sample-10BT", help="Subset for FineWeb (default: sample-10BT)")
53+
54+
args = parser.parse_args()
55+
56+
output_dir = Path(args.output_dir)
57+
output_dir.mkdir(parents=True, exist_ok=True)
58+
59+
tokenizer = get_tokenizer(args.tokenizer)
60+
print(f"Loaded tokenizer: {args.tokenizer} (vocab size: {tokenizer.vocab_size})")
61+
62+
if tokenizer.vocab_size >= 65535:
63+
print("Error: Tokenizer vocab size too large for uint16 storage!")
64+
sys.exit(1)
65+
66+
# Load Dataset
67+
print(f"Loading dataset {args.dataset}...")
68+
if args.dataset == 'fineweb':
69+
# Use streaming for large datasets
70+
ds = load_dataset("HuggingFaceFW/fineweb-edu", name=args.subset, split="train", streaming=True)
71+
process_fn = process_fineweb
72+
prefix = "fineweb"
73+
elif args.dataset == 'gsm8k':
74+
ds = load_dataset("gsm8k", "main", split="train", streaming=True)
75+
process_fn = process_gsm8k
76+
prefix = "gsm8k"
77+
78+
token_buffer = []
79+
shard_idx = 0
80+
total_tokens = 0
81+
82+
print(f"Processing and tokenizing to {output_dir}...")
83+
84+
pbar = tqdm(desc="Tokens", unit="tok")
85+
86+
for example in ds:
87+
tokens = process_fn(example, tokenizer)
88+
token_buffer.extend(tokens)
89+
90+
# Update pbar occasionally
91+
if len(token_buffer) % 1000 == 0:
92+
pbar.update(len(tokens)) # Approximate update
93+
94+
if len(token_buffer) >= args.shard_size:
95+
# Write shard
96+
filename = output_dir / f"{prefix}_train_{shard_idx:04d}.bin"
97+
write_to_bin(token_buffer, filename)
98+
99+
total_tokens += len(token_buffer)
100+
print(f"Saved {filename} ({len(token_buffer)} tokens)")
101+
102+
token_buffer = []
103+
shard_idx += 1
104+
105+
# For demonstration/testing purposes, we might stop early if running in CI/Test env
106+
# But this is a script, so let it run.
107+
108+
# Write remaining
109+
if token_buffer:
110+
filename = output_dir / f"{prefix}_train_{shard_idx:04d}.bin"
111+
write_to_bin(token_buffer, filename)
112+
total_tokens += len(token_buffer)
113+
print(f"Saved {filename} ({len(token_buffer)} tokens)")
114+
115+
print(f"Done. Total tokens: {total_tokens}")
116+
117+
if __name__ == "__main__":
118+
main()

tests/test_dataset_continuity.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
2+
import unittest
3+
import torch
4+
import numpy as np
5+
from pathlib import Path
6+
import shutil
7+
import tempfile
8+
from crsm.dataset import PretokenizedDataset
9+
10+
class TestPretokenizedDatasetContinuity(unittest.TestCase):
11+
def setUp(self):
12+
self.test_dir = Path(tempfile.mkdtemp())
13+
self.seq_len = 5
14+
self.chunk_len = self.seq_len + 1 # 6
15+
16+
# Create two files.
17+
# File 1: 0, 1, 2, 3 (4 tokens) - too small for one sequence
18+
# File 2: 4, 5, 6, 7, 8, 9, 10, 11 (8 tokens)
19+
20+
# Combined: 0..11 (12 tokens)
21+
# Sequence 1: 0,1,2,3,4,5 (input 0..4, target 1..5)
22+
# Sequence 2: 6,7,8,9,10,11 (input 6..10, target 7..11)
23+
24+
# Wait, simple streaming uses chunks of size seq_len + 1.
25+
# So we need 6 tokens per yield.
26+
27+
self.split = "train"
28+
29+
f1 = self.test_dir / f"data_{self.split}_001.bin"
30+
tokens1 = np.arange(4, dtype=np.uint16)
31+
with open(f1, "wb") as f:
32+
f.write(tokens1.tobytes())
33+
34+
f2 = self.test_dir / f"data_{self.split}_002.bin"
35+
tokens2 = np.arange(4, 12, dtype=np.uint16)
36+
with open(f2, "wb") as f:
37+
f.write(tokens2.tobytes())
38+
39+
def tearDown(self):
40+
shutil.rmtree(self.test_dir)
41+
42+
def test_continuity(self):
43+
ds = PretokenizedDataset(self.test_dir, seq_len=self.seq_len, split=self.split)
44+
iterator = iter(ds)
45+
46+
# First batch should come from merging File 1 and start of File 2
47+
# Data: 0,1,2,3 + 4,5
48+
x1, y1 = next(iterator)
49+
print(f"Batch 1: {x1.tolist()}")
50+
self.assertEqual(x1.tolist(), [0, 1, 2, 3, 4])
51+
self.assertEqual(y1.tolist(), [1, 2, 3, 4, 5])
52+
53+
# Second batch should come from File 2
54+
# Remaining in File 2: 6,7,8,9,10,11 (6 tokens left)
55+
# This is exactly one chunk.
56+
57+
# But wait, my logic was:
58+
# 1. needed = 6 - 4 = 2.
59+
# 2. Take 2 from File 2 (4,5). Yield.
60+
# 3. current_idx becomes 2.
61+
# 4. Total len of File 2 is 8. Remaining = 6.
62+
# 5. num_chunks = 1.
63+
# 6. Yield next chunk (from idx 2 to 8 -> 6,7,8,9,10,11).
64+
65+
try:
66+
x2, y2 = next(iterator)
67+
print(f"Batch 2: {x2.tolist()}")
68+
self.assertEqual(x2.tolist(), [6, 7, 8, 9, 10])
69+
self.assertEqual(y2.tolist(), [7, 8, 9, 10, 11])
70+
except StopIteration:
71+
self.fail("Should have yielded a second batch")
72+
73+
# Should be empty now
74+
with self.assertRaises(StopIteration):
75+
next(iterator)
76+
77+
if __name__ == "__main__":
78+
unittest.main()

0 commit comments

Comments
 (0)