test(perf): enforce immutable allocation scaling
- commit
- 8c140ad548457d00a070c61c6645ca483b5b221b
- author
- brice · · 2026-07-11 06:58 UTC
- parent
- 440c1a2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 18 | 18 | ||
| 19 | 19 | For before/after comparison, collect repeated samples for the **full matrix** | |
| 20 | 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 | 26 | ```sh | |
| 24 | 27 | 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 | 76 | Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated | |
| 74 | 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: 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 | |
| 77 | 84 | behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or | |
| 78 | 85 | UDP impairment/latency remain out of scope. | |
| 79 | 86 | ||
| @@ -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 | ```sh | 26 | ```sh | ||
| 24 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt | 27 | 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 repeated | 76 | 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: queries | 78 | + | 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. Coordinator | 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 | |||
| 77 | behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or | 84 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/base64" | |
| 5 | + | "slices" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "unicode" | |
| 7 | 8 | ||
| @@ -106,7 +107,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||
| 106 | 107 | haystack, cellIndexes := searchableCells(cells) | |
| 107 | 108 | var text string | |
| 108 | 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 | 111 | start++ | |
| 111 | 112 | continue | |
| 112 | 113 | } | |
| @@ -150,18 +151,6 @@ func lowerRunes(s string) []rune { | |||
| 150 | 151 | 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 | 154 | func (m *Mode) Search(s Snapshot, query string) bool { | |
| 166 | 155 | return m.SetSearchMatches(s, query, FindMatches(s, query), 0) | |
| 167 | 156 | } | |
| @@ -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 string | 108 | 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 | continue | 112 | continue | ||
| 112 | } | 113 | } | ||
| @@ -150,18 +151,6 @@ func lowerRunes(s string) []rune { | |||||
| 150 | return runes | 151 | 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) { | |||
| 42 | 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 | + | } | |
| @@ -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) { | |||
| 107 | 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 | 153 | func BenchmarkVisualSearchClone10KRows(b *testing.B) { | |
| 111 | 154 | const historyRows = 10_000 | |
| 112 | 155 | ||
| @@ -115,6 +158,12 @@ func BenchmarkVisualSearchClone10KRows(b *testing.B) { | |||
| 115 | 158 | lines[i] = "immutable history row" | |
| 116 | 159 | } | |
| 117 | 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 | 168 | b.ReportAllocs() | |
| 120 | 169 | 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_000 | 154 | 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() | ||