perf(history): make full-history eviction constant-time
Skip the retained row-ID scan for IDs above nextRowID, advance an uncaptured head chunk wrapper in place instead of allocating one per evicted row, and collect row styles in a small inline set. Adds BenchmarkScreenScrollbackFlood (160x45, history 0/10k/50k). history-10000: 4.85 -> 8.87 MiB/s, 1078 -> 86 allocs/op history-50000: 3.69 -> 8.87 MiB/s
- commit
- 269f2d6f687c1ed70f1985018c035a378e05fe8a
- author
- brice · · 2026-10-07 16:19 UTC
- parent
- b250c5e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +319 -55
M benchmark_test.go +36 -0
| @@ -3,6 +3,7 @@ package vt | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "encoding/base64" | |
| 6 | + | "fmt" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | renderer "github.com/bnema/vev-vt/core" | |
| @@ -260,6 +261,41 @@ func BenchmarkScreenKittyAPC(b *testing.B) { | |||
| 260 | 261 | } | |
| 261 | 262 | } | |
| 262 | 263 | ||
| 264 | + | // scrollbackFloodChunk is 1000 colored 160-column lines ending in CRLF, the | |
| 265 | + | // shape of a build log or `yes`-style flood scrolling into history. | |
| 266 | + | func scrollbackFloodChunk() []byte { | |
| 267 | + | var chunk bytes.Buffer | |
| 268 | + | line := bytes.Repeat([]byte("x"), 140) | |
| 269 | + | for i := range 1000 { | |
| 270 | + | fmt.Fprintf(&chunk, "\x1b[3%dmflood %06d %s\x1b[0m\r\n", i%8, i, line) | |
| 271 | + | } | |
| 272 | + | return chunk.Bytes() | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // BenchmarkScreenScrollbackFlood measures steady-state output once history is | |
| 276 | + | // full, so every new line evicts the oldest retained row. | |
| 277 | + | func BenchmarkScreenScrollbackFlood(b *testing.B) { | |
| 278 | + | chunk := scrollbackFloodChunk() | |
| 279 | + | for _, rows := range []int{0, 10_000, 50_000} { | |
| 280 | + | b.Run(fmt.Sprintf("history-%d", rows), func(b *testing.B) { | |
| 281 | + | s := NewScreen(160, 45) | |
| 282 | + | if rows > 0 { | |
| 283 | + | s = NewScreenWithHistory(160, 45, HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30}) | |
| 284 | + | } | |
| 285 | + | for range rows/1000 + 1 { | |
| 286 | + | s.Write(chunk) | |
| 287 | + | } | |
| 288 | + | s.ClearDamage() | |
| 289 | + | b.SetBytes(int64(len(chunk))) | |
| 290 | + | b.ReportAllocs() | |
| 291 | + | for b.Loop() { | |
| 292 | + | s.Write(chunk) | |
| 293 | + | s.ClearDamage() | |
| 294 | + | } | |
| 295 | + | }) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 263 | 299 | func BenchmarkScreenMixedUTF8(b *testing.B) { | |
| 264 | 300 | s := NewScreen(120, 40) | |
| 265 | 301 | chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r') | |
| @@ -3,6 +3,7 @@ package vt | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | + | "fmt" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | renderer "github.com/bnema/vev-vt/core" | 9 | renderer "github.com/bnema/vev-vt/core" | ||
| @@ -260,6 +261,41 @@ func BenchmarkScreenKittyAPC(b *testing.B) { | |||||
| 260 | } | 261 | } | ||
| 261 | } | 262 | } | ||
| 262 | 263 | ||||
| 264 | + | // scrollbackFloodChunk is 1000 colored 160-column lines ending in CRLF, the | |||
| 265 | + | // shape of a build log or `yes`-style flood scrolling into history. | |||
| 266 | + | func scrollbackFloodChunk() []byte { | |||
| 267 | + | var chunk bytes.Buffer | |||
| 268 | + | line := bytes.Repeat([]byte("x"), 140) | |||
| 269 | + | for i := range 1000 { | |||
| 270 | + | fmt.Fprintf(&chunk, "\x1b[3%dmflood %06d %s\x1b[0m\r\n", i%8, i, line) | |||
| 271 | + | } | |||
| 272 | + | return chunk.Bytes() | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // BenchmarkScreenScrollbackFlood measures steady-state output once history is | |||
| 276 | + | // full, so every new line evicts the oldest retained row. | |||
| 277 | + | func BenchmarkScreenScrollbackFlood(b *testing.B) { | |||
| 278 | + | chunk := scrollbackFloodChunk() | |||
| 279 | + | for _, rows := range []int{0, 10_000, 50_000} { | |||
| 280 | + | b.Run(fmt.Sprintf("history-%d", rows), func(b *testing.B) { | |||
| 281 | + | s := NewScreen(160, 45) | |||
| 282 | + | if rows > 0 { | |||
| 283 | + | s = NewScreenWithHistory(160, 45, HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30}) | |||
| 284 | + | } | |||
| 285 | + | for range rows/1000 + 1 { | |||
| 286 | + | s.Write(chunk) | |||
| 287 | + | } | |||
| 288 | + | s.ClearDamage() | |||
| 289 | + | b.SetBytes(int64(len(chunk))) | |||
| 290 | + | b.ReportAllocs() | |||
| 291 | + | for b.Loop() { | |||
| 292 | + | s.Write(chunk) | |||
| 293 | + | s.ClearDamage() | |||
| 294 | + | } | |||
| 295 | + | }) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | ||||
| 263 | func BenchmarkScreenMixedUTF8(b *testing.B) { | 299 | func BenchmarkScreenMixedUTF8(b *testing.B) { | ||
| 264 | s := NewScreen(120, 40) | 300 | s := NewScreen(120, 40) | ||
| 265 | chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r') | 301 | chunk := append(bytes.Repeat([]byte("Aé界"), 20), '\r') | ||
M history.go +57 -47
| @@ -41,7 +41,7 @@ type History struct { | |||
| 41 | 41 | tailPageWidth int | |
| 42 | 42 | tailPageRows int | |
| 43 | 43 | tailPageStyles map[renderer.Style]struct{} | |
| 44 | - | styleScratch map[renderer.Style]struct{} | |
| 44 | + | styleScratch styleSet | |
| 45 | 45 | payloadScratch map[renderer.CellPayload]struct{} | |
| 46 | 46 | tailPagePayloads map[renderer.CellPayload]struct{} | |
| 47 | 47 | styleRowScratch map[renderer.Style]int | |
| @@ -50,6 +50,10 @@ type History struct { | |||
| 50 | 50 | logicalBytes uint64 | |
| 51 | 51 | nextRowID RowID | |
| 52 | 52 | compressionCursor int | |
| 53 | + | // privateHead is the oldest sealed chunk's wrapper when it was created by | |
| 54 | + | // eviction and no view has captured it since. Eviction may then advance it | |
| 55 | + | // in place instead of allocating a new wrapper per evicted row. | |
| 56 | + | privateHead *HistoryChunk | |
| 53 | 57 | } | |
| 54 | 58 | ||
| 55 | 59 | // HistoryChunk is an immutable compact slab of equal-width history rows. Its | |
| @@ -270,62 +274,45 @@ func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 { | |||
| 270 | 274 | ||
| 271 | 275 | func (h *History) prepareRowStyles(row []renderer.Cell) uint64 { | |
| 272 | 276 | clear(h.payloadScratch) | |
| 273 | - | if h.styleScratch == nil { | |
| 274 | - | h.styleScratch = make(map[renderer.Style]struct{}) | |
| 275 | - | } else { | |
| 276 | - | clear(h.styleScratch) | |
| 277 | - | } | |
| 278 | - | if len(row) > 0 { | |
| 279 | - | firstRaw := row[0].Style | |
| 280 | - | same := true | |
| 281 | - | for _, cell := range row { | |
| 282 | - | h.recordPayloadScratch(cell.Payload) | |
| 283 | - | if cell.Style != firstRaw { | |
| 284 | - | same = false | |
| 285 | - | } | |
| 277 | + | h.styleScratch.reset() | |
| 278 | + | defaultStyle := renderer.DefaultStyle() | |
| 279 | + | // A run of identical raw styles canonicalizes identically, so only the | |
| 280 | + | // first cell of each run pays for Canonical and the set insert. | |
| 281 | + | var previous renderer.Style | |
| 282 | + | for i, cell := range row { | |
| 283 | + | h.recordPayloadScratch(cell.Payload) | |
| 284 | + | if i > 0 && cell.Style == previous { | |
| 285 | + | continue | |
| 286 | 286 | } | |
| 287 | - | if same { | |
| 288 | - | first := firstRaw.Canonical() | |
| 289 | - | if first != renderer.DefaultStyle() { | |
| 290 | - | h.styleScratch[first] = struct{}{} | |
| 291 | - | } | |
| 292 | - | } else { | |
| 293 | - | for _, cell := range row { | |
| 294 | - | style := cell.Style.Canonical() | |
| 295 | - | if style != renderer.DefaultStyle() { | |
| 296 | - | h.styleScratch[style] = struct{}{} | |
| 297 | - | } | |
| 298 | - | } | |
| 287 | + | previous = cell.Style | |
| 288 | + | if style := cell.Style.Canonical(); style != defaultStyle { | |
| 289 | + | h.styleScratch.add(style) | |
| 299 | 290 | } | |
| 300 | 291 | } | |
| 301 | - | return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 292 | + | return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 302 | 293 | } | |
| 303 | 294 | ||
| 304 | 295 | func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 { | |
| 305 | 296 | clear(h.payloadScratch) | |
| 306 | - | if h.styleScratch == nil { | |
| 307 | - | h.styleScratch = make(map[renderer.Style]struct{}) | |
| 308 | - | } else { | |
| 309 | - | clear(h.styleScratch) | |
| 310 | - | } | |
| 297 | + | h.styleScratch.reset() | |
| 298 | + | defaultStyle := renderer.DefaultStyle() | |
| 311 | 299 | for column := range chunk.width { | |
| 312 | 300 | cell := chunk.cell(row, column) | |
| 313 | 301 | h.recordPayloadScratch(cell.Payload) | |
| 314 | - | style := cell.Style.Canonical() | |
| 315 | - | if style != renderer.DefaultStyle() { | |
| 316 | - | h.styleScratch[style] = struct{}{} | |
| 302 | + | if style := cell.Style.Canonical(); style != defaultStyle { | |
| 303 | + | h.styleScratch.add(style) | |
| 317 | 304 | } | |
| 318 | 305 | } | |
| 319 | - | return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 306 | + | return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 320 | 307 | } | |
| 321 | 308 | ||
| 322 | 309 | func (h *History) tailAppendDelta(width int) uint64 { | |
| 323 | 310 | newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) | |
| 324 | 311 | delta := historyRowBaseLogicalBytes(width) | |
| 325 | 312 | if newPage { | |
| 326 | - | return delta + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 313 | + | return delta + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 327 | 314 | } | |
| 328 | - | for style := range h.styleScratch { | |
| 315 | + | for style := range h.styleScratch.all() { | |
| 329 | 316 | if _, ok := h.tailPageStyles[style]; !ok { | |
| 330 | 317 | delta += renderer.StyleRecordLogicalBytes | |
| 331 | 318 | } | |
| @@ -351,7 +338,7 @@ func (h *History) recordTailRowStyles(width int) { | |||
| 351 | 338 | } | |
| 352 | 339 | h.tailPagePayloads[p] = struct{}{} | |
| 353 | 340 | } | |
| 354 | - | for style := range h.styleScratch { | |
| 341 | + | for style := range h.styleScratch.all() { | |
| 355 | 342 | h.tailPageStyles[style] = struct{}{} | |
| 356 | 343 | } | |
| 357 | 344 | h.tailPageRows++ | |
| @@ -468,7 +455,13 @@ func (h *History) allocateRowID() (RowID, error) { | |||
| 468 | 455 | return id, nil | |
| 469 | 456 | } | |
| 470 | 457 | ||
| 458 | + | // hasRowID reports whether id is retained. Every retained ID is below | |
| 459 | + | // nextRowID (appendRow advances it and restore validates it), so the newest-ID | |
| 460 | + | // case used by screen eviction answers without scanning retained rows. | |
| 471 | 461 | func (h *History) hasRowID(id RowID) bool { | |
| 462 | + | if id >= h.nextRowID { | |
| 463 | + | return false | |
| 464 | + | } | |
| 472 | 465 | for _, chunk := range h.chunks { | |
| 473 | 466 | if slices.Contains(chunk.rowIDs, id) { | |
| 474 | 467 | return true | |
| @@ -589,14 +582,19 @@ func (h *History) evictFor(row []renderer.Cell) { | |||
| 589 | 582 | if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta { | |
| 590 | 583 | return | |
| 591 | 584 | } | |
| 592 | - | h.evictOldest() | |
| 593 | - | h.prepareRowStyles(row) | |
| 585 | + | // Only a tail eviction rebuilds tail accounting, which reuses the row | |
| 586 | + | // scratch; sealed-chunk eviction leaves this row's styles intact. | |
| 587 | + | if h.evictOldest() { | |
| 588 | + | h.prepareRowStyles(row) | |
| 589 | + | } | |
| 594 | 590 | } | |
| 595 | 591 | } | |
| 596 | 592 | ||
| 597 | - | func (h *History) evictOldest() { | |
| 593 | + | // evictOldest drops the oldest retained row. It reports whether it rebuilt | |
| 594 | + | // tail accounting, which overwrites the row style and payload scratch. | |
| 595 | + | func (h *History) evictOldest() (scratchClobbered bool) { | |
| 598 | 596 | if h.rows == 0 { | |
| 599 | - | return | |
| 597 | + | return false | |
| 600 | 598 | } | |
| 601 | 599 | if len(h.chunks) > 0 { | |
| 602 | 600 | chunk := h.chunks[0] | |
| @@ -604,6 +602,9 @@ func (h *History) evictOldest() { | |||
| 604 | 602 | h.rows-- | |
| 605 | 603 | h.cells -= chunk.width | |
| 606 | 604 | if chunk.count == 1 { | |
| 605 | + | if chunk == h.privateHead { | |
| 606 | + | h.privateHead = nil | |
| 607 | + | } | |
| 607 | 608 | h.logicalBytes -= oldBytes | |
| 608 | 609 | copy(h.chunks, h.chunks[1:]) | |
| 609 | 610 | h.chunks[len(h.chunks)-1] = nil | |
| @@ -611,14 +612,20 @@ func (h *History) evictOldest() { | |||
| 611 | 612 | } else { | |
| 612 | 613 | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 613 | 614 | // retained view may still refer to the original immutable chunk. The | |
| 614 | - | // wrapper tracks only styles still used by its retained suffix. | |
| 615 | - | h.chunks[0] = chunk.withoutFirstRow() | |
| 615 | + | // wrapper tracks only styles still used by its retained suffix. A | |
| 616 | + | // wrapper no view has seen is advanced in place. | |
| 617 | + | if chunk == h.privateHead { | |
| 618 | + | chunk.dropFirstRow() | |
| 619 | + | } else { | |
| 620 | + | h.chunks[0] = chunk.withoutFirstRow() | |
| 621 | + | h.privateHead = h.chunks[0] | |
| 622 | + | } | |
| 616 | 623 | h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0]) | |
| 617 | 624 | } | |
| 618 | - | return | |
| 625 | + | return false | |
| 619 | 626 | } | |
| 620 | 627 | if len(h.tail) == 0 { | |
| 621 | - | return | |
| 628 | + | return false | |
| 622 | 629 | } | |
| 623 | 630 | h.cachedTail = nil | |
| 624 | 631 | row := h.tail[0] | |
| @@ -633,6 +640,7 @@ func (h *History) evictOldest() { | |||
| 633 | 640 | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | |
| 634 | 641 | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | |
| 635 | 642 | h.rebuildTailAccounting() | |
| 643 | + | return true | |
| 636 | 644 | } | |
| 637 | 645 | ||
| 638 | 646 | // SealAndView rotates the mutable tail into an immutable chunk, then captures | |
| @@ -660,6 +668,7 @@ func (h *History) View() HistoryView { | |||
| 660 | 668 | if h.rows == 0 { | |
| 661 | 669 | return HistoryView{nextRowID: h.nextRowID} | |
| 662 | 670 | } | |
| 671 | + | h.privateHead = nil | |
| 663 | 672 | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | |
| 664 | 673 | copy(chunks, h.chunks) | |
| 665 | 674 | if len(h.tail) > 0 { | |
| @@ -677,6 +686,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||
| 677 | 686 | if h.rows == 0 { | |
| 678 | 687 | return HistorySnapshotView{nextRowID: h.nextRowID} | |
| 679 | 688 | } | |
| 689 | + | h.privateHead = nil | |
| 680 | 690 | tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs) | |
| 681 | 691 | return HistorySnapshotView{ | |
| 682 | 692 | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| @@ -41,7 +41,7 @@ type History struct { | |||||
| 41 | tailPageWidth int | 41 | tailPageWidth int | ||
| 42 | tailPageRows int | 42 | tailPageRows int | ||
| 43 | tailPageStyles map[renderer.Style]struct{} | 43 | tailPageStyles map[renderer.Style]struct{} | ||
| 44 | - | styleScratch map[renderer.Style]struct{} | 44 | + | styleScratch styleSet |
| 45 | payloadScratch map[renderer.CellPayload]struct{} | 45 | payloadScratch map[renderer.CellPayload]struct{} | ||
| 46 | tailPagePayloads map[renderer.CellPayload]struct{} | 46 | tailPagePayloads map[renderer.CellPayload]struct{} | ||
| 47 | styleRowScratch map[renderer.Style]int | 47 | styleRowScratch map[renderer.Style]int | ||
| @@ -50,6 +50,10 @@ type History struct { | |||||
| 50 | logicalBytes uint64 | 50 | logicalBytes uint64 | ||
| 51 | nextRowID RowID | 51 | nextRowID RowID | ||
| 52 | compressionCursor int | 52 | compressionCursor int | ||
| 53 | + | // privateHead is the oldest sealed chunk's wrapper when it was created by | |||
| 54 | + | // eviction and no view has captured it since. Eviction may then advance it | |||
| 55 | + | // in place instead of allocating a new wrapper per evicted row. | |||
| 56 | + | privateHead *HistoryChunk | |||
| 53 | } | 57 | } | ||
| 54 | 58 | ||||
| 55 | // HistoryChunk is an immutable compact slab of equal-width history rows. Its | 59 | // HistoryChunk is an immutable compact slab of equal-width history rows. Its | ||
| @@ -270,62 +274,45 @@ func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 { | |||||
| 270 | 274 | ||||
| 271 | func (h *History) prepareRowStyles(row []renderer.Cell) uint64 { | 275 | func (h *History) prepareRowStyles(row []renderer.Cell) uint64 { | ||
| 272 | clear(h.payloadScratch) | 276 | clear(h.payloadScratch) | ||
| 273 | - | if h.styleScratch == nil { | 277 | + | h.styleScratch.reset() |
| 274 | - | h.styleScratch = make(map[renderer.Style]struct{}) | 278 | + | defaultStyle := renderer.DefaultStyle() |
| 275 | - | } else { | 279 | + | // A run of identical raw styles canonicalizes identically, so only the |
| 276 | - | clear(h.styleScratch) | 280 | + | // first cell of each run pays for Canonical and the set insert. |
| 277 | - | } | 281 | + | var previous renderer.Style |
| 278 | - | if len(row) > 0 { | 282 | + | for i, cell := range row { |
| 279 | - | firstRaw := row[0].Style | 283 | + | h.recordPayloadScratch(cell.Payload) |
| 280 | - | same := true | 284 | + | if i > 0 && cell.Style == previous { |
| 281 | - | for _, cell := range row { | 285 | + | continue |
| 282 | - | h.recordPayloadScratch(cell.Payload) | |||
| 283 | - | if cell.Style != firstRaw { | |||
| 284 | - | same = false | |||
| 285 | - | } | |||
| 286 | } | 286 | } | ||
| 287 | - | if same { | 287 | + | previous = cell.Style |
| 288 | - | first := firstRaw.Canonical() | 288 | + | if style := cell.Style.Canonical(); style != defaultStyle { |
| 289 | - | if first != renderer.DefaultStyle() { | 289 | + | h.styleScratch.add(style) |
| 290 | - | h.styleScratch[first] = struct{}{} | |||
| 291 | - | } | |||
| 292 | - | } else { | |||
| 293 | - | for _, cell := range row { | |||
| 294 | - | style := cell.Style.Canonical() | |||
| 295 | - | if style != renderer.DefaultStyle() { | |||
| 296 | - | h.styleScratch[style] = struct{}{} | |||
| 297 | - | } | |||
| 298 | - | } | |||
| 299 | } | 290 | } | ||
| 300 | } | 291 | } | ||
| 301 | - | return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | 292 | + | return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() |
| 302 | } | 293 | } | ||
| 303 | 294 | ||||
| 304 | func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 { | 295 | func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 { | ||
| 305 | clear(h.payloadScratch) | 296 | clear(h.payloadScratch) | ||
| 306 | - | if h.styleScratch == nil { | 297 | + | h.styleScratch.reset() |
| 307 | - | h.styleScratch = make(map[renderer.Style]struct{}) | 298 | + | defaultStyle := renderer.DefaultStyle() |
| 308 | - | } else { | |||
| 309 | - | clear(h.styleScratch) | |||
| 310 | - | } | |||
| 311 | for column := range chunk.width { | 299 | for column := range chunk.width { | ||
| 312 | cell := chunk.cell(row, column) | 300 | cell := chunk.cell(row, column) | ||
| 313 | h.recordPayloadScratch(cell.Payload) | 301 | h.recordPayloadScratch(cell.Payload) | ||
| 314 | - | style := cell.Style.Canonical() | 302 | + | if style := cell.Style.Canonical(); style != defaultStyle { |
| 315 | - | if style != renderer.DefaultStyle() { | 303 | + | h.styleScratch.add(style) |
| 316 | - | h.styleScratch[style] = struct{}{} | |||
| 317 | } | 304 | } | ||
| 318 | } | 305 | } | ||
| 319 | - | return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | 306 | + | return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() |
| 320 | } | 307 | } | ||
| 321 | 308 | ||||
| 322 | func (h *History) tailAppendDelta(width int) uint64 { | 309 | func (h *History) tailAppendDelta(width int) uint64 { | ||
| 323 | newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) | 310 | newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) | ||
| 324 | delta := historyRowBaseLogicalBytes(width) | 311 | delta := historyRowBaseLogicalBytes(width) | ||
| 325 | if newPage { | 312 | if newPage { | ||
| 326 | - | return delta + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | 313 | + | return delta + renderer.StyleRecordLogicalBytes + uint64(h.styleScratch.len())*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() |
| 327 | } | 314 | } | ||
| 328 | - | for style := range h.styleScratch { | 315 | + | for style := range h.styleScratch.all() { |
| 329 | if _, ok := h.tailPageStyles[style]; !ok { | 316 | if _, ok := h.tailPageStyles[style]; !ok { | ||
| 330 | delta += renderer.StyleRecordLogicalBytes | 317 | delta += renderer.StyleRecordLogicalBytes | ||
| 331 | } | 318 | } | ||
| @@ -351,7 +338,7 @@ func (h *History) recordTailRowStyles(width int) { | |||||
| 351 | } | 338 | } | ||
| 352 | h.tailPagePayloads[p] = struct{}{} | 339 | h.tailPagePayloads[p] = struct{}{} | ||
| 353 | } | 340 | } | ||
| 354 | - | for style := range h.styleScratch { | 341 | + | for style := range h.styleScratch.all() { |
| 355 | h.tailPageStyles[style] = struct{}{} | 342 | h.tailPageStyles[style] = struct{}{} | ||
| 356 | } | 343 | } | ||
| 357 | h.tailPageRows++ | 344 | h.tailPageRows++ | ||
| @@ -468,7 +455,13 @@ func (h *History) allocateRowID() (RowID, error) { | |||||
| 468 | return id, nil | 455 | return id, nil | ||
| 469 | } | 456 | } | ||
| 470 | 457 | ||||
| 458 | + | // hasRowID reports whether id is retained. Every retained ID is below | |||
| 459 | + | // nextRowID (appendRow advances it and restore validates it), so the newest-ID | |||
| 460 | + | // case used by screen eviction answers without scanning retained rows. | |||
| 471 | func (h *History) hasRowID(id RowID) bool { | 461 | func (h *History) hasRowID(id RowID) bool { | ||
| 462 | + | if id >= h.nextRowID { | |||
| 463 | + | return false | |||
| 464 | + | } | |||
| 472 | for _, chunk := range h.chunks { | 465 | for _, chunk := range h.chunks { | ||
| 473 | if slices.Contains(chunk.rowIDs, id) { | 466 | if slices.Contains(chunk.rowIDs, id) { | ||
| 474 | return true | 467 | return true | ||
| @@ -589,14 +582,19 @@ func (h *History) evictFor(row []renderer.Cell) { | |||||
| 589 | if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta { | 582 | if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta { | ||
| 590 | return | 583 | return | ||
| 591 | } | 584 | } | ||
| 592 | - | h.evictOldest() | 585 | + | // Only a tail eviction rebuilds tail accounting, which reuses the row |
| 593 | - | h.prepareRowStyles(row) | 586 | + | // scratch; sealed-chunk eviction leaves this row's styles intact. |
| 587 | + | if h.evictOldest() { | |||
| 588 | + | h.prepareRowStyles(row) | |||
| 589 | + | } | |||
| 594 | } | 590 | } | ||
| 595 | } | 591 | } | ||
| 596 | 592 | ||||
| 597 | - | func (h *History) evictOldest() { | 593 | + | // evictOldest drops the oldest retained row. It reports whether it rebuilt |
| 594 | + | // tail accounting, which overwrites the row style and payload scratch. | |||
| 595 | + | func (h *History) evictOldest() (scratchClobbered bool) { | |||
| 598 | if h.rows == 0 { | 596 | if h.rows == 0 { | ||
| 599 | - | return | 597 | + | return false |
| 600 | } | 598 | } | ||
| 601 | if len(h.chunks) > 0 { | 599 | if len(h.chunks) > 0 { | ||
| 602 | chunk := h.chunks[0] | 600 | chunk := h.chunks[0] | ||
| @@ -604,6 +602,9 @@ func (h *History) evictOldest() { | |||||
| 604 | h.rows-- | 602 | h.rows-- | ||
| 605 | h.cells -= chunk.width | 603 | h.cells -= chunk.width | ||
| 606 | if chunk.count == 1 { | 604 | if chunk.count == 1 { | ||
| 605 | + | if chunk == h.privateHead { | |||
| 606 | + | h.privateHead = nil | |||
| 607 | + | } | |||
| 607 | h.logicalBytes -= oldBytes | 608 | h.logicalBytes -= oldBytes | ||
| 608 | copy(h.chunks, h.chunks[1:]) | 609 | copy(h.chunks, h.chunks[1:]) | ||
| 609 | h.chunks[len(h.chunks)-1] = nil | 610 | h.chunks[len(h.chunks)-1] = nil | ||
| @@ -611,14 +612,20 @@ func (h *History) evictOldest() { | |||||
| 611 | } else { | 612 | } else { | ||
| 612 | // Preserve cell storage while replacing only the chunk wrapper: a | 613 | // Preserve cell storage while replacing only the chunk wrapper: a | ||
| 613 | // retained view may still refer to the original immutable chunk. The | 614 | // retained view may still refer to the original immutable chunk. The | ||
| 614 | - | // wrapper tracks only styles still used by its retained suffix. | 615 | + | // wrapper tracks only styles still used by its retained suffix. A |
| 615 | - | h.chunks[0] = chunk.withoutFirstRow() | 616 | + | // wrapper no view has seen is advanced in place. |
| 617 | + | if chunk == h.privateHead { | |||
| 618 | + | chunk.dropFirstRow() | |||
| 619 | + | } else { | |||
| 620 | + | h.chunks[0] = chunk.withoutFirstRow() | |||
| 621 | + | h.privateHead = h.chunks[0] | |||
| 622 | + | } | |||
| 616 | h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0]) | 623 | h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0]) | ||
| 617 | } | 624 | } | ||
| 618 | - | return | 625 | + | return false |
| 619 | } | 626 | } | ||
| 620 | if len(h.tail) == 0 { | 627 | if len(h.tail) == 0 { | ||
| 621 | - | return | 628 | + | return false |
| 622 | } | 629 | } | ||
| 623 | h.cachedTail = nil | 630 | h.cachedTail = nil | ||
| 624 | row := h.tail[0] | 631 | row := h.tail[0] | ||
| @@ -633,6 +640,7 @@ func (h *History) evictOldest() { | |||||
| 633 | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | 640 | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | ||
| 634 | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | 641 | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | ||
| 635 | h.rebuildTailAccounting() | 642 | h.rebuildTailAccounting() | ||
| 643 | + | return true | |||
| 636 | } | 644 | } | ||
| 637 | 645 | ||||
| 638 | // SealAndView rotates the mutable tail into an immutable chunk, then captures | 646 | // SealAndView rotates the mutable tail into an immutable chunk, then captures | ||
| @@ -660,6 +668,7 @@ func (h *History) View() HistoryView { | |||||
| 660 | if h.rows == 0 { | 668 | if h.rows == 0 { | ||
| 661 | return HistoryView{nextRowID: h.nextRowID} | 669 | return HistoryView{nextRowID: h.nextRowID} | ||
| 662 | } | 670 | } | ||
| 671 | + | h.privateHead = nil | |||
| 663 | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | 672 | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | ||
| 664 | copy(chunks, h.chunks) | 673 | copy(chunks, h.chunks) | ||
| 665 | if len(h.tail) > 0 { | 674 | if len(h.tail) > 0 { | ||
| @@ -677,6 +686,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||||
| 677 | if h.rows == 0 { | 686 | if h.rows == 0 { | ||
| 678 | return HistorySnapshotView{nextRowID: h.nextRowID} | 687 | return HistorySnapshotView{nextRowID: h.nextRowID} | ||
| 679 | } | 688 | } | ||
| 689 | + | h.privateHead = nil | |||
| 680 | tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs) | 690 | tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs) | ||
| 681 | return HistorySnapshotView{ | 691 | return HistorySnapshotView{ | ||
| 682 | chunks: append([]*HistoryChunk(nil), h.chunks...), | 692 | chunks: append([]*HistoryChunk(nil), h.chunks...), | ||
A history_evict_inplace_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Eviction advances an uncaptured head wrapper in place. A view or snapshot | |
| 12 | + | // taken between evictions must keep its rows, accounting, and IDs. | |
| 13 | + | func TestHistoryInPlaceEvictionKeepsCapturedViewsStable(t *testing.T) { | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | capture func(*History) func() ([]string, int, uint64, RowID) | |
| 17 | + | }{ | |
| 18 | + | {name: "view", capture: func(h *History) func() ([]string, int, uint64, RowID) { | |
| 19 | + | v := h.View() | |
| 20 | + | return func() ([]string, int, uint64, RowID) { | |
| 21 | + | return rangeTextsNoT(v), v.Len(), v.LogicalBytes(), v.RowID(0) | |
| 22 | + | } | |
| 23 | + | }}, | |
| 24 | + | {name: "snapshot", capture: func(h *History) func() ([]string, int, uint64, RowID) { | |
| 25 | + | s := h.SnapshotView() | |
| 26 | + | return func() ([]string, int, uint64, RowID) { | |
| 27 | + | var texts []string | |
| 28 | + | for i := range s.ChunkCount() { | |
| 29 | + | c := s.Chunk(i) | |
| 30 | + | for r := range c.len() { | |
| 31 | + | texts = append(texts, rowText(c.row(r))) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | texts = append(texts, rangeTextsNoT(s.Tail())...) | |
| 35 | + | var first RowID | |
| 36 | + | if s.ChunkCount() > 0 { | |
| 37 | + | first = s.Chunk(0).rowIDs[0] | |
| 38 | + | } | |
| 39 | + | return texts, s.Len(), s.LogicalBytes(), first | |
| 40 | + | } | |
| 41 | + | }}, | |
| 42 | + | } | |
| 43 | + | for _, tt := range tests { | |
| 44 | + | t.Run(tt.name, func(t *testing.T) { | |
| 45 | + | h := NewHistory(HistoryConfig{MaxRows: 12, MaxBytes: 1 << 20, ChunkRows: 4}) | |
| 46 | + | add := func(i int) { | |
| 47 | + | require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%3+1), LineBound{End: 4})) | |
| 48 | + | } | |
| 49 | + | for i := range 14 { // two evictions: the head wrapper is now private | |
| 50 | + | add(i) | |
| 51 | + | } | |
| 52 | + | read := tt.capture(h) | |
| 53 | + | texts, rows, bytes, first := read() | |
| 54 | + | for i := 14; i < 30; i++ { // evicts through the captured head and beyond | |
| 55 | + | add(i) | |
| 56 | + | } | |
| 57 | + | gotTexts, gotRows, gotBytes, gotFirst := read() | |
| 58 | + | require.Equal(t, texts, gotTexts) | |
| 59 | + | require.Equal(t, rows, gotRows) | |
| 60 | + | require.Equal(t, bytes, gotBytes) | |
| 61 | + | require.Equal(t, first, gotFirst) | |
| 62 | + | require.Equal(t, 12, h.Len()) | |
| 63 | + | require.Equal(t, "r018", rangeTextsNoT(h.View())[0]) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // In-place eviction keeps logical accounting identical to wrapper replacement. | |
| 69 | + | func TestHistoryInPlaceEvictionAccountingMatchesFreshBuild(t *testing.T) { | |
| 70 | + | h := NewHistory(HistoryConfig{MaxRows: 10, MaxBytes: 1 << 20, ChunkRows: 4}) | |
| 71 | + | for i := range 37 { | |
| 72 | + | require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%5+1), LineBound{End: 4})) | |
| 73 | + | } | |
| 74 | + | view := h.View() | |
| 75 | + | for i := range view.ChunkCount() { | |
| 76 | + | require.NoError(t, view.Chunk(i).CheckInvariants()) | |
| 77 | + | } | |
| 78 | + | var sum uint64 | |
| 79 | + | for i := range view.ChunkCount() { | |
| 80 | + | sum += historyChunkLogicalBytes(view.Chunk(i)) | |
| 81 | + | } | |
| 82 | + | require.Equal(t, sum, h.LogicalBytes()) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestStyleSet(t *testing.T) { | |
| 86 | + | style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} } | |
| 87 | + | tests := []struct { | |
| 88 | + | name string | |
| 89 | + | input []int | |
| 90 | + | want int | |
| 91 | + | }{ | |
| 92 | + | {name: "empty", want: 0}, | |
| 93 | + | {name: "duplicates", input: []int{1, 1, 2, 2, 1}, want: 2}, | |
| 94 | + | {name: "inline capacity", input: seqInts(styleSetInline), want: styleSetInline}, | |
| 95 | + | {name: "spills to map", input: append(seqInts(styleSetInline+5), 0, 1, 2), want: styleSetInline + 5}, | |
| 96 | + | {name: "large", input: seqInts(400), want: 400}, | |
| 97 | + | } | |
| 98 | + | var s styleSet | |
| 99 | + | for _, tt := range tests { | |
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |
| 101 | + | s.reset() | |
| 102 | + | want := map[renderer.Style]struct{}{} | |
| 103 | + | for _, i := range tt.input { | |
| 104 | + | s.add(style(i)) | |
| 105 | + | want[style(i)] = struct{}{} | |
| 106 | + | } | |
| 107 | + | require.Equal(t, tt.want, s.len()) | |
| 108 | + | got := map[renderer.Style]struct{}{} | |
| 109 | + | for st := range s.all() { | |
| 110 | + | _, dup := got[st] | |
| 111 | + | require.False(t, dup, "style yielded twice") | |
| 112 | + | got[st] = struct{}{} | |
| 113 | + | } | |
| 114 | + | require.Equal(t, want, got) | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func seqInts(n int) []int { | |
| 120 | + | out := make([]int, n) | |
| 121 | + | for i := range out { | |
| 122 | + | out[i] = i | |
| 123 | + | } | |
| 124 | + | return out | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func rangeTextsNoT(v HistoryView) []string { | |
| 128 | + | var out []string | |
| 129 | + | _ = v.Range(func(row []renderer.Cell) bool { | |
| 130 | + | out = append(out, rowText(row)) | |
| 131 | + | return true | |
| 132 | + | }) | |
| 133 | + | return out | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Eviction advances an uncaptured head wrapper in place. A view or snapshot | |||
| 12 | + | // taken between evictions must keep its rows, accounting, and IDs. | |||
| 13 | + | func TestHistoryInPlaceEvictionKeepsCapturedViewsStable(t *testing.T) { | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | capture func(*History) func() ([]string, int, uint64, RowID) | |||
| 17 | + | }{ | |||
| 18 | + | {name: "view", capture: func(h *History) func() ([]string, int, uint64, RowID) { | |||
| 19 | + | v := h.View() | |||
| 20 | + | return func() ([]string, int, uint64, RowID) { | |||
| 21 | + | return rangeTextsNoT(v), v.Len(), v.LogicalBytes(), v.RowID(0) | |||
| 22 | + | } | |||
| 23 | + | }}, | |||
| 24 | + | {name: "snapshot", capture: func(h *History) func() ([]string, int, uint64, RowID) { | |||
| 25 | + | s := h.SnapshotView() | |||
| 26 | + | return func() ([]string, int, uint64, RowID) { | |||
| 27 | + | var texts []string | |||
| 28 | + | for i := range s.ChunkCount() { | |||
| 29 | + | c := s.Chunk(i) | |||
| 30 | + | for r := range c.len() { | |||
| 31 | + | texts = append(texts, rowText(c.row(r))) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | texts = append(texts, rangeTextsNoT(s.Tail())...) | |||
| 35 | + | var first RowID | |||
| 36 | + | if s.ChunkCount() > 0 { | |||
| 37 | + | first = s.Chunk(0).rowIDs[0] | |||
| 38 | + | } | |||
| 39 | + | return texts, s.Len(), s.LogicalBytes(), first | |||
| 40 | + | } | |||
| 41 | + | }}, | |||
| 42 | + | } | |||
| 43 | + | for _, tt := range tests { | |||
| 44 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 45 | + | h := NewHistory(HistoryConfig{MaxRows: 12, MaxBytes: 1 << 20, ChunkRows: 4}) | |||
| 46 | + | add := func(i int) { | |||
| 47 | + | require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%3+1), LineBound{End: 4})) | |||
| 48 | + | } | |||
| 49 | + | for i := range 14 { // two evictions: the head wrapper is now private | |||
| 50 | + | add(i) | |||
| 51 | + | } | |||
| 52 | + | read := tt.capture(h) | |||
| 53 | + | texts, rows, bytes, first := read() | |||
| 54 | + | for i := 14; i < 30; i++ { // evicts through the captured head and beyond | |||
| 55 | + | add(i) | |||
| 56 | + | } | |||
| 57 | + | gotTexts, gotRows, gotBytes, gotFirst := read() | |||
| 58 | + | require.Equal(t, texts, gotTexts) | |||
| 59 | + | require.Equal(t, rows, gotRows) | |||
| 60 | + | require.Equal(t, bytes, gotBytes) | |||
| 61 | + | require.Equal(t, first, gotFirst) | |||
| 62 | + | require.Equal(t, 12, h.Len()) | |||
| 63 | + | require.Equal(t, "r018", rangeTextsNoT(h.View())[0]) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // In-place eviction keeps logical accounting identical to wrapper replacement. | |||
| 69 | + | func TestHistoryInPlaceEvictionAccountingMatchesFreshBuild(t *testing.T) { | |||
| 70 | + | h := NewHistory(HistoryConfig{MaxRows: 10, MaxBytes: 1 << 20, ChunkRows: 4}) | |||
| 71 | + | for i := range 37 { | |||
| 72 | + | require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%5+1), LineBound{End: 4})) | |||
| 73 | + | } | |||
| 74 | + | view := h.View() | |||
| 75 | + | for i := range view.ChunkCount() { | |||
| 76 | + | require.NoError(t, view.Chunk(i).CheckInvariants()) | |||
| 77 | + | } | |||
| 78 | + | var sum uint64 | |||
| 79 | + | for i := range view.ChunkCount() { | |||
| 80 | + | sum += historyChunkLogicalBytes(view.Chunk(i)) | |||
| 81 | + | } | |||
| 82 | + | require.Equal(t, sum, h.LogicalBytes()) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestStyleSet(t *testing.T) { | |||
| 86 | + | style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} } | |||
| 87 | + | tests := []struct { | |||
| 88 | + | name string | |||
| 89 | + | input []int | |||
| 90 | + | want int | |||
| 91 | + | }{ | |||
| 92 | + | {name: "empty", want: 0}, | |||
| 93 | + | {name: "duplicates", input: []int{1, 1, 2, 2, 1}, want: 2}, | |||
| 94 | + | {name: "inline capacity", input: seqInts(styleSetInline), want: styleSetInline}, | |||
| 95 | + | {name: "spills to map", input: append(seqInts(styleSetInline+5), 0, 1, 2), want: styleSetInline + 5}, | |||
| 96 | + | {name: "large", input: seqInts(400), want: 400}, | |||
| 97 | + | } | |||
| 98 | + | var s styleSet | |||
| 99 | + | for _, tt := range tests { | |||
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 101 | + | s.reset() | |||
| 102 | + | want := map[renderer.Style]struct{}{} | |||
| 103 | + | for _, i := range tt.input { | |||
| 104 | + | s.add(style(i)) | |||
| 105 | + | want[style(i)] = struct{}{} | |||
| 106 | + | } | |||
| 107 | + | require.Equal(t, tt.want, s.len()) | |||
| 108 | + | got := map[renderer.Style]struct{}{} | |||
| 109 | + | for st := range s.all() { | |||
| 110 | + | _, dup := got[st] | |||
| 111 | + | require.False(t, dup, "style yielded twice") | |||
| 112 | + | got[st] = struct{}{} | |||
| 113 | + | } | |||
| 114 | + | require.Equal(t, want, got) | |||
| 115 | + | }) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func seqInts(n int) []int { | |||
| 120 | + | out := make([]int, n) | |||
| 121 | + | for i := range out { | |||
| 122 | + | out[i] = i | |||
| 123 | + | } | |||
| 124 | + | return out | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func rangeTextsNoT(v HistoryView) []string { | |||
| 128 | + | var out []string | |||
| 129 | + | _ = v.Range(func(row []renderer.Cell) bool { | |||
| 130 | + | out = append(out, rowText(row)) | |||
| 131 | + | return true | |||
| 132 | + | }) | |||
| 133 | + | return out | |||
| 134 | + | } | |||
M history_payload.go +14 -8
| @@ -31,16 +31,22 @@ func (c *HistoryChunk) recordPayloads() { | |||
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | func (c *HistoryChunk) withoutFirstRow() *HistoryChunk { | |
| 34 | - | payloadBytes := c.payloadBytes | |
| 34 | + | next := *c | |
| 35 | + | next.dropFirstRow() | |
| 36 | + | return &next | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // dropFirstRow advances the wrapper past its first row in place. Only a wrapper | |
| 40 | + | // no view has captured may be mutated; see History.privateHead. | |
| 41 | + | func (c *HistoryChunk) dropFirstRow() { | |
| 35 | 42 | if len(c.payloadDrops) != 0 { | |
| 36 | - | payloadBytes -= c.payloadDrops[c.start] | |
| 37 | - | } | |
| 38 | - | return &HistoryChunk{ | |
| 39 | - | page: c.page, start: c.start + 1, count: c.count - 1, width: c.width, | |
| 40 | - | bounds: c.bounds[1:], rowIDs: c.rowIDs[1:], | |
| 41 | - | styleDrops: c.styleDrops, styleCount: c.styleCount - c.styleDrops[c.start], | |
| 42 | - | payloadDrops: c.payloadDrops, payloadBytes: payloadBytes, | |
| 43 | + | c.payloadBytes -= c.payloadDrops[c.start] | |
| 43 | 44 | } | |
| 45 | + | c.styleCount -= c.styleDrops[c.start] | |
| 46 | + | c.start++ | |
| 47 | + | c.count-- | |
| 48 | + | c.bounds = c.bounds[1:] | |
| 49 | + | c.rowIDs = c.rowIDs[1:] | |
| 44 | 50 | } | |
| 45 | 51 | ||
| 46 | 52 | func (h *History) recordPayloadScratch(p renderer.CellPayload) { | |
| @@ -31,16 +31,22 @@ func (c *HistoryChunk) recordPayloads() { | |||||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | func (c *HistoryChunk) withoutFirstRow() *HistoryChunk { | 33 | func (c *HistoryChunk) withoutFirstRow() *HistoryChunk { | ||
| 34 | - | payloadBytes := c.payloadBytes | 34 | + | next := *c |
| 35 | + | next.dropFirstRow() | |||
| 36 | + | return &next | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // dropFirstRow advances the wrapper past its first row in place. Only a wrapper | |||
| 40 | + | // no view has captured may be mutated; see History.privateHead. | |||
| 41 | + | func (c *HistoryChunk) dropFirstRow() { | |||
| 35 | if len(c.payloadDrops) != 0 { | 42 | if len(c.payloadDrops) != 0 { | ||
| 36 | - | payloadBytes -= c.payloadDrops[c.start] | 43 | + | c.payloadBytes -= c.payloadDrops[c.start] |
| 37 | - | } | |||
| 38 | - | return &HistoryChunk{ | |||
| 39 | - | page: c.page, start: c.start + 1, count: c.count - 1, width: c.width, | |||
| 40 | - | bounds: c.bounds[1:], rowIDs: c.rowIDs[1:], | |||
| 41 | - | styleDrops: c.styleDrops, styleCount: c.styleCount - c.styleDrops[c.start], | |||
| 42 | - | payloadDrops: c.payloadDrops, payloadBytes: payloadBytes, | |||
| 43 | } | 44 | } | ||
| 45 | + | c.styleCount -= c.styleDrops[c.start] | |||
| 46 | + | c.start++ | |||
| 47 | + | c.count-- | |||
| 48 | + | c.bounds = c.bounds[1:] | |||
| 49 | + | c.rowIDs = c.rowIDs[1:] | |||
| 44 | } | 50 | } | ||
| 45 | 51 | ||||
| 46 | func (h *History) recordPayloadScratch(p renderer.CellPayload) { | 52 | func (h *History) recordPayloadScratch(p renderer.CellPayload) { | ||
A history_styleset.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "iter" | |
| 5 | + | "slices" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // styleSetInline is the number of distinct styles kept in a linear slice | |
| 11 | + | // before the set switches to a map. Typical rows use a handful of styles, where | |
| 12 | + | // a short compare loop beats hashing the 56-byte Style key. | |
| 13 | + | const styleSetInline = 16 | |
| 14 | + | ||
| 15 | + | // styleSet is a reusable set of canonical styles for one history row. It keeps | |
| 16 | + | // small sets in a slice and only builds a map for high-cardinality rows. The | |
| 17 | + | // zero value is empty and ready to use. | |
| 18 | + | type styleSet struct { | |
| 19 | + | small []renderer.Style | |
| 20 | + | large map[renderer.Style]struct{} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (s *styleSet) reset() { | |
| 24 | + | s.small = s.small[:0] | |
| 25 | + | if len(s.large) > maxRetainedStyleScratch { | |
| 26 | + | s.large = nil | |
| 27 | + | } else { | |
| 28 | + | clear(s.large) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (s *styleSet) add(style renderer.Style) { | |
| 33 | + | if len(s.large) > 0 { | |
| 34 | + | s.large[style] = struct{}{} | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | if slices.Contains(s.small, style) { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | if len(s.small) < styleSetInline { | |
| 41 | + | s.small = append(s.small, style) | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | if s.large == nil { | |
| 45 | + | s.large = make(map[renderer.Style]struct{}, 2*styleSetInline) | |
| 46 | + | } | |
| 47 | + | for _, existing := range s.small { | |
| 48 | + | s.large[existing] = struct{}{} | |
| 49 | + | } | |
| 50 | + | s.large[style] = struct{}{} | |
| 51 | + | s.small = s.small[:0] | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (s *styleSet) len() int { | |
| 55 | + | if len(s.large) > 0 { | |
| 56 | + | return len(s.large) | |
| 57 | + | } | |
| 58 | + | return len(s.small) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // all yields each style once, in unspecified order. | |
| 62 | + | func (s *styleSet) all() iter.Seq[renderer.Style] { | |
| 63 | + | return func(yield func(renderer.Style) bool) { | |
| 64 | + | if len(s.large) > 0 { | |
| 65 | + | for style := range s.large { | |
| 66 | + | if !yield(style) { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | for _, style := range s.small { | |
| 73 | + | if !yield(style) { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "iter" | |||
| 5 | + | "slices" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // styleSetInline is the number of distinct styles kept in a linear slice | |||
| 11 | + | // before the set switches to a map. Typical rows use a handful of styles, where | |||
| 12 | + | // a short compare loop beats hashing the 56-byte Style key. | |||
| 13 | + | const styleSetInline = 16 | |||
| 14 | + | ||||
| 15 | + | // styleSet is a reusable set of canonical styles for one history row. It keeps | |||
| 16 | + | // small sets in a slice and only builds a map for high-cardinality rows. The | |||
| 17 | + | // zero value is empty and ready to use. | |||
| 18 | + | type styleSet struct { | |||
| 19 | + | small []renderer.Style | |||
| 20 | + | large map[renderer.Style]struct{} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (s *styleSet) reset() { | |||
| 24 | + | s.small = s.small[:0] | |||
| 25 | + | if len(s.large) > maxRetainedStyleScratch { | |||
| 26 | + | s.large = nil | |||
| 27 | + | } else { | |||
| 28 | + | clear(s.large) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (s *styleSet) add(style renderer.Style) { | |||
| 33 | + | if len(s.large) > 0 { | |||
| 34 | + | s.large[style] = struct{}{} | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | if slices.Contains(s.small, style) { | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | if len(s.small) < styleSetInline { | |||
| 41 | + | s.small = append(s.small, style) | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | if s.large == nil { | |||
| 45 | + | s.large = make(map[renderer.Style]struct{}, 2*styleSetInline) | |||
| 46 | + | } | |||
| 47 | + | for _, existing := range s.small { | |||
| 48 | + | s.large[existing] = struct{}{} | |||
| 49 | + | } | |||
| 50 | + | s.large[style] = struct{}{} | |||
| 51 | + | s.small = s.small[:0] | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (s *styleSet) len() int { | |||
| 55 | + | if len(s.large) > 0 { | |||
| 56 | + | return len(s.large) | |||
| 57 | + | } | |||
| 58 | + | return len(s.small) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // all yields each style once, in unspecified order. | |||
| 62 | + | func (s *styleSet) all() iter.Seq[renderer.Style] { | |||
| 63 | + | return func(yield func(renderer.Style) bool) { | |||
| 64 | + | if len(s.large) > 0 { | |||
| 65 | + | for style := range s.large { | |||
| 66 | + | if !yield(style) { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 72 | + | for _, style := range s.small { | |||
| 73 | + | if !yield(style) { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
A vev-vt.test +0 -0 binary file: no text diff
binary file: no text diff