detached 5e9d579

perf: add m5 instrumentation pass

commit
5e9d5796e8939790b843b1daa5e0bf3c7bb99c21
author
brice · · 2026-07-02 16:16 UTC
parent
fb67a13
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +163 -9
A docs/performance.md +19 -0
@@ -0,0 +1,19 @@
1+# Performance methodology (M5)
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.
4+
5+Run microbenchmarks with:
6+
7+```sh
8+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem
9+```
10+
11+Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof.
12+
13+Comparison table placeholders (record local machine details before filling):
14+
15+| workload | vev baseline | tmux baseline | notes |
16+| --- | --- | --- | --- |
17+| yes | TODO local run | TODO local run | same terminal size |
18+| seq | TODO local run | TODO local run | same duration |
19+| cat | TODO local run | TODO local run | same input file |
@@ -0,0 +1,19 @@
1+# Performance methodology (M5)
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.
4+
5+Run microbenchmarks with:
6+
7+```sh
8+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem
9+```
10+
11+Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof.
12+
13+Comparison table placeholders (record local machine details before filling):
14+
15+| workload | vev baseline | tmux baseline | notes |
16+| --- | --- | --- | --- |
17+| yes | TODO local run | TODO local run | same terminal size |
18+| seq | TODO local run | TODO local run | same duration |
19+| cat | TODO local run | TODO local run | same input file |
A internal/adapters/ipc/transport_benchmark_test.go +31 -0
@@ -0,0 +1,31 @@
1+package ipc
2+
3+import (
4+ "net"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+func BenchmarkTransportRecvReuse(b *testing.B) {
11+ c1, c2 := net.Pipe()
12+ defer func() { _ = c1.Close() }()
13+ defer func() { _ = c2.Close() }()
14+ tr := NewTransport(c2)
15+ payload := []byte("payload payload payload")
16+ done := make(chan struct{})
17+ go func() {
18+ defer close(done)
19+ for i := 0; i < b.N; i++ {
20+ _ = NewTransport(c1).Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})
21+ }
22+ }()
23+ b.ReportAllocs()
24+ b.ResetTimer()
25+ for i := 0; i < b.N; i++ {
26+ if _, err := tr.Recv(); err != nil {
27+ b.Fatal(err)
28+ }
29+ }
30+ <-done
31+}
@@ -0,0 +1,31 @@
1+package ipc
2+
3+import (
4+ "net"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+func BenchmarkTransportRecvReuse(b *testing.B) {
11+ c1, c2 := net.Pipe()
12+ defer func() { _ = c1.Close() }()
13+ defer func() { _ = c2.Close() }()
14+ tr := NewTransport(c2)
15+ payload := []byte("payload payload payload")
16+ done := make(chan struct{})
17+ go func() {
18+ defer close(done)
19+ for i := 0; i < b.N; i++ {
20+ _ = NewTransport(c1).Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})
21+ }
22+ }()
23+ b.ReportAllocs()
24+ b.ResetTimer()
25+ for i := 0; i < b.N; i++ {
26+ if _, err := tr.Recv(); err != nil {
27+ b.Fatal(err)
28+ }
29+ }
30+ <-done
31+}
M internal/app/run.go +11 -0
@@ -10,6 +10,8 @@ import (
1010 "fmt"
1111 "io"
1212 "log/slog"
13+ "net/http"
14+ _ "net/http/pprof"
1315 "os"
1416 "os/signal"
1517 "syscall"
@@ -158,6 +160,15 @@ func runDaemon() error {
158160 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
159161 defer stop()
160162
163+ if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
164+ go func() {
165+ if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
166+ slog.Error("pprof server exited", "err", err)
167+ }
168+ }()
169+ slog.Info("pprof enabled", "addr", addr)
170+ }
171+
161172 slog.Info("daemon starting", "socket", ln.Addr())
162173 d := daemon.New(pty.NewFactory(), clock.New(), slog.Default())
163174 if err := d.Serve(ctx, ln); err != nil {
@@ -10,6 +10,8 @@ import (
10 "fmt"10 "fmt"
11 "io"11 "io"
12 "log/slog"12 "log/slog"
13+ "net/http"
14+ _ "net/http/pprof"
13 "os"15 "os"
14 "os/signal"16 "os/signal"
15 "syscall"17 "syscall"
@@ -158,6 +160,15 @@ func runDaemon() error {
158 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)160 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
159 defer stop()161 defer stop()
160 162
163+ if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
164+ go func() {
165+ if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
166+ slog.Error("pprof server exited", "err", err)
167+ }
168+ }()
169+ slog.Info("pprof enabled", "addr", addr)
170+ }
171+
161 slog.Info("daemon starting", "socket", ln.Addr())172 slog.Info("daemon starting", "socket", ln.Addr())
162 d := daemon.New(pty.NewFactory(), clock.New(), slog.Default())173 d := daemon.New(pty.NewFactory(), clock.New(), slog.Default())
163 if err := d.Serve(ctx, ln); err != nil {174 if err := d.Serve(ctx, ln); err != nil {
M internal/usecase/daemon/daemon.go +25 -8
@@ -45,10 +45,16 @@ import (
4545 "github.com/bnema/vev/pkg/vt"
4646 )
4747
48-// debounceInterval is the fixed window opened on the first dirty signal: all
49-// further dirties arriving within it are absorbed and coalesced into a single
50-// render. It bounds render frequency to ~100Hz per session.
51-const debounceInterval = 10 * time.Millisecond
48+// Scheduler debounce bounds. Idle updates use the minimum for low latency;
49+// sustained floods step toward the maximum to reduce frame/syscall pressure.
50+const (
51+ minDebounceInterval = 2 * time.Millisecond
52+ maxDebounceInterval = 16 * time.Millisecond
53+ debounceStep = 2 * time.Millisecond
54+)
55+
56+// debounceInterval is kept as a test/back-compat alias for the initial delay.
57+const debounceInterval = minDebounceInterval
5258
5359 // ptyReadBufSize is the PTY reader's read buffer.
5460 const ptyReadBufSize = 32 * 1024
@@ -951,10 +957,12 @@ func (d *Daemon) ptyReader(sess *session, win *window) {
951957 }
952958 }
953959
954-// scheduler debounces dirty signals: the first opens a fixed window during
955-// which further dirties are absorbed, then it renders exactly once.
960+// scheduler debounces dirty signals. The first dirty opens a short window;
961+// sustained floods progressively widen that window, while isolated updates
962+// return to the minimum delay for interactive latency.
956963 func (d *Daemon) scheduler(sess *session, win *window) {
957964 defer d.sessWg.Done()
965+ delay := minDebounceInterval
958966 for {
959967 select {
960968 case <-sess.ctx.Done():
@@ -964,7 +972,8 @@ func (d *Daemon) scheduler(sess *session, win *window) {
964972 case <-win.dirty:
965973 }
966974
967- timer := d.clock.NewTimer(debounceInterval)
975+ coalesced := 0
976+ timer := d.clock.NewTimer(delay)
968977 absorb:
969978 for {
970979 select {
@@ -975,11 +984,19 @@ func (d *Daemon) scheduler(sess *session, win *window) {
975984 timer.Stop()
976985 return
977986 case <-win.dirty:
978- // Coalesced into the pending render.
987+ coalesced++
979988 case <-timer.C():
980989 break absorb
981990 }
982991 }
992+ if coalesced == 0 {
993+ delay = minDebounceInterval
994+ } else if delay < maxDebounceInterval {
995+ delay += debounceStep
996+ if delay > maxDebounceInterval {
997+ delay = maxDebounceInterval
998+ }
999+ }
9831000 d.render(sess, win)
9841001 }
9851002 }
@@ -45,10 +45,16 @@ import (
45 "github.com/bnema/vev/pkg/vt"45 "github.com/bnema/vev/pkg/vt"
46 )46 )
47 47
48-// debounceInterval is the fixed window opened on the first dirty signal: all48+// Scheduler debounce bounds. Idle updates use the minimum for low latency;
49-// further dirties arriving within it are absorbed and coalesced into a single49+// sustained floods step toward the maximum to reduce frame/syscall pressure.
50-// render. It bounds render frequency to ~100Hz per session.50+const (
51-const debounceInterval = 10 * time.Millisecond51+ minDebounceInterval = 2 * time.Millisecond
52+ maxDebounceInterval = 16 * time.Millisecond
53+ debounceStep = 2 * time.Millisecond
54+)
55+
56+// debounceInterval is kept as a test/back-compat alias for the initial delay.
57+const debounceInterval = minDebounceInterval
52 58
53 // ptyReadBufSize is the PTY reader's read buffer.59 // ptyReadBufSize is the PTY reader's read buffer.
54 const ptyReadBufSize = 32 * 102460 const ptyReadBufSize = 32 * 1024
@@ -951,10 +957,12 @@ func (d *Daemon) ptyReader(sess *session, win *window) {
951 }957 }
952 }958 }
953 959
954-// scheduler debounces dirty signals: the first opens a fixed window during960+// scheduler debounces dirty signals. The first dirty opens a short window;
955-// which further dirties are absorbed, then it renders exactly once.961+// sustained floods progressively widen that window, while isolated updates
962+// return to the minimum delay for interactive latency.
956 func (d *Daemon) scheduler(sess *session, win *window) {963 func (d *Daemon) scheduler(sess *session, win *window) {
957 defer d.sessWg.Done()964 defer d.sessWg.Done()
965+ delay := minDebounceInterval
958 for {966 for {
959 select {967 select {
960 case <-sess.ctx.Done():968 case <-sess.ctx.Done():
@@ -964,7 +972,8 @@ func (d *Daemon) scheduler(sess *session, win *window) {
964 case <-win.dirty:972 case <-win.dirty:
965 }973 }
966 974
967- timer := d.clock.NewTimer(debounceInterval)975+ coalesced := 0
976+ timer := d.clock.NewTimer(delay)
968 absorb:977 absorb:
969 for {978 for {
970 select {979 select {
@@ -975,11 +984,19 @@ func (d *Daemon) scheduler(sess *session, win *window) {
975 timer.Stop()984 timer.Stop()
976 return985 return
977 case <-win.dirty:986 case <-win.dirty:
978- // Coalesced into the pending render.987+ coalesced++
979 case <-timer.C():988 case <-timer.C():
980 break absorb989 break absorb
981 }990 }
982 }991 }
992+ if coalesced == 0 {
993+ delay = minDebounceInterval
994+ } else if delay < maxDebounceInterval {
995+ delay += debounceStep
996+ if delay > maxDebounceInterval {
997+ delay = maxDebounceInterval
998+ }
999+ }
983 d.render(sess, win)1000 d.render(sess, win)
984 }1001 }
985 }1002 }
M internal/usecase/daemon/daemon_test.go +53 -1
@@ -933,7 +933,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
933933 timerCh := make(chan time.Time, 1)
934934 newTimerCalled := make(chan struct{}, 4)
935935
936- mc.EXPECT().NewTimer(debounceInterval).RunAndReturn(func(time.Duration) ports.Timer {
936+ mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
937937 select {
938938 case newTimerCalled <- struct{}{}:
939939 default:
@@ -991,6 +991,58 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
991991 require.Equal(t, int32(1), outputs.Load(), "N dirties in one window must render exactly once")
992992 }
993993
994+func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
995+ mc := portsmocks.NewMockClock(t)
996+ timers := make(chan chan time.Time, 4)
997+ var delaysMu sync.Mutex
998+ var delays []time.Duration
999+ mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
1000+ delaysMu.Lock()
1001+ delays = append(delays, d)
1002+ delaysMu.Unlock()
1003+ mt := portsmocks.NewMockTimer(t)
1004+ ch := make(chan time.Time, 1)
1005+ mt.EXPECT().C().Return(ch).Maybe()
1006+ mt.EXPECT().Stop().Return(true).Maybe()
1007+ timers <- ch
1008+ return mt
1009+ }).Maybe()
1010+
1011+ tr := portsmocks.NewMockTransport(t)
1012+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1013+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1014+ sctx, cancel := context.WithCancel(context.Background())
1015+ win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1016+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1017+ sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
1018+ d.sessWg.Add(1)
1019+ go d.scheduler(sess, win)
1020+
1021+ for range 2 {
1022+ win.dirty <- struct{}{}
1023+ ch := <-timers
1024+ for range 4 {
1025+ select {
1026+ case win.dirty <- struct{}{}:
1027+ default:
1028+ }
1029+ }
1030+ ch <- time.Now()
1031+ }
1032+ win.dirty <- struct{}{}
1033+ ch := <-timers
1034+ ch <- time.Now()
1035+
1036+ cancel()
1037+ d.sessWg.Wait()
1038+ delaysMu.Lock()
1039+ defer delaysMu.Unlock()
1040+ require.GreaterOrEqual(t, len(delays), 3)
1041+ require.Equal(t, minDebounceInterval, delays[0])
1042+ require.Greater(t, delays[1], delays[0], "sustained flood should widen debounce")
1043+ require.Greater(t, delays[2], delays[1], "continued flood should keep adapting")
1044+}
1045+
9941046 // --- resize ordering --------------------------------------------------------
9951047
9961048 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
@@ -933,7 +933,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
933 timerCh := make(chan time.Time, 1)933 timerCh := make(chan time.Time, 1)
934 newTimerCalled := make(chan struct{}, 4)934 newTimerCalled := make(chan struct{}, 4)
935 935
936- mc.EXPECT().NewTimer(debounceInterval).RunAndReturn(func(time.Duration) ports.Timer {936+ mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
937 select {937 select {
938 case newTimerCalled <- struct{}{}:938 case newTimerCalled <- struct{}{}:
939 default:939 default:
@@ -991,6 +991,58 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
991 require.Equal(t, int32(1), outputs.Load(), "N dirties in one window must render exactly once")991 require.Equal(t, int32(1), outputs.Load(), "N dirties in one window must render exactly once")
992 }992 }
993 993
994+func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
995+ mc := portsmocks.NewMockClock(t)
996+ timers := make(chan chan time.Time, 4)
997+ var delaysMu sync.Mutex
998+ var delays []time.Duration
999+ mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
1000+ delaysMu.Lock()
1001+ delays = append(delays, d)
1002+ delaysMu.Unlock()
1003+ mt := portsmocks.NewMockTimer(t)
1004+ ch := make(chan time.Time, 1)
1005+ mt.EXPECT().C().Return(ch).Maybe()
1006+ mt.EXPECT().Stop().Return(true).Maybe()
1007+ timers <- ch
1008+ return mt
1009+ }).Maybe()
1010+
1011+ tr := portsmocks.NewMockTransport(t)
1012+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1013+ d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1014+ sctx, cancel := context.WithCancel(context.Background())
1015+ win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1016+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1017+ sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
1018+ d.sessWg.Add(1)
1019+ go d.scheduler(sess, win)
1020+
1021+ for range 2 {
1022+ win.dirty <- struct{}{}
1023+ ch := <-timers
1024+ for range 4 {
1025+ select {
1026+ case win.dirty <- struct{}{}:
1027+ default:
1028+ }
1029+ }
1030+ ch <- time.Now()
1031+ }
1032+ win.dirty <- struct{}{}
1033+ ch := <-timers
1034+ ch <- time.Now()
1035+
1036+ cancel()
1037+ d.sessWg.Wait()
1038+ delaysMu.Lock()
1039+ defer delaysMu.Unlock()
1040+ require.GreaterOrEqual(t, len(delays), 3)
1041+ require.Equal(t, minDebounceInterval, delays[0])
1042+ require.Greater(t, delays[1], delays[0], "sustained flood should widen debounce")
1043+ require.Greater(t, delays[2], delays[1], "continued flood should keep adapting")
1044+}
1045+
994 // --- resize ordering --------------------------------------------------------1046 // --- resize ordering --------------------------------------------------------
995 1047
996 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {1048 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
A scripts/bench-workloads.sh +24 -0
@@ -0,0 +1,24 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+# Demo workload driver for comparing vev and tmux manually.
5+# Usage: TARGET=vev|tmux WORKLOAD=yes|seq|cat DURATION=5 ./scripts/bench-workloads.sh
6+TARGET=${TARGET:-vev}
7+WORKLOAD=${WORKLOAD:-yes}
8+DURATION=${DURATION:-5}
9+
10+case "$WORKLOAD" in
11+ yes) CMD='yes vev-benchmark' ;;
12+ seq) CMD='while :; do seq 1 20000; done' ;;
13+ cat) CMD='cat go.sum go.mod >/dev/null; while :; do cat go.sum; done' ;;
14+ *) echo "unknown WORKLOAD: $WORKLOAD" >&2; exit 2 ;;
15+esac
16+
17+echo "target=$TARGET workload=$WORKLOAD duration=${DURATION}s"
18+echo "command=$CMD"
19+case "$TARGET" in
20+ vev) timeout "$DURATION" ./vev new bench -- sh -lc "$CMD" ;;
21+ tmux) timeout "$DURATION" tmux new-session -d -s vev-bench "$CMD" && sleep "$DURATION" && tmux kill-session -t vev-bench ;;
22+ raw) timeout "$DURATION" sh -lc "$CMD" ;;
23+ *) echo "unknown TARGET: $TARGET" >&2; exit 2 ;;
24+esac
@@ -0,0 +1,24 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+# Demo workload driver for comparing vev and tmux manually.
5+# Usage: TARGET=vev|tmux WORKLOAD=yes|seq|cat DURATION=5 ./scripts/bench-workloads.sh
6+TARGET=${TARGET:-vev}
7+WORKLOAD=${WORKLOAD:-yes}
8+DURATION=${DURATION:-5}
9+
10+case "$WORKLOAD" in
11+ yes) CMD='yes vev-benchmark' ;;
12+ seq) CMD='while :; do seq 1 20000; done' ;;
13+ cat) CMD='cat go.sum go.mod >/dev/null; while :; do cat go.sum; done' ;;
14+ *) echo "unknown WORKLOAD: $WORKLOAD" >&2; exit 2 ;;
15+esac
16+
17+echo "target=$TARGET workload=$WORKLOAD duration=${DURATION}s"
18+echo "command=$CMD"
19+case "$TARGET" in
20+ vev) timeout "$DURATION" ./vev new bench -- sh -lc "$CMD" ;;
21+ tmux) timeout "$DURATION" tmux new-session -d -s vev-bench "$CMD" && sleep "$DURATION" && tmux kill-session -t vev-bench ;;
22+ raw) timeout "$DURATION" sh -lc "$CMD" ;;
23+ *) echo "unknown TARGET: $TARGET" >&2; exit 2 ;;
24+esac

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 12

Tags 11

↑↓ moveenter openesc close