mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #71799 from NousResearch/bb/model-picker-perf
perf(desktop): faster model picker open + idle CPU on stacked tabs
This commit is contained in:
commit
d471fc9560
4 changed files with 202 additions and 45 deletions
80
apps/desktop/scripts/probe-model-picker.mjs
Normal file
80
apps/desktop/scripts/probe-model-picker.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Model picker open latency probe: click the composer model pill, time until
|
||||
// the dropdown/dialog content paints, repeat. Run with --cpuprofile via the
|
||||
// harness runner once promoted; standalone for iteration.
|
||||
// node scripts/perf/probe-model-picker.mjs [--port 9222] [--rounds 5]
|
||||
import { CDP } from './perf/lib/cdp.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = name => {
|
||||
const i = args.indexOf(`--${name}`)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
const port = Number(flag('port') ?? 9222)
|
||||
const rounds = Number(flag('rounds') ?? 5)
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const cdp = await CDP.connect({ port })
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
// Find the pill (dropdown path) — the composer model selector button.
|
||||
const PILL = `(() => {
|
||||
const btns = [...document.querySelectorAll('button[aria-label]')]
|
||||
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '') && b.closest('[data-slot]'))
|
||||
return pill ? (pill.getAttribute('aria-label') || 'found') : null
|
||||
})()`
|
||||
|
||||
console.log('pill:', await cdp.eval(PILL))
|
||||
|
||||
const MEASURE = `
|
||||
(async () => {
|
||||
const btns = [...document.querySelectorAll('button[aria-label]')]
|
||||
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || ''))
|
||||
if (!pill) return JSON.stringify({ error: 'no pill' })
|
||||
|
||||
const t0 = performance.now()
|
||||
pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
pill.click()
|
||||
|
||||
// Wait for menu/dialog content to exist AND paint (double rAF after found).
|
||||
const found = await new Promise(resolve => {
|
||||
const deadline = performance.now() + 5000
|
||||
const check = () => {
|
||||
const menu = document.querySelector('[role="menu"], [role="dialog"] [cmdk-list]')
|
||||
if (menu && menu.childElementCount > 0) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now())))
|
||||
return
|
||||
}
|
||||
if (performance.now() > deadline) { resolve(null); return }
|
||||
requestAnimationFrame(check)
|
||||
}
|
||||
check()
|
||||
})
|
||||
|
||||
const openMs = found ? found - t0 : null
|
||||
const rows = document.querySelectorAll('[role="menu"] [role="menuitem"], [cmdk-item]').length
|
||||
|
||||
// Close: Escape.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
const menu = document.querySelector('[role="menu"], [role="dialog"]')
|
||||
menu?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
return JSON.stringify({ openMs, rows })
|
||||
})()
|
||||
`
|
||||
|
||||
const samples = []
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const raw = await cdp.eval(MEASURE, { awaitPromise: true })
|
||||
const r = JSON.parse(raw)
|
||||
console.log(`round ${i}:`, r)
|
||||
if (typeof r.openMs === 'number') samples.push(r.openMs)
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
samples.sort((a, b) => a - b)
|
||||
console.log('\nopen latency ms — min/median/max:',
|
||||
Math.round(samples[0]), '/', Math.round(samples[Math.floor(samples.length / 2)]), '/', Math.round(samples.at(-1)))
|
||||
cdp.close()
|
||||
60
apps/desktop/scripts/profile-model-picker.mjs
Normal file
60
apps/desktop/scripts/profile-model-picker.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// CPU-profile one model-picker open.
|
||||
// node scripts/profile-model-picker.mjs [--port 9222]
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
import { CDP } from './perf/lib/cdp.mjs'
|
||||
import { cpuProfileTopSelf } from './perf/lib/stats.mjs'
|
||||
|
||||
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
|
||||
const cdp = await CDP.connect({ port })
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.send('Profiler.enable')
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 100 })
|
||||
|
||||
const OPEN = `
|
||||
(async () => {
|
||||
// Reset: close any open menu first.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
|
||||
const btns = [...document.querySelectorAll('button[aria-label]')]
|
||||
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || ''))
|
||||
if (!pill) return -1
|
||||
const t0 = performance.now()
|
||||
pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
pill.click()
|
||||
const found = await new Promise(resolve => {
|
||||
const deadline = performance.now() + 8000
|
||||
const check = () => {
|
||||
const menu = document.querySelector('[role="menu"]')
|
||||
if (menu && menu.childElementCount > 0) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now())))
|
||||
return
|
||||
}
|
||||
if (performance.now() > deadline) { resolve(-1); return }
|
||||
requestAnimationFrame(check)
|
||||
}
|
||||
check()
|
||||
})
|
||||
return found < 0 ? -1 : found - t0
|
||||
})()
|
||||
`
|
||||
|
||||
await cdp.send('Profiler.start')
|
||||
const openMs = await cdp.eval(OPEN)
|
||||
const { profile } = await cdp.send('Profiler.stop')
|
||||
|
||||
console.log('openMs:', Math.round(openMs))
|
||||
const out = `/tmp/model-picker-open.cpuprofile`
|
||||
writeFileSync(out, JSON.stringify(profile))
|
||||
console.log('wrote', out)
|
||||
console.log('top self-time (ms):')
|
||||
|
||||
for (const r of cpuProfileTopSelf(profile, 20)) {
|
||||
console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(44)} ${r.url.split('/').slice(-2).join('/')}:${r.line}`)
|
||||
}
|
||||
|
||||
// Close the menu again.
|
||||
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
cdp.close()
|
||||
|
|
@ -96,7 +96,20 @@ interface ModelEditSubmenuProps {
|
|||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function ModelEditSubmenu({
|
||||
export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
|
||||
// The panel mounts one of these per model row; only the hovered row's
|
||||
// submenu is ever open. Keep this wrapper hook-free and render the body as
|
||||
// a CHILD of SubContent so Radix's Presence gate leaves it unrendered until
|
||||
// the sub actually opens — eagerly running the body's hooks/JSX for every
|
||||
// row made opening the menu itself lag on large catalogs.
|
||||
return (
|
||||
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
|
||||
<ModelEditSubmenuBody {...props} />
|
||||
</DropdownMenuSubContent>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelEditSubmenuBody({
|
||||
effort,
|
||||
fastControl,
|
||||
isActive,
|
||||
|
|
@ -213,50 +226,46 @@ export function ModelEditSubmenu({
|
|||
const hasFast = fastControl.kind !== 'none'
|
||||
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
||||
|
||||
return (
|
||||
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
|
||||
{!hasFast && !reasoning ? (
|
||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
||||
) : (
|
||||
return !hasFast && !reasoning ? (
|
||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
{copy.thinking}
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
{copy.fast}
|
||||
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{reasoning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
{copy.thinking}
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
{copy.fast}
|
||||
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{reasoning ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
|
||||
{REASONING_EFFORTS.map(value => (
|
||||
<DropdownMenuRadioItem
|
||||
className={dropdownMenuRow}
|
||||
key={value}
|
||||
onSelect={event => event.preventDefault()}
|
||||
value={value}
|
||||
>
|
||||
{copy[value]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
|
||||
{REASONING_EFFORTS.map(value => (
|
||||
<DropdownMenuRadioItem
|
||||
className={dropdownMenuRow}
|
||||
key={value}
|
||||
onSelect={event => event.preventDefault()}
|
||||
value={value}
|
||||
>
|
||||
{copy[value]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations'
|
||||
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { SpinnerName }
|
||||
|
|
@ -43,13 +44,20 @@ interface GlyphSpinnerProps {
|
|||
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
|
||||
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille!
|
||||
const [frame, setFrame] = useState(0)
|
||||
// Pause when this surface is a hidden (kept-alive) tab: N mounted tabs each
|
||||
// ticking a setInterval + setState burn CPU for pixels nobody can see.
|
||||
const visible = usePaneVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
setFrame(0)
|
||||
const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [spin])
|
||||
}, [spin, visible])
|
||||
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue