- Corpus::load_cached: tokenize the (large) corpus ONCE, cache the id stream to
<corpus>.u16.bin (gpt2 vocab 50257 < 65536 → exact u16), read cache on reruns.
- Corpus::split_tail: hold out a tail slice as a validation corpus.
- train(): take an optional valid corpus + eval_every/eval_batches; periodic
deterministic val-loss eval that checkpoints the BEST val model; returns
TrainResult{train_losses, evals, best_val}. T6 fixed-cadence path preserved.
- bin/train + bin/export_safetensors: read architecture (--heads/--head-dim/
--layers/--ffn) + opt knobs (--steps/--batch/--seq/--max-lr/--val-tokens/
--eval-every) from CLI flags; defaults reproduce the v0-baseline tiny config.
- gitignore the multi-GB corpus + *.u16.bin caches + *.ckpt (dash5-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
6.6 KiB
Rust
165 lines
6.6 KiB
Rust
//! Data pipeline: load the GPT-2 BPE (reusing xserv's from-scratch tokenizer),
|
|
//! tokenize a text corpus into one flat token stream, and sample fixed-length
|
|
//! `(input, target)` windows for next-token prediction. Host-only (no GPU).
|
|
//!
|
|
//! For the scaling runs the corpus is large (full TinyStories ≈ 2 GB / ~470 M
|
|
//! tokens), and the from-scratch BPE is slow, so [`Corpus::load_cached`]
|
|
//! tokenizes ONCE and caches the id stream to a `<corpus>.u16.bin` next to the
|
|
//! text (GPT-2 vocab = 50257 < 65536, so u16 is exact). Subsequent runs mmap-read
|
|
//! the cache instead of re-tokenizing.
|
|
|
|
use std::io::{BufReader, BufWriter, Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use xserv_tokenizer::Tokenizer;
|
|
|
|
/// A tokenized corpus: one flat stream of token ids, plus the vocab size.
|
|
pub struct Corpus {
|
|
pub tokens: Vec<i32>,
|
|
pub vocab_size: usize,
|
|
}
|
|
|
|
impl Corpus {
|
|
/// Load `tokenizer.json` (GPT-2 BPE) and tokenize the UTF-8 text at
|
|
/// `corpus_path` into a single id stream. TinyStories separates stories with
|
|
/// `<|endoftext|>`; the GPT-2 tokenizer emits that as a single special token,
|
|
/// so document boundaries are preserved in the stream.
|
|
pub fn load(tokenizer_path: &Path, corpus_path: &Path) -> Self {
|
|
let tok = Tokenizer::from_file(tokenizer_path);
|
|
let text = std::fs::read_to_string(corpus_path)
|
|
.unwrap_or_else(|e| panic!("failed to read corpus {}: {e}", corpus_path.display()));
|
|
// The range-fetched corpus may start/end mid-story; drop a leading partial
|
|
// line and a trailing partial story so we only train on whole sentences.
|
|
let text = trim_to_whole_stories(&text);
|
|
let ids: Vec<i32> = tok.encode(text).into_iter().map(|t| t as i32).collect();
|
|
Self {
|
|
tokens: ids,
|
|
vocab_size: tok.vocab_size(),
|
|
}
|
|
}
|
|
|
|
/// Like [`load`](Self::load) but caches the tokenized id stream to
|
|
/// `<corpus_path>.u16.bin`. On the first run it tokenizes the (large) corpus
|
|
/// and writes the cache; on later runs it reads the cache directly, skipping
|
|
/// the slow BPE. The cache is just a flat little-endian `[u16]` (no header) —
|
|
/// it is keyed only by path, so delete it if the corpus or tokenizer changes.
|
|
pub fn load_cached(tokenizer_path: &Path, corpus_path: &Path) -> Self {
|
|
let cache = cache_path(corpus_path);
|
|
let vocab_size = Tokenizer::from_file(tokenizer_path).vocab_size();
|
|
if cache.exists() {
|
|
let tokens = read_u16_cache(&cache);
|
|
println!(
|
|
"corpus: read {} cached tokens from {}",
|
|
tokens.len(),
|
|
cache.display()
|
|
);
|
|
return Self { tokens, vocab_size };
|
|
}
|
|
let me = Self::load(tokenizer_path, corpus_path);
|
|
write_u16_cache(&cache, &me.tokens);
|
|
println!(
|
|
"corpus: tokenized {} tokens → cached to {}",
|
|
me.tokens.len(),
|
|
cache.display()
|
|
);
|
|
me
|
|
}
|
|
|
|
/// Split off the last `n` tokens as a held-out validation corpus, leaving the
|
|
/// rest as the train corpus. Returns `(train, valid)`. Used for periodic val
|
|
/// loss during training without leaking the eval window into training.
|
|
pub fn split_tail(self, n: usize) -> (Self, Self) {
|
|
let n = n.min(self.tokens.len() / 10); // never hand off more than 10%
|
|
let cut = self.tokens.len() - n;
|
|
let valid = self.tokens[cut..].to_vec();
|
|
let mut train = self.tokens;
|
|
train.truncate(cut);
|
|
(
|
|
Self {
|
|
tokens: train,
|
|
vocab_size: self.vocab_size,
|
|
},
|
|
Self {
|
|
tokens: valid,
|
|
vocab_size: self.vocab_size,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Total number of tokens.
|
|
pub fn len(&self) -> usize {
|
|
self.tokens.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.tokens.is_empty()
|
|
}
|
|
|
|
/// Sample one `(input, target)` pair of length `seq` for next-token
|
|
/// prediction: a window `[s, s+seq+1)` → input `[s, s+seq)`, target shifted
|
|
/// by one. `rng_state` is advanced (a tiny LCG, so sampling is reproducible
|
|
/// from a seed without pulling in an RNG crate).
|
|
pub fn sample(&self, seq: usize, rng_state: &mut u64) -> (Vec<i32>, Vec<i32>) {
|
|
assert!(self.tokens.len() > seq + 1, "corpus shorter than a window");
|
|
let max_start = self.tokens.len() - seq - 1;
|
|
let start = (next_rand(rng_state) % (max_start as u64 + 1)) as usize;
|
|
let input = self.tokens[start..start + seq].to_vec();
|
|
let target = self.tokens[start + 1..start + seq + 1].to_vec();
|
|
(input, target)
|
|
}
|
|
}
|
|
|
|
/// Drop a leading partial line (before the first newline) and everything after
|
|
/// the last `<|endoftext|>` marker, so a byte-range download still yields only
|
|
/// complete stories. Falls back to the raw text if no marker is present.
|
|
fn trim_to_whole_stories(text: &str) -> &str {
|
|
let start = text.find('\n').map(|i| i + 1).unwrap_or(0);
|
|
let body = &text[start..];
|
|
match body.rfind("<|endoftext|>") {
|
|
Some(end) => &body[..end + "<|endoftext|>".len()],
|
|
None => body,
|
|
}
|
|
}
|
|
|
|
/// `<corpus_path>.u16.bin` — the token-id cache beside the corpus text.
|
|
fn cache_path(corpus_path: &Path) -> PathBuf {
|
|
let mut s = corpus_path.as_os_str().to_os_string();
|
|
s.push(".u16.bin");
|
|
PathBuf::from(s)
|
|
}
|
|
|
|
/// Read a flat little-endian `[u16]` cache into an `i32` id stream.
|
|
fn read_u16_cache(path: &Path) -> Vec<i32> {
|
|
let mut r = BufReader::new(
|
|
std::fs::File::open(path).unwrap_or_else(|e| panic!("open cache {}: {e}", path.display())),
|
|
);
|
|
let mut buf = Vec::new();
|
|
r.read_to_end(&mut buf).expect("read cache");
|
|
assert!(buf.len() % 2 == 0, "corrupt u16 cache (odd byte count)");
|
|
buf.chunks_exact(2)
|
|
.map(|b| u16::from_le_bytes([b[0], b[1]]) as i32)
|
|
.collect()
|
|
}
|
|
|
|
/// Write an id stream as a flat little-endian `[u16]` cache. Ids must fit in u16
|
|
/// (GPT-2 vocab = 50257 < 65536); asserts otherwise.
|
|
fn write_u16_cache(path: &Path, tokens: &[i32]) {
|
|
let mut w = BufWriter::new(
|
|
std::fs::File::create(path)
|
|
.unwrap_or_else(|e| panic!("create cache {}: {e}", path.display())),
|
|
);
|
|
for &t in tokens {
|
|
assert!((0..=u16::MAX as i32).contains(&t), "token id {t} > u16");
|
|
w.write_all(&(t as u16).to_le_bytes()).expect("write cache");
|
|
}
|
|
w.flush().expect("flush cache");
|
|
}
|
|
|
|
/// Tiny LCG (same constants as the model tests' deterministic fill) so dataset
|
|
/// sampling is reproducible from a single u64 seed.
|
|
fn next_rand(state: &mut u64) -> u64 {
|
|
*state = state
|
|
.wrapping_mul(6364136223846793005)
|
|
.wrapping_add(1442695040888963407);
|
|
*state >> 16
|
|
}
|