train_chess.py
python · 294 lines · Exact bytes
Version & provenance · month-20261028
Recorded SHA-256 8147238e3684c68343c80a365d3c13f6d061e921b50fe9bcb28d75a18423b279Rendered SHA-256 8147238e3684c68343c80a365d3c13f6d061e921b50fe9bcb28d75a18423b279
Apache-2.0 for original LittlePly code, declared by the project owner on 2026-10-28. Historical source bytes and hashes are unchanged. Third-party components retain their own licenses; Lichess data remain CC0-1.0. License notes. Line links are stable within this snapshot. SHA-256 compared to persisted run identity/manifest. Unavailable files are never replaced with newer versions. Rendered derivatives preserve line numbers and redact private paths; original and rendered hashes are distinct.
1"""Bounded two-GPU pilot training. No network, downloads, or test-set tuning."""
2from collections import Counter
3from datetime import timedelta
4import hashlib
5import json
6import math
7import os
8from pathlib import Path
9import random
10import subprocess
11import threading
12import time
13import argparse
14import sys
15import torch
16import torch.distributed as dist
17from torch.nn.parallel import DistributedDataParallel as DDP
18import torch.nn.functional as F
19import training_core as c
20from training_deadline import Deadline,read_deadline
21from chessdata import State,BOS,EOS,VERSION,decode_move
22from chessdata.reader import games
23from chessdata.convert import file_hash
24
25
26def log(root,event):
27 event={'time_unix':time.time(),**event}
28 with (root/'events.jsonl').open('a') as f:f.write(json.dumps(event)+'\n')
29 print(json.dumps(event),flush=True)
30
31
32def telemetry(root,stop):
33 with (root/'gpu-telemetry.jsonl').open('a') as f:
34 while not stop.is_set():
35 r=subprocess.run(['nvidia-smi','--query-gpu=index,utilization.gpu,memory.used,power.draw','--format=csv,noheader,nounits'],capture_output=True,text=True)
36 f.write(json.dumps({'time_unix':time.time(),'gpus':r.stdout.strip()})+'\n');f.flush();stop.wait(1)
37
38
39@torch.no_grad()
40def evaluate(net,data,device,micro=8,deadline=None,legality_games=None):
41 """Raw teacher-forced metrics. No logits are masked/renormalized.
42
43 Triple argmax is teacher-forced: destination sees the GOLD source, promotion
44 sees GOLD source+destination. This is not a free-running legal-move rate.
45 """
46 net.eval();total=Counter();phase_n=Counter();phase_correct=Counter();start=time.perf_counter()
47 if not data:raise ValueError('validation data must be nonempty')
48 sample_size=min(len(data),legality_games) if legality_games is not None else len(data)
49 if sample_size<1:raise ValueError('legality sample must be positive')
50 legality_indices={i*len(data)//sample_size for i in range(sample_size)}
51 for offset in range(0,len(data),micro):
52 if deadline is not None and deadline.soft_stop():
53 net.train()
54 return None # Partial validation never changes best/patience.
55 samples=data[offset:offset+micro];x,y,mask=c.collate(samples,device)
56 with torch.autocast('cuda',dtype=torch.bfloat16):logits=net(x,attention_mask=mask,use_cache=False).logits
57 total['nll_sum']+=F.cross_entropy(logits.float().reshape(-1,128),y.reshape(-1),ignore_index=-100,reduction='sum').item()
58 probs=logits.float().softmax(-1).cpu();pred=probs.argmax(-1);gold=y.cpu();valid=gold.ne(-100)
59 total['targets']+=int(valid.sum())
60 total['reserved_probability_sum']+=float((probs[:,:,72:].sum(-1)*valid).sum())
61 positions=torch.arange(gold.shape[1]).unsqueeze(0)
62 for phase,index in (('source',0),('destination',1),('promotion',2),('eos',None)):
63 selected=valid & (gold.eq(EOS) if index is None else (positions.remainder(3).eq(index)&gold.ne(EOS)))
64 phase_n[phase]+=int(selected.sum());phase_correct[phase]+=int((selected&pred.eq(gold)).sum())
65 for row,tokens in enumerate(samples):
66 if offset+row not in legality_indices:continue
67 total['legality_games']+=1
68 state=State();state.feed(BOS)
69 for pos,target in enumerate(tokens[1:]):
70 total['legality_targets']+=1
71 legal=torch.tensor(state.mask(legal=True),dtype=torch.bool)
72 total['illegal_probability_sum']+=probs[row,pos,~legal].sum().item()
73 if pos%3==0 and target!=EOS:
74 total['moves']+=1
75 try:
76 move=decode_move(pred[row,pos:pos+3].tolist())
77 total['legal_triples']+=int(move in state.board.legal_moves)
78 except ValueError:pass
79 state.feed(target)
80 net.train()
81 return {'nll':total['nll_sum']/total['targets'],'valid_targets':total['targets'],'games':len(data),
82 'phase_accuracy':{p:phase_correct[p]/n if n else None for p,n in phase_n.items()},
83 'phase_counts':dict(phase_n),'teacher_forced_triple_legality':total['legal_triples']/max(1,total['moves']),
84 'raw_reserved_probability':total['reserved_probability_sum']/total['targets'],
85 'raw_illegal_token_probability':total['illegal_probability_sum']/max(1,total['legality_targets']),
86 'legality_games':total['legality_games'],'legality_targets':total['legality_targets'],
87 'legality_subset':'N evenly spaced game indices across immutable validation shard order; N=None means all',
88 'protocol':'raw128 teacher-forced next-token logits; triple destination/promotion conditioned on gold prefix; not free-running',
89 'seconds':time.perf_counter()-start}
90
91
92def terminal_resume_status(saved, fallback_epochs):
93 """Patience/finished schedule are terminal; a time-budget pause is resumable."""
94 if saved['metrics']['bad_checks']>=5:
95 return 'early_stop_5_checks'
96 maximum=saved.get('identity',{}).get('max_updates')
97 if maximum is not None and saved['cursor']['step']>=maximum:
98 return 'max_updates'
99 scheduled=saved.get('identity',{}).get('epochs_schedule',fallback_epochs)
100 if saved['cursor']['epoch']>=scheduled:
101 return 'max_epochs'
102 return None
103
104
105def main(args):
106 # Read and verify the checkpoint on CPU before touching CUDA, DDP, caches or
107 # reports. Completed runs are idempotent no-ops, even through torchrun. No
108 # implicit patience reset or fresh training allowance is granted by --resume.
109 resume_saved=c.load_checkpoint(args.resume) if args.resume else None
110 if resume_saved is not None:
111 terminal=terminal_resume_status(resume_saved,args.epochs)
112 if terminal:
113 receipt={'status':terminal,'resume_action':'no_op_terminal',
114 'cursor':resume_saved['cursor'],'best_step':resume_saved['metrics']['best_step'],
115 'bad_checks':resume_saved['metrics']['bad_checks'],
116 'optimizer_updates':0,'validation_checks':0,'checkpoint_written':False}
117 if int(os.environ.get('RANK','0'))==0:print(json.dumps(receipt),flush=True)
118 return receipt
119 deadline=Deadline(read_deadline(os.environ['CHESS_JOB_DEADLINE'])) if os.environ.get('CHESS_JOB_DEADLINE') else None
120 if deadline is not None and deadline.soft_stop():raise TimeoutError('job deadline reached before CUDA setup')
121 rank=int(os.environ.get('RANK','0'));local=int(os.environ.get('LOCAL_RANK','0'));world=int(os.environ.get('WORLD_SIZE','1'))
122 if world!=2:raise ValueError('baseline requires exactly two DDP workers')
123 torch.set_num_threads(4);torch.cuda.set_device(local);device=f'cuda:{local}'
124 rendezvous=os.environ.get('CHESS_DDP_INIT_FILE')
125 if rendezvous:
126 dist.init_process_group('nccl',init_method='file://'+str(Path(rendezvous).resolve()),rank=rank,world_size=world,timeout=timedelta(seconds=120))
127 else:dist.init_process_group('nccl',timeout=timedelta(seconds=120))
128 root=Path(args.output);root.mkdir(parents=True,exist_ok=True)
129 stop=threading.Event();monitor=None;status='started';started=time.perf_counter()
130 try:
131 # Rank 0 validates and caches train/validation only. Reader preflights hashes
132 # of all splits, but the test tokens are never forwarded to the model.
133 manifest=json.loads((Path(args.dataset)/'manifest.json').read_text())
134 if rank==0:
135 if getattr(args,'integrity_receipt',None):
136 from verified_training_data import load
137 data=load(args.dataset,args.integrity_receipt)
138 else:data={s:[tokens for tokens,meta in games(args.dataset,s)] for s in ('train','validation')}
139 if not data['train'] or not data['validation']:raise ValueError('nonempty train and validation required')
140 c.atomic_save(root/'data-cache.pt',data)
141 log(root,{'stage':'data_validated','train_games':len(data['train']),'validation_games':len(data['validation']),
142 'train_targets':sum(len(s)-1 for s in data['train']),'test_evaluated':False})
143 dist.barrier()
144 data=torch.load(root/'data-cache.pt',weights_only=True);train=data['train'];val=data['validation']
145 lengths=[len(s)-1 for s in train]
146 torch.manual_seed(args.seed);random.seed(args.seed)
147 net=c.model(device=device)
148 count=sum(p.numel() for p in net.parameters())
149 if count!=85940736:raise ValueError(f'baseline parameter mismatch: {count}')
150 if any(p.dtype!=torch.float32 for p in net.parameters()):raise ValueError('expected FP32 weights')
151 wrapped=DDP(net,device_ids=[local],gradient_as_bucket_view=True)
152 opt=c.optimizer(net,args.lr,fused=True)
153 step_count=len(c.groups(c.batches(lengths,0,args.seed,args.micro,world),args.accum))
154 schedule_steps=getattr(args,'schedule_steps',None) or args.epochs*step_count
155 warmup_steps=getattr(args,'warmup_steps',None) or max(1,round(schedule_steps*.1))
156 if warmup_steps>=schedule_steps:raise ValueError('warmup must be shorter than scheduler horizon')
157 sched=c.scheduler(opt,schedule_steps,warmup_steps)
158 identity={'tokenizer':{'version':VERSION,'sha256':file_hash(Path(__file__).parent/'chessdata'/'tokenizer.py')},
159 'model':c.BASELINE,'parameters':count,'dataset_fingerprint':manifest['fingerprint'],
160 'training_code':{f:file_hash(Path(__file__).parent/f) for f in ('training_core.py','train_chess.py','training_deadline.py','verified_training_data.py')},
161 'world_size':world,'seed':args.seed,'microbatch':args.micro,'accumulation':args.accum,
162 'launcher':'owned-process-group-file-store' if rendezvous else 'external-env-rendezvous',
163 'lr':args.lr,'epochs_schedule':args.epochs,'optimizer':'FP32 fused AdamW betas .9/.95 matrix wd .1 clip1',
164 'precision':'FP32 weights/gradients/optimizer; BF16 autocast SDPA','torch':str(torch.__version__),
165 'sampling':'seeded length buckets, disjoint full games; right pad; zero-target dummy rank on partial global batches'}
166 if deadline is not None:identity['total_job_deadline']=deadline.value
167 identity['validation_every_steps']=getattr(args,'validation_every_steps',None)
168 identity['legality_eval_games']=getattr(args,'legality_eval_games',None)
169 identity['schedule_steps']=schedule_steps;identity['warmup_steps']=warmup_steps
170 identity['max_updates']=getattr(args,'max_updates',None)
171 if getattr(args,'integrity_receipt',None):identity['integrity_receipt_sha256']=file_hash(args.integrity_receipt)
172 cursor={'epoch':0,'next_group':0,'step':0};history=[];best=float('inf');bad=0;best_step=None
173 if args.resume:
174 saved=resume_saved
175 if saved['identity']!=identity:raise ValueError('incompatible checkpoint identity')
176 net.load_state_dict(saved['model_state_dict'])
177 opt.load_state_dict(saved['optimizer_state_dict']);sched.load_state_dict(saved['scheduler_state_dict'])
178 cursor=saved['cursor'];history=saved['metrics']['history'];best=saved['metrics']['best_nll'];bad=saved['metrics']['bad_checks'];best_step=saved['metrics']['best_step']
179 c.restore_rng(saved['rng_by_rank'][rank],device)
180 dist.barrier();torch.cuda.synchronize();gpu_start=time.perf_counter();torch.cuda.reset_peak_memory_stats()
181 if rank==0:
182 monitor=threading.Thread(target=telemetry,args=(root,stop),daemon=True);monitor.start()
183 (root/'identity.json').write_text(json.dumps(identity,indent=2))
184 if args.mode=='probe':
185 longest=max(train,key=len);probe=[longest]*16;results=[]
186 for micro in (2,8):
187 torch.cuda.reset_peak_memory_stats();times=[]
188 for rep in range(3):
189 opt.zero_grad(set_to_none=True);dist.barrier();torch.cuda.synchronize();t=time.perf_counter()
190 values=c.backward_group(wrapped,probe,[list(range(micro*world))],micro,rank,world,device,True)
191 torch.nn.utils.clip_grad_norm_(net.parameters(),1.0);opt.step();torch.cuda.synchronize();elapsed=time.perf_counter()-t
192 if rep:times.append(elapsed)
193 memory=[None]*world;dist.all_gather_object(memory,{'rank':rank,'peak_allocated':torch.cuda.max_memory_allocated(),'peak_reserved':torch.cuda.max_memory_reserved()})
194 results.append({'microbatch_per_gpu':micro,'sequence_length':len(longest)-1,'timed_steps':2,
195 'step_seconds':times,'useful_targets_per_second':values['valid_targets']*2/sum(times),
196 'padded_tokens_per_second':values['padded_tokens']*2/sum(times),'memory':memory})
197 if rank==0:
198 result={'status':'passed','parameters':count,'results':results,'gpu_work_wall_seconds':time.perf_counter()-gpu_start,
199 'scope':'six optimizer steps total, one warmup per shape; repeated longest pilot game; discarded weights; not production throughput'}
200 (root/'probe-results.json').write_text(json.dumps(result,indent=2));log(root,{'stage':'probe_complete',**result})
201 return
202 initial=[evaluate(net,val,device,deadline=deadline,legality_games=getattr(args,'legality_eval_games',None)) if rank==0 and not args.resume else None]
203 dist.broadcast_object_list(initial,src=0)
204 if rank==0 and initial[0]:log(root,{'stage':'initial_validation',**initial[0]})
205 totals=Counter();stop_training=False
206 def save_state(improved):
207 rngs=[None]*world;dist.all_gather_object(rngs,c.rng_state(device))
208 if rank==0:
209 metrics={'history':history,'best_nll':best,'bad_checks':bad,'best_step':best_step,'test_evaluated':False}
210 c.atomic_save(root/'last.pt',c.checkpoint_payload(wrapped,opt,sched,identity,cursor,rngs,metrics))
211 if improved:c.link_best(root/'last.pt',root/'best.pt')
212 dist.barrier()
213 while cursor['epoch']<args.epochs and not stop_training:
214 epoch=cursor['epoch'];plan=c.groups(c.batches(lengths,epoch,args.seed,args.micro,world),args.accum)
215 epoch_totals=Counter();epoch_start=time.perf_counter();completed=False
216 for group_index in range(cursor['next_group'],len(plan)):
217 maximum=getattr(args,'max_updates',None)
218 reason='max_updates' if maximum is not None and cursor['step']>=maximum else None
219 if reason is None and (deadline.soft_stop() if deadline is not None else time.perf_counter()-gpu_start>=args.max_seconds):reason='time_budget'
220 should_stop=[reason if rank==0 else None]
221 dist.broadcast_object_list(should_stop,src=0)
222 if should_stop[0]:status=should_stop[0];stop_training=True;break
223 opt.zero_grad(set_to_none=True);torch.cuda.synchronize();t=time.perf_counter()
224 values=c.backward_group(wrapped,train,plan[group_index],args.micro,rank,world,device,True)
225 norm=torch.nn.utils.clip_grad_norm_(net.parameters(),1.,error_if_nonfinite=True)
226 opt.step();sched.step();torch.cuda.synchronize();elapsed=time.perf_counter()-t
227 values['train_seconds']=elapsed;totals.update(values);epoch_totals.update(values)
228 cursor={'epoch':epoch,'next_group':group_index+1,'step':cursor['step']+1}
229 if rank==0 and cursor['step']%10==0:
230 log(root,{'stage':'train','epoch':epoch+1,'step':cursor['step'],'nll':values['loss_sum']/values['valid_targets'],
231 'lr':sched.get_last_lr()[0],'gradient_norm_before_clip':float(norm),
232 'useful_targets_per_second':values['valid_targets']/elapsed,'padded_tokens_per_second':values['padded_tokens']/elapsed,
233 'padding_fraction':1-values['valid_targets']/values['padded_tokens'],'gpu_work_wall_seconds':time.perf_counter()-gpu_start})
234 cadence=getattr(args,'validation_every_steps',None)
235 if maximum is not None and cursor['step']>=maximum:
236 status='max_updates';stop_training=True;break
237 if cadence and cursor['step']%cadence==0:break
238 if cursor['next_group']==len(plan):
239 completed=True;cursor={'epoch':epoch+1,'next_group':0,'step':cursor['step']}
240 dist.barrier()
241 check=[evaluate(net,val,device,deadline=deadline,legality_games=getattr(args,'legality_eval_games',None)) if rank==0 else None];dist.broadcast_object_list(check,src=0)
242 if check[0] is None:
243 status='total_deadline_soft_stop';stop_training=True
244 save_state(False)
245 break
246 metrics={'epoch':epoch+1,'complete_epoch':completed,'step':cursor['step'],'validation':check[0],
247 'train_nll':epoch_totals['loss_sum']/max(1,epoch_totals['valid_targets']),
248 'useful_targets_per_second':epoch_totals['valid_targets']/max(1e-9,epoch_totals['train_seconds']),
249 'padded_tokens_per_second':epoch_totals['padded_tokens']/max(1e-9,epoch_totals['train_seconds']),
250 'padding_fraction':1-epoch_totals['valid_targets']/max(1,epoch_totals['padded_tokens'])}
251 history.append(metrics);improved=check[0]['nll']<best-1e-4
252 if improved:best=check[0]['nll'];bad=0;best_step=cursor['step']
253 else:bad+=1
254 if rank==0:log(root,{'stage':'validation','best_nll':best,'bad_checks':bad,**metrics})
255 save_state(improved)
256 if bad>=5:status='early_stop_5_checks';stop_training=True
257 if status=='started':status='max_epochs'
258 memory=[None]*world;dist.all_gather_object(memory,{'rank':rank,'peak_allocated':torch.cuda.max_memory_allocated(),'peak_reserved':torch.cuda.max_memory_reserved()})
259 if rank==0:
260 result={'status':status,'cursor':cursor,'best_step':best_step,'best_validation_nll':best,'history':history,
261 'initial_validation':initial[0],'totals':dict(totals),'memory':memory,'gpu_work_wall_seconds':time.perf_counter()-gpu_start,
262 'whole_process_seconds':time.perf_counter()-started,'test_evaluated':False,
263 'best_checkpoint':'best.pt' if (root/'best.pt').exists() else None,'best_checkpoint_sha256':file_hash(root/'best.pt') if (root/'best.pt').exists() else None,
264 'last_checkpoint_sha256':file_hash(root/'last.pt'),'identity_sha256':c.digest_json(identity)}
265 (root/'results.json').write_text(json.dumps(result,indent=2));log(root,{'stage':'finished','status':status,'best_step':best_step,'best_nll':best})
266 except BaseException as error:
267 (root/f'failure-rank{rank}.json').write_text(json.dumps({'error':repr(error),'time_unix':time.time()}))
268 raise
269 finally:
270 stop.set()
271 if monitor:monitor.join(timeout=3)
272 dist.destroy_process_group()
273
274
275if __name__=='__main__':
276 p=argparse.ArgumentParser();p.add_argument('--dataset',default='lichess-pilot-202609-20261028/training-context1024')
277 p.add_argument('--output',required=True);p.add_argument('--mode',choices=['probe','train'],default='train')
278 p.add_argument('--micro',type=int,default=8);p.add_argument('--accum',type=int,default=2);p.add_argument('--lr',type=float,default=3e-4)
279 p.add_argument('--epochs',type=int,default=20);p.add_argument('--seed',type=int,default=2026);p.add_argument('--max-seconds',type=int,default=420);p.add_argument('--resume')
280 p.add_argument('--validation-every-steps',type=int);p.add_argument('--legality-eval-games',type=int)
281 p.add_argument('--integrity-receipt')
282 p.add_argument('--schedule-steps',type=int);p.add_argument('--warmup-steps',type=int)
283 p.add_argument('--max-updates',type=int)
284 p.add_argument('--launch-ddp',action='store_true')
285 args=p.parse_args()
286 if not 1<=args.epochs<=20 or not 1<=args.max_seconds<=420 or args.micro<1 or args.accum<1:p.error('bounded calibration limits violated')
287 if args.validation_every_steps is not None and args.validation_every_steps<1:p.error('invalid validation cadence')
288 if args.legality_eval_games is not None and args.legality_eval_games<1:p.error('legality sample must be positive')
289 if any(v is not None and v<1 for v in (args.schedule_steps,args.warmup_steps)):p.error('scheduler steps must be positive')
290 if args.max_updates is not None and args.max_updates<1:p.error('max updates must be positive')
291 if args.launch_ddp:
292 raise SystemExit(c.launch_owned_workers(Path(__file__),[arg for arg in sys.argv[1:] if arg!='--launch-ddp']))
293 main(args)
294