File size: 12,286 Bytes
3cc572b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 | """
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
# Must be set before the first CUDA allocation: lets the allocator grow/shrink
# its arena instead of crashing on fragmentation, which matters a lot on a 6 GB
# card running a model that fills most of VRAM.
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"
# ~58M model: bigger => base.pt ~230MB (vs 80MB) and smarter, still trains fine
# on a GTX 1060 in fp32 (~1.5 s/step). Reuses the finished BPE corpus.
BLOCK_SIZE = 384
BATCH_SIZE = 1
GRAD_ACCUM = 32
N_LAYER = 8
N_HEAD = 8
N_EMBD = 512
MAX_ITERS = 60000 # high on purpose: model keeps improving, user stops when happy
EVAL_INTERVAL = 250
# Save both the inference-ready base.pt AND the resume checkpoint this often, so
# stopping loses at most SAVE_INTERVAL steps ("resumable download") and a fresh
# testable base.pt exists every ~1 min. Tighter than this thrashes the HDD.
SAVE_INTERVAL = 50
LEARNING_RATE = 3e-4
# Standard GPT recipe (nanoGPT/Chinchilla style): linear warmup then cosine
# decay to 10% -- avoids early divergence and squeezes better final loss out
# of the same steps.
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 # ponytail: throttle so training never hogs GPU/CPU, raise if PC still lags
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":
# Safe throughput win: lets cuDNN pick the fastest kernels for the fixed
# block/batch shapes. No accuracy or stability cost.
torch.backends.cudnn.benchmark = True
# Pascal (sm_6x, e.g. GTX 1060) has no usable fp16/bf16 hardware path --
# autocast there runs emulated and is far SLOWER than plain fp32. AMP only
# pays off on Volta+ (compute capability >= 7).
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):
# data is a numpy memmap: only the sampled BLOCK_SIZE windows are ever
# copied into RAM, so the multi-GB corpus stays on disk.
max_start = len(data) - BLOCK_SIZE - 1
# dtype=int64: corpus is ~3.9B tokens, past int32 max, and numpy defaults to
# int32 on Windows -> "high is out of bounds for int32" without this.
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):
# Write to a temp file then rename: a test process reading base.pt never
# sees a half-written file, and a crash mid-save can't corrupt the good one.
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"],))
# The corpus lives on an HDD: random batch sampling from a memmap means
# constant disk seeks that starve the GPU. Pull it fully into RAM when
# there's comfortable headroom (needs corpus + 8 GB spare).
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):
# last_val != last_val is a NaN check: forces an eval on the very first
# iteration after a resume so the LIVE status shows a real loss, not NaN.
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:
# VRAM spike (desktop shares this GPU). Try to recover in-process;
# if the CUDA context is already corrupt even empty_cache() throws --
# then exit(3) and let supervisor.py restart from the checkpoint.
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
# Early throughput probe: report real s/step in the first ~2 minutes so
# the ETA is measured, not guessed.
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
# Heartbeat every 250 steps with ETA so the window keeps updating.
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)
# Save often so base.pt is always fresh/testable and a stop loses <=50
# steps. base.pt = inference-ready (model + cfg); base_inprogress.pt =
# full resume state. The status line prints every ~minute so the user
# sees it's alive and getting smarter (loss falling).
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()
|