| """ |
| Trains KairoGPT from scratch on backend/learn/data/corpus_v2.txt (falls back to |
| corpus.txt if corpus_v2.txt doesn't exist yet). |
| Run prepare_corpus.py then prepare_code_corpus.py first. |
| |
| Usage: python train_base.py |
| """ |
| import json |
| import logging |
| import math |
| import os |
| import time |
| from pathlib import Path |
|
|
| |
| |
| |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") |
|
|
| import numpy as np |
| import psutil |
| import torch |
|
|
| from model import KairoGPT, KairoGPTConfig |
| from tokenizer import CharTokenizer |
|
|
| logging.basicConfig(level=logging.INFO) |
| _LOG_FILE = Path(__file__).parent / "pipeline_full.err.log" |
| _fh = logging.FileHandler(_LOG_FILE, mode="a", encoding="utf-8") |
| _fh.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s")) |
| logging.getLogger().addHandler(_fh) |
| logger = logging.getLogger("kairo.learn.train") |
|
|
| DATA_DIR = Path(__file__).parent / "data" |
| CKPT_DIR = Path(__file__).parent / "checkpoints" |
| CORPUS_V2_FILE = DATA_DIR / "corpus_v2.txt" |
| CORPUS_FILE = DATA_DIR / "corpus.txt" |
| TOKENIZER_FILE = CKPT_DIR / "tokenizer.json" |
| MODEL_FILE = CKPT_DIR / "base.pt" |
| INPROGRESS_FILE = CKPT_DIR / "base_inprogress.pt" |
|
|
| |
| |
| BLOCK_SIZE = 384 |
| BATCH_SIZE = 1 |
| GRAD_ACCUM = 32 |
| N_LAYER = 8 |
| N_HEAD = 8 |
| N_EMBD = 512 |
| MAX_ITERS = 60000 |
| EVAL_INTERVAL = 250 |
| |
| |
| |
| SAVE_INTERVAL = 50 |
| LEARNING_RATE = 3e-4 |
| |
| |
| |
| WARMUP_ITERS = 2000 |
| MIN_LR = LEARNING_RATE / 10 |
|
|
|
|
| def lr_at(it: int) -> float: |
| if it < WARMUP_ITERS: |
| return LEARNING_RATE * (it + 1) / WARMUP_ITERS |
| progress = (it - WARMUP_ITERS) / max(1, MAX_ITERS - WARMUP_ITERS) |
| return MIN_LR + 0.5 * (LEARNING_RATE - MIN_LR) * (1 + math.cos(math.pi * progress)) |
| STEP_SLEEP = 0.02 |
| IDS_DAT_FILE = CKPT_DIR / "corpus_ids.dat" |
| IDS_META_FILE = CKPT_DIR / "corpus_ids.meta.json" |
|
|
|
|
| def gpu_smoke_test() -> bool: |
| """Small matmul+backward on CUDA -- catches CUBLAS/driver issues before a |
| multi-week run commits to a device that will crash mid-training.""" |
| try: |
| a = torch.randn(256, 256, device="cuda", requires_grad=True) |
| b = torch.randn(256, 256, device="cuda") |
| (a @ b).sum().backward() |
| torch.cuda.synchronize() |
| return True |
| except Exception as exc: |
| logger.warning("GPU smoke test failed (%s), falling back to CPU", exc) |
| return False |
|
|
|
|
| def pick_device() -> str: |
| if torch.cuda.is_available() and gpu_smoke_test(): |
| return "cuda" |
| return "cpu" |
|
|
|
|
| DEVICE = pick_device() |
| CUDA_CC = torch.cuda.get_device_capability(0)[0] if DEVICE == "cuda" else 0 |
| if DEVICE == "cuda": |
| |
| |
| torch.backends.cudnn.benchmark = True |
| |
| |
| |
| USE_AMP = DEVICE == "cuda" and CUDA_CC >= 7 |
| AMP_DTYPE = torch.bfloat16 if (USE_AMP and torch.cuda.is_bf16_supported()) else torch.float16 |
| SCALER = torch.cuda.amp.GradScaler(enabled=USE_AMP and AMP_DTYPE == torch.float16) |
|
|
|
|
| def get_batch(data): |
| |
| |
| max_start = len(data) - BLOCK_SIZE - 1 |
| |
| |
| ix = np.random.randint(0, max_start, size=BATCH_SIZE, dtype=np.int64) |
| x = np.stack([data[i:i + BLOCK_SIZE] for i in ix]).astype(np.int64) |
| y = np.stack([data[i + 1:i + BLOCK_SIZE + 1] for i in ix]).astype(np.int64) |
| x = torch.from_numpy(x).to(DEVICE, non_blocking=True) |
| y = torch.from_numpy(y).to(DEVICE, non_blocking=True) |
| return x, y |
|
|
|
|
| @torch.no_grad() |
| def estimate_loss(model, train_data, val_data): |
| model.eval() |
| losses = {} |
| for name, data in (("train", train_data), ("val", val_data)): |
| total = 0.0 |
| for _ in range(20): |
| x, y = get_batch(data) |
| with torch.autocast(device_type=DEVICE, dtype=AMP_DTYPE, enabled=USE_AMP): |
| _, loss = model(x, y) |
| total += loss.item() |
| losses[name] = total / 20 |
| model.train() |
| return losses |
|
|
|
|
| def _save_atomic(obj, dest): |
| |
| |
| tmp = dest.with_suffix(dest.suffix + ".tmp") |
| torch.save(obj, tmp) |
| os.replace(tmp, dest) |
|
|
|
|
| def main(): |
| psutil.Process().nice(psutil.BELOW_NORMAL_PRIORITY_CLASS) |
| if DEVICE == "cpu": |
| torch.set_num_threads(psutil.cpu_count(logical=True) or 4) |
|
|
| CKPT_DIR.mkdir(parents=True, exist_ok=True) |
| corpus_path = CORPUS_V2_FILE if CORPUS_V2_FILE.exists() else CORPUS_FILE |
| logger.info("Corpus: %s (%d bytes), device=%s, amp=%s", corpus_path.name, corpus_path.stat().st_size, DEVICE, AMP_DTYPE if USE_AMP else "off") |
|
|
| if TOKENIZER_FILE.exists() and IDS_DAT_FILE.exists() and IDS_META_FILE.exists(): |
| tokenizer = CharTokenizer.load(TOKENIZER_FILE) |
| meta = json.loads(IDS_META_FILE.read_text()) |
| data = np.memmap(IDS_DAT_FILE, dtype=np.int32, mode="r", shape=(meta["length"],)) |
| |
| |
| |
| need = data.nbytes + 8 * 1024**3 |
| avail = psutil.virtual_memory().available |
| if avail > need: |
| logger.info("Loading %d MB of ids into RAM (avail %d MB)...", data.nbytes // 2**20, avail // 2**20) |
| data = np.asarray(data) |
| logger.info("Corpus in RAM, HDD seeks eliminated") |
| else: |
| logger.info("Keeping ids on-disk memmap (avail RAM %d MB too small)", avail // 2**20) |
| logger.info("Loaded tokenizer + ids (%d tokens)", len(data)) |
| else: |
| raise SystemExit( |
| f"Missing encoded corpus. Run run_full_pipeline.py first " |
| f"(need {TOKENIZER_FILE.name}, {IDS_DAT_FILE.name}, {IDS_META_FILE.name})." |
| ) |
| logger.info("Vocab size: %d, tokens: %d", tokenizer.vocab_size, len(data)) |
|
|
| split = int(0.9 * len(data)) |
| train_data, val_data = data[:split], data[split:] |
|
|
| cfg = KairoGPTConfig( |
| vocab_size=tokenizer.vocab_size, block_size=BLOCK_SIZE, |
| n_layer=N_LAYER, n_head=N_HEAD, n_embd=N_EMBD, |
| ) |
| model = KairoGPT(cfg).to(DEVICE) |
| logger.info("Params: %.2fM", sum(p.numel() for p in model.parameters()) / 1e6) |
|
|
| try: |
| optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE, fused=True) |
| except (RuntimeError, ValueError, TypeError): |
| optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE) |
|
|
| start_it = 0 |
| if INPROGRESS_FILE.exists(): |
| ckpt = torch.load(INPROGRESS_FILE, map_location=DEVICE) |
| model.load_state_dict(ckpt["model"]) |
| optimizer.load_state_dict(ckpt["optimizer"]) |
| start_it = ckpt["it"] + 1 |
| logger.info("Resuming from %s at step %d", INPROGRESS_FILE, start_it) |
|
|
| run_start = time.time() |
| win_start, win_steps = run_start, 0 |
| last_val = float("nan") |
| for it in range(start_it, MAX_ITERS): |
| |
| |
| if it % EVAL_INTERVAL == 0 or it == MAX_ITERS - 1 or last_val != last_val: |
| losses = estimate_loss(model, train_data, val_data) |
| last_val = losses["val"] |
| logger.info("step %d: train %.4f val %.4f", it, losses["train"], losses["val"]) |
|
|
| cur_lr = lr_at(it) |
| for g in optimizer.param_groups: |
| g["lr"] = cur_lr |
|
|
| optimizer.zero_grad(set_to_none=True) |
| try: |
| for _ in range(GRAD_ACCUM): |
| x, y = get_batch(train_data) |
| with torch.autocast(device_type=DEVICE, dtype=AMP_DTYPE, enabled=USE_AMP): |
| _, loss = model(x, y) |
| SCALER.scale(loss / GRAD_ACCUM).backward() |
| if SCALER.is_enabled(): |
| SCALER.unscale_(optimizer) |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) |
| SCALER.step(optimizer) |
| SCALER.update() |
| except RuntimeError as exc: |
| |
| |
| |
| if "out of memory" in str(exc).lower(): |
| try: |
| optimizer.zero_grad(set_to_none=True) |
| if DEVICE == "cuda": |
| torch.cuda.empty_cache() |
| time.sleep(5) |
| logger.warning("CUDA OOM at step %d -> cleared cache, skipped step", it) |
| continue |
| except RuntimeError: |
| logger.error("CUDA context dead after OOM at step %d, exiting for supervisor restart", it) |
| raise SystemExit(3) |
| raise |
| if STEP_SLEEP: |
| time.sleep(STEP_SLEEP) |
| win_steps += 1 |
|
|
| |
| |
| if start_it < it <= start_it + 60 and it % 20 == 0: |
| dt = time.time() - win_start |
| sps = win_steps / dt if dt > 0 else 0.0 |
| tok_s = sps * GRAD_ACCUM * BATCH_SIZE * BLOCK_SIZE |
| logger.info("throughput: %.2f s/step, %.0f tok/s", (1 / sps if sps else 0), tok_s) |
| win_start, win_steps = time.time(), 0 |
|
|
| |
| if it % 250 == 0 and it > 0: |
| elapsed = time.time() - run_start |
| done = max(1, it - start_it) |
| sps = done / elapsed |
| eta_h = (MAX_ITERS - it) / sps / 3600 if sps > 0 else 0 |
| logger.info("HEARTBEAT: %d/%d (%.1f%%), %.2f s/step, ETA %.1f h", |
| it, MAX_ITERS, 100.0 * it / MAX_ITERS, 1 / sps, eta_h) |
|
|
| |
| |
| |
| |
| if it % SAVE_INTERVAL == 0 and it > start_it: |
| _save_atomic({"model": model.state_dict(), "cfg": vars(cfg)}, MODEL_FILE) |
| _save_atomic( |
| {"model": model.state_dict(), "optimizer": optimizer.state_dict(), "it": it}, |
| INPROGRESS_FILE, |
| ) |
| mb = MODEL_FILE.stat().st_size / 2**20 |
| logger.info("[LIVE] step %d/%d (%.1f%%) | loss %.3f | base.pt %.0f MB gespeichert -- laeuft", |
| it, MAX_ITERS, 100.0 * it / MAX_ITERS, last_val, mb) |
|
|
| _save_atomic({"model": model.state_dict(), "cfg": vars(cfg)}, MODEL_FILE) |
| logger.info("Training reached MAX_ITERS, final base.pt saved -> %s", MODEL_FILE) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|