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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 511 | 511 | prealloc = min(prealloc, h.maxBytes/renderer.StoredCellLogicalBytes, uint64(width)*uint64(h.chunkRows)) | |
| 512 | 512 | capacity = max(capacity, max(width, int(prealloc))) | |
| 513 | 513 | } | |
| 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) | |
| 523 | 515 | } | |
| 524 | 516 | h.tailCells = h.tailCells[:end] | |
| 525 | 517 | 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 | 548 | func (h *History) appendTailRow(row []renderer.Cell) { | |
| 529 | 549 | start, end := h.reserveTailRow(len(row)) | |
| 530 | 550 | 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, end | 517 | 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) { | |||
| 82 | 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 | 108 | func TestStyleSet(t *testing.T) { | |
| 86 | 109 | style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} } | |
| 87 | 110 | 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) { | |||
| 168 | 168 | // CompressIdle visits at most maxPages sealed pages. Call it from the history | |
| 169 | 169 | // owner's idle scheduler, never concurrently with append/eviction. A page must | |
| 170 | 170 | // 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 | 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 | 179 | return 0, nil | |
| 175 | 180 | } | |
| 176 | 181 | 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 history | 168 | // CompressIdle visits at most maxPages sealed pages. Call it from the history | ||
| 169 | // owner's idle scheduler, never concurrently with append/eviction. A page must | 169 | // 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 are | 170 | // 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, nil | 179 | return 0, nil | ||
| 175 | } | 180 | } | ||
| 176 | eligible := len(h.chunks) - 1 | 181 | eligible := len(h.chunks) - 1 | ||