perf(copy): reuse sealed history rows
- commit
- e53346fc8939f091668cb1e6f7d01c76adc32d03
- author
- brice · · 2026-07-12 21:51 UTC
- parent
- a4c844c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +52 -42
M internal/usecase/copy/mode.go +5 -3
| @@ -27,11 +27,13 @@ type Snapshot struct { | |||
| 27 | 27 | Height int | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | // NewSnapshot freezes the current VT history view and clones the visible screen. | |
| 30 | + | // NewSnapshot seals the current VT history tail, then freezes that immutable | |
| 31 | + | // view and clones the visible screen. Sealing prevents repeated copy entry from | |
| 32 | + | // cloning every row in a partial tail. | |
| 31 | 33 | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |
| 32 | 34 | var history vt.HistoryView | |
| 33 | 35 | if historySource != nil { | |
| 34 | - | history = historySource.View() | |
| 36 | + | history = historySource.SealAndView() | |
| 35 | 37 | } | |
| 36 | 38 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 37 | 39 | } | |
| @@ -57,7 +59,7 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 57 | 59 | return nil | |
| 58 | 60 | } | |
| 59 | 61 | if i < s.history.Len() { | |
| 60 | - | return s.history.Row(i) | |
| 62 | + | return s.history.BorrowedRow(i) | |
| 61 | 63 | } | |
| 62 | 64 | i -= s.history.Len() | |
| 63 | 65 | if i >= s.screen.Height { | |
| @@ -27,11 +27,13 @@ type Snapshot struct { | |||||
| 27 | Height int | 27 | Height int | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | - | // NewSnapshot freezes the current VT history view and clones the visible screen. | 30 | + | // NewSnapshot seals the current VT history tail, then freezes that immutable |
| 31 | + | // view and clones the visible screen. Sealing prevents repeated copy entry from | |||
| 32 | + | // cloning every row in a partial tail. | |||
| 31 | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | 33 | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | ||
| 32 | var history vt.HistoryView | 34 | var history vt.HistoryView | ||
| 33 | if historySource != nil { | 35 | if historySource != nil { | ||
| 34 | - | history = historySource.View() | 36 | + | history = historySource.SealAndView() |
| 35 | } | 37 | } | ||
| 36 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | 38 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | ||
| 37 | } | 39 | } | ||
| @@ -57,7 +59,7 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||||
| 57 | return nil | 59 | return nil | ||
| 58 | } | 60 | } | ||
| 59 | if i < s.history.Len() { | 61 | if i < s.history.Len() { | ||
| 60 | - | return s.history.Row(i) | 62 | + | return s.history.BorrowedRow(i) |
| 61 | } | 63 | } | ||
| 62 | i -= s.history.Len() | 64 | i -= s.history.Len() | ||
| 63 | if i >= s.screen.Height { | 65 | if i >= s.screen.Height { | ||
M internal/usecase/daemon/performance_benchmark_test.go +39 -38
| @@ -230,6 +230,10 @@ func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | |||
| 230 | 230 | benchmarkDaemonSnapshotRestore(b) | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | + | // BenchmarkDaemonHistoryCopyEnter uses the parent benchmark's repeated-entry | |
| 234 | + | // semantics: each operation replaces the active copy document. Exit cleanup is | |
| 235 | + | // intentionally outside this benchmark, so its allocations cannot be reported | |
| 236 | + | // as copy-entry work. | |
| 233 | 237 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | |
| 234 | 238 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | |
| 235 | 239 | } | |
| @@ -271,8 +275,8 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||
| 271 | 275 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |
| 272 | 276 | b.Fatal("invalid daemon history fixture") | |
| 273 | 277 | } | |
| 274 | - | if workload == "copy-enter" || workload == "copy-search" { | |
| 275 | - | benchmarkDaemonCopyOperation(b, fixture, workload, run) | |
| 278 | + | if workload == "copy-search" { | |
| 279 | + | benchmarkDaemonCopySearch(b, fixture, run) | |
| 276 | 280 | return | |
| 277 | 281 | } | |
| 278 | 282 | b.ReportAllocs() | |
| @@ -295,43 +299,21 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||
| 295 | 299 | } | |
| 296 | 300 | } | |
| 297 | 301 | ||
| 298 | - | func benchmarkDaemonCopyOperation(b *testing.B, fixture *performanceFixture, workload string, run func(*performanceFixture)) { | |
| 302 | + | func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) { | |
| 299 | 303 | b.Helper() | |
| 304 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |
| 305 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |
| 300 | 306 | b.ReportAllocs() | |
| 301 | - | if workload == "copy-search" { | |
| 302 | - | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |
| 303 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | |
| 304 | - | b.ResetTimer() | |
| 305 | - | for range b.N { | |
| 306 | - | run(fixture) | |
| 307 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | |
| 308 | - | } | |
| 309 | - | b.StopTimer() | |
| 310 | - | if fixture.searchMatches() == 0 { | |
| 311 | - | b.Fatal("copy search produced no deterministic matches") | |
| 312 | - | } | |
| 313 | - | b.ReportMetric(1, "copyoperations/op") | |
| 314 | - | return | |
| 315 | - | } | |
| 316 | - | ||
| 317 | 307 | b.ResetTimer() | |
| 318 | - | operations := 0 | |
| 319 | 308 | for range b.N { | |
| 320 | - | b.StopTimer() | |
| 321 | - | fixture.d.exitCopyMode(fixture.ac) | |
| 322 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | |
| 323 | - | b.StartTimer() | |
| 324 | 309 | run(fixture) | |
| 325 | - | b.StopTimer() | |
| 326 | - | if !fixture.copyModeActive() { | |
| 327 | - | b.Fatal("copy enter did not install a copy mode") | |
| 328 | - | } | |
| 329 | - | operations++ | |
| 310 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |
| 330 | 311 | } | |
| 331 | - | if operations != b.N { | |
| 332 | - | b.Fatalf("copy workload ran %d operations for %d iterations", operations, b.N) | |
| 312 | + | b.StopTimer() | |
| 313 | + | if fixture.searchMatches() == 0 { | |
| 314 | + | b.Fatal("copy search produced no deterministic matches") | |
| 333 | 315 | } | |
| 334 | - | b.ReportMetric(float64(operations)/float64(b.N), "copyoperations/op") | |
| 316 | + | b.ReportMetric(1, "copyoperations/op") | |
| 335 | 317 | } | |
| 336 | 318 | ||
| 337 | 319 | func benchmarkDaemonSnapshotCapture(b *testing.B) { | |
| @@ -541,6 +523,31 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||
| 541 | 523 | } | |
| 542 | 524 | } | |
| 543 | 525 | ||
| 526 | + | // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy | |
| 527 | + | // rendering borrows sealed VT history rows; allocating a copy per viewport row | |
| 528 | + | // is a production regression even though the capture itself remains immutable. | |
| 529 | + | func TestCopyEnterAllocationBudget(t *testing.T) { | |
| 530 | + | for _, tt := range []struct { | |
| 531 | + | name string | |
| 532 | + | tabs, panes, max int | |
| 533 | + | }{ | |
| 534 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 38}, | |
| 535 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 43}, | |
| 536 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42}, | |
| 537 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48}, | |
| 538 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50}, | |
| 539 | + | } { | |
| 540 | + | t.Run(tt.name, func(t *testing.T) { | |
| 541 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |
| 542 | + | allocs := testing.AllocsPerRun(20, func() { | |
| 543 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |
| 544 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |
| 545 | + | }) | |
| 546 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline") | |
| 547 | + | }) | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | ||
| 544 | 551 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | |
| 545 | 552 | fixture := newPerformanceFixture(t, performanceConfig{}) | |
| 546 | 553 | ||
| @@ -853,12 +860,6 @@ func (f *performanceFixture) hasHistoryTopology(tabs, panes, historyRows int) bo | |||
| 853 | 860 | return true | |
| 854 | 861 | } | |
| 855 | 862 | ||
| 856 | - | func (f *performanceFixture) copyModeActive() bool { | |
| 857 | - | f.ac.overlays.copyMu.Lock() | |
| 858 | - | defer f.ac.overlays.copyMu.Unlock() | |
| 859 | - | return f.ac.overlays.copyMode != nil | |
| 860 | - | } | |
| 861 | - | ||
| 862 | 863 | func (f *performanceFixture) searchMatches() int { | |
| 863 | 864 | f.ac.overlays.copyMu.Lock() | |
| 864 | 865 | defer f.ac.overlays.copyMu.Unlock() | |
| @@ -230,6 +230,10 @@ func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | |||||
| 230 | benchmarkDaemonSnapshotRestore(b) | 230 | benchmarkDaemonSnapshotRestore(b) | ||
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
| 233 | + | // BenchmarkDaemonHistoryCopyEnter uses the parent benchmark's repeated-entry | |||
| 234 | + | // semantics: each operation replaces the active copy document. Exit cleanup is | |||
| 235 | + | // intentionally outside this benchmark, so its allocations cannot be reported | |||
| 236 | + | // as copy-entry work. | |||
| 233 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | 237 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | ||
| 234 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | 238 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | ||
| 235 | } | 239 | } | ||
| @@ -271,8 +275,8 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||||
| 271 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | 275 | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | ||
| 272 | b.Fatal("invalid daemon history fixture") | 276 | b.Fatal("invalid daemon history fixture") | ||
| 273 | } | 277 | } | ||
| 274 | - | if workload == "copy-enter" || workload == "copy-search" { | 278 | + | if workload == "copy-search" { |
| 275 | - | benchmarkDaemonCopyOperation(b, fixture, workload, run) | 279 | + | benchmarkDaemonCopySearch(b, fixture, run) |
| 276 | return | 280 | return | ||
| 277 | } | 281 | } | ||
| 278 | b.ReportAllocs() | 282 | b.ReportAllocs() | ||
| @@ -295,43 +299,21 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||||
| 295 | } | 299 | } | ||
| 296 | } | 300 | } | ||
| 297 | 301 | ||||
| 298 | - | func benchmarkDaemonCopyOperation(b *testing.B, fixture *performanceFixture, workload string, run func(*performanceFixture)) { | 302 | + | func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) { |
| 299 | b.Helper() | 303 | b.Helper() | ||
| 304 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 305 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 300 | b.ReportAllocs() | 306 | b.ReportAllocs() | ||
| 301 | - | if workload == "copy-search" { | |||
| 302 | - | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 303 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 304 | - | b.ResetTimer() | |||
| 305 | - | for range b.N { | |||
| 306 | - | run(fixture) | |||
| 307 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 308 | - | } | |||
| 309 | - | b.StopTimer() | |||
| 310 | - | if fixture.searchMatches() == 0 { | |||
| 311 | - | b.Fatal("copy search produced no deterministic matches") | |||
| 312 | - | } | |||
| 313 | - | b.ReportMetric(1, "copyoperations/op") | |||
| 314 | - | return | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | b.ResetTimer() | 307 | b.ResetTimer() | ||
| 318 | - | operations := 0 | |||
| 319 | for range b.N { | 308 | for range b.N { | ||
| 320 | - | b.StopTimer() | |||
| 321 | - | fixture.d.exitCopyMode(fixture.ac) | |||
| 322 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 323 | - | b.StartTimer() | |||
| 324 | run(fixture) | 309 | run(fixture) | ||
| 325 | - | b.StopTimer() | 310 | + | fixture.ac.ackOutputState(fixture.ac.output.next) |
| 326 | - | if !fixture.copyModeActive() { | |||
| 327 | - | b.Fatal("copy enter did not install a copy mode") | |||
| 328 | - | } | |||
| 329 | - | operations++ | |||
| 330 | } | 311 | } | ||
| 331 | - | if operations != b.N { | 312 | + | b.StopTimer() |
| 332 | - | b.Fatalf("copy workload ran %d operations for %d iterations", operations, b.N) | 313 | + | if fixture.searchMatches() == 0 { |
| 314 | + | b.Fatal("copy search produced no deterministic matches") | |||
| 333 | } | 315 | } | ||
| 334 | - | b.ReportMetric(float64(operations)/float64(b.N), "copyoperations/op") | 316 | + | b.ReportMetric(1, "copyoperations/op") |
| 335 | } | 317 | } | ||
| 336 | 318 | ||||
| 337 | func benchmarkDaemonSnapshotCapture(b *testing.B) { | 319 | func benchmarkDaemonSnapshotCapture(b *testing.B) { | ||
| @@ -541,6 +523,31 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||||
| 541 | } | 523 | } | ||
| 542 | } | 524 | } | ||
| 543 | 525 | ||||
| 526 | + | // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy | |||
| 527 | + | // rendering borrows sealed VT history rows; allocating a copy per viewport row | |||
| 528 | + | // is a production regression even though the capture itself remains immutable. | |||
| 529 | + | func TestCopyEnterAllocationBudget(t *testing.T) { | |||
| 530 | + | for _, tt := range []struct { | |||
| 531 | + | name string | |||
| 532 | + | tabs, panes, max int | |||
| 533 | + | }{ | |||
| 534 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 38}, | |||
| 535 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 43}, | |||
| 536 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42}, | |||
| 537 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48}, | |||
| 538 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50}, | |||
| 539 | + | } { | |||
| 540 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 541 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |||
| 542 | + | allocs := testing.AllocsPerRun(20, func() { | |||
| 543 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 544 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 545 | + | }) | |||
| 546 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline") | |||
| 547 | + | }) | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | ||||
| 544 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | 551 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | ||
| 545 | fixture := newPerformanceFixture(t, performanceConfig{}) | 552 | fixture := newPerformanceFixture(t, performanceConfig{}) | ||
| 546 | 553 | ||||
| @@ -853,12 +860,6 @@ func (f *performanceFixture) hasHistoryTopology(tabs, panes, historyRows int) bo | |||||
| 853 | return true | 860 | return true | ||
| 854 | } | 861 | } | ||
| 855 | 862 | ||||
| 856 | - | func (f *performanceFixture) copyModeActive() bool { | |||
| 857 | - | f.ac.overlays.copyMu.Lock() | |||
| 858 | - | defer f.ac.overlays.copyMu.Unlock() | |||
| 859 | - | return f.ac.overlays.copyMode != nil | |||
| 860 | - | } | |||
| 861 | - | ||||
| 862 | func (f *performanceFixture) searchMatches() int { | 863 | func (f *performanceFixture) searchMatches() int { | ||
| 863 | f.ac.overlays.copyMu.Lock() | 864 | f.ac.overlays.copyMu.Lock() | ||
| 864 | defer f.ac.overlays.copyMu.Unlock() | 865 | defer f.ac.overlays.copyMu.Unlock() | ||
M pkg/vt/history.go +8 -1
| @@ -143,12 +143,19 @@ func (v HistoryView) Range(yield func([]renderer.Cell) bool) { | |||
| 143 | 143 | ||
| 144 | 144 | // Row returns a copy of the row at i, or nil when i is out of range. | |
| 145 | 145 | func (v HistoryView) Row(i int) []renderer.Cell { | |
| 146 | + | return append([]renderer.Cell(nil), v.BorrowedRow(i)...) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // BorrowedRow returns immutable storage for the row at i, or nil when i is out | |
| 150 | + | // of range. The caller must not mutate or retain the result after it no longer | |
| 151 | + | // retains v. Consumers that need ownership should use Row instead. | |
| 152 | + | func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |
| 146 | 153 | if i < 0 { | |
| 147 | 154 | return nil | |
| 148 | 155 | } | |
| 149 | 156 | for _, chunk := range v.chunks { | |
| 150 | 157 | if i < len(chunk.rows) { | |
| 151 | - | return append([]renderer.Cell(nil), chunk.rows[i]...) | |
| 158 | + | return chunk.rows[i] | |
| 152 | 159 | } | |
| 153 | 160 | i -= len(chunk.rows) | |
| 154 | 161 | } | |
| @@ -143,12 +143,19 @@ func (v HistoryView) Range(yield func([]renderer.Cell) bool) { | |||||
| 143 | 143 | ||||
| 144 | // Row returns a copy of the row at i, or nil when i is out of range. | 144 | // Row returns a copy of the row at i, or nil when i is out of range. | ||
| 145 | func (v HistoryView) Row(i int) []renderer.Cell { | 145 | func (v HistoryView) Row(i int) []renderer.Cell { | ||
| 146 | + | return append([]renderer.Cell(nil), v.BorrowedRow(i)...) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // BorrowedRow returns immutable storage for the row at i, or nil when i is out | |||
| 150 | + | // of range. The caller must not mutate or retain the result after it no longer | |||
| 151 | + | // retains v. Consumers that need ownership should use Row instead. | |||
| 152 | + | func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 146 | if i < 0 { | 153 | if i < 0 { | ||
| 147 | return nil | 154 | return nil | ||
| 148 | } | 155 | } | ||
| 149 | for _, chunk := range v.chunks { | 156 | for _, chunk := range v.chunks { | ||
| 150 | if i < len(chunk.rows) { | 157 | if i < len(chunk.rows) { | ||
| 151 | - | return append([]renderer.Cell(nil), chunk.rows[i]...) | 158 | + | return chunk.rows[i] |
| 152 | } | 159 | } | ||
| 153 | i -= len(chunk.rows) | 160 | i -= len(chunk.rows) | ||
| 154 | } | 161 | } | ||