From 4b1dfa1c4dd05a3be79b3b609c7219be862b27d6 Mon Sep 17 00:00:00 2001 From: Daisuke Maki Date: Sun, 22 Feb 2026 00:26:02 +0900 Subject: [PATCH 1/3] fix: fast-path StripANSISequence to skip regexp when no ESC byte Benchmark (10k lines): plain text: 1023us -> 37us (-96%), 1.7MiB -> 0B (-100%), 30k -> 0 allocs 95% plain: 1064us -> 162us (-85%), 1720KiB -> 63KiB (-96%), 30.5k -> 2k allocs --- internal/util/bench_test.go | 40 +++++++++++++++++++++++++++++++++++++ internal/util/util.go | 8 +++++++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 internal/util/bench_test.go diff --git a/internal/util/bench_test.go b/internal/util/bench_test.go new file mode 100644 index 0000000..bf621a6 --- /dev/null +++ b/internal/util/bench_test.go @@ -0,0 +1,40 @@ +package util + +import ( + "fmt" + "testing" +) + +func BenchmarkStripANSISequenceBulk(b *testing.B) { + b.Run("10k_plain_lines", func(b *testing.B) { + lines := make([]string, 10_000) + for i := range lines { + lines[i] = fmt.Sprintf("line %d: this is a typical line without any ansi codes at all padding", i) + } + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + for _, l := range lines { + StripANSISequence(l) + } + } + }) + + b.Run("10k_mixed_95pct_plain", func(b *testing.B) { + lines := make([]string, 10_000) + for i := range lines { + if i%20 == 0 { + lines[i] = fmt.Sprintf("\x1b[31mline %d\x1b[0m: this has ansi codes in it for color", i) + } else { + lines[i] = fmt.Sprintf("line %d: this is a typical line without any ansi codes at all padding", i) + } + } + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + for _, l := range lines { + StripANSISequence(l) + } + } + }) +} diff --git a/internal/util/util.go b/internal/util/util.go index 61e34da..3576e18 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -3,6 +3,7 @@ package util import ( "errors" "regexp" + "strings" "unicode" ) @@ -44,8 +45,13 @@ func ContainsUpper(query string) bool { // Global var used to strips ansi sequences var reANSIEscapeChars = regexp.MustCompile("\x1B\\[(?:[0-9]{1,2}(?:;[0-9]{1,2})?)*[a-zA-Z]") -// StripANSISequence strips ANSI escape sequences from the given string +// StripANSISequence strips ANSI escape sequences from the given string. +// Fast-path: if the string contains no ESC byte, return it unchanged +// to avoid a regexp allocation. func StripANSISequence(s string) string { + if !strings.Contains(s, "\x1b") { + return s + } return reANSIEscapeChars.ReplaceAllString(s, "") } From 4b6baa3751f9781cc4e252474614c71a739481c3 Mon Sep 17 00:00:00 2001 From: Daisuke Maki Date: Sun, 22 Feb 2026 00:26:06 +0900 Subject: [PATCH 2/3] fix: in-place mergeMatches to avoid per-merge heap allocation Benchmark (10k lines, overlapping matches): 47.4ms -> 42.8ms (-10%), 27.3MiB -> 21.3MiB (-22%), 861k -> 471k allocs (-45%) --- filter/bench_test.go | 30 ++++++++++++++++++++++++++++++ filter/filter.go | 14 ++++---------- filter/filter_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/filter/bench_test.go b/filter/bench_test.go index f782eed..04af857 100644 --- a/filter/bench_test.go +++ b/filter/bench_test.go @@ -77,3 +77,33 @@ func BenchmarkRegexpFilter(b *testing.B) { cancel() } } + +// BenchmarkRegexpFilterOverlapping benchmarks the regexp filter with a query +// that produces overlapping match ranges, exercising the mergeMatches path. +// Each line contains repeating "aabb" patterns; the query terms "aab" and "abb" +// produce match ranges that overlap (e.g. [0,3] and [1,4]), forcing mergeMatches +// to be called on every line. With 10k lines the aggregate allocation difference +// from in-place vs make([]int,2) becomes measurable. +func BenchmarkRegexpFilterOverlapping(b *testing.B) { + // Build a line with many "aabb" repeats so that "aab" and "abb" each match + // many times with overlapping ranges between the two terms. + base := strings.Repeat("aabb", 20) // 80 chars + lines := make([]line.Line, 10_000) + for i := range lines { + lines[i] = line.NewRaw(uint64(i), base, false, false) + } + + f := NewIgnoreCase() + // "aab" and "abb" share the middle "ab" in each "aabb" group, so their + // match ranges overlap after sorting by start position. + query := "aab abb" + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + ctx, cancel := context.WithTimeout(f.NewContext(context.Background(), query), 10*time.Second) + ch := make(chan line.Line, len(lines)) + _ = f.Apply(ctx, lines, pipeline.ChanOutput(ch)) + cancel() + } +} diff --git a/filter/filter.go b/filter/filter.go index 46adc9f..f51aa7b 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -93,15 +93,9 @@ func matchOverlaps(a []int, b []int) bool { } // mergeMatches combines two overlapping match ranges into a single range -// spanning both. +// spanning both. It mutates and returns a to avoid a heap allocation. func mergeMatches(a []int, b []int) []int { - ret := make([]int, 2) - - // Note: In practice this should never happen - // because we're sorting by N[0] before calling - // this routine, but for completeness' sake... - ret[0] = min(a[0], b[0]) - - ret[1] = max(a[1], b[1]) - return ret + a[0] = min(a[0], b[0]) + a[1] = max(a[1], b[1]) + return a } diff --git a/filter/filter_test.go b/filter/filter_test.go index 5886bfd..2cae457 100644 --- a/filter/filter_test.go +++ b/filter/filter_test.go @@ -634,6 +634,31 @@ func testFuzzyMatch(octx context.Context, t *testing.T, filter Filter) { } } +func TestMergeMatches(t *testing.T) { + t.Parallel() + tests := []struct { + name string + a []int + b []int + want []int + }{ + {"a before b overlapping", []int{1, 5}, []int{3, 7}, []int{1, 7}}, + {"b before a overlapping", []int{3, 7}, []int{1, 5}, []int{1, 7}}, + {"identical ranges", []int{2, 4}, []int{2, 4}, []int{2, 4}}, + {"a contains b", []int{0, 10}, []int{3, 7}, []int{0, 10}}, + {"adjacent ranges", []int{0, 3}, []int{3, 6}, []int{0, 6}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mergeMatches(tt.a, tt.b) + require.Equal(t, tt.want, got) + // Verify in-place mutation: got should be the same slice as a + require.Same(t, &tt.a[0], &got[0], "mergeMatches should mutate a in place") + }) + } +} + // TestMatchAcrossANSIColorBoundary verifies that filter queries match // against the ANSI-stripped text, so a pattern spanning characters // rendered in different colors still produces a match. From b4c5d2e928586a0e96413857ad99307d18e2e8ab Mon Sep 17 00:00:00 2001 From: Daisuke Maki Date: Sun, 22 Feb 2026 00:26:11 +0900 Subject: [PATCH 3/3] fix: avoid redundant displayString storage and alloc in DisplayString Benchmark (10k lines, enableANSI=false): 1244us -> 331us (-73%), 2541KiB -> 781KiB (-69%), 40k -> 10k allocs (-75%) --- internal/util/util.go | 4 ++++ line/bench_test.go | 35 +++++++++++++++++++++++++++++++++++ line/raw.go | 12 ++++++++---- 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 line/bench_test.go diff --git a/internal/util/util.go b/internal/util/util.go index 61e34da..50e76a0 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -3,6 +3,7 @@ package util import ( "errors" "regexp" + "strings" "unicode" ) @@ -46,6 +47,9 @@ var reANSIEscapeChars = regexp.MustCompile("\x1B\\[(?:[0-9]{1,2}(?:;[0-9]{1,2})? // StripANSISequence strips ANSI escape sequences from the given string func StripANSISequence(s string) string { + if !strings.Contains(s, "\x1b") { + return s + } return reANSIEscapeChars.ReplaceAllString(s, "") } diff --git a/line/bench_test.go b/line/bench_test.go new file mode 100644 index 0000000..9162522 --- /dev/null +++ b/line/bench_test.go @@ -0,0 +1,35 @@ +package line + +import ( + "fmt" + "testing" +) + +// BenchmarkNewRawAndDisplay benchmarks creating Raw lines and calling +// DisplayString(), simulating peco's startup + render path for 10k lines. +func BenchmarkNewRawAndDisplay(b *testing.B) { + texts := make([]string, 10_000) + for i := range texts { + texts[i] = fmt.Sprintf("line %d: this is a typical plain text line without any ansi codes", i) + } + + b.Run("enableANSI_true_plain_text", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + for i, t := range texts { + r := NewRaw(uint64(i), t, false, true) + _ = r.DisplayString() + } + } + }) + + b.Run("enableANSI_false_plain_text", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + for i, t := range texts { + r := NewRaw(uint64(i), t, false, false) + _ = r.DisplayString() + } + } + }) +} diff --git a/line/raw.go b/line/raw.go index 5124bc9..74ad146 100644 --- a/line/raw.go +++ b/line/raw.go @@ -77,8 +77,12 @@ func NewRaw(id uint64, v string, enableSep bool, enableANSI bool) *Raw { src = rl.buf[:rl.sepLoc] } r := ansi.Parse(src) - rl.displayString = r.Stripped rl.ansiAttrs = r.Attrs + // Only store displayString when it actually differs from buf + // (i.e. when there's a separator or ANSI attributes) + if rl.sepLoc > -1 || r.Attrs != nil { + rl.displayString = r.Stripped + } } return rl @@ -121,10 +125,10 @@ func (rl *Raw) DisplayString() string { if i := rl.sepLoc; i > -1 { rl.displayString = util.StripANSISequence(rl.buf[:i]) - } else { - rl.displayString = util.StripANSISequence(rl.buf) + return rl.displayString } - return rl.displayString + // No separator: strip ANSI (fast-path returns buf unchanged when no ESC present) + return util.StripANSISequence(rl.buf) } // ANSIAttrs returns the run-length encoded ANSI attributes for this line.