detached 8c140ad

test(perf): enforce immutable allocation scaling

commit
8c140ad548457d00a070c61c6645ca483b5b221b
author
brice · · 2026-07-11 06:58 UTC
parent
440c1a2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +99 -16
M docs/performance.md +10 -3
@@ -18,7 +18,10 @@ go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -be
1818
1919 For before/after comparison, collect repeated samples for the **full matrix**
2020 (all workloads and topologies), pinning CPU/governor and recording the host.
21-The fixed duration and count make the output suitable for `benchstat`:
21+The benchmark harness must be committed and present in **both** measured
22+revisions: collect the baseline after the harness is introduced but before the
23+optimization, then collect the optimized revision with that same harness. The
24+fixed duration and count make the output suitable for `benchstat`:
2225
2326 ```sh
2427 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt
@@ -72,8 +75,12 @@ go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench
7275
7376 Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated
7477 output with the accepted baseline before merging, and investigate any increase.
75-This scope does not yet include an incremental/full-text search index: queries
76-still scan the immutable document and produce a fresh match list. Coordinator
78+The automated scaling gates also compare equal row/match counts at narrow and
79+wide row widths; `B/op` may grow by at most 2x, a conservative relative
80+allowance that catches full cell-row copies without depending on an absolute
81+Go-version-specific budget. This scope does not yet include an
82+incremental/full-text search index: queries still scan the immutable document
83+and produce a fresh match list. Coordinator
7784 behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or
7885 UDP impairment/latency remain out of scope.
7986
@@ -18,7 +18,10 @@ go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -be
18 18
19 For before/after comparison, collect repeated samples for the **full matrix**19 For before/after comparison, collect repeated samples for the **full matrix**
20 (all workloads and topologies), pinning CPU/governor and recording the host.20 (all workloads and topologies), pinning CPU/governor and recording the host.
21-The fixed duration and count make the output suitable for `benchstat`:21+The benchmark harness must be committed and present in **both** measured
22+revisions: collect the baseline after the harness is introduced but before the
23+optimization, then collect the optimized revision with that same harness. The
24+fixed duration and count make the output suitable for `benchstat`:
22 25
23 ```sh26 ```sh
24 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt27 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt
@@ -72,8 +75,12 @@ go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench
72 75
73 Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated76 Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated
74 output with the accepted baseline before merging, and investigate any increase.77 output with the accepted baseline before merging, and investigate any increase.
75-This scope does not yet include an incremental/full-text search index: queries78+The automated scaling gates also compare equal row/match counts at narrow and
76-still scan the immutable document and produce a fresh match list. Coordinator79+wide row widths; `B/op` may grow by at most 2x, a conservative relative
80+allowance that catches full cell-row copies without depending on an absolute
81+Go-version-specific budget. This scope does not yet include an
82+incremental/full-text search index: queries still scan the immutable document
83+and produce a fresh match list. Coordinator
77 behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or84 behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or
78 UDP impairment/latency remain out of scope.85 UDP impairment/latency remain out of scope.
79 86
M internal/usecase/copy/mode.go +2 -13
@@ -2,6 +2,7 @@ package copy
22
33 import (
44 "encoding/base64"
5+ "slices"
56 "strings"
67 "unicode"
78
@@ -106,7 +107,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
106107 haystack, cellIndexes := searchableCells(cells)
107108 var text string
108109 for start := 0; start+len(needle) <= len(haystack); {
109- if !sameRunes(haystack[start:start+len(needle)], needle) {
110+ if !slices.Equal(haystack[start:start+len(needle)], needle) {
110111 start++
111112 continue
112113 }
@@ -150,18 +151,6 @@ func lowerRunes(s string) []rune {
150151 return runes
151152 }
152153
153-func sameRunes(a, b []rune) bool {
154- if len(a) != len(b) {
155- return false
156- }
157- for i := range a {
158- if a[i] != b[i] {
159- return false
160- }
161- }
162- return true
163-}
164-
165154 func (m *Mode) Search(s Snapshot, query string) bool {
166155 return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
167156 }
@@ -2,6 +2,7 @@ package copy
2 2
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5+ "slices"
5 "strings"6 "strings"
6 "unicode"7 "unicode"
7 8
@@ -106,7 +107,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
106 haystack, cellIndexes := searchableCells(cells)107 haystack, cellIndexes := searchableCells(cells)
107 var text string108 var text string
108 for start := 0; start+len(needle) <= len(haystack); {109 for start := 0; start+len(needle) <= len(haystack); {
109- if !sameRunes(haystack[start:start+len(needle)], needle) {110+ if !slices.Equal(haystack[start:start+len(needle)], needle) {
110 start++111 start++
111 continue112 continue
112 }113 }
@@ -150,18 +151,6 @@ func lowerRunes(s string) []rune {
150 return runes151 return runes
151 }152 }
152 153
153-func sameRunes(a, b []rune) bool {
154- if len(a) != len(b) {
155- return false
156- }
157- for i := range a {
158- if a[i] != b[i] {
159- return false
160- }
161- }
162- return true
163-}
164-
165 func (m *Mode) Search(s Snapshot, query string) bool {154 func (m *Mode) Search(s Snapshot, query string) bool {
166 return m.SetSearchMatches(s, query, FindMatches(s, query), 0)155 return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
167 }156 }
M internal/usecase/copy/snapshot_test.go +38 -0
@@ -42,3 +42,41 @@ func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
4242 t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
4343 }
4444 }
45+
46+// This scaling gate deliberately compares equal row counts rather than an
47+// absolute budget, which varies between Go releases. A full history-row clone
48+// grows with row width; the immutable HistoryView allocation does not.
49+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50+ narrow := benchmarkNewSnapshotBytes(16)
51+ wide := benchmarkNewSnapshotBytes(512)
52+ assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
53+}
54+
55+func benchmarkNewSnapshotBytes(width int) int64 {
56+ const historyRows = 256
57+
58+ scrollback := NewScrollback(historyRows)
59+ for range historyRows {
60+ scrollback.Append(make([]renderer.Cell, width))
61+ }
62+ // Keep visible-frame work constant so the comparison isolates history rows.
63+ screen := renderer.NewFrame(8, 4)
64+ result := testing.Benchmark(func(b *testing.B) {
65+ b.ReportAllocs()
66+ b.ResetTimer()
67+ for b.Loop() {
68+ benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
69+ }
70+ })
71+ return result.AllocedBytesPerOp()
72+}
73+
74+func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
75+ t.Helper()
76+ // Two times permits allocator noise while decisively rejecting a cell-row
77+ // copy, whose allocation grows by 32x for the widths above.
78+ const conservativeWidthTolerance = 2
79+ if wide > narrow*conservativeWidthTolerance {
80+ t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
81+ }
82+}
@@ -42,3 +42,41 @@ func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
42 t.Fatalf("Row(0) = %q, want deep-owned caller row", got)42 t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
43 }43 }
44 }44 }
45+
46+// This scaling gate deliberately compares equal row counts rather than an
47+// absolute budget, which varies between Go releases. A full history-row clone
48+// grows with row width; the immutable HistoryView allocation does not.
49+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50+ narrow := benchmarkNewSnapshotBytes(16)
51+ wide := benchmarkNewSnapshotBytes(512)
52+ assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
53+}
54+
55+func benchmarkNewSnapshotBytes(width int) int64 {
56+ const historyRows = 256
57+
58+ scrollback := NewScrollback(historyRows)
59+ for range historyRows {
60+ scrollback.Append(make([]renderer.Cell, width))
61+ }
62+ // Keep visible-frame work constant so the comparison isolates history rows.
63+ screen := renderer.NewFrame(8, 4)
64+ result := testing.Benchmark(func(b *testing.B) {
65+ b.ReportAllocs()
66+ b.ResetTimer()
67+ for b.Loop() {
68+ benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
69+ }
70+ })
71+ return result.AllocedBytesPerOp()
72+}
73+
74+func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
75+ t.Helper()
76+ // Two times permits allocator noise while decisively rejecting a cell-row
77+ // copy, whose allocation grows by 32x for the widths above.
78+ const conservativeWidthTolerance = 2
79+ if wide > narrow*conservativeWidthTolerance {
80+ t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
81+ }
82+}
M internal/usecase/visualsearch/model_test.go +49 -0
@@ -107,6 +107,49 @@ func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
107107 require.Contains(t, text, "2:6 beta alpha")
108108 }
109109
110+func TestVisualSearchCloneAllocationIsWidthIndependent(t *testing.T) {
111+ narrow := benchmarkVisualSearchCloneBytes(16)
112+ wide := benchmarkVisualSearchCloneBytes(512)
113+ assertWidthIndependentCloneAllocations(t, narrow, wide)
114+}
115+
116+// The comparison holds row and match counts equal. Model.Clone must copy only
117+// mutable UI/match state, so a document-row clone is observable as width-scaled
118+// B/op without relying on a Go-version-specific absolute allocation budget.
119+func benchmarkVisualSearchCloneBytes(width int) int64 {
120+ const rows = 256
121+
122+ line := strings.Repeat("x", width-7) + "needle"
123+ lines := make([]string, rows)
124+ for i := range lines {
125+ lines[i] = line
126+ }
127+ model := New(testSnapshot(lines...))
128+ for _, r := range "needle" {
129+ model.Insert(r)
130+ }
131+ if got := len(model.Matches()); got != rows {
132+ panic("visual-search allocation gate requires one match per row")
133+ }
134+
135+ result := testing.Benchmark(func(b *testing.B) {
136+ b.ReportAllocs()
137+ b.ResetTimer()
138+ for b.Loop() {
139+ benchmarkModelSink = model.Clone()
140+ }
141+ })
142+ return result.AllocedBytesPerOp()
143+}
144+
145+func assertWidthIndependentCloneAllocations(t *testing.T, narrow, wide int64) {
146+ t.Helper()
147+ const conservativeWidthTolerance = 2
148+ if wide > narrow*conservativeWidthTolerance {
149+ t.Fatalf("VisualSearch Model.Clone B/op scaled with row width: narrow=%d wide=%d (limit=%d)", narrow, wide, narrow*conservativeWidthTolerance)
150+ }
151+}
152+
110153 func BenchmarkVisualSearchClone10KRows(b *testing.B) {
111154 const historyRows = 10_000
112155
@@ -115,6 +158,12 @@ func BenchmarkVisualSearchClone10KRows(b *testing.B) {
115158 lines[i] = "immutable history row"
116159 }
117160 model := New(testSnapshot(lines...))
161+ for _, r := range "history" {
162+ model.Insert(r)
163+ }
164+ if got := len(model.Matches()); got != historyRows {
165+ b.Fatalf("representative query matches = %d, want %d", got, historyRows)
166+ }
118167
119168 b.ReportAllocs()
120169 b.ResetTimer()
@@ -107,6 +107,49 @@ func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
107 require.Contains(t, text, "2:6 beta alpha")107 require.Contains(t, text, "2:6 beta alpha")
108 }108 }
109 109
110+func TestVisualSearchCloneAllocationIsWidthIndependent(t *testing.T) {
111+ narrow := benchmarkVisualSearchCloneBytes(16)
112+ wide := benchmarkVisualSearchCloneBytes(512)
113+ assertWidthIndependentCloneAllocations(t, narrow, wide)
114+}
115+
116+// The comparison holds row and match counts equal. Model.Clone must copy only
117+// mutable UI/match state, so a document-row clone is observable as width-scaled
118+// B/op without relying on a Go-version-specific absolute allocation budget.
119+func benchmarkVisualSearchCloneBytes(width int) int64 {
120+ const rows = 256
121+
122+ line := strings.Repeat("x", width-7) + "needle"
123+ lines := make([]string, rows)
124+ for i := range lines {
125+ lines[i] = line
126+ }
127+ model := New(testSnapshot(lines...))
128+ for _, r := range "needle" {
129+ model.Insert(r)
130+ }
131+ if got := len(model.Matches()); got != rows {
132+ panic("visual-search allocation gate requires one match per row")
133+ }
134+
135+ result := testing.Benchmark(func(b *testing.B) {
136+ b.ReportAllocs()
137+ b.ResetTimer()
138+ for b.Loop() {
139+ benchmarkModelSink = model.Clone()
140+ }
141+ })
142+ return result.AllocedBytesPerOp()
143+}
144+
145+func assertWidthIndependentCloneAllocations(t *testing.T, narrow, wide int64) {
146+ t.Helper()
147+ const conservativeWidthTolerance = 2
148+ if wide > narrow*conservativeWidthTolerance {
149+ t.Fatalf("VisualSearch Model.Clone B/op scaled with row width: narrow=%d wide=%d (limit=%d)", narrow, wide, narrow*conservativeWidthTolerance)
150+ }
151+}
152+
110 func BenchmarkVisualSearchClone10KRows(b *testing.B) {153 func BenchmarkVisualSearchClone10KRows(b *testing.B) {
111 const historyRows = 10_000154 const historyRows = 10_000
112 155
@@ -115,6 +158,12 @@ func BenchmarkVisualSearchClone10KRows(b *testing.B) {
115 lines[i] = "immutable history row"158 lines[i] = "immutable history row"
116 }159 }
117 model := New(testSnapshot(lines...))160 model := New(testSnapshot(lines...))
161+ for _, r := range "history" {
162+ model.Insert(r)
163+ }
164+ if got := len(model.Matches()); got != historyRows {
165+ b.Fatalf("representative query matches = %d, want %d", got, historyRows)
166+ }
118 167
119 b.ReportAllocs()168 b.ReportAllocs()
120 b.ResetTimer()169 b.ResetTimer()

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