perf(vt): reduce transcript recovery allocations
- commit
- 8c784f8b9f2f5d9e2e9baf3b74934f2877dc7967
- author
- brice · · 2026-07-27 12:05 UTC
- parent
- 35ddca2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +74 -8
M pkg/vt/recovery_transcript.go +5 -1
| @@ -52,8 +52,12 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |||
| 52 | 52 | rows: make([][]renderer.Cell, rowCount), | |
| 53 | 53 | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |
| 54 | 54 | } | |
| 55 | + | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |
| 55 | 56 | for y := range rowCount { | |
| 56 | - | segment.rows[y] = append([]renderer.Cell(nil), b.frame.Row(y)...) | |
| 57 | + | start := y * b.frame.Width | |
| 58 | + | end := start + b.frame.Width | |
| 59 | + | segment.rows[y] = cells[start:end:end] | |
| 60 | + | copy(segment.rows[y], b.frame.Row(y)) | |
| 57 | 61 | } | |
| 58 | 62 | segment.bounds[rowCount-1].Soft = false | |
| 59 | 63 | return segment | |
| @@ -52,8 +52,12 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |||||
| 52 | rows: make([][]renderer.Cell, rowCount), | 52 | rows: make([][]renderer.Cell, rowCount), | ||
| 53 | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | 53 | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | ||
| 54 | } | 54 | } | ||
| 55 | + | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |||
| 55 | for y := range rowCount { | 56 | for y := range rowCount { | ||
| 56 | - | segment.rows[y] = append([]renderer.Cell(nil), b.frame.Row(y)...) | 57 | + | start := y * b.frame.Width |
| 58 | + | end := start + b.frame.Width | |||
| 59 | + | segment.rows[y] = cells[start:end:end] | |||
| 60 | + | copy(segment.rows[y], b.frame.Row(y)) | |||
| 57 | } | 61 | } | ||
| 58 | segment.bounds[rowCount-1].Soft = false | 62 | segment.bounds[rowCount-1].Soft = false | ||
| 59 | return segment | 63 | return segment | ||
M pkg/vt/recovery_transcript_test.go +58 -0
| @@ -7,6 +7,11 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | var ( | |
| 11 | + | recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot | |
| 12 | + | recoveryTranscriptScreenSink *Screen | |
| 13 | + | ) | |
| 14 | + | ||
| 10 | 15 | func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |
| 11 | 16 | s := NewScreen(4, 2) | |
| 12 | 17 | s.Write([]byte("keep")) | |
| @@ -20,6 +25,34 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||
| 20 | 25 | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | |
| 21 | 26 | } | |
| 22 | 27 | ||
| 28 | + | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | |
| 29 | + | const ( | |
| 30 | + | shortHeight = 8 | |
| 31 | + | tallHeight = 512 | |
| 32 | + | ) | |
| 33 | + | newRetainedScreen := func(height int) *Screen { | |
| 34 | + | screen := NewScreen(4, height) | |
| 35 | + | for y := range height { | |
| 36 | + | screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 37 | + | screen.buffer.boundaries[y] = LineBound{End: 1} | |
| 38 | + | } | |
| 39 | + | return screen | |
| 40 | + | } | |
| 41 | + | short, tall := newRetainedScreen(shortHeight), newRetainedScreen(tallHeight) | |
| 42 | + | ||
| 43 | + | shortAllocs := testing.AllocsPerRun(20, func() { | |
| 44 | + | recoveryTranscriptSnapshotSink = short.RecoveryTranscriptSnapshot() | |
| 45 | + | }) | |
| 46 | + | tallAllocs := testing.AllocsPerRun(20, func() { | |
| 47 | + | recoveryTranscriptSnapshotSink = tall.RecoveryTranscriptSnapshot() | |
| 48 | + | }) | |
| 49 | + | ||
| 50 | + | // Allow a small fixed variance while rejecting the former allocation per row. | |
| 51 | + | if tallAllocs > shortAllocs+4 { | |
| 52 | + | t.Fatalf("snapshot allocations scale with retained rows: height %d = %.0f, height %d = %.0f", shortHeight, shortAllocs, tallHeight, tallAllocs) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 23 | 56 | func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) { | |
| 24 | 57 | s := NewScreen(4, 3) | |
| 25 | 58 | s.Write([]byte("prim")) | |
| @@ -255,6 +288,31 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing | |||
| 255 | 288 | require.Nil(t, screen) | |
| 256 | 289 | } | |
| 257 | 290 | ||
| 291 | + | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |
| 292 | + | const rows = maxHistoryRows | |
| 293 | + | source := NewScreen(1, rows) | |
| 294 | + | for y := range rows { | |
| 295 | + | source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 296 | + | source.buffer.boundaries[y] = LineBound{End: 1} | |
| 297 | + | } | |
| 298 | + | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | |
| 299 | + | require.NoError(b, err) | |
| 300 | + | tail, err := MarshalEmptyHistoryTail() | |
| 301 | + | require.NoError(b, err) | |
| 302 | + | config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows} | |
| 303 | + | ||
| 304 | + | b.ReportAllocs() | |
| 305 | + | b.ReportMetric(float64(rows), "rows/restore") | |
| 306 | + | b.ReportMetric(float64((rows+maxHistoryChunkRows-1)/maxHistoryChunkRows), "chunks/restore") | |
| 307 | + | b.ResetTimer() | |
| 308 | + | for b.Loop() { | |
| 309 | + | recoveryTranscriptScreenSink, err = NewScreenWithRecoveryTranscript(1, 1, config, nil, tail, transcript) | |
| 310 | + | if err != nil { | |
| 311 | + | b.Fatal(err) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 258 | 316 | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | |
| 259 | 317 | t.Helper() | |
| 260 | 318 | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | |
| @@ -7,6 +7,11 @@ import ( | |||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | var ( | |||
| 11 | + | recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot | |||
| 12 | + | recoveryTranscriptScreenSink *Screen | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 10 | func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | 15 | func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | ||
| 11 | s := NewScreen(4, 2) | 16 | s := NewScreen(4, 2) | ||
| 12 | s.Write([]byte("keep")) | 17 | s.Write([]byte("keep")) | ||
| @@ -20,6 +25,34 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||||
| 20 | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | 25 | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | ||
| 21 | } | 26 | } | ||
| 22 | 27 | ||||
| 28 | + | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | |||
| 29 | + | const ( | |||
| 30 | + | shortHeight = 8 | |||
| 31 | + | tallHeight = 512 | |||
| 32 | + | ) | |||
| 33 | + | newRetainedScreen := func(height int) *Screen { | |||
| 34 | + | screen := NewScreen(4, height) | |||
| 35 | + | for y := range height { | |||
| 36 | + | screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 37 | + | screen.buffer.boundaries[y] = LineBound{End: 1} | |||
| 38 | + | } | |||
| 39 | + | return screen | |||
| 40 | + | } | |||
| 41 | + | short, tall := newRetainedScreen(shortHeight), newRetainedScreen(tallHeight) | |||
| 42 | + | ||||
| 43 | + | shortAllocs := testing.AllocsPerRun(20, func() { | |||
| 44 | + | recoveryTranscriptSnapshotSink = short.RecoveryTranscriptSnapshot() | |||
| 45 | + | }) | |||
| 46 | + | tallAllocs := testing.AllocsPerRun(20, func() { | |||
| 47 | + | recoveryTranscriptSnapshotSink = tall.RecoveryTranscriptSnapshot() | |||
| 48 | + | }) | |||
| 49 | + | ||||
| 50 | + | // Allow a small fixed variance while rejecting the former allocation per row. | |||
| 51 | + | if tallAllocs > shortAllocs+4 { | |||
| 52 | + | t.Fatalf("snapshot allocations scale with retained rows: height %d = %.0f, height %d = %.0f", shortHeight, shortAllocs, tallHeight, tallAllocs) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 23 | func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) { | 56 | func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) { | ||
| 24 | s := NewScreen(4, 3) | 57 | s := NewScreen(4, 3) | ||
| 25 | s.Write([]byte("prim")) | 58 | s.Write([]byte("prim")) | ||
| @@ -255,6 +288,31 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing | |||||
| 255 | require.Nil(t, screen) | 288 | require.Nil(t, screen) | ||
| 256 | } | 289 | } | ||
| 257 | 290 | ||||
| 291 | + | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |||
| 292 | + | const rows = maxHistoryRows | |||
| 293 | + | source := NewScreen(1, rows) | |||
| 294 | + | for y := range rows { | |||
| 295 | + | source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 296 | + | source.buffer.boundaries[y] = LineBound{End: 1} | |||
| 297 | + | } | |||
| 298 | + | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | |||
| 299 | + | require.NoError(b, err) | |||
| 300 | + | tail, err := MarshalEmptyHistoryTail() | |||
| 301 | + | require.NoError(b, err) | |||
| 302 | + | config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows} | |||
| 303 | + | ||||
| 304 | + | b.ReportAllocs() | |||
| 305 | + | b.ReportMetric(float64(rows), "rows/restore") | |||
| 306 | + | b.ReportMetric(float64((rows+maxHistoryChunkRows-1)/maxHistoryChunkRows), "chunks/restore") | |||
| 307 | + | b.ResetTimer() | |||
| 308 | + | for b.Loop() { | |||
| 309 | + | recoveryTranscriptScreenSink, err = NewScreenWithRecoveryTranscript(1, 1, config, nil, tail, transcript) | |||
| 310 | + | if err != nil { | |||
| 311 | + | b.Fatal(err) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 258 | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | 316 | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | ||
| 259 | t.Helper() | 317 | t.Helper() | ||
| 260 | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | 318 | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | ||
M pkg/vt/snapshot.go +11 -7
| @@ -124,13 +124,17 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 124 | 124 | if err != nil { | |
| 125 | 125 | return nil, err | |
| 126 | 126 | } | |
| 127 | - | for i := range transcriptView.Len() { | |
| 128 | - | bound := transcriptView.Bound(i) | |
| 129 | - | if i == transcriptView.Len()-1 { | |
| 130 | - | bound.Soft = false | |
| 131 | - | } | |
| 132 | - | if err := history.Append(transcriptView.BorrowedRow(i), bound); err != nil { | |
| 133 | - | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 127 | + | remainingRows := transcriptView.rows | |
| 128 | + | for _, chunk := range transcriptView.chunks { | |
| 129 | + | for i, row := range chunk.rows { | |
| 130 | + | remainingRows-- | |
| 131 | + | bound := chunk.bounds[i] | |
| 132 | + | if remainingRows == 0 { | |
| 133 | + | bound.Soft = false | |
| 134 | + | } | |
| 135 | + | if err := history.Append(row, bound); err != nil { | |
| 136 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 137 | + | } | |
| 134 | 138 | } | |
| 135 | 139 | } | |
| 136 | 140 | ||
| @@ -124,13 +124,17 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 124 | if err != nil { | 124 | if err != nil { | ||
| 125 | return nil, err | 125 | return nil, err | ||
| 126 | } | 126 | } | ||
| 127 | - | for i := range transcriptView.Len() { | 127 | + | remainingRows := transcriptView.rows |
| 128 | - | bound := transcriptView.Bound(i) | 128 | + | for _, chunk := range transcriptView.chunks { |
| 129 | - | if i == transcriptView.Len()-1 { | 129 | + | for i, row := range chunk.rows { |
| 130 | - | bound.Soft = false | 130 | + | remainingRows-- |
| 131 | - | } | 131 | + | bound := chunk.bounds[i] |
| 132 | - | if err := history.Append(transcriptView.BorrowedRow(i), bound); err != nil { | 132 | + | if remainingRows == 0 { |
| 133 | - | return nil, fmt.Errorf("restore recovery transcript: %w", err) | 133 | + | bound.Soft = false |
| 134 | + | } | |||
| 135 | + | if err := history.Append(row, bound); err != nil { | |||
| 136 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |||
| 137 | + | } | |||
| 134 | } | 138 | } | ||
| 135 | } | 139 | } | ||
| 136 | 140 | ||||