Pad/internal/perf/perf.go
Greg Pomerantz ac1c32e4c0 Add pre-release frame-regression profiling
Ensures the app never generates frames without a cause (frame emission
is event-driven; a spinner would burn CPU/battery for the app's entire
idle life). Verified on the emulator and the phone: healthy runs show
ZERO frames across all idle windows (PERF-PRESENT fps < 1).

- perf: rows whose previous frame is >= 100ms away are idle gaps, not
  slow frames — flagged in the CSV (new gap column) and kept out of the
  logcat summary's latency percentiles (reported as gaps=N maxGap=...).
  A frame >= 2s after the previous one flushes the CSV, so a burst's
  rows are persisted the moment the next burst starts (an idle tail or
  force-stop no longer loses the last burst).
- editor: debug 'open <path>' command (cmd-file poller, perf mode):
  opens a file from any page via the same OpenFile path as a browser
  tap — deterministic, no pixel tapping.
- e2e: TestNoFramesWhileIdle (browser + editor after load/scroll/find)
  asserts the logic emits ZERO frames across an idle window once
  settled — the headless contract, in the regular go-test suite.
- scripts/profile_emulator.sh: drives launch, 8s idle, open (seeded
  4000-line file), 16s idle, 3 scrolls on a device/emulator; groups the
  CSV into phases at idle gaps >= 2s and FAILs on a phase over its frame
  budget (1/s spinner exceeds a 16s idle budget; faster ones balloon a
  phase or show in PERF-PRESENT). Validated both ways: PASS on a healthy
  build (emulator + phone), FAIL on an injected 500ms frame spinner.
- doc: architecture.md §11 updated (gap handling, burst flush, debug
  open, frame-regression guard, 2026-08 measurements).
2026-08-20 14:18:08 -04:00

242 lines
6.4 KiB
Go

// Package perf provides a lightweight, dependency-free in-app frame profiler.
//
// It is owned by a single goroutine (the logic goroutine) and records
// per-frame timing plus a small context (scroll offset, max scroll, total
// lines, visible byte range) so scroll performance and clamping can be
// analysed offline. It is an instrumentation facility: the app enables it at
// runtime (see cmd/pad) and the per-frame cost is a slice append plus
// occasional log/flush. With the record hook nil (the default), it is unused.
package perf
import (
"fmt"
"log"
"math"
"os"
"path/filepath"
"sort"
"time"
)
// Ctx is the per-frame context recorded alongside timing.
type Ctx struct {
Page string // "editor" | "browser"
ScrollDP float32 // scroll offset (Dp)
MaxScrollDP float32 // max scroll (Dp)
TotalLines int
VisStart int // visible byte range start
VisEnd int // visible byte range end (exclusive)
}
// Row is one recorded frame, the unit of CSV output.
type Row struct {
Seq int
Tms float64 // ms since profiling started
DeltaMs float64 // ms since previous frame emission
Page string
ScrollDP float32
MaxScrollDP float32
TotalLines int
VisStart int
VisEnd int
Gap bool // DeltaMs is an idle gap, not frame latency (see gapThreshold)
}
// gapThreshold separates "the app was idle" from "a frame was slow": a
// gap of this length or more means no frames were emitted for that whole
// time, so the first frame of the next burst carries the entire idle period
// in DeltaMs. A genuinely slow frame on a real device is at most a few
// tens of ms.
const gapThreshold = 100 * time.Millisecond
// Profiler records logic-frame cadence plus context. All methods must be
// called from the owning goroutine only (no locks).
type Profiler struct {
enabled bool
outPath string
start time.Time
last time.Time
lastLog time.Time
lastFlush time.Time
buf []Row
seq int
file *os.File
}
// New creates a profiler. If enabled and outDir is non-empty, it creates the
// output directory and file (named outDir/name). A failed file disables CSV
// output but keeps the logcat summaries.
func New(enabled bool, outDir, name string) *Profiler {
p := &Profiler{enabled: enabled}
if !enabled {
return p
}
p.start = time.Now()
p.lastLog = time.Now()
p.lastFlush = time.Now()
if outDir != "" {
if err := os.MkdirAll(outDir, 0o755); err == nil {
p.outPath = filepath.Join(outDir, name)
if f, err := os.Create(p.outPath); err == nil {
p.file = f
} else {
log.Printf("PERF: cannot create %s: %v", p.outPath, err)
}
} else {
log.Printf("PERF: cannot create dir %s: %v", outDir, err)
}
}
return p
}
// Record is called once per frame emission, on the owner goroutine. It is the
// only hot-path cost: a slice append, with a logcat summary ~1/s and a disk
// flush ~2/s (the flush is a single small write, never per-frame).
func (p *Profiler) Record(ctx Ctx) {
if !p.enabled {
return
}
now := time.Now()
if p.start.IsZero() {
p.start = now
p.lastLog = now
p.lastFlush = now
}
var delta time.Duration
if !p.last.IsZero() {
delta = now.Sub(p.last)
}
p.last = now
p.seq++
p.buf = append(p.buf, Row{
Seq: p.seq,
Tms: float64(now.Sub(p.start).Nanoseconds()) / 1e6,
DeltaMs: float64(delta.Nanoseconds()) / 1e6,
Page: ctx.Page,
ScrollDP: ctx.ScrollDP,
MaxScrollDP: ctx.MaxScrollDP,
TotalLines: ctx.TotalLines,
VisStart: ctx.VisStart,
VisEnd: ctx.VisEnd,
Gap: delta >= gapThreshold,
})
if now.Sub(p.lastLog) >= time.Second {
p.logSummary()
p.lastLog = now
}
if now.Sub(p.lastFlush) >= 2*time.Second {
p.flush()
p.lastFlush = now
}
// Burst ended: persist immediately. The periodic flush above only runs
// inside Record, so without this an idle tail (or a force-stopped app)
// would lose the last burst's rows; the frame that opens a new burst is
// the moment the previous burst's rows become final.
if delta >= 2*time.Second {
p.flush()
p.lastFlush = now
}
}
// logSummary prints a rolling percentile summary of the frames buffered since
// the last flush to logcat. Idle gaps are kept OUT of the latency
// percentiles (they are pauses between bursts of activity, not slow frames)
// and reported separately.
func (p *Profiler) logSummary() {
n := len(p.buf)
if n == 0 {
return
}
gaps, maxGap := 0, 0.0
ds := make([]float64, 0, n)
for _, r := range p.buf {
if r.Gap {
gaps++
if r.DeltaMs > maxGap {
maxGap = r.DeltaMs
}
continue
}
ds = append(ds, r.DeltaMs)
}
if len(ds) == 0 {
log.Printf("PERF frames=%d (all idle gaps; max=%.0f ms)", n, maxGap)
return
}
p50, p90, p99, mx := percentiles(ds)
over16, over33 := 0, 0
for _, d := range ds {
if d > 16.7 {
over16++
}
if d > 33.4 {
over33++
}
}
log.Printf("PERF frames=%d p50=%.1f p90=%.1f p99=%.1f max=%.1f ms over16.7=%.0f%% over33.4=%.0f%% gaps=%d maxGap=%.0fms",
n, p50, p90, p99, mx, pct(over16, len(ds)), pct(over33, len(ds)), gaps, maxGap)
}
// flush appends the buffered rows to the CSV file and clears the buffer.
func (p *Profiler) flush() {
if len(p.buf) > 0 && p.file != nil {
for _, r := range p.buf {
fmt.Fprintf(p.file, "%d,%.3f,%.3f,%s,%.1f,%.1f,%d,%d,%d,%v\n",
r.Seq, r.Tms, r.DeltaMs, r.Page, r.ScrollDP, r.MaxScrollDP, r.TotalLines, r.VisStart, r.VisEnd, r.Gap)
}
}
p.buf = p.buf[:0]
}
// Stop flushes remaining rows and closes the file. Call once on shutdown.
func (p *Profiler) Stop() {
if !p.enabled {
return
}
p.flush()
if p.file != nil {
p.file.Close()
p.file = nil
}
log.Printf("PERF stopped: %d frames, out=%s", p.seq, p.outPath)
}
// percentiles returns p50/p90/p99/max of ds in ms.
func percentiles(ds []float64) (p50, p90, p99, mx float64) {
if len(ds) == 0 {
return
}
s := make([]float64, len(ds))
copy(s, ds)
sort.Float64s(s)
p50 = s[pctlIdx(len(s), 0.50)]
p90 = s[pctlIdx(len(s), 0.90)]
p99 = s[pctlIdx(len(s), 0.99)]
if p99 < p90 {
p99 = p90
}
mx = s[len(s)-1]
return
}
// pctlIdx returns the nearest-rank index for fraction f (in [0,1]) of n
// samples: the smallest i such that i/n >= f, clamped to [0, n-1].
func pctlIdx(n int, f float64) int {
i := int(math.Ceil(float64(n)*f)) - 1
if i < 0 {
i = 0
}
if i >= n {
i = n - 1
}
return i
}
func pct(part, whole int) float64 {
if whole == 0 {
return 0
}
return 100 * float64(part) / float64(whole)
}