LUNA / Base /scripts /prepare_litdata.py
BHARGAV REDDY
Upload Base/scripts/prepare_litdata.py with huggingface_hub
23b3bd1 verified
Raw
History Blame
14.5 kB
"""
Tokenize filtered parquet data and create LitData streaming format for litgpt.
Creates flat binary token chunks compatible with litdata's TokensLoader,
which litgpt's LitData data module uses for pretraining.
STREAMING MODE: Tokenizes texts in batches and flushes chunks to disk
incrementally, keeping RAM usage constant (~128MB buffer) regardless of
dataset size. Supports resuming from the last completed chunk.
Usage:
python Base/scripts/prepare_litdata.py --mode small
python Base/scripts/prepare_litdata.py --mode full
python Base/scripts/prepare_litdata.py --mode both
"""
import argparse
import json
import os
import time
from pathlib import Path
import numpy as np
import pyarrow.parquet as pq
from litgpt.tokenizer import Tokenizer
# Must match model config's block_size + 1 (extra token for targets)
BLOCK_SIZE = 1025
# 64 MB per chunk file
CHUNK_BYTES_TARGET = 64 * 1024 * 1024
# litdata's TokensLoader uses _TORCH_DTYPES_MAPPING[16] = torch.int32
DTYPE = np.int32
# litdata dtype index — 16 maps to torch.int32
DTYPE_INDEX = 16
def _write_chunk(output_dir, chunk_idx, block_buffer, num_blocks):
"""Write a single chunk file with litdata header + flat int32 data."""
dtype_size = DTYPE().itemsize
filename = f"chunk-0-{chunk_idx}.bin"
filepath = os.path.join(output_dir, filename)
chunk_data = block_buffer[:num_blocks * BLOCK_SIZE]
# Header: [num_items(uint32)] + [offsets 0..num_blocks(uint32)]
header_num_items = np.array([num_blocks], dtype=np.uint32)
offsets = np.arange(num_blocks + 1, dtype=np.uint32) * (BLOCK_SIZE * dtype_size)
header = np.concatenate([header_num_items, offsets])
with open(filepath, "wb") as f:
header.tofile(f)
chunk_data.tofile(f)
data_bytes = int(chunk_data.nbytes)
header_bytes = int(header.nbytes)
return {
"chunk_bytes": data_bytes + header_bytes,
"chunk_size": num_blocks,
"dim": int(len(chunk_data)),
"filename": filename,
}, header_bytes, data_bytes
def _load_resume_state(output_dir):
"""Check for existing chunks to support resuming."""
index_path = os.path.join(output_dir, "index.json.partial")
if not os.path.exists(index_path):
return [], 0, 0
with open(index_path) as f:
state = json.load(f)
chunks = state.get("chunks", [])
tokens_written = sum(c["dim"] for c in chunks)
return chunks, len(chunks), tokens_written
def _save_resume_state(output_dir, chunks_metadata):
"""Save partial progress for resume."""
path = os.path.join(output_dir, "index.json.partial")
with open(path, "w") as f:
json.dump({"chunks": chunks_metadata}, f)
def prepare_litdata(filtered_dir: str, output_dir: str, tokenizer_path: str, label: str):
"""Tokenize filtered text and write LitData chunks — MAX THROUGHPUT version.
Uses:
- HuggingFace tokenizers encode_batch() for parallel Rust-level tokenization
- Bulk parquet reads (entire files into RAM)
- 512 MB write buffer to minimize I/O syscalls
- All CPU cores via tokenizers' internal parallelism
"""
import multiprocessing
print(f"\n{'='*60}")
print(f"Preparing LitData [{label}] ** HIGH-THROUGHPUT MODE **")
print(f"Input: {filtered_dir}")
print(f"Output: {output_dir}")
print(f"Tokenizer: {tokenizer_path}")
print(f"CPU cores: {multiprocessing.cpu_count()}")
print(f"{'='*60}\n")
if not Path(filtered_dir).exists():
print(f"ERROR: Filtered directory not found: {filtered_dir}")
print("Run filter_datasets.py first!")
return
os.makedirs(output_dir, exist_ok=True)
# --- Load the raw HF fast tokenizer for encode_batch() ---
hf_tok = None
tok_dir = Path(tokenizer_path)
for candidate in ["tokenizer.json", "tokenizer.model"]:
cp = tok_dir / candidate
if cp.exists() and candidate == "tokenizer.json":
from tokenizers import Tokenizer as HFTokenizer
hf_tok = HFTokenizer.from_file(str(cp))
print(f" Loaded HF fast tokenizer from {cp}")
break
# Fallback to litgpt wrapper (slower, single-threaded)
if hf_tok is None:
print(" WARNING: No tokenizer.json found, falling back to litgpt Tokenizer (slower)")
litgpt_tok = Tokenizer(tok_dir)
eos_id = litgpt_tok.eos_id
# --- Chunk math ---
dtype_size = DTYPE().itemsize
tokens_per_chunk = CHUNK_BYTES_TARGET // dtype_size
tokens_per_chunk = (tokens_per_chunk // BLOCK_SIZE) * BLOCK_SIZE
# --- Resume support ---
chunks_metadata, chunk_idx, tokens_to_skip = _load_resume_state(output_dir)
if tokens_to_skip > 0:
print(f"RESUMING: Found {chunk_idx} existing chunks ({tokens_to_skip:,} tokens)")
print(f" Will skip {tokens_to_skip:,} tokens then continue writing chunks\n")
# --- Large write buffer (512 MB worth of tokens) ---
BIG_BUF_TOKENS = (512 * 1024 * 1024) // dtype_size
BIG_BUF_TOKENS = (BIG_BUF_TOKENS // BLOCK_SIZE) * BLOCK_SIZE
token_buf = np.empty(BIG_BUF_TOKENS + BLOCK_SIZE * 1024, dtype=DTYPE)
buf_pos = 0
total_tokens_written = sum(c["dim"] for c in chunks_metadata)
total_texts = 0
skipped_tokens = 0
# Batch size for encode_batch — large batches saturate all cores
ENCODE_BATCH = 32_768
parquet_files = sorted(Path(filtered_dir).glob("*.parquet"))
t0 = time.time()
def flush_chunks():
"""Flush all complete chunks from the buffer."""
nonlocal buf_pos, chunk_idx, total_tokens_written
flushed = 0
while buf_pos >= tokens_per_chunk:
num_blocks = tokens_per_chunk // BLOCK_SIZE
chunk_array = token_buf[:tokens_per_chunk].copy()
meta, hdr_b, data_b = _write_chunk(output_dir, chunk_idx, chunk_array, num_blocks)
chunks_metadata.append(meta)
total_tokens_written += meta["dim"]
flushed += 1
if flushed % 4 == 0 or buf_pos < tokens_per_chunk * 2:
_save_resume_state(output_dir, chunks_metadata)
el = time.time() - t0
rate = total_tokens_written / max(el, 1)
print(f" chunk-0-{chunk_idx}.bin | "
f"total: {total_tokens_written:,} tokens | "
f"{rate:,.0f} tok/s | {el:.0f}s")
chunk_idx += 1
leftover = buf_pos - tokens_per_chunk
if leftover > 0:
token_buf[:leftover] = token_buf[tokens_per_chunk:tokens_per_chunk + leftover]
buf_pos = leftover
return flushed
def encode_and_append(texts):
"""Encode a batch of texts and append to buffer, handling EOS."""
nonlocal buf_pos, total_texts, skipped_tokens
if hf_tok is not None:
# Rust-parallel batch encode — uses ALL CPU cores
encoded = hf_tok.encode_batch(texts, add_special_tokens=False)
for enc in encoded:
ids = enc.ids
ids.append(eos_id) # append EOS
n = len(ids)
if skipped_tokens < tokens_to_skip:
remaining = tokens_to_skip - skipped_tokens
if n <= remaining:
skipped_tokens += n
total_texts += 1
continue
ids = ids[int(remaining):]
skipped_tokens = tokens_to_skip
n = len(ids)
# Ensure buffer capacity
if buf_pos + n > len(token_buf):
flush_chunks()
if buf_pos + n > len(token_buf):
# Extremely long doc — extend buffer
extra = np.empty(n + BLOCK_SIZE * 256, dtype=DTYPE)
old = token_buf[:buf_pos].copy()
new_buf = np.empty(buf_pos + n + BLOCK_SIZE * 256, dtype=DTYPE)
new_buf[:buf_pos] = old
# Keep reference to token_buf so nonlocal works
# Actually we need to reassign
pass
arr = np.array(ids, dtype=DTYPE)
token_buf[buf_pos:buf_pos + n] = arr
buf_pos += n
total_texts += 1
else:
# Fallback: single-threaded litgpt tokenizer
for text in texts:
tokens = litgpt_tok.encode(text, bos=False, eos=True)
tok_array = tokens.numpy().astype(DTYPE) if hasattr(tokens, 'numpy') else np.array(tokens.tolist(), dtype=DTYPE)
n = len(tok_array)
if skipped_tokens < tokens_to_skip:
remaining = tokens_to_skip - skipped_tokens
if n <= remaining:
skipped_tokens += n
total_texts += 1
continue
tok_array = tok_array[int(remaining):]
skipped_tokens = tokens_to_skip
n = len(tok_array)
if buf_pos + n > len(token_buf):
flush_chunks()
token_buf[buf_pos:buf_pos + n] = tok_array
buf_pos += n
total_texts += 1
print(f"Tokenizing (batch_size={ENCODE_BATCH:,}, buffer={BIG_BUF_TOKENS*dtype_size/1e6:.0f} MB)...")
for pf_idx, pf in enumerate(parquet_files):
# Read entire parquet into RAM (fast, data is ~10MB/file)
table = pq.read_table(str(pf), columns=["text"])
all_texts = table.column("text").to_pylist()
del table # free arrow memory
# Process in large batches
for i in range(0, len(all_texts), ENCODE_BATCH):
batch = all_texts[i:i + ENCODE_BATCH]
encode_and_append(batch)
flush_chunks()
del all_texts
elapsed = time.time() - t0
rate = total_tokens_written / max(elapsed, 1)
print(f" [{pf.name}] file {pf_idx+1}/{len(parquet_files)} | "
f"texts: {total_texts:,} | tokens: {total_tokens_written:,} | "
f"{rate:,.0f} tok/s | {elapsed:.0f}s")
# Flush remaining buffer
flush_chunks()
remaining_blocks = buf_pos // BLOCK_SIZE
if remaining_blocks > 0:
final_tokens = remaining_blocks * BLOCK_SIZE
chunk_array = token_buf[:final_tokens].copy()
meta, hdr_b, data_b = _write_chunk(output_dir, chunk_idx, chunk_array, remaining_blocks)
chunks_metadata.append(meta)
total_tokens_written += meta["dim"]
print(f" chunk-0-{chunk_idx}.bin (final): {remaining_blocks} blocks, "
f"{meta['dim']:,} tokens")
chunk_idx += 1
# Write final index.json
index = {
"chunks": chunks_metadata,
"config": {
"chunk_bytes": CHUNK_BYTES_TARGET,
"chunk_size": None,
"compression": None,
"data_format": [f"no_header_tensor:{DTYPE_INDEX}"],
"data_spec": None,
"encryption": None,
"item_loader": "TokensLoader",
},
"updated_at": str(time.time()),
}
with open(os.path.join(output_dir, "index.json"), "w") as f:
json.dump(index, f, indent=2)
# Clean up partial state
partial_path = os.path.join(output_dir, "index.json.partial")
if os.path.exists(partial_path):
os.remove(partial_path)
elapsed = time.time() - t0
print(f"\n--- LitData preparation [{label}] complete ---")
print(f"Chunks: {chunk_idx}")
print(f"Total tokens: {total_tokens_written:,}")
print(f"Total blocks: {total_tokens_written // BLOCK_SIZE:,}")
print(f"Texts processed: {total_texts:,}")
print(f"Time: {elapsed:.0f}s ({total_tokens_written/max(elapsed,1):,.0f} tok/s)")
print(f"Output: {output_dir}")
def main():
parser = argparse.ArgumentParser(description="Prepare LitData from filtered parquet")
parser.add_argument(
"--mode",
choices=["small", "full", "both", "english"],
default="small",
help="Which dataset to prepare (english = ultra-clean English corpus)",
)
parser.add_argument(
"--tokenizer_path",
type=str,
default="Base/checkpoints/EleutherAI/pythia-160m",
help="Path to tokenizer directory",
)
parser.add_argument(
"--filtered_dir",
type=str,
default=None,
help="Custom filtered parquet directory to tokenize",
)
parser.add_argument(
"--output_dir",
type=str,
default=None,
help="Custom LitData output directory",
)
parser.add_argument(
"--label",
type=str,
default="CUSTOM",
help="Label shown in logs for custom directory mode",
)
args = parser.parse_args()
if args.filtered_dir or args.output_dir:
if not args.filtered_dir or not args.output_dir:
raise ValueError("Both --filtered_dir and --output_dir must be provided together")
prepare_litdata(
filtered_dir=args.filtered_dir,
output_dir=args.output_dir,
tokenizer_path=args.tokenizer_path,
label=args.label,
)
return
if args.mode in ("small", "both"):
prepare_litdata(
filtered_dir="Base/data/filtered_10m",
output_dir="Base/data/litdata_10m",
tokenizer_path=args.tokenizer_path,
label="SMALL (10M)",
)
if args.mode in ("full", "both"):
prepare_litdata(
filtered_dir="Base/data/filtered_3b",
output_dir="Base/data/litdata_3b",
tokenizer_path=args.tokenizer_path,
label="FULL (3B)",
)
if args.mode == "english":
prepare_litdata(
filtered_dir="Base/data/filtered_english",
output_dir="Base/data/litdata_english",
tokenizer_path=args.tokenizer_path,
label="ENGLISH (ultra-clean)",
)
print("\nDone! Next step: run litgpt pretrain with the appropriate config.")
if __name__ == "__main__":
main()