detached b590931

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +156 -68
M docs/performance.md +53 -26
@@ -1,43 +1,70 @@
11 # Performance methodology (M5)
22
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.
49
5-Run microbenchmarks with:
10+## Large-history daemon baseline
11+
12+Run the full daemon matrix once when establishing a baseline:
613
714 ```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
916 ```
1017
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
1235
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.
1442
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:
1645
1746 ```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
2355 ```
2456
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
2658
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:
2861
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+```
3265
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:
3867
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 ```sh14 ```sh
8-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem15+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 status36+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 ```sh46 ```sh
18-VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-daemon.strace vev --daemon47+{
19-strace -ff -c -o vev-client.strace vev new perf-manual48+ 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 behavior59+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) {
3636 require.True(t, fixture.resized())
3737 }
3838
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+
39116 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
40117 fixture := newPerformanceFixture(t, performanceConfig{})
41118
@@ -54,7 +131,10 @@ func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.
54131 // performanceConfig intentionally describes only an in-process daemon shape:
55132 // no PTY readers, schedulers, clocks, or transports are started by this fixture.
56133 type performanceConfig struct {
57- size domain.Size
134+ size domain.Size
135+ tabs int
136+ panes int
137+ historyRows int
58138 }
59139
60140 type performanceMetrics struct {
@@ -83,7 +163,17 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
83163 if !config.size.Valid() {
84164 config.size = domain.Size{Cols: 80, Rows: 24}
85165 }
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...)
87177 output := &countingOutputTransport{}
88178 ac.tr = output
89179 ac.size = config.size
@@ -102,7 +192,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
102192 }
103193 WithSnapshotStore(fixture.snaps)(d)
104194 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))
106196 }
107197 fixture.activePane = fixture.findActivePane()
108198
@@ -113,28 +203,29 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
113203 return fixture
114204 }
115205
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) {
117207 f.t.Helper()
118208 tb.mu.Lock()
209+ defer tb.mu.Unlock()
119210 tb.size = size
120211 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+ }
125217 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
126218 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)
128220 f.d.applyLayoutLocked(tb)
129221 for _, p := range tb.panes {
130222 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++ {
133225 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
134226 }
135227 p.mu.Unlock()
136228 }
137- tb.mu.Unlock()
138229 }
139230
140231 // 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.Size134+ 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 = output178 ac.tr = output
89 ac.size = config.size179 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 fixture203 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 = size210 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] = right214+ 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.Height223+ 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 without231 // 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) {
648648 require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
649649 }
650650
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-
681651 func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
682652 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
683653 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close