perf(copy): gate large-history allocations
- commit
- 25d9db5d5a6d5563cddae780982ab63c3bc61999
- author
- brice · · 2026-07-11 06:24 UTC
- parent
- 70ae2be
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +61 -0
M docs/performance.md +18 -0
| @@ -59,6 +59,24 @@ context: | |||
| 59 | 59 | } > /tmp/vev-history-before.txt 2>&1 | |
| 60 | 60 | ``` | |
| 61 | 61 | ||
| 62 | + | ## Immutable document allocation gate | |
| 63 | + | ||
| 64 | + | The copy-mode document retains an immutable view of existing scrollback rows and | |
| 65 | + | clones only the visible frame. Visual-search models share that document; cloning | |
| 66 | + | a model copies only its mutable input and match state. Check those two paths at | |
| 67 | + | 10,000 history rows with: | |
| 68 | + | ||
| 69 | + | ```sh | |
| 70 | + | go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem | |
| 71 | + | ``` | |
| 72 | + | ||
| 73 | + | 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. | |
| 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 | |
| 77 | + | behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or | |
| 78 | + | UDP impairment/latency remain out of scope. | |
| 79 | + | ||
| 62 | 80 | ## Lower-level smoke baseline | |
| 63 | 81 | ||
| 64 | 82 | Use this command to check renderer, VT, IPC, and daemon microbenchmarks without | |
| @@ -59,6 +59,24 @@ context: | |||||
| 59 | } > /tmp/vev-history-before.txt 2>&1 | 59 | } > /tmp/vev-history-before.txt 2>&1 | ||
| 60 | ``` | 60 | ``` | ||
| 61 | 61 | ||||
| 62 | + | ## Immutable document allocation gate | |||
| 63 | + | ||||
| 64 | + | The copy-mode document retains an immutable view of existing scrollback rows and | |||
| 65 | + | clones only the visible frame. Visual-search models share that document; cloning | |||
| 66 | + | a model copies only its mutable input and match state. Check those two paths at | |||
| 67 | + | 10,000 history rows with: | |||
| 68 | + | ||||
| 69 | + | ```sh | |||
| 70 | + | go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem | |||
| 71 | + | ``` | |||
| 72 | + | ||||
| 73 | + | 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. | |||
| 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 | |||
| 77 | + | behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or | |||
| 78 | + | UDP impairment/latency remain out of scope. | |||
| 79 | + | ||||
| 62 | ## Lower-level smoke baseline | 80 | ## Lower-level smoke baseline | ||
| 63 | 81 | ||||
| 64 | Use this command to check renderer, VT, IPC, and daemon microbenchmarks without | 82 | Use this command to check renderer, VT, IPC, and daemon microbenchmarks without | ||
M internal/usecase/copy/mode_test.go +25 -0
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | var benchmarkSnapshotSink Snapshot | |
| 14 | + | ||
| 13 | 15 | func snapshot(lines []string, height int) Snapshot { | |
| 14 | 16 | rows := make([][]renderer.Cell, len(lines)) | |
| 15 | 17 | for i, line := range lines { | |
| @@ -476,6 +478,29 @@ func TestOSC52FromBase64(t *testing.T) { | |||
| 476 | 478 | } | |
| 477 | 479 | } | |
| 478 | 480 | ||
| 481 | + | func BenchmarkNewSnapshot10KRows(b *testing.B) { | |
| 482 | + | const ( | |
| 483 | + | width = 120 | |
| 484 | + | height = 40 | |
| 485 | + | historyRows = 10_000 | |
| 486 | + | ) | |
| 487 | + | ||
| 488 | + | scrollback := NewScrollback(historyRows) | |
| 489 | + | for range historyRows { | |
| 490 | + | scrollback.Append(row(strings.Repeat("x", width))) | |
| 491 | + | } | |
| 492 | + | screen := renderer.NewFrame(width, height) | |
| 493 | + | for y := range height { | |
| 494 | + | copy(screen.Row(y), row(strings.Repeat("s", width))) | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | b.ReportAllocs() | |
| 498 | + | b.ResetTimer() | |
| 499 | + | for b.Loop() { | |
| 500 | + | benchmarkSnapshotSink = NewSnapshot(scrollback, screen) | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 479 | 504 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | |
| 480 | 505 | t.Helper() | |
| 481 | 506 | s := string(chunk) | |
| @@ -10,6 +10,8 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | var benchmarkSnapshotSink Snapshot | |||
| 14 | + | ||||
| 13 | func snapshot(lines []string, height int) Snapshot { | 15 | func snapshot(lines []string, height int) Snapshot { | ||
| 14 | rows := make([][]renderer.Cell, len(lines)) | 16 | rows := make([][]renderer.Cell, len(lines)) | ||
| 15 | for i, line := range lines { | 17 | for i, line := range lines { | ||
| @@ -476,6 +478,29 @@ func TestOSC52FromBase64(t *testing.T) { | |||||
| 476 | } | 478 | } | ||
| 477 | } | 479 | } | ||
| 478 | 480 | ||||
| 481 | + | func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||
| 482 | + | const ( | |||
| 483 | + | width = 120 | |||
| 484 | + | height = 40 | |||
| 485 | + | historyRows = 10_000 | |||
| 486 | + | ) | |||
| 487 | + | ||||
| 488 | + | scrollback := NewScrollback(historyRows) | |||
| 489 | + | for range historyRows { | |||
| 490 | + | scrollback.Append(row(strings.Repeat("x", width))) | |||
| 491 | + | } | |||
| 492 | + | screen := renderer.NewFrame(width, height) | |||
| 493 | + | for y := range height { | |||
| 494 | + | copy(screen.Row(y), row(strings.Repeat("s", width))) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | b.ReportAllocs() | |||
| 498 | + | b.ResetTimer() | |||
| 499 | + | for b.Loop() { | |||
| 500 | + | benchmarkSnapshotSink = NewSnapshot(scrollback, screen) | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 479 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | 504 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | ||
| 480 | t.Helper() | 505 | t.Helper() | ||
| 481 | s := string(chunk) | 506 | s := string(chunk) | ||
M internal/usecase/visualsearch/model_test.go +18 -0
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | var benchmarkModelSink *Model | |
| 15 | + | ||
| 14 | 16 | func testSnapshot(lines ...string) scopy.Snapshot { | |
| 15 | 17 | rows := make([][]renderer.Cell, len(lines)) | |
| 16 | 18 | for i, line := range lines { | |
| @@ -105,6 +107,22 @@ func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) { | |||
| 105 | 107 | require.Contains(t, text, "2:6 beta alpha") | |
| 106 | 108 | } | |
| 107 | 109 | ||
| 110 | + | func BenchmarkVisualSearchClone10KRows(b *testing.B) { | |
| 111 | + | const historyRows = 10_000 | |
| 112 | + | ||
| 113 | + | lines := make([]string, historyRows) | |
| 114 | + | for i := range lines { | |
| 115 | + | lines[i] = "immutable history row" | |
| 116 | + | } | |
| 117 | + | model := New(testSnapshot(lines...)) | |
| 118 | + | ||
| 119 | + | b.ReportAllocs() | |
| 120 | + | b.ResetTimer() | |
| 121 | + | for b.Loop() { | |
| 122 | + | benchmarkModelSink = model.Clone() | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 108 | 126 | func rowText(row []renderer.Cell) string { | |
| 109 | 127 | var b strings.Builder | |
| 110 | 128 | for _, c := range row { | |
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | "github.com/bnema/vev/pkg/renderer" | 11 | "github.com/bnema/vev/pkg/renderer" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | var benchmarkModelSink *Model | |||
| 15 | + | ||||
| 14 | func testSnapshot(lines ...string) scopy.Snapshot { | 16 | func testSnapshot(lines ...string) scopy.Snapshot { | ||
| 15 | rows := make([][]renderer.Cell, len(lines)) | 17 | rows := make([][]renderer.Cell, len(lines)) | ||
| 16 | for i, line := range lines { | 18 | for i, line := range lines { | ||
| @@ -105,6 +107,22 @@ func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) { | |||||
| 105 | require.Contains(t, text, "2:6 beta alpha") | 107 | require.Contains(t, text, "2:6 beta alpha") | ||
| 106 | } | 108 | } | ||
| 107 | 109 | ||||
| 110 | + | func BenchmarkVisualSearchClone10KRows(b *testing.B) { | |||
| 111 | + | const historyRows = 10_000 | |||
| 112 | + | ||||
| 113 | + | lines := make([]string, historyRows) | |||
| 114 | + | for i := range lines { | |||
| 115 | + | lines[i] = "immutable history row" | |||
| 116 | + | } | |||
| 117 | + | model := New(testSnapshot(lines...)) | |||
| 118 | + | ||||
| 119 | + | b.ReportAllocs() | |||
| 120 | + | b.ResetTimer() | |||
| 121 | + | for b.Loop() { | |||
| 122 | + | benchmarkModelSink = model.Clone() | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 108 | func rowText(row []renderer.Cell) string { | 126 | func rowText(row []renderer.Cell) string { | ||
| 109 | var b strings.Builder | 127 | var b strings.Builder | ||
| 110 | for _, c := range row { | 128 | for _, c := range row { | ||