Concrete perf metrics: live counters in --profile + benchmark suite
Live metrics (--profile): - New metricsTracker instruments OnPTYOut, viewport renderer, stdout writes, libghostty-vt Write/Title CGO calls, sidebar / tabbar / status draws (with cache-hit accounting), snapshot replays, and the chrome ticker (so we can see ticker fires that did nothing). - Writes metrics.jsonl (one snapshot per second) and metrics.json + summary.txt on exit, alongside the existing pprof files. - All record* methods are nil-safe so disabled paths pay only a cheap nil check; counters are atomic so the per-PTY-chunk hot path stays lock-free. Benchmark suite (go test -bench=.): - Three workload fixtures — plain ASCII, SGR-styled lines, and a ratatui-style cursor-shuffling burst — plus a containsOSC microbenchmark. Reports ns/op, MB/s, allocs/op, B/op. - Initial baseline numbers added to TODO under the perf-audit section, alongside two new findings (renderer allocs ~1 per 4 bytes on styled chunks; styled throughput tops out near 90 MB/s) those benchmarks surfaced.
This commit is contained in:
@@ -34,6 +34,12 @@ type Options struct {
|
||||
// <DebugDir>/<child-id>.raw. The dir is created if missing. Events
|
||||
// (spawn / exit / state change) land in <DebugDir>/events.jsonl.
|
||||
DebugDir string
|
||||
// ProfileDir, when non-empty, enables in-process performance
|
||||
// counters. patterm writes a per-second JSONL snapshot stream to
|
||||
// <ProfileDir>/metrics.jsonl, a final aggregate to metrics.json,
|
||||
// and a human-readable summary.txt on shutdown. The pprof files
|
||||
// written by --profile sit alongside these in the same dir.
|
||||
ProfileDir string
|
||||
}
|
||||
|
||||
const keyCtrlK byte = 0x0b
|
||||
@@ -134,6 +140,18 @@ func Run(ctx context.Context, opts Options) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Performance tracker — instrumented hot-path timings written to
|
||||
// <ProfileDir>. nil when --profile is off, in which case every
|
||||
// record*() call is a fast nil check.
|
||||
metrics, err := newMetricsTracker(opts.ProfileDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: metrics tracker: %w", err)
|
||||
}
|
||||
if metrics != nil {
|
||||
go metrics.run(ctx)
|
||||
defer metrics.close()
|
||||
}
|
||||
|
||||
// Per-session idle-detection classifier. One goroutine ticks every
|
||||
// 250ms over every live child and updates IdleState. It stops when
|
||||
// ctx is cancelled.
|
||||
@@ -150,7 +168,9 @@ func Run(ctx context.Context, opts Options) error {
|
||||
hostCols: cols,
|
||||
hostRows: rows,
|
||||
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
metrics: metrics,
|
||||
}
|
||||
sess.SetMetrics(metrics)
|
||||
host.attention = st
|
||||
host.focus = st
|
||||
host.prompter = st
|
||||
@@ -271,7 +291,9 @@ func Run(ctx context.Context, opts Options) error {
|
||||
}
|
||||
chromeChanged := st.chromeDirty.Swap(false)
|
||||
sidebarChanged := st.sidebarDirty.Swap(false)
|
||||
if !chromeChanged && !sidebarChanged {
|
||||
didWork := chromeChanged || sidebarChanged
|
||||
st.metrics.recordTickerFire(didWork)
|
||||
if !didWork {
|
||||
continue
|
||||
}
|
||||
if chromeChanged {
|
||||
@@ -383,6 +405,11 @@ type uiState struct {
|
||||
hostCols, hostRows uint16
|
||||
stdinTTY bool
|
||||
|
||||
// metrics is the optional performance tracker. nil when --profile
|
||||
// is off. Hot paths call metrics.recordX which is a fast nil
|
||||
// check on the disabled path.
|
||||
metrics *metricsTracker
|
||||
|
||||
// chromeCacheMu guards the last-rendered byte cache for each chrome
|
||||
// element. The tab bar, sidebar, and status line all repaint on
|
||||
// many state changes and on every PTY chunk, but their content
|
||||
@@ -787,6 +814,10 @@ func (st *uiState) scheduleAutoRestart(c *Child) {
|
||||
// disabled only around the replay so long styled runs cannot wrap into
|
||||
// the right rail.
|
||||
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||
var entry time.Time
|
||||
if st.metrics != nil {
|
||||
entry = time.Now()
|
||||
}
|
||||
layout := st.layoutSnapshot()
|
||||
st.mu.Lock()
|
||||
focus := st.focusedID
|
||||
@@ -803,16 +834,31 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if palOpen || focus != childID || renderer == nil {
|
||||
st.metrics.recordPTYOutDrop()
|
||||
return
|
||||
}
|
||||
var out []byte
|
||||
if forceRepaint {
|
||||
var snapStart time.Time
|
||||
if st.metrics != nil {
|
||||
snapStart = time.Now()
|
||||
}
|
||||
out = st.renderFocusedSnapshot(childID, renderer, layout)
|
||||
if st.metrics != nil {
|
||||
st.metrics.recordSnapshot(time.Since(snapStart))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var rstart time.Time
|
||||
if st.metrics != nil {
|
||||
rstart = time.Now()
|
||||
}
|
||||
out = renderer.Render(chunk)
|
||||
if st.metrics != nil {
|
||||
st.metrics.recordRender(time.Since(rstart))
|
||||
}
|
||||
}
|
||||
// One write covers the autowrap-disable prelude, the chunk, and the
|
||||
// autowrap-restore postlude — three syscalls collapsed into one
|
||||
@@ -822,9 +868,16 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||
wrapped = append(wrapped, "\x1b[?7l"...)
|
||||
wrapped = append(wrapped, out...)
|
||||
wrapped = append(wrapped, "\x1b[?7h"...)
|
||||
var wstart time.Time
|
||||
if st.metrics != nil {
|
||||
wstart = time.Now()
|
||||
}
|
||||
st.outMu.Lock()
|
||||
_, _ = os.Stdout.Write(wrapped)
|
||||
st.outMu.Unlock()
|
||||
if st.metrics != nil {
|
||||
st.metrics.recordStdout(time.Since(wstart), len(wrapped))
|
||||
}
|
||||
// RI / IND / NEL / SU / SD / IL / DL and bottom-margin LF / VT / FF
|
||||
// scroll content within the host's scroll region, which spans every
|
||||
// column — so any of them drags the right-hand sidebar's session-tree
|
||||
@@ -851,6 +904,9 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||
// avoiding the string build, FindChild, and locking on every
|
||||
// chunk pulls steady-state CPU off the hot path.
|
||||
st.markChromeDirty()
|
||||
if st.metrics != nil {
|
||||
st.metrics.recordPTYOut(time.Since(entry), len(chunk))
|
||||
}
|
||||
}
|
||||
|
||||
func (st *uiState) enterScreen() {
|
||||
@@ -990,6 +1046,10 @@ func (st *uiState) renderPaletteLocked() {
|
||||
// attention ask. Right side: palette hint. The PTY child occupies
|
||||
// host_rows-1 rows so this row is exclusively ours.
|
||||
func (st *uiState) drawStatusLine() {
|
||||
var entry time.Time
|
||||
if st.metrics != nil {
|
||||
entry = time.Now()
|
||||
}
|
||||
st.mu.Lock()
|
||||
palOpen := st.palette != nil
|
||||
focusID := st.focusedID
|
||||
@@ -1076,10 +1136,16 @@ func (st *uiState) drawStatusLine() {
|
||||
st.chromeCacheMu.Lock()
|
||||
if line == st.statusLineCache {
|
||||
st.chromeCacheMu.Unlock()
|
||||
if st.metrics != nil {
|
||||
st.metrics.recordStatus(time.Since(entry), true)
|
||||
}
|
||||
return
|
||||
}
|
||||
st.statusLineCache = line
|
||||
st.chromeCacheMu.Unlock()
|
||||
if st.metrics != nil {
|
||||
defer func() { st.metrics.recordStatus(time.Since(entry), false) }()
|
||||
}
|
||||
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user