Files
hermes-agent/apps/desktop/scripts/profile-long-stream.mjs
T
2026-06-14 14:30:48 -04:00

192 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
// Long-running stream profile + frame-rate timeline. Submits a prompt that
// asks for ~30 paragraphs of output, then captures both a CPU profile and
// a per-100ms frame counter so we can see if FPS sags as the message grows.
import { writeFileSync } from 'node:fs'
const args = Object.fromEntries(
process.argv.slice(2).flatMap(s => {
const m = s.match(/^--([^=]+)(?:=(.*))?$/)
return m ? [[m[1], m[2] ?? true]] : []
})
)
const PORT = Number(args.port ?? 9222)
const OUT = String(args.out ?? `/tmp/hermes-long-stream-${Date.now()}`)
const STREAM_SEC = Number(args.seconds ?? 25)
async function pickRenderer() {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
return list.find(t => t.type === 'page' && t.url.startsWith('http'))
}
function connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url)
let id = 0
const pending = new Map()
ws.addEventListener('open', () =>
resolve({
send(method, params = {}) {
const myId = ++id
ws.send(JSON.stringify({ id: myId, method, params }))
return new Promise((res, rej) => pending.set(myId, { res, rej }))
},
close: () => ws.close()
})
)
ws.addEventListener('error', reject)
ws.addEventListener('message', ev => {
const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8'))
if (m.id != null) {
const p = pending.get(m.id)
if (!p) return
pending.delete(m.id)
m.error ? p.rej(new Error(m.error.message)) : p.res(m.result)
}
})
})
}
async function evalP(cdp, expr) {
const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text)
return r.result.value
}
async function main() {
const tgt = await pickRenderer()
console.log('target', tgt.url)
const cdp = await connect(tgt.webSocketDebuggerUrl)
await cdp.send('Runtime.enable')
await cdp.send('Profiler.enable')
await cdp.send('Performance.enable')
// Submit a long-form prompt
await evalP(
cdp,
`(() => {
const el = document.querySelector('[data-slot="composer-rich-input"]')
el.focus()
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
})()`
)
const prompt = 'write 15 paragraphs about gpu memory bandwidth, memory hierarchies, roofline model, and how modern transformer inference benefits from these. include diagrams in ascii where relevant. no code. fully detailed.'
for (const c of prompt) {
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
await new Promise(r => setTimeout(r, 5))
}
await new Promise(r => setTimeout(r, 200))
await cdp.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
})
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
console.log('waiting for assistant…')
let streaming = false
for (let i = 0; i < 100; i++) {
const c = await evalP(cdp, `document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length`)
if (c > 0) { streaming = true; break }
await new Promise(r => setTimeout(r, 100))
}
if (!streaming) {
console.error('no assistant message')
cdp.close()
return
}
// Install a per-rAF frame counter
await evalP(
cdp,
`(() => {
window.__fpsSamples = []
window.__fpsT0 = performance.now()
window.__fpsLast = performance.now()
window.__fpsFrameCount = 0
window.__fpsHistogram = [] // {t, fps, contentLen}
const tick = () => {
const now = performance.now()
const dt = now - window.__fpsLast
window.__fpsLast = now
window.__fpsFrameCount++
window.__fpsSamples.push({ t: now - window.__fpsT0, dt })
if (performance.now() - window.__fpsT0 < ${STREAM_SEC * 1000}) {
requestAnimationFrame(tick)
}
}
requestAnimationFrame(tick)
// Bucket fps every 500ms
window.__fpsBucket = setInterval(() => {
const now = performance.now()
const recentCount = window.__fpsSamples.filter(s => now - window.__fpsT0 - s.t < 500).length
const root = document.querySelector('[data-slot="aui_thread-content"]')
const len = root ? root.innerText.length : 0
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
window.__fpsHistogram.push({
t: now - window.__fpsT0,
frames500ms: recentCount,
fps: recentCount * 2,
contentLen: len,
scrollTop: v?.scrollTop ?? 0,
scrollHeight: v?.scrollHeight ?? 0
})
}, 500)
})()`
)
// Start CPU profile
await cdp.send('Profiler.setSamplingInterval', { interval: 1000 })
await cdp.send('Profiler.start')
await new Promise(r => setTimeout(r, STREAM_SEC * 1000))
const { profile } = await cdp.send('Profiler.stop')
await evalP(cdp, `clearInterval(window.__fpsBucket)`)
writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile))
console.log(`cpu profile → ${OUT}.cpuprofile`)
// Pull fps histogram
const hist = JSON.parse(await evalP(cdp, `JSON.stringify(window.__fpsHistogram || [])`))
writeFileSync(`${OUT}.fps.json`, JSON.stringify(hist, null, 2))
console.log(`\n=== FPS over time ===`)
console.log(` t(s) fps contentLen scrollTop/scrollHeight`)
for (const h of hist) {
const bar = '█'.repeat(Math.min(40, Math.max(0, Math.round(h.fps / 2))))
console.log(` ${(h.t / 1000).toFixed(1).padStart(5)} ${String(h.fps).padStart(3)} ${String(h.contentLen).padStart(10)} ${h.scrollTop}/${h.scrollHeight} ${bar}`)
}
// Top self frames
const total = (profile.endTime - profile.startTime) / 1000
const intMs = total / Math.max(1, profile.samples?.length ?? 1)
const counts = new Map()
for (const s of profile.samples ?? []) counts.set(s, (counts.get(s) ?? 0) + 1)
const rows = profile.nodes
.map(n => ({ id: n.id, fn: n.callFrame.functionName || '(anon)', url: n.callFrame.url || '', line: n.callFrame.lineNumber, self: counts.get(n.id) ?? 0 }))
.sort((a, b) => b.self - a.self)
.slice(0, 25)
console.log(`\n=== ${total.toFixed(0)}ms wall, ${profile.samples?.length ?? 0} samples (${intMs.toFixed(2)}ms each) ===`)
for (const r of rows) {
if (r.self === 0) break
const url = r.url.replace(/^.*\/src\//, 'src/').replace(/\?.*$/, '').slice(0, 70)
console.log(` ${(r.self * intMs).toFixed(1).padStart(7)}ms (${String(r.self).padStart(4)} samp) ${r.fn.padEnd(45)} ${url}:${r.line}`)
}
await evalP(cdp, `
(() => {
for (const b of document.querySelectorAll('button')) {
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return }
}
})()
`)
cdp.close()
}
main().catch(e => {
console.error('fatal:', e.stack ?? e.message)
process.exit(1)
})