Skip to content

Commit

Permalink
[fix] improve memory performance of linebuffer
Browse files Browse the repository at this point in the history
  • Loading branch information
robinovitch61 committed Jan 19, 2025
1 parent dfc315c commit f214160
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 14 deletions.
2 changes: 0 additions & 2 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ var AttemptUpdateSinceTimeInterval = 500 * time.Millisecond

// *********************************************************************************************************************

var AnsiRegex = regexp.MustCompile("\x1b\\[[0-9;]*m")

var EmptySequenceRegex = regexp.MustCompile("\x1b\\[[0-9;]+m\x1b\\[m")

// LeftPageWidthFraction controls the width of the left page as a fraction of the terminal width
Expand Down
102 changes: 92 additions & 10 deletions internal/linebuffer/linebuffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,48 @@ func New(line string) LineBuffer {
leftRuneIdx: 0,
}

lb.ansiCodeIndexes = constants.AnsiRegex.FindAllStringIndex(line, -1)
lb.lineNoAnsi = stripAnsi(line)
lb.ansiCodeIndexes = findAnsiRanges(line)

lb.lineNoAnsiRunes = []rune(lb.lineNoAnsi)
n := len(lb.lineNoAnsiRunes)
lb.runeIdxToByteOffset = make([]int, n+1)
lb.lineNoAnsiWidths = make([]int, n)
lb.lineNoAnsiCumWidths = make([]int, n)
if len(lb.ansiCodeIndexes) > 0 {
totalLen := len(line)
for _, r := range lb.ansiCodeIndexes {
totalLen -= r[1] - r[0]
}

buf := make([]byte, 0, totalLen)
lastPos := 0
for _, r := range lb.ansiCodeIndexes {
buf = append(buf, line[lastPos:r[0]]...)
lastPos = r[1]
}
buf = append(buf, line[lastPos:]...)
lb.lineNoAnsi = string(buf)
} else {
lb.lineNoAnsi = line
}

n := utf8.RuneCountInString(lb.lineNoAnsi)

// single allocation for all integer slices
combined := make([]int, n+1+n+n)
lb.runeIdxToByteOffset = combined[:n+1]
lb.lineNoAnsiWidths = combined[n+1 : n+1+n]
lb.lineNoAnsiCumWidths = combined[n+1+n:]

lb.lineNoAnsiRunes = make([]rune, n)

currentOffset := 0
cumWidth := 0
for i, r := range lb.lineNoAnsiRunes {
i := 0
for _, r := range lb.lineNoAnsi {
lb.runeIdxToByteOffset[i] = currentOffset
currentOffset += utf8.RuneLen(r)

lb.lineNoAnsiRunes[i] = r
width := runewidth.RuneWidth(r)
lb.lineNoAnsiWidths[i] = width
cumWidth += width
lb.lineNoAnsiCumWidths[i] = cumWidth
i++
}
lb.runeIdxToByteOffset[n] = currentOffset

Expand Down Expand Up @@ -339,7 +362,28 @@ func highlightLine(line, highlight string, highlightStyle lipgloss.Style, start,
}

func stripAnsi(input string) string {
return constants.AnsiRegex.ReplaceAllString(input, "")
ranges := findAnsiRanges(input)
if len(ranges) == 0 {
return input
}

totalAnsiLen := 0
for _, r := range ranges {
totalAnsiLen += r[1] - r[0]
}

finalLen := len(input) - totalAnsiLen
var builder strings.Builder
builder.Grow(finalLen)

lastPos := 0
for _, r := range ranges {
builder.WriteString(input[lastPos:r[0]])
lastPos = r[1]
}

builder.WriteString(input[lastPos:])
return builder.String()
}

func simplifyAnsiCodes(ansis []string) []string {
Expand Down Expand Up @@ -565,3 +609,41 @@ func replaceRuneWithRunes(rs []rune, idxToReplace int, replaceWith []rune) []run
copy(result[idxToReplace+len(replaceWith):], rs[idxToReplace+1:])
return result
}

func findAnsiRanges(s string) [][]int {
// pre-count to allocate exact size
count := strings.Count(s, "\x1b[")
if count == 0 {
return nil
}

allRanges := make([]int, count*2)
ranges := make([][]int, count)

for i := 0; i < count; i++ {
ranges[i] = allRanges[i*2 : i*2+2]
}

rangeIdx := 0
for i := 0; i < len(s); {
if i+1 < len(s) && s[i] == '\x1b' && s[i+1] == '[' {
start := i
i += 2 // skip \x1b[

// find the 'm' that ends this sequence
for i < len(s) && s[i] != 'm' {
i++
}

if i < len(s) && s[i] == 'm' {
allRanges[rangeIdx*2] = start
allRanges[rangeIdx*2+1] = i + 1
rangeIdx++
i++
continue
}
}
i++
}
return ranges[:rangeIdx]
}
105 changes: 105 additions & 0 deletions internal/linebuffer/linebuffer_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package linebuffer

import (
"fmt"
"strings"
"testing"
)

// Example of interpreting output of `go test -v -bench=. -run=^$ -benchmem ./internal/linebuffer`
// BenchmarkNewLongLine-8 7842 152640 ns/op 904063 B/op 8 allocs/op
// - 7842: benchmark ran 7,842 iterations to get a stable measurement
// - 152640 ns/op: each call to New() takes about 153 microseconds
// - 904063 B/op: each operation allocates about 904KB of memory
// - 8 allocs/op: each call to New() makes 8 distinct memory allocations

func BenchmarkNewLongLine(b *testing.B) {
base := strings.Repeat("hi there random words woohoo ", 1000)

// reset timer to exclude setup time
b.ResetTimer()

// enable memory profiling
b.ReportAllocs()

for i := 0; i < b.N; i++ {
lb := New(base)
// prevent compiler optimizations from eliminating the call
_ = lb
}
}

func BenchmarkMemoryComparisonAscii(b *testing.B) {
// tests different string lengths to see how memory usage scales
sizes := []int{10, 100, 1000, 10000}

for _, size := range sizes {
baseString := strings.Repeat("h", size)

b.Run(fmt.Sprintf("String_%d", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := baseString
_ = s
}
})

b.Run(fmt.Sprintf("LineBuffer_%d", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
lb := New(baseString)
_ = lb
}
})
}
}

func BenchmarkMemoryComparisonAsciiWithAnsi(b *testing.B) {
// tests different string lengths to see how memory usage scales
sizes := []int{10, 100, 1000, 10000}

for _, size := range sizes {
baseString := strings.Repeat("\x1b[31mh\x1b[0m", size)

b.Run(fmt.Sprintf("String_%d", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := baseString
_ = s
}
})

b.Run(fmt.Sprintf("LineBuffer_%d", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
lb := New(baseString)
_ = lb
}
})
}
}

func BenchmarkMemoryComparisonAsciiWithUnicode(b *testing.B) {
// tests different string lengths to see how memory usage scales
sizes := []int{10, 100, 1000, 10000}

for _, size := range sizes {
baseString := strings.Repeat("世", size)

b.Run(fmt.Sprintf("String_%d", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := baseString
_ = s
}
})

b.Run(fmt.Sprintf("LineBuffer_%d", size), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
lb := New(baseString)
_ = lb
}
})
}
}
6 changes: 4 additions & 2 deletions internal/linebuffer/linebuffer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package linebuffer

import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/robinovitch61/kl/internal/constants"
"github.com/robinovitch61/kl/internal/util"
"regexp"
"strings"
"testing"
)
Expand Down Expand Up @@ -1311,9 +1311,11 @@ func TestLineBuffer_reapplyAnsi(t *testing.T) {
},
}

ansiRegex := regexp.MustCompile("\x1b\\[[0-9;]*m")

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ansiCodeIndexes := constants.AnsiRegex.FindAllStringIndex(tt.original, -1)
ansiCodeIndexes := ansiRegex.FindAllStringIndex(tt.original, -1)
actual := reapplyAnsi(tt.original, tt.truncated, tt.truncByteOffset, ansiCodeIndexes)
util.CmpStr(t, tt.expected, actual)
})
Expand Down

0 comments on commit f214160

Please sign in to comment.