#!/usr/bin/env python3 """Canonical deterministic MaleCNS LIF reference backend and validation tasks.""" from __future__ import annotations import argparse import hashlib import json import os import time from pathlib import Path from typing import Any import numpy as np import pyarrow as pa import pyarrow.parquet as pq import torch import yaml from torch.utils.cpp_extension import load as load_extension import phase4c def sha_bytes(*arrays: np.ndarray) -> str: h=hashlib.sha256() for x in arrays:h.update(np.ascontiguousarray(x).tobytes()) return h.hexdigest() def save(path: Path,x: object): path.parent.mkdir(parents=True,exist_ok=True) path.write_text(json.dumps(x,indent=2,sort_keys=True,default=str)+'\n') def build_extension(model:Path,exp:Path): build=exp/'gpu-reference/extension-build';build.mkdir(parents=True,exist_ok=True) start=time.perf_counter() ext=load_extension(name='male_cns_lif_deterministic_v2_ext', sources=[str(model/'phase4p_ext.cpp'),str(model/'phase4p_ext.cu')], build_directory=str(build),extra_cflags=['-O3','-ffp-contract=off'], extra_cuda_cflags=['-O3','--fmad=false','-std=c++20'],verbose=True) report={'status':'PASS','build_seconds':time.perf_counter()-start,'binary':str(Path(ext.__file__)), 'binary_sha256':phase4c.sha256(Path(ext.__file__)),'flags':{'cxx':['-O3','-ffp-contract=off'],'cuda':['-O3','--fmad=false','-std=c++20']}} save(exp/'gpu-reference/extension.json',report);return ext class CanonicalRuntime: def __init__(self,cfg:dict[str,Any],model:Path,exp:Path): self.cfg=cfg;self.exp=exp;self.device=torch.device('cuda:0') if torch.cuda.device_count()!=1 or cfg['device']['required_name_substring'] not in torch.cuda.get_device_name(0): raise RuntimeError('exactly one visible RTX 4090 required') p4cfg=yaml.safe_load((Path(cfg['paths']['historical_runtime_root'])/'phase4c.yaml').read_text()) paths=phase4c.exact_paths(p4cfg) self.indptr_np,self.indices_np,self.raw_np=phase4c.p4b.validate_graph(p4cfg,paths) self.table=phase4c.full_metadata(paths['metadata']);self.pops=phase4c.populations(self.table) base=phase4c.Runtime(p4cfg,self.table,self.indptr_np,self.indices_np,self.raw_np,self.device,None) self.indptr,self.indices,self.weights=base.indptr,base.indices,base.weights self.ext=build_extension(model,exp) m=cfg['model'];self.n=int(cfg['expected_graph']['neurons']);self.dt=float(m['dt_ms']) self.delay_steps=int(round(m['synaptic_delay_ms']/self.dt));self.qlen=self.delay_steps+1 self.ref_steps=int(round(m['refractory_ms']/self.dt));self.gd=np.float32(1-self.dt/m['synaptic_tau_ms']) self.alpha=np.float32(self.dt/m['membrane_tau_ms']);self.rest=np.float32(m['resting_potential_mv']) self.resetv=np.float32(m['reset_potential_mv']);self.threshold=np.float32(m['threshold_mv']) self.superclass=np.asarray(self.table['annotation_superclass'].to_pylist(),object) def reset(self): d=self.device;self.v=torch.full((self.n,),float(self.rest),dtype=torch.float32,device=d) self.g=torch.zeros(self.n,dtype=torch.float32,device=d);self.ref=torch.zeros(self.n,dtype=torch.int32,device=d) self.queue=torch.zeros((self.qlen,self.n),dtype=torch.float32,device=d) self.spike_u8=torch.zeros(self.n,dtype=torch.uint8,device=d);self.counts=torch.zeros(self.n,dtype=torch.int32,device=d) self.step_index=0;self.event_steps=[];self.event_nodes=[] def canonical_fanout(self,sources:torch.Tensor,destination:torch.Tensor)->dict[str,float|int]: if not sources.numel():return {'contributions':0,'segments':0,'sort_seconds':0.,'reduce_seconds':0.} starts=self.indptr[sources];degree=self.indptr[sources+1]-starts;cum=torch.cumsum(degree,0);total=int(cum[-1].item()) edges=torch.empty(total,dtype=torch.int64,device=self.device) self.ext.fill_edges(self.indptr,sources,cum-degree,edges) t0=time.perf_counter();targets=self.indices[edges] order=torch.argsort(targets,stable=True);sorted_edges=edges[order];sorted_targets=targets[order] boundary=torch.ones(total,dtype=torch.bool,device=self.device) if total>1:boundary[1:]=sorted_targets[1:]!=sorted_targets[:-1] seg_starts=torch.nonzero(boundary,as_tuple=False).flatten().to(torch.int64) unique_targets=sorted_targets[seg_starts].to(torch.int32);sort_sec=time.perf_counter()-t0 t1=time.perf_counter();self.ext.segmented_sum(sorted_edges,seg_starts,unique_targets,self.weights,destination) return {'contributions':total,'segments':int(len(unique_targets)),'sort_seconds':sort_sec,'reduce_seconds':time.perf_counter()-t1} def run(self,targets_np:np.ndarray,rate:float,seed:int,duration_ms:float,capture=True,forced_override=None): self.reset();steps=int(round(duration_ms/self.dt));targets=torch.as_tensor(targets_np,dtype=torch.int64,device=self.device) forced=forced_override if forced_override is not None else phase4c.generate_forced(steps,len(targets_np),rate,self.dt,seed,self.device) torch.cuda.synchronize();t0=time.perf_counter();sort_t=reduce_t=0.;contrib=0 for step in range(steps): slot=step%self.qlen self.ext.state_step(self.v,self.g,self.ref,self.queue[slot],self.spike_u8,self.counts, float(self.gd),float(self.alpha),float(self.rest),float(self.resetv),float(self.threshold),self.ref_steps) self.ext.force_step(self.v,self.ref,self.spike_u8,self.counts,targets,forced[step],float(self.resetv),self.ref_steps) sources=torch.nonzero(self.spike_u8,as_tuple=False).flatten() stats=self.canonical_fanout(sources,self.queue[(step+self.delay_steps)%self.qlen]) sort_t+=stats['sort_seconds'];reduce_t+=stats['reduce_seconds'];contrib+=stats['contributions'] if capture and sources.numel(): x=sources.cpu().numpy().astype(np.int32,copy=True);self.event_nodes.append(x);self.event_steps.append(np.full(len(x),step,np.int32)) self.step_index+=1 torch.cuda.synchronize();wall=time.perf_counter()-t0 es=np.concatenate(self.event_steps) if self.event_steps else np.empty(0,np.int32) en=np.concatenate(self.event_nodes) if self.event_nodes else np.empty(0,np.int32) return {'step':es,'neuron_idx':en,'counts':self.counts.cpu().numpy().copy(),'v':self.v.cpu().numpy().copy(),'g':self.g.cpu().numpy().copy(), 'metrics':{'wall_seconds':wall,'biological_ms':duration_ms,'realtime_factor':wall/(duration_ms/1000), 'peak_vram_bytes':int(torch.cuda.max_memory_allocated()),'contributions':int(contrib), 'sort_submit_seconds':sort_t,'reduce_submit_seconds':reduce_t,'events':len(en),'active':int((self.counts>0).sum().item())}} def cpu_ordered_sum(edge_ids:np.ndarray,indices:np.ndarray,weights:np.ndarray): edge_ids=np.asarray(edge_ids,np.int64);targets=np.asarray(indices[edge_ids],np.int32) order=np.argsort(targets,kind='stable');ordered=edge_ids[order];ordered_target=targets[order] unique=[];sums=[];begin=0 while begin