import { readFile, writeFile, mkdir, rename, rm, statfs } from 'node:fs/promises'; import { createWriteStream } from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { createHash } from 'node:crypto'; import { cpus, totalmem, release } from 'node:os'; import { setTimeout as delay } from 'node:timers/promises'; import { writeBenchmarkReport } from './html-gpu-benchmark-report.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); process.chdir(root); const { values: args } = parseArgs({options:{ 'video-project':{type:'string'}, 'mg-project':{type:'string'}, out:{type:'string',default:'dist/html-gpu-benchmark'}, repetitions:{type:'string',default:'5'}, job:{type:'string'}, help:{type:'boolean'}, }}); if(args.help){console.log('node --import tsx scripts/benchmark-html-gpu.mjs --video-project --mg-project [--out ] [--repetitions 5]');process.exit(0);} const MiB=1024**2, script=fileURLToPath(import.meta.url); async function json(file,value){await mkdir(dirname(file),{recursive:true});await writeFile(file+'.tmp',JSON.stringify(value,null,2));await rename(file+'.tmp',file);} function command(binary,argv){const r=spawnSync(binary,argv,{encoding:'utf8',timeout:10000,maxBuffer:4*MiB});if(r.status!==0)throw Error(`${binary}: ${r.error??r.stderr}`);return r.stdout.trim();} function alive(pid){try{process.kill(pid,0);return true;}catch{return false;}} if(args.job){ const job=JSON.parse(await readFile(args.job,'utf8')); const { loadFixtures, verifyIdentifiers }=await import('./html-gpu-benchmark-fixtures.mjs'); const { MemoryResourceStore }=await import('../packages/executor/src/resources.ts'); const { rasterizeHtmlProgram, resolveExecutionOptions }=await import('../packages/provider-html-local/src/render.ts'); const fixtures=await loadFixtures(job.videoProject,job.mgProject), controllers=new Set(), pids=new Set(); const interrupted=()=>{for(const controller of controllers)controller.abort(Error('Benchmark interrupted'));}; process.on('SIGTERM',interrupted);process.on('SIGINT',interrupted); const event=phase=>console.log('BENCH_EVENT '+JSON.stringify({phase})); async function render(item){ const fixture=fixtures[item.doc], resources=new MemoryResourceStore(), diagnostics=[], controller=new AbortController(); for(const [artifact,bytes]of fixture.blobs)await resources.write(artifact,bytes); controllers.add(controller);let fired=false,visual,error,file,verification; const ownPids=new Set(); const began=performance.now(), effective=resolveExecutionOptions(item.config??{}); try{ visual=await rasterizeHtmlProgram({program:fixture.program,...(item.range?{range:item.range}:{})},{ resources,renderer:'electron',processTimeoutMs:180000,initializationTimeoutMs:30000,frameTimeoutMs:30000, ...item.config,signal:controller.signal, onDiagnostic:async d=>diagnostics.push(d), onProgress:e=>{ if(e.browserPid){pids.add(e.browserPid);ownPids.add(e.browserPid);} if(item.action&&!fired&&e.phase==='worker-progress'&&e.completed>10){fired=true; if(item.action==='cancel')controller.abort(Error('Benchmark cancellation')); else if(ownPids.size)process.kill([...ownPids].at(-1),'SIGKILL'); } }, }); }catch(e){error=String(e);}finally{controllers.delete(controller);} const seconds=(performance.now()-began)/1000; if(visual&&item.verify){file=resolve(job.directory,item.id+'.mp4');await writeFile(file,await resources.get(visual.artifact.resource));} const videoStats=diagnostics.flatMap(d=>[...d.message.matchAll(/video: (\{[^\n]+\})/g)].map(m=>JSON.parse(m[1]))); return {id:item.id,doc:item.doc,group:item.group,config:item.config??{}, effective:{sourceMiB:effective.maxDecodedSourceBytes/MiB,pendingMiB:effective.maxPendingFrameBytes/MiB,workers:effective.workers}, seconds,frames:visual?.frameCount,bytes:visual?.artifact.size,error,action:item.action,actionTriggered:fired, file,verifyKind:item.verify,verification,diagnostics,videoStats}; } let result; try{ event('export');const began=performance.now(); const exports=await Promise.all(job.items.map(render)), seconds=(performance.now()-began)/1000; event('verification'); for(const item of exports)if(item.file){ try{item.verification=verifyIdentifiers(item.file,item.verifyKind,item.frames);} catch(e){item.verification={error:String(e)};} } await delay(1500); const survivors=[...pids].filter(alive); const passed=exports.every(r=>r.action?r.actionTriggered&&!!r.error:!r.error&&r.frames>0) &&exports.every(r=>!r.verifyKind||(r.verification&&!r.verification.error&&r.verification.mismatches===0))&&survivors.length===0; result={id:job.id,passed,seconds,exports,survivors}; }catch(e){result={id:job.id,passed:false,error:String(e)};} await json(job.resultFile,result);event('done');process.exitCode=result.passed?0:1; }else{ if(process.platform!=='darwin')throw Error('This measurement runner requires macOS process and memory tools.'); if(!args['video-project']||!args['mg-project'])throw Error('Provide --video-project and --mg-project; see --help.'); const reps=Number(args.repetitions);if(!Number.isInteger(reps)||reps<5)throw Error('Use at least five repetitions.'); const directory=resolve(args.out),videoProject=resolve(args['video-project']),mgProject=resolve(args['mg-project']); await mkdir(directory,{recursive:true}); const lock=resolve(directory,'runner.lock'); try{await mkdir(lock);}catch(e){if(e.code!=='EEXIST')throw e;const pid=Number(await readFile(resolve(lock,'pid'),'utf8').catch(()=>0));if(alive(pid)&&pid>0)throw Error('Benchmark already running: '+pid);await rm(lock,{recursive:true,force:true});await mkdir(lock);} await writeFile(resolve(lock,'pid'),String(process.pid)); let stopping=false,current; const stop=()=>{stopping=true;if(current)try{process.kill(-current,'SIGTERM');}catch{}}; process.on('SIGTERM',stop);process.on('SIGINT',stop); try{ command('ffmpeg',['-version']);command('ffprobe',['-version']); const { loadProject }=await import('./html-gpu-benchmark-fixtures.mjs'); const fingerprint=createHash('sha256'); fingerprint.update(JSON.stringify({scheme:3,node:process.version,platform:process.platform,arch:process.arch, cpu:cpus()[0]?.model,release:release(),memory:totalmem()})); const isSource=file=>!/(?:^|\/)(?:test|dist|node_modules)(?:\/|$)/.test(file)&&/\.(?:[cm]?[jt]s|json|yaml)$/.test(file); const sourceList=spawnSync('git',['ls-files','--cached','--others','--exclude-standard','-z','--', 'packages','package.json','pnpm-lock.yaml','tsconfig.json'],{encoding:'utf8',maxBuffer:4*MiB}); if(sourceList.status!==0)throw Error('Cannot enumerate benchmark source files: '+sourceList.stderr); const sources=[...new Set(sourceList.stdout.split('\0').filter(file=>file&&isSource(file)))].sort(); const harness=['scripts/benchmark-html-gpu.mjs','scripts/html-gpu-benchmark-fixtures.mjs','scripts/html-gpu-benchmark-report.mjs']; for(const file of [...sources,...harness]){fingerprint.update(file+'\0');fingerprint.update(await readFile(resolve(root,file)));fingerprint.update('\0');} const inputs=[]; for(const project of [videoProject,mgProject]){const data=await loadProject(project);inputs.push(JSON.stringify(data.program));for(const [artifact,bytes]of data.blobs)inputs.push(artifact.resource,bytes);} inputs.push(String(reps));for(const input of inputs){fingerprint.update(input);fingerprint.update('\0');} const identity=fingerprint.digest('hex'),stateFile=resolve(directory,'state.json'); let state;try{state=JSON.parse(await readFile(stateFile,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;} if(state&&state.identityScheme!==3){ const legacyHead=state.resumptions?.at(-1)?.head??state.head; const oldFile=file=>{const result=spawnSync('git',['show',legacyHead+':'+file],{maxBuffer:4*MiB}); if(result.status!==0)throw Error('Cannot recover the legacy benchmark harness; select a fresh --out directory.');return result.stdout;}; const legacyHarness=harness.map(oldFile),oldRunner=legacyHarness[0].toString(); const stripIdentity=text=>{const a=text.indexOf(" const fingerprint=createHash('sha256');"),b=text.indexOf('\n state??={',a)+1; if(a<0||b<1)throw Error('Unrecognized benchmark identity format.');return text.slice(0,a)+''+text.slice(b).replace(' state.identityScheme=3;\n','');}; const changed=command('git',['diff','--name-only',legacyHead,'--','packages','package.json','pnpm-lock.yaml','tsconfig.json']) .split('\n').filter(file=>file&&isSource(file)); const untracked=spawnSync('git',['ls-files','--others','--exclude-standard','-z','--','packages'],{encoding:'utf8'}); if(untracked.status!==0||untracked.stdout.split('\0').some(file=>file&&isSource(file)))throw Error('Untracked production source prevents legacy resume.'); const sameHost=state.host.platform===process.platform&&state.host.release===release()&&state.host.node===process.version &&state.host.cpu===cpus()[0]?.model&&state.host.totalMemoryMiB===totalmem()/MiB; if(changed.length||!sameHost||stripIdentity(oldRunner)!==stripIdentity(await readFile(script,'utf8')) ||!legacyHarness[1].equals(await readFile(resolve(root,harness[1])))||!legacyHarness[2].equals(await readFile(resolve(root,harness[2])))) throw Error('Legacy benchmark source or host changed; select a fresh --out directory.'); const legacy=createHash('sha256').update(legacyHead);for(const bytes of [...legacyHarness,...inputs])legacy.update(bytes); if(legacy.digest('hex')!==state.identity)throw Error('Legacy script and fixture fingerprint did not match.'); state.identityMigration={at:new Date().toISOString(),legacyHead,previous:state.identity, reason:'Verified identical production source, measurement harness, fixtures and host before migrating to a source-content identity.'}; state.identity=identity; } if(state&&state.identity!==identity)throw Error('Source, fixtures, host or repetition count changed. Select a fresh --out directory.'); state??={identity,head:command('git',['rev-parse','HEAD']),dirty:!!command('git',['status','--porcelain']), startedAt:new Date().toISOString(),host:{platform:process.platform,release:release(),cpu:cpus()[0]?.model,totalMemoryMiB:totalmem()/MiB,node:process.version}, videoProject,mgProject,repetitions:reps,results:[]}; state.identityScheme=3; let readUsage; try{const {default:koffi}=await import('koffi');readUsage=koffi.load('/usr/lib/libproc.dylib').func('int proc_pid_rusage(int pid, int flavor, void *buffer)');}catch{} function processes(rootPid, observed) { const raw=spawnSync('ps',['-axo','pid=,ppid=,pgid=,rss=,stat=,lstart=,comm='],{encoding:'utf8',env:{...process.env,LC_ALL:'C'}}); if(raw.status!==0)throw Error('Process sampler failed: '+raw.stderr); const rows=raw.stdout.split('\n').flatMap(line=>{ const m=line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+(.*)$/); if(!m||m[5].startsWith('Z'))return[]; return[{pid:+m[1],ppid:+m[2],pgid:+m[3],rssMiB:+m[4]/1024,started:m[6],command:Buffer.from(m[7]).toString()}]; }); const owned=new Set([rootPid]); for(const row of rows){const old=observed.get(row.pid);if(old&&old.started===row.started&&old.command===row.command)owned.add(row.pid);} for(let i=0;i<32;i++){const size=owned.size;for(const row of rows)if(owned.has(row.ppid))owned.add(row.pid);if(size===owned.size)break;} return rows.filter(row=>owned.has(row.pid)).map(row=>{ observed.set(row.pid,{started:row.started,command:row.command}); row.footprintMiB=null;if(readUsage){const b=Buffer.alloc(96);if(readUsage(row.pid,0,b)===0)row.footprintMiB=Number(b.readBigUInt64LE(72))/MiB;} return row; }); } function stats(){return{at:new Date().toISOString(),swap:command('sysctl',['vm.swapusage']),thermal:command('pmset',['-g','therm'])};} const median=xs=>{xs=[...xs].sort((a,b)=>a-b);return xs.length%2?xs[Math.floor(xs.length/2)]:(xs[xs.length/2-1]+xs[xs.length/2])/2;}; async function summarize(){ await writeBenchmarkReport(directory,state); await json(stateFile,state); } async function run(id,items){ if(stopping)return; if(state.results.some(r=>r.id===id))return; const space=await statfs(directory);if(space.bavail*space.bsize<2*1024**3)throw Error('Less than 2 GiB disk space available. Resume after freeing space.'); const jobDir=resolve(directory,'jobs',id);await mkdir(jobDir,{recursive:true}); const resultFile=resolve(jobDir,'result.json'),jobFile=resolve(jobDir,'job.json'); await json(jobFile,{id,items,directory:jobDir,resultFile,videoProject,mgProject}); const before=stats(),samples=[],log=createWriteStream(resolve(jobDir,'output.log'));let phase='startup',timedOut=false,closed=false;const observed=new Map(); const child=spawn(process.execPath,['--expose-gc','--import','tsx',script,'--job',jobFile],{cwd:root,detached:true,stdio:['ignore','pipe','pipe'],env:{...process.env,NODE_NO_WARNINGS:'1'}}); current=child.pid;console.log('START',id);let buffer=''; child.stdout.on('data',chunk=>{log.write(chunk);buffer+=chunk.toString();let i;while((i=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,i);buffer=buffer.slice(i+1);if(line.startsWith('BENCH_EVENT '))phase=JSON.parse(line.slice(12)).phase;}}); child.stderr.on('data',chunk=>log.write(chunk)); const timer=setInterval(()=>{try{const rows=processes(child.pid,observed);samples.push({ms:Date.now(),phase,processes:rows,rssMiB:rows.reduce((s,r)=>s+r.rssMiB,0),footprintMiB:readUsage?rows.reduce((s,r)=>s+(r.footprintMiB??0),0):null});}catch(e){log.write(String(e)+'\n');}},250); const timeout=setTimeout(()=>{timedOut=true;try{process.kill(-child.pid,'SIGTERM');}catch{}},300000); const force=setTimeout(()=>{if(!closed)try{process.kill(-child.pid,'SIGKILL');}catch{}},315000); let code,signal,spawnError; await new Promise(done=>{child.on('error',e=>{spawnError=String(e);done();});child.on('close',(c,s)=>{code=c;signal=s;closed=true;done();});}); clearInterval(timer);clearTimeout(timeout);clearTimeout(force);current=undefined;log.end(); let remaining=child.pid?processes(child.pid,observed):[]; for(let n=0;remaining.length&&n<10;n++){await delay(200);remaining=processes(child.pid,observed);} const leaked=remaining; if(remaining.length){for(const row of remaining)try{process.kill(row.pid,'SIGTERM');}catch{}await delay(1000);for(const row of processes(child.pid,observed))try{process.kill(row.pid,'SIGKILL');}catch{}} let result;try{result=JSON.parse(await readFile(resultFile,'utf8'));}catch{result={id,passed:false,error:'Child did not write a result'};} const captured=samples.filter(s=>s.phase==='export'); Object.assign(result,{code,signal,spawnError,timedOut,interrupted:stopping,before,after:stats(),leaked, peakRssMiB:Math.max(0,...captured.map(s=>s.rssMiB)),peakFootprintMiB:readUsage?Math.max(0,...captured.map(s=>s.footprintMiB)):null}); result.passed=!!result.passed&&code===0&&!timedOut&&!stopping&&!leaked.length; await json(resolve(jobDir,'samples.json'),samples); if(!stopping)state.results.push(result);else state.lastInterrupted=result; await summarize();console.log('DONE',id,result.passed?'PASS':'FAIL',result.seconds?.toFixed(2)); } const item=(id,doc,config,group,verify)=>({id,doc,config,group,verify}); const repeat=async(group,doc,configs)=>{for(let r=0;r['accuracy-defaults','defaults-video','mg-defaults'].includes(r.id)&&!r.passed)){state.status='preflight_failed';await summarize();throw Error('Frame-ID preflight failed; see the saved report.');} await repeat('source','staggered',[ {workers:1,maxDecodedSourceBytes:1024*MiB,maxPendingFrameBytes:256*MiB}, {},{workers:1,maxDecodedSourceBytes:3072*MiB,maxPendingFrameBytes:128*MiB}, {workers:1,maxDecodedSourceBytes:4096*MiB,maxPendingFrameBytes:128*MiB}, ]); await repeat('queue','mg',[128,256,512].map(n=>({workers:4,maxPendingFrameBytes:n*MiB}))); const workerOptions=[1,2,3,4,5,6,7,8,'auto']; for(const workers of workerOptions)await run('workers-screen-'+workers,[item('workers-screen-'+workers,'mg',{workers},'workers')]); const ranked=state.results.flatMap(r=>r.passed?(r.exports??[]):[]).filter(r=>r.group==='workers'&&r.id.startsWith('workers-screen-')).sort((a,b)=>a.seconds-b.seconds); const finalists=[];for(const row of ranked)if(!finalists.some(c=>c.workers===row.config.workers)&&finalists.length<2)finalists.push(row.config); if(!finalists.some(config=>config.workers==='auto'))finalists.push({workers:'auto'}); state.workerFinalists=finalists;await summarize(); for(let r=1;rr.passed).flatMap(r=>r.exports??[]).filter(r=>r.group==='queue'); const queueGroups=Object.entries(Object.groupBy(queueRuns,r=>JSON.stringify(r.config))).filter(([,v])=>v.length===reps).sort((a,b)=>median(a[1].map(x=>x.seconds))-median(b[1].map(x=>x.seconds))); if(queueGroups.length)await run('accuracy-queue-fastest',[item('accuracy-queue-fastest','mg',JSON.parse(queueGroups[0][0]),undefined,'mg')]); await run('accuracy-low-cache',[{...item('accuracy-low-cache','accuracy',{maxDecodedSourceBytes:32*MiB},undefined,'accuracy'),range:{startFrame:0,endFrameExclusive:120}}]); await run('accuracy-small-queue',[{...item('accuracy-small-queue','mg',{workers:4,maxPendingFrameBytes:1},undefined,'mg'),range:{startFrame:0,endFrameExclusive:120}}]); const bestSource=state.results.filter(r=>r.passed).flatMap(r=>r.exports??[]).filter(r=>r.group==='source'); const sourceGroups=Object.groupBy(bestSource,r=>JSON.stringify(r.config)); const rankedSource=Object.entries(sourceGroups).filter(([,v])=>v.length===reps).sort((a,b)=>median(a[1].map(x=>x.seconds))-median(b[1].map(x=>x.seconds))); if(rankedSource.length)await run('accuracy-fastest',[item('accuracy-fastest','accuracy',JSON.parse(rankedSource[0][0]),undefined,'accuracy')]); for(const n of [2,4])await run('concurrent-'+n,Array.from({length:n},(_,i)=>item(`concurrent-${n}-${i}`,'video',{},undefined,'video'))); await run('concurrent-faults',[ item('fault-peer','video',{},undefined,'video'), {...item('fault-cancel','mg',{}),action:'cancel'}, {...item('fault-crash','video',{}),action:'crash'}, ]); await run('recovery',[item('recovery','video',{},undefined,'video')]); state.status=stopping?'interrupted':state.results.some(r=>!r.passed)?'completed_with_failures':'completed';state.finishedAt=new Date().toISOString();await summarize(); console.log('REPORT',resolve(directory,'REPORT.md'));if(state.status!=='completed')process.exitCode=1; }finally{await rm(lock,{recursive:true,force:true});} }