detached adadad1

perf(history): release spare tail capacity on idle passes

CompressIdle now trims the mutable tail to the rows it holds. The tail
uses the wide semantic cell layout and is preallocated or recycled at up
to 131k cells, which dominated the idle footprint of a pane.

BenchmarkColdHistoryRetained10Kx120/plain-ascii: 4.22 MB -> 1.23 MB
commit
adadad161c52dcb46749e85d14753d4061edbbfb
author
brice · · 2026-10-07 16:23 UTC
parent
269f2d6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +59 -11
M history.go +29 -9
@@ -511,20 +511,40 @@ func (h *History) reserveTailRow(width int) (start, end int) {
511511 prealloc = min(prealloc, h.maxBytes/renderer.StoredCellLogicalBytes, uint64(width)*uint64(h.chunkRows))
512512 capacity = max(capacity, max(width, int(prealloc)))
513513 }
514- cells := make([]renderer.Cell, len(h.tailCells), capacity)
515- copy(cells, h.tailCells)
516- h.tailCells = cells
517- offset := 0
518- for i := range h.tail {
519- rowWidth := len(h.tail[i])
520- h.tail[i] = h.tailCells[offset : offset+rowWidth : offset+rowWidth]
521- offset += rowWidth
522- }
514+ h.rebaseTail(capacity)
523515 }
524516 h.tailCells = h.tailCells[:end]
525517 return start, end
526518 }
527519
520+// rebaseTail moves the mutable tail rows into fresh storage of the given
521+// capacity (at least the used length) and re-slices every row into it.
522+func (h *History) rebaseTail(capacity int) {
523+ cells := make([]renderer.Cell, len(h.tailCells), capacity)
524+ copy(cells, h.tailCells)
525+ h.tailCells = cells
526+ offset := 0
527+ for i := range h.tail {
528+ rowWidth := len(h.tail[i])
529+ h.tail[i] = h.tailCells[offset : offset+rowWidth : offset+rowWidth]
530+ offset += rowWidth
531+ }
532+}
533+
534+// trimIdleTail releases mutable-tail capacity beyond the rows it holds. Tail
535+// cells use the wide semantic layout, so preallocated or recycled capacity is
536+// the largest idle cost of a pane; the next append regrows it on demand.
537+func (h *History) trimIdleTail() {
538+ if cap(h.tailCells) == len(h.tailCells) {
539+ return
540+ }
541+ if len(h.tailCells) == 0 {
542+ h.tailCells = nil
543+ return
544+ }
545+ h.rebaseTail(len(h.tailCells))
546+}
547+
528548 func (h *History) appendTailRow(row []renderer.Cell) {
529549 start, end := h.reserveTailRow(len(row))
530550 copy(h.tailCells[start:end], row)
@@ -511,20 +511,40 @@ func (h *History) reserveTailRow(width int) (start, end int) {
511 prealloc = min(prealloc, h.maxBytes/renderer.StoredCellLogicalBytes, uint64(width)*uint64(h.chunkRows))511 prealloc = min(prealloc, h.maxBytes/renderer.StoredCellLogicalBytes, uint64(width)*uint64(h.chunkRows))
512 capacity = max(capacity, max(width, int(prealloc)))512 capacity = max(capacity, max(width, int(prealloc)))
513 }513 }
514- cells := make([]renderer.Cell, len(h.tailCells), capacity)514+ h.rebaseTail(capacity)
515- copy(cells, h.tailCells)
516- h.tailCells = cells
517- offset := 0
518- for i := range h.tail {
519- rowWidth := len(h.tail[i])
520- h.tail[i] = h.tailCells[offset : offset+rowWidth : offset+rowWidth]
521- offset += rowWidth
522- }
523 }515 }
524 h.tailCells = h.tailCells[:end]516 h.tailCells = h.tailCells[:end]
525 return start, end517 return start, end
526 }518 }
527 519
520+// rebaseTail moves the mutable tail rows into fresh storage of the given
521+// capacity (at least the used length) and re-slices every row into it.
522+func (h *History) rebaseTail(capacity int) {
523+ cells := make([]renderer.Cell, len(h.tailCells), capacity)
524+ copy(cells, h.tailCells)
525+ h.tailCells = cells
526+ offset := 0
527+ for i := range h.tail {
528+ rowWidth := len(h.tail[i])
529+ h.tail[i] = h.tailCells[offset : offset+rowWidth : offset+rowWidth]
530+ offset += rowWidth
531+ }
532+}
533+
534+// trimIdleTail releases mutable-tail capacity beyond the rows it holds. Tail
535+// cells use the wide semantic layout, so preallocated or recycled capacity is
536+// the largest idle cost of a pane; the next append regrows it on demand.
537+func (h *History) trimIdleTail() {
538+ if cap(h.tailCells) == len(h.tailCells) {
539+ return
540+ }
541+ if len(h.tailCells) == 0 {
542+ h.tailCells = nil
543+ return
544+ }
545+ h.rebaseTail(len(h.tailCells))
546+}
547+
528 func (h *History) appendTailRow(row []renderer.Cell) {548 func (h *History) appendTailRow(row []renderer.Cell) {
529 start, end := h.reserveTailRow(len(row))549 start, end := h.reserveTailRow(len(row))
530 copy(h.tailCells[start:end], row)550 copy(h.tailCells[start:end], row)
M history_evict_inplace_test.go +23 -0
@@ -82,6 +82,29 @@ func TestHistoryInPlaceEvictionAccountingMatchesFreshBuild(t *testing.T) {
8282 require.Equal(t, sum, h.LogicalBytes())
8383 }
8484
85+// An idle pass releases spare mutable-tail capacity without changing content,
86+// and later appends regrow it.
87+func TestHistoryCompressIdleTrimsTailCapacity(t *testing.T) {
88+ for _, tailRows := range []int{0, 3} {
89+ t.Run(fmt.Sprintf("tail-%d", tailRows), func(t *testing.T) {
90+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 8})
91+ for i := range 16 + tailRows {
92+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%4+1), LineBound{End: 4}))
93+ }
94+ before := rangeTextsNoT(h.View())
95+ bytes := h.LogicalBytes()
96+ _, err := h.CompressIdle(1)
97+ require.NoError(t, err)
98+ require.Equal(t, len(h.tailCells), cap(h.tailCells))
99+ require.Equal(t, before, rangeTextsNoT(h.View()))
100+ require.Equal(t, bytes, h.LogicalBytes())
101+ require.NoError(t, h.Append(styledRow("next", 1), LineBound{End: 4}))
102+ got := rangeTextsNoT(h.View())
103+ require.Equal(t, append(before, "next"), got)
104+ })
105+ }
106+}
107+
85108 func TestStyleSet(t *testing.T) {
86109 style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }
87110 tests := []struct {
@@ -82,6 +82,29 @@ func TestHistoryInPlaceEvictionAccountingMatchesFreshBuild(t *testing.T) {
82 require.Equal(t, sum, h.LogicalBytes())82 require.Equal(t, sum, h.LogicalBytes())
83 }83 }
84 84
85+// An idle pass releases spare mutable-tail capacity without changing content,
86+// and later appends regrow it.
87+func TestHistoryCompressIdleTrimsTailCapacity(t *testing.T) {
88+ for _, tailRows := range []int{0, 3} {
89+ t.Run(fmt.Sprintf("tail-%d", tailRows), func(t *testing.T) {
90+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 8})
91+ for i := range 16 + tailRows {
92+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%4+1), LineBound{End: 4}))
93+ }
94+ before := rangeTextsNoT(h.View())
95+ bytes := h.LogicalBytes()
96+ _, err := h.CompressIdle(1)
97+ require.NoError(t, err)
98+ require.Equal(t, len(h.tailCells), cap(h.tailCells))
99+ require.Equal(t, before, rangeTextsNoT(h.View()))
100+ require.Equal(t, bytes, h.LogicalBytes())
101+ require.NoError(t, h.Append(styledRow("next", 1), LineBound{End: 4}))
102+ got := rangeTextsNoT(h.View())
103+ require.Equal(t, append(before, "next"), got)
104+ })
105+ }
106+}
107+
85 func TestStyleSet(t *testing.T) {108 func TestStyleSet(t *testing.T) {
86 style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }109 style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }
87 tests := []struct {110 tests := []struct {
M history_storage.go +7 -2
@@ -168,9 +168,14 @@ func (p *sealedPage) compressIfIdle() (bool, error) {
168168 // CompressIdle visits at most maxPages sealed pages. Call it from the history
169169 // owner's idle scheduler, never concurrently with append/eviction. A page must
170170 // remain unread across two visits; the newest sealed chunk and mutable tail are
171-// kept hot. There are no internal goroutines, clocks, pooling or mmap mappings.
171+// kept hot, though unused mutable-tail capacity is released. There are no
172+// internal goroutines, clocks, pooling or mmap mappings.
172173 func (h *History) CompressIdle(maxPages int) (int, error) {
173- if h == nil || maxPages <= 0 || len(h.chunks) < 2 {
174+ if h == nil {
175+ return 0, nil
176+ }
177+ h.trimIdleTail()
178+ if maxPages <= 0 || len(h.chunks) < 2 {
174179 return 0, nil
175180 }
176181 eligible := len(h.chunks) - 1
@@ -168,9 +168,14 @@ func (p *sealedPage) compressIfIdle() (bool, error) {
168 // CompressIdle visits at most maxPages sealed pages. Call it from the history168 // CompressIdle visits at most maxPages sealed pages. Call it from the history
169 // owner's idle scheduler, never concurrently with append/eviction. A page must169 // owner's idle scheduler, never concurrently with append/eviction. A page must
170 // remain unread across two visits; the newest sealed chunk and mutable tail are170 // remain unread across two visits; the newest sealed chunk and mutable tail are
171-// kept hot. There are no internal goroutines, clocks, pooling or mmap mappings.171+// kept hot, though unused mutable-tail capacity is released. There are no
172+// internal goroutines, clocks, pooling or mmap mappings.
172 func (h *History) CompressIdle(maxPages int) (int, error) {173 func (h *History) CompressIdle(maxPages int) (int, error) {
173- if h == nil || maxPages <= 0 || len(h.chunks) < 2 {174+ if h == nil {
175+ return 0, nil
176+ }
177+ h.trimIdleTail()
178+ if maxPages <= 0 || len(h.chunks) < 2 {
174 return 0, nil179 return 0, nil
175 }180 }
176 eligible := len(h.chunks) - 1181 eligible := len(h.chunks) - 1

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 2

Tags 13

↑↓ moveenter openesc close