detached 8c784f8

perf(vt): reduce transcript recovery allocations

commit
8c784f8b9f2f5d9e2e9baf3b74934f2877dc7967
author
brice · · 2026-07-27 12:05 UTC
parent
35ddca2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5252 rows: make([][]renderer.Cell, rowCount),
5353 bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
5454 }
55+ cells := make([]renderer.Cell, rowCount*b.frame.Width)
5556 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))
5761 }
5862 segment.bounds[rowCount-1].Soft = false
5963 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 = false62 segment.bounds[rowCount-1].Soft = false
59 return segment63 return segment
M pkg/vt/recovery_transcript_test.go +58 -0
@@ -7,6 +7,11 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10+var (
11+ recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot
12+ recoveryTranscriptScreenSink *Screen
13+)
14+
1015 func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
1116 s := NewScreen(4, 2)
1217 s.Write([]byte("keep"))
@@ -20,6 +25,34 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
2025 require.Equal(t, LineBound{End: 4}, view.Bound(0))
2126 }
2227
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+
2356 func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) {
2457 s := NewScreen(4, 3)
2558 s.Write([]byte("prim"))
@@ -255,6 +288,31 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing
255288 require.Nil(t, screen)
256289 }
257290
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+
258316 func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {
259317 t.Helper()
260318 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
124124 if err != nil {
125125 return nil, err
126126 }
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+ }
134138 }
135139 }
136140
@@ -124,13 +124,17 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
124 if err != nil {124 if err != nil {
125 return nil, err125 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 = false130+ 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

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 12

Tags 11

↑↓ moveenter openesc close