perf(history): add allocation-free text scans and pooled page reads
HistoryView.RangeText streams each row's runes, cell columns and width through reused buffers, reading stored runes directly instead of materializing styled cells. Cold page restores reuse zlib readers and read straight into an exactly sized buffer; truncation, trailing data and checksum failures are still rejected as ErrHistoryCorrupt.
- commit
- 30aa2bccf218bffe15c13ee59ead7d1bf0dbb685
- author
- brice · · 2026-10-07 16:29 UTC
- parent
- 61dc249
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +137 -8
M core/frame.go +23 -0
| @@ -267,6 +267,29 @@ func (f Frame) Cell(x, y int) Cell { | |||
| 267 | 267 | } | |
| 268 | 268 | func (f Frame) At(x, y int) Cell { return f.Cell(x, y) } | |
| 269 | 269 | ||
| 270 | + | // AppendRowText appends one rune per non-continuation cell of row y to runes, | |
| 271 | + | // and that cell's column to columns, and returns both extended slices. A zero | |
| 272 | + | // rune is reported as a space. It reads only runes and flags, so text scans | |
| 273 | + | // avoid materializing styled cells. | |
| 274 | + | func (f Frame) AppendRowText(runes []rune, columns []int, y int) ([]rune, []int) { | |
| 275 | + | if f.Width == 0 { | |
| 276 | + | return runes, columns | |
| 277 | + | } | |
| 278 | + | start := f.offset(0, y) | |
| 279 | + | for x, stored := range f.page.cells[start : start+f.Width] { | |
| 280 | + | if stored.flags&continuationFlag != 0 { | |
| 281 | + | continue | |
| 282 | + | } | |
| 283 | + | r := rune(stored.rune) | |
| 284 | + | if r == 0 { | |
| 285 | + | r = ' ' | |
| 286 | + | } | |
| 287 | + | runes = append(runes, r) | |
| 288 | + | columns = append(columns, x) | |
| 289 | + | } | |
| 290 | + | return runes, columns | |
| 291 | + | } | |
| 292 | + | ||
| 270 | 293 | // IsContinuation reports whether (x,y) is the right half of a wide pair | |
| 271 | 294 | // without materializing the cell. | |
| 272 | 295 | func (f Frame) IsContinuation(x, y int) bool { | |
| @@ -267,6 +267,29 @@ func (f Frame) Cell(x, y int) Cell { | |||||
| 267 | } | 267 | } | ||
| 268 | func (f Frame) At(x, y int) Cell { return f.Cell(x, y) } | 268 | func (f Frame) At(x, y int) Cell { return f.Cell(x, y) } | ||
| 269 | 269 | ||||
| 270 | + | // AppendRowText appends one rune per non-continuation cell of row y to runes, | |||
| 271 | + | // and that cell's column to columns, and returns both extended slices. A zero | |||
| 272 | + | // rune is reported as a space. It reads only runes and flags, so text scans | |||
| 273 | + | // avoid materializing styled cells. | |||
| 274 | + | func (f Frame) AppendRowText(runes []rune, columns []int, y int) ([]rune, []int) { | |||
| 275 | + | if f.Width == 0 { | |||
| 276 | + | return runes, columns | |||
| 277 | + | } | |||
| 278 | + | start := f.offset(0, y) | |||
| 279 | + | for x, stored := range f.page.cells[start : start+f.Width] { | |||
| 280 | + | if stored.flags&continuationFlag != 0 { | |||
| 281 | + | continue | |||
| 282 | + | } | |||
| 283 | + | r := rune(stored.rune) | |||
| 284 | + | if r == 0 { | |||
| 285 | + | r = ' ' | |||
| 286 | + | } | |||
| 287 | + | runes = append(runes, r) | |||
| 288 | + | columns = append(columns, x) | |||
| 289 | + | } | |||
| 290 | + | return runes, columns | |||
| 291 | + | } | |||
| 292 | + | ||||
| 270 | // IsContinuation reports whether (x,y) is the right half of a wide pair | 293 | // IsContinuation reports whether (x,y) is the right half of a wide pair | ||
| 271 | // without materializing the cell. | 294 | // without materializing the cell. | ||
| 272 | func (f Frame) IsContinuation(x, y int) bool { | 295 | func (f Frame) IsContinuation(x, y int) bool { | ||
M history_evict_inplace_test.go +58 -0
| @@ -105,6 +105,64 @@ func TestHistoryCompressIdleTrimsTailCapacity(t *testing.T) { | |||
| 105 | 105 | } | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | + | // RangeText must agree with Range on every row, including wide pairs, zero | |
| 109 | + | // runes, evicted prefixes and cold pages. | |
| 110 | + | func TestHistoryRangeTextMatchesRange(t *testing.T) { | |
| 111 | + | build := func(cold bool) HistoryView { | |
| 112 | + | h := NewHistory(HistoryConfig{MaxRows: 20, MaxBytes: 1 << 20, ChunkRows: 4}) | |
| 113 | + | for i := range 27 { | |
| 114 | + | row := historyRow(fmt.Sprintf("r%02d", i)) | |
| 115 | + | row = append(row, | |
| 116 | + | renderer.Cell{Rune: '界'}, renderer.Cell{Continuation: true}, // wide pair | |
| 117 | + | renderer.Cell{}, // zero rune reads as a space | |
| 118 | + | renderer.Cell{Rune: 'x'}) | |
| 119 | + | require.NoError(t, h.Append(row, LineBound{End: len(row)})) | |
| 120 | + | } | |
| 121 | + | if cold { | |
| 122 | + | for range 2 { | |
| 123 | + | _, err := h.CompressIdle(100) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return h.View() | |
| 128 | + | } | |
| 129 | + | for _, cold := range []bool{false, true} { | |
| 130 | + | t.Run(fmt.Sprintf("cold-%v", cold), func(t *testing.T) { | |
| 131 | + | view := build(cold) | |
| 132 | + | type rowText struct { | |
| 133 | + | runes []rune | |
| 134 | + | columns []int | |
| 135 | + | width int | |
| 136 | + | } | |
| 137 | + | var want []rowText | |
| 138 | + | require.NoError(t, view.Range(func(row []renderer.Cell) bool { | |
| 139 | + | w := rowText{width: len(row)} | |
| 140 | + | for x, c := range row { | |
| 141 | + | if c.Continuation { | |
| 142 | + | continue | |
| 143 | + | } | |
| 144 | + | r := c.Rune | |
| 145 | + | if r == 0 { | |
| 146 | + | r = ' ' | |
| 147 | + | } | |
| 148 | + | w.runes = append(w.runes, r) | |
| 149 | + | w.columns = append(w.columns, x) | |
| 150 | + | } | |
| 151 | + | want = append(want, w) | |
| 152 | + | return true | |
| 153 | + | })) | |
| 154 | + | var got []rowText | |
| 155 | + | require.NoError(t, view.RangeText(func(i int, runes []rune, columns []int, width int) bool { | |
| 156 | + | require.Equal(t, len(got), i) | |
| 157 | + | got = append(got, rowText{append([]rune(nil), runes...), append([]int(nil), columns...), width}) | |
| 158 | + | return true | |
| 159 | + | })) | |
| 160 | + | require.Equal(t, want, got) | |
| 161 | + | require.Len(t, got, 20) | |
| 162 | + | }) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 108 | 166 | func TestStyleSet(t *testing.T) { | |
| 109 | 167 | style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} } | |
| 110 | 168 | tests := []struct { | |
| @@ -105,6 +105,64 @@ func TestHistoryCompressIdleTrimsTailCapacity(t *testing.T) { | |||||
| 105 | } | 105 | } | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | // RangeText must agree with Range on every row, including wide pairs, zero | |||
| 109 | + | // runes, evicted prefixes and cold pages. | |||
| 110 | + | func TestHistoryRangeTextMatchesRange(t *testing.T) { | |||
| 111 | + | build := func(cold bool) HistoryView { | |||
| 112 | + | h := NewHistory(HistoryConfig{MaxRows: 20, MaxBytes: 1 << 20, ChunkRows: 4}) | |||
| 113 | + | for i := range 27 { | |||
| 114 | + | row := historyRow(fmt.Sprintf("r%02d", i)) | |||
| 115 | + | row = append(row, | |||
| 116 | + | renderer.Cell{Rune: '界'}, renderer.Cell{Continuation: true}, // wide pair | |||
| 117 | + | renderer.Cell{}, // zero rune reads as a space | |||
| 118 | + | renderer.Cell{Rune: 'x'}) | |||
| 119 | + | require.NoError(t, h.Append(row, LineBound{End: len(row)})) | |||
| 120 | + | } | |||
| 121 | + | if cold { | |||
| 122 | + | for range 2 { | |||
| 123 | + | _, err := h.CompressIdle(100) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | return h.View() | |||
| 128 | + | } | |||
| 129 | + | for _, cold := range []bool{false, true} { | |||
| 130 | + | t.Run(fmt.Sprintf("cold-%v", cold), func(t *testing.T) { | |||
| 131 | + | view := build(cold) | |||
| 132 | + | type rowText struct { | |||
| 133 | + | runes []rune | |||
| 134 | + | columns []int | |||
| 135 | + | width int | |||
| 136 | + | } | |||
| 137 | + | var want []rowText | |||
| 138 | + | require.NoError(t, view.Range(func(row []renderer.Cell) bool { | |||
| 139 | + | w := rowText{width: len(row)} | |||
| 140 | + | for x, c := range row { | |||
| 141 | + | if c.Continuation { | |||
| 142 | + | continue | |||
| 143 | + | } | |||
| 144 | + | r := c.Rune | |||
| 145 | + | if r == 0 { | |||
| 146 | + | r = ' ' | |||
| 147 | + | } | |||
| 148 | + | w.runes = append(w.runes, r) | |||
| 149 | + | w.columns = append(w.columns, x) | |||
| 150 | + | } | |||
| 151 | + | want = append(want, w) | |||
| 152 | + | return true | |||
| 153 | + | })) | |||
| 154 | + | var got []rowText | |||
| 155 | + | require.NoError(t, view.RangeText(func(i int, runes []rune, columns []int, width int) bool { | |||
| 156 | + | require.Equal(t, len(got), i) | |||
| 157 | + | got = append(got, rowText{append([]rune(nil), runes...), append([]int(nil), columns...), width}) | |||
| 158 | + | return true | |||
| 159 | + | })) | |||
| 160 | + | require.Equal(t, want, got) | |||
| 161 | + | require.Len(t, got, 20) | |||
| 162 | + | }) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 108 | func TestStyleSet(t *testing.T) { | 166 | func TestStyleSet(t *testing.T) { | ||
| 109 | style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} } | 167 | style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} } | ||
| 110 | tests := []struct { | 168 | tests := []struct { | ||
M history_read.go +26 -0
| @@ -38,6 +38,32 @@ func (v HistoryView) CopyRow(y int, dst []renderer.Cell) int { | |||
| 38 | 38 | return n | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | // RangeText streams each row's text in order without allocating per row. For | |
| 42 | + | // row i of the given cell width, runes holds one rune per non-continuation | |
| 43 | + | // cell (zero runes read as a space) and columns holds the cell column of each | |
| 44 | + | // rune. Both slices are only valid during the callback. Like Range, it | |
| 45 | + | // restores at most one cold page at a time and does not populate the page | |
| 46 | + | // cache. | |
| 47 | + | func (v HistoryView) RangeText(yield func(i int, runes []rune, columns []int, width int) bool) error { | |
| 48 | + | var runes []rune | |
| 49 | + | var columns []int | |
| 50 | + | i := 0 | |
| 51 | + | for _, chunk := range v.chunks { | |
| 52 | + | frame, err := chunk.page.readFrame(false) | |
| 53 | + | if err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | for row := range chunk.len() { | |
| 57 | + | runes, columns = frame.AppendRowText(runes[:0], columns[:0], chunk.start+row) | |
| 58 | + | if !yield(i, runes, columns, chunk.width) { | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | i++ | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 41 | 67 | func (v HistoryView) locateRow(y int) (*HistoryChunk, int) { | |
| 42 | 68 | if y < 0 { | |
| 43 | 69 | return nil, 0 | |
| @@ -38,6 +38,32 @@ func (v HistoryView) CopyRow(y int, dst []renderer.Cell) int { | |||||
| 38 | return n | 38 | return n | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | // RangeText streams each row's text in order without allocating per row. For | |||
| 42 | + | // row i of the given cell width, runes holds one rune per non-continuation | |||
| 43 | + | // cell (zero runes read as a space) and columns holds the cell column of each | |||
| 44 | + | // rune. Both slices are only valid during the callback. Like Range, it | |||
| 45 | + | // restores at most one cold page at a time and does not populate the page | |||
| 46 | + | // cache. | |||
| 47 | + | func (v HistoryView) RangeText(yield func(i int, runes []rune, columns []int, width int) bool) error { | |||
| 48 | + | var runes []rune | |||
| 49 | + | var columns []int | |||
| 50 | + | i := 0 | |||
| 51 | + | for _, chunk := range v.chunks { | |||
| 52 | + | frame, err := chunk.page.readFrame(false) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return err | |||
| 55 | + | } | |||
| 56 | + | for row := range chunk.len() { | |||
| 57 | + | runes, columns = frame.AppendRowText(runes[:0], columns[:0], chunk.start+row) | |||
| 58 | + | if !yield(i, runes, columns, chunk.width) { | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | i++ | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 41 | func (v HistoryView) locateRow(y int) (*HistoryChunk, int) { | 67 | func (v HistoryView) locateRow(y int) (*HistoryChunk, int) { | ||
| 42 | if y < 0 { | 68 | if y < 0 { | ||
| 43 | return nil, 0 | 69 | return nil, 0 | ||
M history_storage.go +30 -8
| @@ -65,19 +65,28 @@ func (p *sealedPage) decode() (renderer.Frame, error) { | |||
| 65 | 65 | return invalid(errors.New("missing backing")) | |
| 66 | 66 | } | |
| 67 | 67 | source := bytes.NewReader(p.compressed) | |
| 68 | - | r, err := zlib.NewReader(source) | |
| 68 | + | r, err := pageDecompressor(source) | |
| 69 | 69 | if err != nil { | |
| 70 | 70 | return invalid(err) | |
| 71 | 71 | } | |
| 72 | - | data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1)) | |
| 73 | - | closeErr := r.Close() | |
| 74 | - | if readErr != nil { | |
| 75 | - | return invalid(readErr) | |
| 72 | + | defer pageDecompressorPool.Put(r) | |
| 73 | + | // Read exactly the recorded size, then require a clean end of stream (which | |
| 74 | + | // also verifies the zlib checksum) with no trailing compressed bytes. | |
| 75 | + | data := make([]byte, p.encodedSize) | |
| 76 | + | if _, err := io.ReadFull(r, data); err != nil { | |
| 77 | + | return invalid(err) | |
| 76 | 78 | } | |
| 77 | - | if closeErr != nil { | |
| 78 | - | return invalid(closeErr) | |
| 79 | + | var extra [1]byte | |
| 80 | + | if n, err := r.Read(extra[:]); n != 0 || err != io.EOF { | |
| 81 | + | if err == nil || err == io.EOF { | |
| 82 | + | err = errors.New("backing length mismatch") | |
| 83 | + | } | |
| 84 | + | return invalid(err) | |
| 85 | + | } | |
| 86 | + | if err := r.Close(); err != nil { | |
| 87 | + | return invalid(err) | |
| 79 | 88 | } | |
| 80 | - | if len(data) != p.encodedSize || source.Len() != 0 { | |
| 89 | + | if source.Len() != 0 { | |
| 81 | 90 | return invalid(errors.New("backing length mismatch")) | |
| 82 | 91 | } | |
| 83 | 92 | view, err := UnmarshalHistory(data) | |
| @@ -113,6 +122,19 @@ func (c *HistoryChunk) Restore() error { | |||
| 113 | 122 | return err | |
| 114 | 123 | } | |
| 115 | 124 | ||
| 125 | + | // pageDecompressorPool reuses zlib readers; each one owns a 32 KiB window. | |
| 126 | + | var pageDecompressorPool sync.Pool | |
| 127 | + | ||
| 128 | + | func pageDecompressor(source io.Reader) (io.ReadCloser, error) { | |
| 129 | + | if pooled, ok := pageDecompressorPool.Get().(io.ReadCloser); ok { | |
| 130 | + | if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | return pooled, nil | |
| 134 | + | } | |
| 135 | + | return zlib.NewReader(source) | |
| 136 | + | } | |
| 137 | + | ||
| 116 | 138 | // pageCompressorPool reuses BestSpeed writers; each one owns ~600 KiB of | |
| 117 | 139 | // deflate state that would otherwise be allocated per compressed page. | |
| 118 | 140 | var pageCompressorPool = sync.Pool{New: func() any { | |
| @@ -65,19 +65,28 @@ func (p *sealedPage) decode() (renderer.Frame, error) { | |||||
| 65 | return invalid(errors.New("missing backing")) | 65 | return invalid(errors.New("missing backing")) | ||
| 66 | } | 66 | } | ||
| 67 | source := bytes.NewReader(p.compressed) | 67 | source := bytes.NewReader(p.compressed) | ||
| 68 | - | r, err := zlib.NewReader(source) | 68 | + | r, err := pageDecompressor(source) |
| 69 | if err != nil { | 69 | if err != nil { | ||
| 70 | return invalid(err) | 70 | return invalid(err) | ||
| 71 | } | 71 | } | ||
| 72 | - | data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1)) | 72 | + | defer pageDecompressorPool.Put(r) |
| 73 | - | closeErr := r.Close() | 73 | + | // Read exactly the recorded size, then require a clean end of stream (which |
| 74 | - | if readErr != nil { | 74 | + | // also verifies the zlib checksum) with no trailing compressed bytes. |
| 75 | - | return invalid(readErr) | 75 | + | data := make([]byte, p.encodedSize) |
| 76 | + | if _, err := io.ReadFull(r, data); err != nil { | |||
| 77 | + | return invalid(err) | |||
| 76 | } | 78 | } | ||
| 77 | - | if closeErr != nil { | 79 | + | var extra [1]byte |
| 78 | - | return invalid(closeErr) | 80 | + | if n, err := r.Read(extra[:]); n != 0 || err != io.EOF { |
| 81 | + | if err == nil || err == io.EOF { | |||
| 82 | + | err = errors.New("backing length mismatch") | |||
| 83 | + | } | |||
| 84 | + | return invalid(err) | |||
| 85 | + | } | |||
| 86 | + | if err := r.Close(); err != nil { | |||
| 87 | + | return invalid(err) | |||
| 79 | } | 88 | } | ||
| 80 | - | if len(data) != p.encodedSize || source.Len() != 0 { | 89 | + | if source.Len() != 0 { |
| 81 | return invalid(errors.New("backing length mismatch")) | 90 | return invalid(errors.New("backing length mismatch")) | ||
| 82 | } | 91 | } | ||
| 83 | view, err := UnmarshalHistory(data) | 92 | view, err := UnmarshalHistory(data) | ||
| @@ -113,6 +122,19 @@ func (c *HistoryChunk) Restore() error { | |||||
| 113 | return err | 122 | return err | ||
| 114 | } | 123 | } | ||
| 115 | 124 | ||||
| 125 | + | // pageDecompressorPool reuses zlib readers; each one owns a 32 KiB window. | |||
| 126 | + | var pageDecompressorPool sync.Pool | |||
| 127 | + | ||||
| 128 | + | func pageDecompressor(source io.Reader) (io.ReadCloser, error) { | |||
| 129 | + | if pooled, ok := pageDecompressorPool.Get().(io.ReadCloser); ok { | |||
| 130 | + | if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil { | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | return pooled, nil | |||
| 134 | + | } | |||
| 135 | + | return zlib.NewReader(source) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 116 | // pageCompressorPool reuses BestSpeed writers; each one owns ~600 KiB of | 138 | // pageCompressorPool reuses BestSpeed writers; each one owns ~600 KiB of | ||
| 117 | // deflate state that would otherwise be allocated per compressed page. | 139 | // deflate state that would otherwise be allocated per compressed page. | ||
| 118 | var pageCompressorPool = sync.Pool{New: func() any { | 140 | var pageCompressorPool = sync.Pool{New: func() any { | ||
M vev-vt.test +0 -0 binary file: no text diff
binary file: no text diff