1 Commits
Author SHA1 Message Date
Lixen df94556101 v0.6.1 flush: memcmp finds a row's first and last changed cells
The whole-row memcmp settled an unchanged row, but on a changed row the cell
loop then read again the prefix memcmp had already compared, and the trailing
trim walked unchanged cells one by one. Chunks of eight cells now find the
first and last changed cell, so no cell is compared twice and only the span
between them is scanned cell by cell. Output is byte-identical.

Measured on frames captured from vif at 60 fps (median of 15, one core):
vanguard 120x40 73.7 -> 66.6 us a frame (-10%), vanguard 240x66
158.8 -> 131.8 (-17%), main with the gun firing 40.0 -> 34.1 (-15%), main
idle 20.3 -> 14.0 (-31%). Dropping the row compare instead costs +15% to
+120% on the same frames.
2026-10-28 22:03:41 -04:00
3 changed files with 59 additions and 14 deletions

No files matched your search

+3 -1
View File
@@ -45,7 +45,9 @@ behaves as `New`'s Terminal does, the writer goroutine included; `New` is
The application owns a flat `[]Cell` buffer (row-major, `cells[y*width+x]`) and
passes it to `Flush`. The output buffer diffs against the previously flushed frame:
- Rows are scanned with early termination (trailing unchanged cells skipped).
- A row's first and last changed cells are found by memcmp, eight cells a compare:
an unchanged row costs those compares alone, and only the cells between are
scanned one by one.
- Cursor moves are emitted only when the write position is non-contiguous.
- SGR state (fg, bg, attributes) is coalesced across cells; redundant sequences
are suppressed.
+36 -13
View File
@@ -76,8 +76,8 @@ func cellEqual(a, b Cell) bool {
// fails to compile should a field change leave padding behind
var _ [0]struct{} = [unsafe.Sizeof(Cell{}) - unsafe.Sizeof(rune(0)) - 2*unsafe.Sizeof(color.RGB{}) - unsafe.Sizeof(Attr(0))]struct{}{}
// rowEqual reports whether two rows of cells hold the same cells, as one memcmp
func rowEqual(a, b []Cell) bool {
// cellsEqual reports whether two runs of cells hold the same cells, as one memcmp
func cellsEqual(a, b []Cell) bool {
if len(a) != len(b) {
return false
}
@@ -88,6 +88,34 @@ func rowEqual(a, b []Cell) bool {
return bytes.Equal(unsafe.Slice((*byte)(unsafe.Pointer(&a[0])), n), unsafe.Slice((*byte)(unsafe.Pointer(&b[0])), n))
}
// diffChunk is the cells one memcmp settles while a row's changes are sought: few
// enough that a changed chunk costs little to search again cell by cell
const diffChunk = 8
// firstChange is the first cell where a and b differ, len(a) when none does
func firstChange(a, b []Cell) int {
i := 0
for i+diffChunk <= len(a) && cellsEqual(a[i:i+diffChunk], b[i:i+diffChunk]) {
i += diffChunk
}
for i < len(a) && cellEqual(a[i], b[i]) {
i++
}
return i
}
// lastChange is one past the last cell from on where a and b differ, from when none does
func lastChange(a, b []Cell, from int) int {
j := len(a)
for j-diffChunk >= from && cellsEqual(a[j-diffChunk:j], b[j-diffChunk:j]) {
j -= diffChunk
}
for j > from && cellEqual(a[j-1], b[j-1]) {
j--
}
return j
}
// flush renders the back buffer into buf, diffing against front buffer
func (o *outputBuffer) flush(cells []Cell, width, height int) {
if width != o.width || height != o.height {
@@ -104,20 +132,15 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
for y := 0; y < height; y++ {
rowStart := y * width
// Most rows of a frame are unchanged; one compare settles each of those
if rowEqual(cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]) {
// Memcmp finds the row's first and last changed cells, so an unchanged row is
// one pass of compares and a changed one is scanned cell by cell between them
row, front := cells[rowStart:rowStart+width], o.front[rowStart:rowStart+width]
x := firstChange(row, front)
if x == width {
continue
}
rowEnd := lastChange(row, front, x+1)
rowEnd := width
for rowEnd > 0 && cellEqual(cells[rowStart+rowEnd-1], o.front[rowStart+rowEnd-1]) {
rowEnd--
}
if rowEnd == 0 {
continue
}
x := 0
for x < rowEnd {
idx := rowStart + x
+20
View File
@@ -3,6 +3,7 @@ package terminal
import (
"bytes"
"cmp"
"math/rand/v2"
"slices"
"strings"
"sync"
@@ -133,6 +134,25 @@ func TestUnchangedFrameWritesNothing(t *testing.T) {
}
}
// Every cell a frame changes is written, wherever in its row it lies and however
// wide the row: after a flush the screen the terminal holds is the frame
func TestEveryChangedCellIsWritten(t *testing.T) {
rng := rand.New(rand.NewPCG(1, 2))
for _, width := range []int{1, 7, 8, 9, 17, 120} {
o := newOutputBuffer(ColorModeTrueColor)
frame := make([]Cell, width*4)
for range 200 {
for range rng.IntN(len(frame)/2 + 1) {
frame[rng.IntN(len(frame))] = Cell{Rune: 'a' + rng.Int32N(4), Fg: color.RGB{G: uint8(rng.IntN(2))}}
}
o.flush(frame, width, 4)
if !slices.Equal(o.front, frame) {
t.Fatalf("width %d: a changed cell went unwritten", width)
}
}
}
}
// A paste arrives as one event, whatever reads split it into: an escape
// inside it is text even when a poll times out on it, and a paste the
// terminal never ends ends when input pauses, whether reads time out or