| """
|
| 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
|
|
|
|
|
|
|
| BLOCK_SIZE = 1025
|
|
|
| CHUNK_BYTES_TARGET = 64 * 1024 * 1024
|
|
|
| DTYPE = np.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 = 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)
|
|
|
|
|
| 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
|
|
|
| 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
|
|
|
|
|
| dtype_size = DTYPE().itemsize
|
| tokens_per_chunk = CHUNK_BYTES_TARGET // dtype_size
|
| tokens_per_chunk = (tokens_per_chunk // BLOCK_SIZE) * BLOCK_SIZE
|
|
|
|
|
| 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")
|
|
|
|
|
| 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
|
|
|
|
|
| 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:
|
|
|
| encoded = hf_tok.encode_batch(texts, add_special_tokens=False)
|
| for enc in encoded:
|
| ids = enc.ids
|
| ids.append(eos_id)
|
| 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)
|
|
|
|
|
| if buf_pos + n > len(token_buf):
|
| flush_chunks()
|
| if buf_pos + n > len(token_buf):
|
|
|
| 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
|
|
|
|
|
| pass
|
|
|
| arr = np.array(ids, dtype=DTYPE)
|
| token_buf[buf_pos:buf_pos + n] = arr
|
| buf_pos += n
|
| total_texts += 1
|
| else:
|
|
|
| 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):
|
|
|
| table = pq.read_table(str(pf), columns=["text"])
|
| all_texts = table.column("text").to_pylist()
|
| del table
|
|
|
|
|
| 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_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
|
|
|
|
|
| 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)
|
|
|
|
|
| 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()
|
|
|