perf(daemon): baseline large-history workloads
- commit
- b590931ca8a04cf8b7e5772d66d7d1c359783f9e
- author
- brice · · 2026-07-11 05:32 UTC
- committer
- brice · · 2026-07-11 06:15 UTC
- parent
- 2a2a3fb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +156 -68
M docs/performance.md +53 -26
| @@ -1,43 +1,70 @@ | |||
| 1 | 1 | # Performance methodology (M5) | |
| 2 | 2 | ||
| 3 | - | Enable daemon pprof explicitly with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon` (or by exporting the variable before launching clients). When unset, no debug HTTP server is started. | |
| 3 | + | This baseline measures in-process daemon work only. It deliberately does **not** | |
| 4 | + | measure coordinator behavior, chunk encoding, compression, GC tuning, or real | |
| 5 | + | network latency. Output and snapshot metrics count encoded output bytes and | |
| 6 | + | frames produced by the daemon; they are useful proxies for local rendering and | |
| 7 | + | IPC payload work, not measurements of a Unix socket, SSH, or UDP transport. | |
| 8 | + | Real Unix-socket, SSH, and UDP impairment/latency benchmarks are deferred. | |
| 4 | 9 | ||
| 5 | - | Run microbenchmarks with: | |
| 10 | + | ## Large-history daemon baseline | |
| 11 | + | ||
| 12 | + | Run the full daemon matrix once when establishing a baseline: | |
| 6 | 13 | ||
| 7 | 14 | ```sh | |
| 8 | - | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem | |
| 15 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| 9 | 16 | ``` | |
| 10 | 17 | ||
| 11 | - | Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles for raw/tmux runs. vev does not currently support `vev new <name> -- command`, so automated vev workload command overrides are intentionally not documented. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof. | |
| 18 | + | For repeatable comparison of the focused control workload, run three one-second | |
| 19 | + | samples (pin CPU/governor and record the host when comparing runs): | |
| 20 | + | ||
| 21 | + | ```sh | |
| 22 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistoryLivePaint/1tab-1pane-control$' -benchtime=1s -count=3 -benchmem | |
| 23 | + | ``` | |
| 24 | + | ||
| 25 | + | The matrix exercises live paint, snapshot capture, copy-mode entry, copy search, | |
| 26 | + | and resize at `120x40` client geometry. Each pane is populated with 10,000 | |
| 27 | + | full-width deterministic history rows containing a stable `needle-<tab>-<row>` | |
| 28 | + | prefix and patterned text. The layouts are: | |
| 29 | + | ||
| 30 | + | - 1 tab x 1 pane (control) | |
| 31 | + | - 1 tab x 4 panes | |
| 32 | + | - 4 tabs x 1 pane | |
| 33 | + | - 4 tabs x 4 panes | |
| 34 | + | - 8 tabs x 1 pane | |
| 12 | 35 | ||
| 13 | - | ## Local baseline status | |
| 36 | + | The fixture starts no PTY readers, schedulers, clocks, or transports. It primes | |
| 37 | + | the renderer shadow before timing; live paint then alternates fixed writes. The | |
| 38 | + | reported `outputbytes/op`, `framepayloadbytes/op`, and `outputframes/op` values | |
| 39 | + | are encoded in-process output proxies. `snapshotbytes/op` is the serialized | |
| 40 | + | in-process snapshot size. They must not be presented as network throughput or | |
| 41 | + | latency. | |
| 14 | 42 | ||
| 15 | - | Full end-to-end vev-vs-tmux workload baselines are deferred/waived for this non-interactive branch because they require an interactive terminal/daemon pair, external tracing tools around both processes, and (for automated vev flood workloads) future command override support. Until that exists, only start the daemon/client normally and do not claim a scripted vev flood command. tmux/raw comparison commands remain useful for shaping workloads: | |
| 43 | + | Capture a pre-change result outside the repository, including Go and kernel | |
| 44 | + | context: | |
| 16 | 45 | ||
| 17 | 46 | ```sh | |
| 18 | - | VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-daemon.strace vev --daemon | |
| 19 | - | strace -ff -c -o vev-client.strace vev new perf-manual | |
| 20 | - | strace -ff -c -o tmux-yes.strace tmux new-session -d -s perf-yes 'timeout 30s yes' | |
| 21 | - | strace -ff -c -o tmux-seq.strace tmux new-session -d -s perf-seq 'timeout 30s sh -c "while :; do seq 1 10000; done"' | |
| 22 | - | strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"' | |
| 47 | + | { | |
| 48 | + | date -Is | |
| 49 | + | go version | |
| 50 | + | uname -a | |
| 51 | + | git rev-parse HEAD | |
| 52 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| 53 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistoryLivePaint/1tab-1pane-control$' -benchtime=1s -count=3 -benchmem | |
| 54 | + | } > /tmp/vev-history-before.txt 2>&1 | |
| 23 | 55 | ``` | |
| 24 | 56 | ||
| 25 | - | Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing. | |
| 57 | + | ## Lower-level smoke baseline | |
| 26 | 58 | ||
| 27 | - | ## UDP mobile congestion behavior | |
| 59 | + | Use this command to check renderer, VT, IPC, and daemon microbenchmarks without | |
| 60 | + | claiming an end-to-end transport result: | |
| 28 | 61 | ||
| 29 | - | The datagram transport uses one shared congestion-responsive byte pacer for initial sends and retransmits. Retransmissions therefore consume the same byte budget as new data instead of bypassing congestion feedback. Datagram clients use a one-state output window: the cumulative ACK releases that state, and newer repaint state coalesces while it remains unacknowledged rather than retaining an unbounded queue. | |
| 30 | - | ||
| 31 | - | Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem`: | |
| 62 | + | ```sh | |
| 63 | + | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem | |
| 64 | + | ``` | |
| 32 | 65 | ||
| 33 | - | | benchmark | methodology | | |
| 34 | - | | --- | --- | | |
| 35 | - | | BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. | | |
| 36 | - | | BenchmarkTransportRecvReuse | reads a pre-encoded frame from a looping in-memory reader; no `Send` call, goroutine, channel, or frame production work runs inside the measured loop, so allocations reported here are Recv-side costs only. | | |
| 37 | - | | BenchmarkPaintCachedSinglePaneDamage | writes a small damage update into one pane, paints through the daemon compose→diff→send path, and advances the output ACK each iteration so the measured loop does not hit the unacked-output bailout. | | |
| 66 | + | Enable daemon pprof explicitly when collecting a daemon profile: | |
| 38 | 67 | ||
| 39 | - | | workload | vev baseline | tmux baseline | notes | | |
| 40 | - | | --- | --- | --- | --- | | |
| 41 | - | | yes | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration | | |
| 42 | - | | seq | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration | | |
| 43 | - | | cat | not run in this non-interactive review environment | not run in this non-interactive review environment | use same input file, terminal size, and duration | | |
| 68 | + | ```sh | |
| 69 | + | VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon | |
| 70 | + | ``` | |
| @@ -1,43 +1,70 @@ | |||||
| 1 | # Performance methodology (M5) | 1 | # Performance methodology (M5) | ||
| 2 | 2 | ||||
| 3 | - | Enable daemon pprof explicitly with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon` (or by exporting the variable before launching clients). When unset, no debug HTTP server is started. | 3 | + | This baseline measures in-process daemon work only. It deliberately does **not** |
| 4 | + | measure coordinator behavior, chunk encoding, compression, GC tuning, or real | |||
| 5 | + | network latency. Output and snapshot metrics count encoded output bytes and | |||
| 6 | + | frames produced by the daemon; they are useful proxies for local rendering and | |||
| 7 | + | IPC payload work, not measurements of a Unix socket, SSH, or UDP transport. | |||
| 8 | + | Real Unix-socket, SSH, and UDP impairment/latency benchmarks are deferred. | |||
| 4 | 9 | ||||
| 5 | - | Run microbenchmarks with: | 10 | + | ## Large-history daemon baseline |
| 11 | + | ||||
| 12 | + | Run the full daemon matrix once when establishing a baseline: | |||
| 6 | 13 | ||||
| 7 | ```sh | 14 | ```sh | ||
| 8 | - | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem | 15 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem |
| 9 | ``` | 16 | ``` | ||
| 10 | 17 | ||||
| 11 | - | Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles for raw/tmux runs. vev does not currently support `vev new <name> -- command`, so automated vev workload command overrides are intentionally not documented. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof. | 18 | + | For repeatable comparison of the focused control workload, run three one-second |
| 19 | + | samples (pin CPU/governor and record the host when comparing runs): | |||
| 20 | + | ||||
| 21 | + | ```sh | |||
| 22 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistoryLivePaint/1tab-1pane-control$' -benchtime=1s -count=3 -benchmem | |||
| 23 | + | ``` | |||
| 24 | + | ||||
| 25 | + | The matrix exercises live paint, snapshot capture, copy-mode entry, copy search, | |||
| 26 | + | and resize at `120x40` client geometry. Each pane is populated with 10,000 | |||
| 27 | + | full-width deterministic history rows containing a stable `needle-<tab>-<row>` | |||
| 28 | + | prefix and patterned text. The layouts are: | |||
| 29 | + | ||||
| 30 | + | - 1 tab x 1 pane (control) | |||
| 31 | + | - 1 tab x 4 panes | |||
| 32 | + | - 4 tabs x 1 pane | |||
| 33 | + | - 4 tabs x 4 panes | |||
| 34 | + | - 8 tabs x 1 pane | |||
| 12 | 35 | ||||
| 13 | - | ## Local baseline status | 36 | + | The fixture starts no PTY readers, schedulers, clocks, or transports. It primes |
| 37 | + | the renderer shadow before timing; live paint then alternates fixed writes. The | |||
| 38 | + | reported `outputbytes/op`, `framepayloadbytes/op`, and `outputframes/op` values | |||
| 39 | + | are encoded in-process output proxies. `snapshotbytes/op` is the serialized | |||
| 40 | + | in-process snapshot size. They must not be presented as network throughput or | |||
| 41 | + | latency. | |||
| 14 | 42 | ||||
| 15 | - | Full end-to-end vev-vs-tmux workload baselines are deferred/waived for this non-interactive branch because they require an interactive terminal/daemon pair, external tracing tools around both processes, and (for automated vev flood workloads) future command override support. Until that exists, only start the daemon/client normally and do not claim a scripted vev flood command. tmux/raw comparison commands remain useful for shaping workloads: | 43 | + | Capture a pre-change result outside the repository, including Go and kernel |
| 44 | + | context: | |||
| 16 | 45 | ||||
| 17 | ```sh | 46 | ```sh | ||
| 18 | - | VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-daemon.strace vev --daemon | 47 | + | { |
| 19 | - | strace -ff -c -o vev-client.strace vev new perf-manual | 48 | + | date -Is |
| 20 | - | strace -ff -c -o tmux-yes.strace tmux new-session -d -s perf-yes 'timeout 30s yes' | 49 | + | go version |
| 21 | - | strace -ff -c -o tmux-seq.strace tmux new-session -d -s perf-seq 'timeout 30s sh -c "while :; do seq 1 10000; done"' | 50 | + | uname -a |
| 22 | - | strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"' | 51 | + | git rev-parse HEAD |
| 52 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |||
| 53 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistoryLivePaint/1tab-1pane-control$' -benchtime=1s -count=3 -benchmem | |||
| 54 | + | } > /tmp/vev-history-before.txt 2>&1 | |||
| 23 | ``` | 55 | ``` | ||
| 24 | 56 | ||||
| 25 | - | Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing. | 57 | + | ## Lower-level smoke baseline |
| 26 | 58 | ||||
| 27 | - | ## UDP mobile congestion behavior | 59 | + | Use this command to check renderer, VT, IPC, and daemon microbenchmarks without |
| 60 | + | claiming an end-to-end transport result: | |||
| 28 | 61 | ||||
| 29 | - | The datagram transport uses one shared congestion-responsive byte pacer for initial sends and retransmits. Retransmissions therefore consume the same byte budget as new data instead of bypassing congestion feedback. Datagram clients use a one-state output window: the cumulative ACK releases that state, and newer repaint state coalesces while it remains unacknowledged rather than retaining an unbounded queue. | 62 | + | ```sh |
| 30 | - | 63 | + | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem | |
| 31 | - | Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem`: | 64 | + | ``` |
| 32 | 65 | ||||
| 33 | - | | benchmark | methodology | | 66 | + | Enable daemon pprof explicitly when collecting a daemon profile: |
| 34 | - | | --- | --- | | |||
| 35 | - | | BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. | | |||
| 36 | - | | BenchmarkTransportRecvReuse | reads a pre-encoded frame from a looping in-memory reader; no `Send` call, goroutine, channel, or frame production work runs inside the measured loop, so allocations reported here are Recv-side costs only. | | |||
| 37 | - | | BenchmarkPaintCachedSinglePaneDamage | writes a small damage update into one pane, paints through the daemon compose→diff→send path, and advances the output ACK each iteration so the measured loop does not hit the unacked-output bailout. | | |||
| 38 | 67 | ||||
| 39 | - | | workload | vev baseline | tmux baseline | notes | | 68 | + | ```sh |
| 40 | - | | --- | --- | --- | --- | | 69 | + | VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon |
| 41 | - | | yes | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration | | 70 | + | ``` |
| 42 | - | | seq | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration | | |||
| 43 | - | | cat | not run in this non-interactive review environment | not run in this non-interactive review environment | use same input file, terminal size, and duration | | |||
M internal/usecase/daemon/performance_benchmark_test.go +103 -12
| @@ -36,6 +36,83 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||
| 36 | 36 | require.True(t, fixture.resized()) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |
| 40 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: 10_000}) | |
| 41 | + | ||
| 42 | + | require.Len(t, fixture.sess.tabs, 4) | |
| 43 | + | for _, tb := range fixture.sess.tabs { | |
| 44 | + | require.Len(t, tb.panes, 4) | |
| 45 | + | for _, p := range tb.panes { | |
| 46 | + | require.Equal(t, 10_000, p.scrollback.Len()) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | |
| 52 | + | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | |
| 53 | + | } | |
| 54 | + | func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | |
| 55 | + | benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() }) | |
| 56 | + | } | |
| 57 | + | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | |
| 58 | + | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | |
| 59 | + | } | |
| 60 | + | func BenchmarkDaemonHistoryCopySearch(b *testing.B) { | |
| 61 | + | benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) }) | |
| 62 | + | } | |
| 63 | + | func BenchmarkDaemonHistoryResize(b *testing.B) { | |
| 64 | + | benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() }) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) { | |
| 68 | + | b.Helper() | |
| 69 | + | for _, topology := range []struct { | |
| 70 | + | name string | |
| 71 | + | tabs, panes int | |
| 72 | + | }{ | |
| 73 | + | {name: "1tab-1pane-control", tabs: 1, panes: 1}, | |
| 74 | + | {name: "1tab-4panes", tabs: 1, panes: 4}, | |
| 75 | + | {name: "4tabs-1pane", tabs: 4, panes: 1}, | |
| 76 | + | {name: "4tabs-4panes", tabs: 4, panes: 4}, | |
| 77 | + | {name: "8tabs-1pane", tabs: 8, panes: 1}, | |
| 78 | + | } { | |
| 79 | + | b.Run(topology.name, func(b *testing.B) { | |
| 80 | + | fixture := newPerformanceFixture(b, performanceConfig{ | |
| 81 | + | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | |
| 82 | + | }) | |
| 83 | + | if workload == "copy-search" { | |
| 84 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |
| 85 | + | } | |
| 86 | + | b.ReportAllocs() | |
| 87 | + | fixture.resetMetrics() | |
| 88 | + | b.ResetTimer() | |
| 89 | + | for b.Loop() { | |
| 90 | + | run(fixture) | |
| 91 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |
| 92 | + | } | |
| 93 | + | b.StopTimer() | |
| 94 | + | metrics := fixture.metrics() | |
| 95 | + | if workload == "live-paint" && metrics.outputFrames != uint64(b.N) { | |
| 96 | + | b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N) | |
| 97 | + | } | |
| 98 | + | benchmarkReportMetrics(b, metrics, b.N, 10_000) | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) { | |
| 104 | + | b.Helper() | |
| 105 | + | if operations == 0 { | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | perOperation := float64(operations) | |
| 109 | + | b.ReportMetric(float64(metrics.outputFrames)/perOperation, "outputframes/op") | |
| 110 | + | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | |
| 111 | + | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | |
| 112 | + | b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op") | |
| 113 | + | b.ReportMetric(float64(historyRows), "historyrows/pane") | |
| 114 | + | } | |
| 115 | + | ||
| 39 | 116 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | |
| 40 | 117 | fixture := newPerformanceFixture(t, performanceConfig{}) | |
| 41 | 118 | ||
| @@ -54,7 +131,10 @@ func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing. | |||
| 54 | 131 | // performanceConfig intentionally describes only an in-process daemon shape: | |
| 55 | 132 | // no PTY readers, schedulers, clocks, or transports are started by this fixture. | |
| 56 | 133 | type performanceConfig struct { | |
| 57 | - | size domain.Size | |
| 134 | + | size domain.Size | |
| 135 | + | tabs int | |
| 136 | + | panes int | |
| 137 | + | historyRows int | |
| 58 | 138 | } | |
| 59 | 139 | ||
| 60 | 140 | type performanceMetrics struct { | |
| @@ -83,7 +163,17 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 83 | 163 | if !config.size.Valid() { | |
| 84 | 164 | config.size = domain.Size{Cols: 80, Rows: 24} | |
| 85 | 165 | } | |
| 86 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 166 | + | if config.tabs == 0 { | |
| 167 | + | config.tabs = 1 | |
| 168 | + | } | |
| 169 | + | if config.panes == 0 { | |
| 170 | + | config.panes = 2 | |
| 171 | + | } | |
| 172 | + | if config.historyRows == 0 { | |
| 173 | + | config.historyRows = 1 | |
| 174 | + | } | |
| 175 | + | ptys := make([]ports.PTY, config.tabs) | |
| 176 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | |
| 87 | 177 | output := &countingOutputTransport{} | |
| 88 | 178 | ac.tr = output | |
| 89 | 179 | ac.size = config.size | |
| @@ -102,7 +192,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 102 | 192 | } | |
| 103 | 193 | WithSnapshotStore(fixture.snaps)(d) | |
| 104 | 194 | for tabIndex, tb := range sess.tabs { | |
| 105 | - | fixture.configureTab(tb, tabIndex, tabSize(config.size)) | |
| 195 | + | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | |
| 106 | 196 | } | |
| 107 | 197 | fixture.activePane = fixture.findActivePane() | |
| 108 | 198 | ||
| @@ -113,28 +203,29 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 113 | 203 | return fixture | |
| 114 | 204 | } | |
| 115 | 205 | ||
| 116 | - | func (f *performanceFixture) configureTab(tb *tab, tabIndex int, size domain.Size) { | |
| 206 | + | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | |
| 117 | 207 | f.t.Helper() | |
| 118 | 208 | tb.mu.Lock() | |
| 209 | + | defer tb.mu.Unlock() | |
| 119 | 210 | tb.size = size | |
| 120 | 211 | left := tb.focusedPane() | |
| 121 | - | rightID := layout.PaneID(fmt.Sprintf("pane-%d-right", tabIndex+1)) | |
| 122 | - | right := newPane(rightID, nil, size) | |
| 123 | - | tb.panes[rightID] = right | |
| 124 | - | require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, rightID, domain.Rect{Width: size.Cols, Height: size.Rows})) | |
| 212 | + | for paneIndex := 1; paneIndex < paneCount; paneIndex++ { | |
| 213 | + | id := layout.PaneID(fmt.Sprintf("pane-%d-%d", tabIndex+1, paneIndex+1)) | |
| 214 | + | tb.panes[id] = newPane(id, nil, size) | |
| 215 | + | require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, id, domain.Rect{Width: size.Cols, Height: size.Rows})) | |
| 216 | + | } | |
| 125 | 217 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | |
| 126 | 218 | require.True(f.t, ok, "performance fixture layout must be solvable") | |
| 127 | - | require.Len(f.t, placements, 2) | |
| 219 | + | require.Len(f.t, placements, paneCount) | |
| 128 | 220 | f.d.applyLayoutLocked(tb) | |
| 129 | 221 | for _, p := range tb.panes { | |
| 130 | 222 | p.mu.Lock() | |
| 131 | - | width, height := p.screen.Frame.Width, p.screen.Frame.Height | |
| 132 | - | for row := range max(height-1, 1) { | |
| 223 | + | width := p.screen.Frame.Width | |
| 224 | + | for row := 0; p.scrollback.Len() < historyRows; row++ { | |
| 133 | 225 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | |
| 134 | 226 | } | |
| 135 | 227 | p.mu.Unlock() | |
| 136 | 228 | } | |
| 137 | - | tb.mu.Unlock() | |
| 138 | 229 | } | |
| 139 | 230 | ||
| 140 | 231 | // performanceFullWidthRow produces stable, full-width visible content without | |
| @@ -36,6 +36,83 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||||
| 36 | require.True(t, fixture.resized()) | 36 | require.True(t, fixture.resized()) | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |||
| 40 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: 10_000}) | |||
| 41 | + | ||||
| 42 | + | require.Len(t, fixture.sess.tabs, 4) | |||
| 43 | + | for _, tb := range fixture.sess.tabs { | |||
| 44 | + | require.Len(t, tb.panes, 4) | |||
| 45 | + | for _, p := range tb.panes { | |||
| 46 | + | require.Equal(t, 10_000, p.scrollback.Len()) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | |||
| 52 | + | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | |||
| 53 | + | } | |||
| 54 | + | func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | |||
| 55 | + | benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() }) | |||
| 56 | + | } | |||
| 57 | + | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | |||
| 58 | + | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | |||
| 59 | + | } | |||
| 60 | + | func BenchmarkDaemonHistoryCopySearch(b *testing.B) { | |||
| 61 | + | benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) }) | |||
| 62 | + | } | |||
| 63 | + | func BenchmarkDaemonHistoryResize(b *testing.B) { | |||
| 64 | + | benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() }) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) { | |||
| 68 | + | b.Helper() | |||
| 69 | + | for _, topology := range []struct { | |||
| 70 | + | name string | |||
| 71 | + | tabs, panes int | |||
| 72 | + | }{ | |||
| 73 | + | {name: "1tab-1pane-control", tabs: 1, panes: 1}, | |||
| 74 | + | {name: "1tab-4panes", tabs: 1, panes: 4}, | |||
| 75 | + | {name: "4tabs-1pane", tabs: 4, panes: 1}, | |||
| 76 | + | {name: "4tabs-4panes", tabs: 4, panes: 4}, | |||
| 77 | + | {name: "8tabs-1pane", tabs: 8, panes: 1}, | |||
| 78 | + | } { | |||
| 79 | + | b.Run(topology.name, func(b *testing.B) { | |||
| 80 | + | fixture := newPerformanceFixture(b, performanceConfig{ | |||
| 81 | + | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | |||
| 82 | + | }) | |||
| 83 | + | if workload == "copy-search" { | |||
| 84 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 85 | + | } | |||
| 86 | + | b.ReportAllocs() | |||
| 87 | + | fixture.resetMetrics() | |||
| 88 | + | b.ResetTimer() | |||
| 89 | + | for b.Loop() { | |||
| 90 | + | run(fixture) | |||
| 91 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 92 | + | } | |||
| 93 | + | b.StopTimer() | |||
| 94 | + | metrics := fixture.metrics() | |||
| 95 | + | if workload == "live-paint" && metrics.outputFrames != uint64(b.N) { | |||
| 96 | + | b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N) | |||
| 97 | + | } | |||
| 98 | + | benchmarkReportMetrics(b, metrics, b.N, 10_000) | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) { | |||
| 104 | + | b.Helper() | |||
| 105 | + | if operations == 0 { | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | perOperation := float64(operations) | |||
| 109 | + | b.ReportMetric(float64(metrics.outputFrames)/perOperation, "outputframes/op") | |||
| 110 | + | b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op") | |||
| 111 | + | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | |||
| 112 | + | b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op") | |||
| 113 | + | b.ReportMetric(float64(historyRows), "historyrows/pane") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 39 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | 116 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | ||
| 40 | fixture := newPerformanceFixture(t, performanceConfig{}) | 117 | fixture := newPerformanceFixture(t, performanceConfig{}) | ||
| 41 | 118 | ||||
| @@ -54,7 +131,10 @@ func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing. | |||||
| 54 | // performanceConfig intentionally describes only an in-process daemon shape: | 131 | // performanceConfig intentionally describes only an in-process daemon shape: | ||
| 55 | // no PTY readers, schedulers, clocks, or transports are started by this fixture. | 132 | // no PTY readers, schedulers, clocks, or transports are started by this fixture. | ||
| 56 | type performanceConfig struct { | 133 | type performanceConfig struct { | ||
| 57 | - | size domain.Size | 134 | + | size domain.Size |
| 135 | + | tabs int | |||
| 136 | + | panes int | |||
| 137 | + | historyRows int | |||
| 58 | } | 138 | } | ||
| 59 | 139 | ||||
| 60 | type performanceMetrics struct { | 140 | type performanceMetrics struct { | ||
| @@ -83,7 +163,17 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 83 | if !config.size.Valid() { | 163 | if !config.size.Valid() { | ||
| 84 | config.size = domain.Size{Cols: 80, Rows: 24} | 164 | config.size = domain.Size{Cols: 80, Rows: 24} | ||
| 85 | } | 165 | } | ||
| 86 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | 166 | + | if config.tabs == 0 { |
| 167 | + | config.tabs = 1 | |||
| 168 | + | } | |||
| 169 | + | if config.panes == 0 { | |||
| 170 | + | config.panes = 2 | |||
| 171 | + | } | |||
| 172 | + | if config.historyRows == 0 { | |||
| 173 | + | config.historyRows = 1 | |||
| 174 | + | } | |||
| 175 | + | ptys := make([]ports.PTY, config.tabs) | |||
| 176 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | |||
| 87 | output := &countingOutputTransport{} | 177 | output := &countingOutputTransport{} | ||
| 88 | ac.tr = output | 178 | ac.tr = output | ||
| 89 | ac.size = config.size | 179 | ac.size = config.size | ||
| @@ -102,7 +192,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 102 | } | 192 | } | ||
| 103 | WithSnapshotStore(fixture.snaps)(d) | 193 | WithSnapshotStore(fixture.snaps)(d) | ||
| 104 | for tabIndex, tb := range sess.tabs { | 194 | for tabIndex, tb := range sess.tabs { | ||
| 105 | - | fixture.configureTab(tb, tabIndex, tabSize(config.size)) | 195 | + | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) |
| 106 | } | 196 | } | ||
| 107 | fixture.activePane = fixture.findActivePane() | 197 | fixture.activePane = fixture.findActivePane() | ||
| 108 | 198 | ||||
| @@ -113,28 +203,29 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 113 | return fixture | 203 | return fixture | ||
| 114 | } | 204 | } | ||
| 115 | 205 | ||||
| 116 | - | func (f *performanceFixture) configureTab(tb *tab, tabIndex int, size domain.Size) { | 206 | + | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { |
| 117 | f.t.Helper() | 207 | f.t.Helper() | ||
| 118 | tb.mu.Lock() | 208 | tb.mu.Lock() | ||
| 209 | + | defer tb.mu.Unlock() | |||
| 119 | tb.size = size | 210 | tb.size = size | ||
| 120 | left := tb.focusedPane() | 211 | left := tb.focusedPane() | ||
| 121 | - | rightID := layout.PaneID(fmt.Sprintf("pane-%d-right", tabIndex+1)) | 212 | + | for paneIndex := 1; paneIndex < paneCount; paneIndex++ { |
| 122 | - | right := newPane(rightID, nil, size) | 213 | + | id := layout.PaneID(fmt.Sprintf("pane-%d-%d", tabIndex+1, paneIndex+1)) |
| 123 | - | tb.panes[rightID] = right | 214 | + | tb.panes[id] = newPane(id, nil, size) |
| 124 | - | require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, rightID, domain.Rect{Width: size.Cols, Height: size.Rows})) | 215 | + | require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, id, domain.Rect{Width: size.Cols, Height: size.Rows})) |
| 216 | + | } | |||
| 125 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | 217 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | ||
| 126 | require.True(f.t, ok, "performance fixture layout must be solvable") | 218 | require.True(f.t, ok, "performance fixture layout must be solvable") | ||
| 127 | - | require.Len(f.t, placements, 2) | 219 | + | require.Len(f.t, placements, paneCount) |
| 128 | f.d.applyLayoutLocked(tb) | 220 | f.d.applyLayoutLocked(tb) | ||
| 129 | for _, p := range tb.panes { | 221 | for _, p := range tb.panes { | ||
| 130 | p.mu.Lock() | 222 | p.mu.Lock() | ||
| 131 | - | width, height := p.screen.Frame.Width, p.screen.Frame.Height | 223 | + | width := p.screen.Frame.Width |
| 132 | - | for row := range max(height-1, 1) { | 224 | + | for row := 0; p.scrollback.Len() < historyRows; row++ { |
| 133 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | 225 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | ||
| 134 | } | 226 | } | ||
| 135 | p.mu.Unlock() | 227 | p.mu.Unlock() | ||
| 136 | } | 228 | } | ||
| 137 | - | tb.mu.Unlock() | |||
| 138 | } | 229 | } | ||
| 139 | 230 | ||||
| 140 | // performanceFullWidthRow produces stable, full-width visible content without | 231 | // performanceFullWidthRow produces stable, full-width visible content without | ||
M internal/usecase/daemon/render_test.go +0 -30
| @@ -648,36 +648,6 @@ func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |||
| 648 | 648 | require.Zero(t, allocs, "valid cached title-bar renders must not allocate") | |
| 649 | 649 | } | |
| 650 | 650 | ||
| 651 | - | func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |
| 652 | - | win := newTab(nil, domain.Size{Cols: 81, Rows: 24}) | |
| 653 | - | left := win.focusedPane() | |
| 654 | - | right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24}) | |
| 655 | - | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 656 | - | win.tree.Focus = right.id | |
| 657 | - | win.panes[right.id] = right | |
| 658 | - | left.screen.Write([]byte("left")) | |
| 659 | - | right.screen.Write([]byte("right")) | |
| 660 | - | sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0} | |
| 661 | - | ac := &attachedClient{tr: discardTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 81, Rows: 26}} | |
| 662 | - | ac.initOverlays() | |
| 663 | - | sess.client = ac | |
| 664 | - | ac.setSession(sess) | |
| 665 | - | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 666 | - | d.paint(sess, ac, true) | |
| 667 | - | left.screen.ClearDamage() | |
| 668 | - | right.screen.ClearDamage() | |
| 669 | - | ||
| 670 | - | b.ReportAllocs() | |
| 671 | - | b.ResetTimer() | |
| 672 | - | for i := 0; i < b.N; i++ { | |
| 673 | - | right.screen.Write([]byte("x\b")) | |
| 674 | - | d.paint(sess, ac, false) | |
| 675 | - | // Mirror the client ACK pump so the steady state measures compose→diff→send, | |
| 676 | - | // not the ack-gate bailout after maxUnackedOutputStates frames. | |
| 677 | - | ac.ackOutputState(ac.output.next) | |
| 678 | - | } | |
| 679 | - | } | |
| 680 | - | ||
| 681 | 651 | func TestComposeTabFrameStackUsesShellFallback(t *testing.T) { | |
| 682 | 652 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 683 | 653 | p := win.focusedPane() | |
| @@ -648,36 +648,6 @@ func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |||||
| 648 | require.Zero(t, allocs, "valid cached title-bar renders must not allocate") | 648 | require.Zero(t, allocs, "valid cached title-bar renders must not allocate") | ||
| 649 | } | 649 | } | ||
| 650 | 650 | ||||
| 651 | - | func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||
| 652 | - | win := newTab(nil, domain.Size{Cols: 81, Rows: 24}) | |||
| 653 | - | left := win.focusedPane() | |||
| 654 | - | right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24}) | |||
| 655 | - | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 656 | - | win.tree.Focus = right.id | |||
| 657 | - | win.panes[right.id] = right | |||
| 658 | - | left.screen.Write([]byte("left")) | |||
| 659 | - | right.screen.Write([]byte("right")) | |||
| 660 | - | sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0} | |||
| 661 | - | ac := &attachedClient{tr: discardTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 81, Rows: 26}} | |||
| 662 | - | ac.initOverlays() | |||
| 663 | - | sess.client = ac | |||
| 664 | - | ac.setSession(sess) | |||
| 665 | - | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 666 | - | d.paint(sess, ac, true) | |||
| 667 | - | left.screen.ClearDamage() | |||
| 668 | - | right.screen.ClearDamage() | |||
| 669 | - | ||||
| 670 | - | b.ReportAllocs() | |||
| 671 | - | b.ResetTimer() | |||
| 672 | - | for i := 0; i < b.N; i++ { | |||
| 673 | - | right.screen.Write([]byte("x\b")) | |||
| 674 | - | d.paint(sess, ac, false) | |||
| 675 | - | // Mirror the client ACK pump so the steady state measures compose→diff→send, | |||
| 676 | - | // not the ack-gate bailout after maxUnackedOutputStates frames. | |||
| 677 | - | ac.ackOutputState(ac.output.next) | |||
| 678 | - | } | |||
| 679 | - | } | |||
| 680 | - | ||||
| 681 | func TestComposeTabFrameStackUsesShellFallback(t *testing.T) { | 651 | func TestComposeTabFrameStackUsesShellFallback(t *testing.T) { | ||
| 682 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | 652 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | ||
| 683 | p := win.focusedPane() | 653 | p := win.focusedPane() | ||