internal/perf: single-goroutine profiler (no locks). When enabled by the /storage/emulated/0/PadPerf/enable marker it records one CSV row per logic frame (seq, ms, frame delta, page, scroll Dp, max-scroll Dp, total lines, visible byte range), logs a rolling ~1/s summary, and on Stop reports nearest-rank p50/p90/p99/max. Flushes per row-batch but never fsyncs per flush (avoids periodic hitches in the logic path). editor: PerfRecord package-level hook (nil when off) + ProbeRecord; Logic.emitFrame() now centralizes every frame emission so the profiler sees each logic frame exactly once, on the owner goroutine. State gains VisibleStart/VisibleEnd so the probe can confirm shaping stays viewport-bounded. logic: optional debug cmd poller (off by default) watches <dir>/cmd as a one-shot file (top/bottom/frac <0..1>/dp <int>) and the owner applies a clamped [0,MaxScroll] jump + frame. Enables deterministic large-offset scroll tests without pixel taps. main: wires the profiler when the marker file exists; logs present-fps every 2s; stops the profiler on Destroy. docs: README package inventory (add internal/perf); architecture §11 profiler facility; spec §5 invariant 7 (scroll always clamped to [0,maxScroll]) + §6 measured rows; development_plan v5 + Phase 6 results. Verified: go build/vet + full -race green. On-device 10 MB file: logic-frame cadence flat across offsets 0.02->1.0 (no large-offset degradation), visible byte range <=4.3 KB at every offset, clamping exact across 2->130,955-line files, PSS plateaus ~250 MB (bounded, no leak); profiler overhead negligible.
75 lines
1.9 KiB
Go
75 lines
1.9 KiB
Go
package perf
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPercentiles(t *testing.T) {
|
|
ds := []float64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
|
|
p50, p90, p99, mx := percentiles(ds)
|
|
if p50 != 50 {
|
|
t.Errorf("p50 = %v, want 50", p50)
|
|
}
|
|
if p90 != 90 {
|
|
t.Errorf("p90 = %v, want 90", p90)
|
|
}
|
|
if p99 != 100 {
|
|
t.Errorf("p99 = %v, want 100", p99)
|
|
}
|
|
if mx != 100 {
|
|
t.Errorf("max = %v, want 100", mx)
|
|
}
|
|
// Empty input is a no-op.
|
|
if _, _, _, m := percentiles(nil); m != 0 {
|
|
t.Errorf("empty max = %v, want 0", m)
|
|
}
|
|
}
|
|
|
|
func TestDisabledIsNoOp(t *testing.T) {
|
|
p := New(false, t.TempDir(), "x.csv")
|
|
p.Record(Ctx{Page: "editor"})
|
|
if p.enabled {
|
|
t.Fatalf("disabled profiler reported enabled")
|
|
}
|
|
if p.seq != 0 || len(p.buf) != 0 {
|
|
t.Errorf("disabled profiler recorded: seq=%d buf=%d", p.seq, len(p.buf))
|
|
}
|
|
}
|
|
|
|
func TestRecordFlushAndStop(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := New(true, dir, "logic_frames.csv")
|
|
if p == nil || p.file == nil {
|
|
t.Fatalf("expected an open output file, got %+v", p)
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
p.Record(Ctx{Page: "editor", ScrollDP: float32(i * 10), MaxScrollDP: 1000, TotalLines: 42, VisStart: i, VisEnd: i + 100})
|
|
}
|
|
// Force a flush regardless of the 2s timer.
|
|
p.lastFlush = time.Time{}
|
|
p.Record(Ctx{Page: "editor", ScrollDP: 50})
|
|
p.Stop()
|
|
|
|
out := filepath.Join(dir, "logic_frames.csv")
|
|
b, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatalf("read csv: %v", err)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
|
if len(lines) != 6 {
|
|
t.Fatalf("want 6 CSV rows, got %d:\n%s", len(lines), b)
|
|
}
|
|
// First row is seq 1, editor page.
|
|
if !strings.HasPrefix(lines[0], "1,") || !strings.Contains(lines[0], "editor") {
|
|
t.Errorf("unexpected first row: %s", lines[0])
|
|
}
|
|
// Last row should have TotalLines 42 (from the 5th record) and the 6th (Stop flush).
|
|
if !strings.Contains(lines[4], "42") {
|
|
t.Errorf("row 5 missing TotalLines=42: %s", lines[4])
|
|
}
|
|
}
|