mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
perf(tui): unified Ink cache eviction on memory pressure + session reset
Adds an `evictInkCaches(level)` API that prunes the four hot module-level caches (`widthCache`, `wrapCache`, `sliceCache`, `lineWidthCache`) with either a half-keep LRU pass or a full clear. Wired into: - memoryMonitor: half-prune on 'high', full drop on 'critical', before the heap dump / auto-restart path. Gives long sessions a shot at recovering RSS instead of hard-exiting. - useSessionLifecycle.resetSession: half-prune so a /new session starts with a half-warm pool and the prior session can resume cheaply. Also: lineWidthCache now uses LRU half-eviction on overflow instead of a full `cache.clear()`, matching the other three caches. Comparison vs claude-code: both forks now share the same `prevScreen` blit + dirty-cascade machinery in render-node-to-output. Their smoothness came from sibling-memo discipline (every chrome pane memo'd so dirty cascade doesn't disable transcript blit) — already in place in our appLayout.tsx (TranscriptPane / ComposerPane / StatusRulePane all memo'd). Alt-screen is not the cause; both use it. The remaining gap was per-row CPU on width/wrap/slice, which the previous commit closed.
This commit is contained in:
parent
c370e2e1e5
commit
25767513f2
10 changed files with 147 additions and 4 deletions
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
|
|
@ -4,6 +4,8 @@ export type { StderrHandle } from './src/hooks/use-stderr.ts'
|
|||
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
||||
export type { StdoutHandle } from './src/hooks/use-stdout.ts'
|
||||
export { Ansi } from './src/ink/Ansi.tsx'
|
||||
export { evictInkCaches, inkCacheSizes } from './src/ink/cache-eviction.ts'
|
||||
export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts'
|
||||
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||
export { default as Box } from './src/ink/components/Box.tsx'
|
||||
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
export { default as useStderr } from './hooks/use-stderr.js'
|
||||
export { default as useStdout } from './hooks/use-stdout.js'
|
||||
export { Ansi } from './ink/Ansi.js'
|
||||
export {
|
||||
evictInkCaches,
|
||||
type EvictLevel,
|
||||
type InkCacheSizes,
|
||||
inkCacheSizes
|
||||
} from './ink/cache-eviction.js'
|
||||
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
||||
export { default as Box } from './ink/components/Box.js'
|
||||
export { default as Link } from './ink/components/Link.js'
|
||||
|
|
|
|||
46
ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts
Normal file
46
ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Unified cache eviction for the four hot Ink module-level caches:
|
||||
// - widthCache (stringWidth.ts)
|
||||
// - wrapCache (wrap-text.ts)
|
||||
// - sliceCache (sliceAnsi.ts)
|
||||
// - lineWidthCache (line-width-cache.ts)
|
||||
//
|
||||
// Used by the host (TUI) under memory pressure or on session swap to drop
|
||||
// content-keyed entries that won't recur. All caches are content-keyed
|
||||
// (not session-keyed), so cross-session sharing is normally beneficial —
|
||||
// only evict when memory tightens or when the user explicitly resets.
|
||||
|
||||
import { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
|
||||
import { evictWidthCache, widthCacheSize } from './stringWidth.js'
|
||||
import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
|
||||
|
||||
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
|
||||
|
||||
export interface InkCacheSizes {
|
||||
lineWidth: number
|
||||
slice: number
|
||||
width: number
|
||||
wrap: number
|
||||
}
|
||||
|
||||
export function inkCacheSizes(): InkCacheSizes {
|
||||
return {
|
||||
lineWidth: lineWidthCacheSize(),
|
||||
slice: sliceCacheSize(),
|
||||
width: widthCacheSize(),
|
||||
wrap: wrapCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
export type EvictLevel = 'all' | 'half'
|
||||
|
||||
export function evictInkCaches(level: EvictLevel = 'half'): InkCacheSizes {
|
||||
const before = inkCacheSizes()
|
||||
const keep = level === 'half' ? 0.5 : 0
|
||||
|
||||
evictWidthCache(keep)
|
||||
evictWrapCache(keep)
|
||||
evictSliceCache(keep)
|
||||
evictLineWidthCache(keep)
|
||||
|
||||
return before
|
||||
}
|
||||
|
|
@ -11,18 +11,35 @@ export function lineWidth(line: string): number {
|
|||
const cached = cache.get(line)
|
||||
|
||||
if (cached !== undefined) {
|
||||
cache.delete(line)
|
||||
cache.set(line, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const width = stringWidth(line)
|
||||
|
||||
// Evict when cache grows too large (e.g. after many different responses).
|
||||
// Simple full-clear is fine — the cache repopulates in one frame.
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
cache.clear()
|
||||
cache.delete(cache.keys().next().value!)
|
||||
}
|
||||
|
||||
cache.set(line, width)
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
export function lineWidthCacheSize(): number {
|
||||
return cache.size
|
||||
}
|
||||
|
||||
export function evictLineWidthCache(keepRatio = 0): void {
|
||||
if (keepRatio <= 0) {
|
||||
cache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const target = Math.floor(cache.size * keepRatio)
|
||||
|
||||
while (cache.size > target) {
|
||||
cache.delete(cache.keys().next().value!)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,3 +325,20 @@ export const stringWidth: (str: string) => number = str => {
|
|||
|
||||
return w
|
||||
}
|
||||
|
||||
export function widthCacheSize(): number {
|
||||
return widthCache.size
|
||||
}
|
||||
|
||||
export function evictWidthCache(keepRatio = 0): void {
|
||||
if (keepRatio <= 0) {
|
||||
widthCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const target = Math.floor(widthCache.size * keepRatio)
|
||||
|
||||
while (widthCache.size > target) {
|
||||
widthCache.delete(widthCache.keys().next().value!)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,3 +107,20 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
|
|||
|
||||
return memoizedWrap(text, maxWidth, wrapType)
|
||||
}
|
||||
|
||||
export function wrapCacheSize(): number {
|
||||
return wrapCache.size
|
||||
}
|
||||
|
||||
export function evictWrapCache(keepRatio = 0): void {
|
||||
if (keepRatio <= 0) {
|
||||
wrapCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const target = Math.floor(wrapCache.size * keepRatio)
|
||||
|
||||
while (wrapCache.size > target) {
|
||||
wrapCache.delete(wrapCache.keys().next().value!)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,23 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
|
|||
return computeSlice(str, start, end)
|
||||
}
|
||||
|
||||
export function sliceCacheSize(): number {
|
||||
return sliceCache.size
|
||||
}
|
||||
|
||||
export function evictSliceCache(keepRatio = 0): void {
|
||||
if (keepRatio <= 0) {
|
||||
sliceCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const target = Math.floor(sliceCache.size * keepRatio)
|
||||
|
||||
while (sliceCache.size > target) {
|
||||
sliceCache.delete(sliceCache.keys().next().value!)
|
||||
}
|
||||
}
|
||||
|
||||
function computeSlice(str: string, start: number, end?: number): string {
|
||||
const tokens = tokenize(str)
|
||||
let activeCodes: AnsiCode[] = []
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
import { evictInkCaches, type ScrollBoxHandle } from '@hermes/ink'
|
||||
import { type RefObject, useCallback } from 'react'
|
||||
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
|
|
@ -98,6 +98,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
setLastUserMsg('')
|
||||
setStickyPrompt('')
|
||||
composerActions.setPasteSnips([])
|
||||
// Half-prune Ink content caches: new session has new keys, but a partial
|
||||
// warm pool helps if the user resumes back to the prior session.
|
||||
evictInkCaches('half')
|
||||
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
|
||||
|
||||
const resetVisibleHistory = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { evictInkCaches } from '@hermes/ink'
|
||||
|
||||
import { type HeapDumpResult, performHeapDump } from './memory.js'
|
||||
|
||||
export type MemoryLevel = 'critical' | 'high' | 'normal'
|
||||
|
|
@ -39,6 +41,12 @@ export function startMemoryMonitor({
|
|||
return
|
||||
}
|
||||
|
||||
// Defensive eviction: prune Ink content caches before dumping/exiting.
|
||||
// 'high' = half-prune (still warm enough to recover quickly);
|
||||
// 'critical' = full drop. Reduces post-dump RSS and gives the user a
|
||||
// chance to keep running rather than auto-restart.
|
||||
evictInkCaches(level === 'critical' ? 'all' : 'half')
|
||||
|
||||
dumped.add(level)
|
||||
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
|
||||
|
||||
|
|
|
|||
10
ui-tui/src/types/hermes-ink.d.ts
vendored
10
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -124,6 +124,16 @@ declare module '@hermes/ink' {
|
|||
export const scrollFastPathStats: ScrollFastPathStats
|
||||
export function resetScrollFastPathStats(): void
|
||||
|
||||
export type EvictLevel = 'all' | 'half'
|
||||
export type InkCacheSizes = {
|
||||
readonly lineWidth: number
|
||||
readonly slice: number
|
||||
readonly width: number
|
||||
readonly wrap: number
|
||||
}
|
||||
export function evictInkCaches(level?: EvictLevel): InkCacheSizes
|
||||
export function inkCacheSizes(): InkCacheSizes
|
||||
|
||||
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
|
||||
|
||||
export function useApp(): { readonly exit: (error?: Error) => void }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue