diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e2af48..eb353217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,10 @@ CHANGELOG 0.74.2 ------ -- Single-character queries are up to 2.4x faster - - This is the most latency-sensitive case; the first keystroke scans the entire list before the result cache can help +- Performance optimizations for short queries + - The most critical case: fzf narrows results as you type, so short queries scan the largest candidate sets and the first keystroke scans the whole input. + - Single-character queries are up to 2.4x faster + - Two-character queries are up to 1.4x faster - Faster sorting of search results; the default two-criteria ranking skips redundant radix passes - Fixed nondeterministic match highlight positions - fzf now detects terminal resize on Windows in `--height` mode (#4790) (@Cyrus580529) diff --git a/Makefile b/Makefile index acf65c8f..ab93c7f0 100644 --- a/Makefile +++ b/Makefile @@ -97,6 +97,16 @@ test: $(SOURCES) itest: ruby test/runner.rb +# Actively fuzz the matcher fast paths against the general algorithm. +# Go fuzzes one target at a time, so iterate. Override duration with +# FUZZTIME (e.g. make fuzz FUZZTIME=5m). +FUZZTIME ?= 30s +fuzz: + @for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two; do \ + echo "== $$t =="; \ + $(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \ + done + bench: cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem diff --git a/src/algo/algo.go b/src/algo/algo.go index 983c5e82..d1ceec99 100644 --- a/src/algo/algo.go +++ b/src/algo/algo.go @@ -463,6 +463,179 @@ func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b b return result, &pos } +// Test hooks: force the general path instead of a fast path, so the two can +// be compared for equivalence. +var ( + disableSingle bool + disableTwo bool +) + +// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on +// ASCII input. It replicates Phase 2 (row 0) and Phase 3 (row 1) of +// FuzzyMatchV2 in a single pass, carrying the row-0 diagonal/left values and +// the row-1 left value as scalars instead of materializing score arrays. +// When withPos is set, the two DP rows are stored so the backtrace can +// recover the matched character positions, exactly as the general Phase 4. +func fuzzyMatchV2Two(caseSensitive bool, forward bool, input *util.Chars, pchar0 byte, pchar1 byte, minIdx int, maxIdx int, withPos bool, slab *util.Slab) (Result, *[]int) { + sl := input.Bytes() + N := maxIdx - minIdx + + // Row storage, only needed for the backtrace + var H0, C0, H1, C1 []int16 + if withPos { + o := 0 + o, H0 = alloc16(o, slab, N) + o, C0 = alloc16(o, slab, N) + o, H1 = alloc16(o, slab, N) + _, C1 = alloc16(o, slab, N) + } + + maxScore, maxScorePos := int16(0), 0 + prevClass := initialCharClass + + // Subsequence tracking (equivalent to F[0], F[1] in Phase 2). The scope + // from asciiFuzzyIndex ends exactly at the last pchar1, so row 1's upper + // bound (Phase 3 lastIdx) is the final loop position; no separate var. + f0, f1 := -1, -1 + + // Row 0 running state at the previous position + var h0Prev, c0Prev, bPrev int16 + inGap0 := false + + // Row 1 running state + var h1Prev int16 + inGap1 := false + + for off := range N { + pos := minIdx + off + b := sl[pos] + class := asciiCharClasses[b] + lb := b + if !caseSensitive && b >= 'A' && b <= 'Z' { + lb = b + 32 + } + bonus := bonusMatrix[prevClass][class] + prevClass = class + + // Subsequence advance: pchar0 then pchar1 + if f0 < 0 { + if lb == pchar0 { + f0 = off + } + } else if lb == pchar1 && f1 < 0 { + f1 = off + } + + // Row 0 (pchar0) + var h0Cur, c0Cur int16 + if lb == pchar0 { + h0Cur = scoreMatch + bonus*bonusFirstCharMultiplier + c0Cur = 1 + inGap0 = false + } else { + if inGap0 { + h0Cur = max(h0Prev+scoreGapExtension, 0) + } else { + h0Cur = max(h0Prev+scoreGapStart, 0) + } + c0Cur = 0 + inGap0 = true + } + if withPos { + H0[off], C0[off] = h0Cur, c0Cur + } + + // Row 1 (pchar1), only within [f1, lastIdx] + if f1 >= 0 && off >= f1 { + var s1, s2, consecutive int16 + hleft := h1Prev + if off == f1 { + hleft = 0 + } + if inGap1 { + s2 = hleft + scoreGapExtension + } else { + s2 = hleft + scoreGapStart + } + if lb == pchar1 { + s1 = h0Prev + scoreMatch + bb := bonus + consecutive = c0Prev + 1 + if consecutive > 1 { + fb := bPrev + if bb >= bonusBoundary && bb > fb { + consecutive = 1 + } else { + bb = max(bb, bonusConsecutive, fb) + } + } + if s1+bb < s2 { + s1 += bonus + consecutive = 0 + } else { + s1 += bb + } + } + inGap1 = s1 < s2 + score := max(s1, s2, 0) + if forward && score > maxScore || !forward && score >= maxScore { + maxScore, maxScorePos = score, off + } + h1Prev = score + if withPos { + H1[off], C1[off] = score, consecutive + } + } + + h0Prev, c0Prev, bPrev = h0Cur, c0Cur, bonus + } + + if f1 < 0 { + return Result{-1, -1, 0}, nil + } + if !withPos { + return Result{minIdx + f0, minIdx + maxScorePos + 1, int(maxScore)}, nil + } + + // Phase 4 backtrace, specialized to two rows. Mirrors the general loop: + // record a cell when it dominates its diagonal and left neighbors, then + // step up a row; otherwise step left. preferMatch breaks score ties and + // must not read row 1 left of f1 (unwritten, possibly stale slab data). + pos := posArray(true, 2) + i := 1 + j := maxScorePos + preferMatch := true + for { + var s, s1, s2, cCur int16 + if i == 1 { + s, cCur = H1[j], C1[j] + if j >= f1 { + s1 = H0[j-1] + } + if j > f1 { + s2 = H1[j-1] + } + } else { + s, cCur = H0[j], C0[j] + if j > f0 { + s2 = H0[j-1] + } + } + row := i + if s > s1 && (s > s2 || s == s2 && preferMatch) { + *pos = append(*pos, j+minIdx) + if i == 0 { + break + } + i-- + } + preferMatch = cCur > 1 || + row == 0 && j < N-1 && j+1 >= f1 && C1[j+1] > 0 + j-- + } + return Result{minIdx + j, minIdx + maxScorePos + 1, int(maxScore)}, pos +} + func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) { // Assume that pattern is given in lowercase if case-insensitive. // First check if there's a match and calculate bonus for each position. @@ -487,7 +660,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util. // Single-character ASCII pattern needs neither the prefilter nor the // score matrix - if M == 1 && input.IsBytes() && pattern[0] < utf8.RuneSelf { + if !disableSingle && M == 1 && input.IsBytes() && pattern[0] < utf8.RuneSelf { return fuzzyMatchV2Single(caseSensitive, forward, input, byte(pattern[0]), withPos) } @@ -499,6 +672,13 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util. // fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString()) N = maxIdx - minIdx + // Two-character ASCII pattern: rows 0 and 1 collapse to scalar running + // state, so the general score arrays are unnecessary + if !disableTwo && M == 2 && input.IsBytes() && + pattern[0] < utf8.RuneSelf && pattern[1] < utf8.RuneSelf { + return fuzzyMatchV2Two(caseSensitive, forward, input, byte(pattern[0]), byte(pattern[1]), minIdx, maxIdx, withPos, slab) + } + // Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages offset16 := 0 offset32 := 0 diff --git a/src/algo/algo_test.go b/src/algo/algo_test.go index 45578521..caae23fd 100644 --- a/src/algo/algo_test.go +++ b/src/algo/algo_test.go @@ -241,3 +241,75 @@ func TestResultPositionsWithReusedSlab(t *testing.T) { } } } + +// TestFuzzyMatchV2TwoEquivalence verifies that the two-character fast path +// produces the same Result and positions as the general algorithm across +// case sensitivity, direction, and withPos, using a reused slab to surface +// any stale-data reads in the backtrace. +func TestFuzzyMatchV2TwoEquivalence(t *testing.T) { + words := []string{"src", "main", "core", "config", "parser", "render", "server", + "client", "index", "handler", "util", "list", "cache", "reader"} + exts := []string{".go", ".rb", ".py", ".md", ".c", ".txt"} + // Deterministic corpus (LCG), plus adversarial short/repeated items + corpus := []util.Chars{} + seed := uint32(12345) + next := func(n int) int { seed = seed*1664525 + 1013904223; return int(seed>>8) % n } + for i := 0; i < 4000; i++ { + depth := 2 + next(4) + s := "" + for d := 0; d < depth; d++ { + if d > 0 { + s += "/" + } + s += words[next(len(words))] + if next(5) == 0 { + s += "_" + words[next(len(words))] + } + } + s += exts[next(len(exts))] + corpus = append(corpus, util.ToChars([]byte(s))) + } + for _, s := range []string{"", "a", "ab", "aa", "aXb", "a/b", "//", "..", "abcabc", + "AaBb", "x.y.z", "a_b_c", "CoreCore", " co"} { + corpus = append(corpus, util.ToChars([]byte(s))) + } + + pats := []string{"co", "ab", "aa", "//", "..", "sr", "a/", "_c", "oo", "Ab", + "z.", "b.", "1a", "ll", "re", "er", "Co"} + slab := util.MakeSlab(100*1024, 2048) + for _, cs := range []bool{false, true} { + for _, fwd := range []bool{true, false} { + for _, wp := range []bool{false, true} { + for _, p := range pats { + pattern := []rune(p) + for j := range corpus { + disableTwo = true + rg, pg := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab) + disableTwo = false + rt, pt := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab) + if rg != rt { + t.Fatalf("Result cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v", + cs, fwd, wp, p, corpus[j].ToString(), rg, rt) + } + if (pg == nil) != (pt == nil) || (pg != nil && !equalInts(*pg, *pt)) { + t.Fatalf("Pos cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v", + cs, fwd, wp, p, corpus[j].ToString(), pg, pt) + } + } + } + } + } + } +} + +func equalInts(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/src/algo/fastpath_equiv_test.go b/src/algo/fastpath_equiv_test.go new file mode 100644 index 00000000..e4ede5c3 --- /dev/null +++ b/src/algo/fastpath_equiv_test.go @@ -0,0 +1,143 @@ +package algo + +// Equivalence tests for the single- and two-character fast paths against the +// general FuzzyMatchV2 algorithm, which serves as the oracle. +// +// Two complementary strategies: +// - Exhaustive: every string up to a fixed length over an alphabet that +// covers all ASCII character classes, so every local scoring branch is +// exercised (not merely sampled). +// - Fuzz: coverage-guided, arbitrary length, to reach cases the bounded +// exhaustive sweep cannot (e.g. long gaps where a high score decays). + +import ( + "testing" + + "github.com/junegunn/fzf/src/util" +) + +// One char of each ASCII class that affects scoring: lower, upper, delimiter, +// number, non-word, whitespace. +var equivAlphabet = []byte{'a', 'B', '/', '1', '_', ' '} + +func samePos(a, b *[]int) bool { + if (a == nil) != (b == nil) { + return false + } + if a == nil { + return true + } + return equalInts(*a, *b) +} + +// compareFastPath runs a single input/pattern/params pair through both the +// fast path and the general algorithm and fails on any difference. +func compareFastPath(t *testing.T, chars *util.Chars, pattern []rune, cs, fwd, wp bool, disable *bool, slab *util.Slab) { + t.Helper() + *disable = true + rg, pg := FuzzyMatchV2(cs, false, fwd, chars, pattern, wp, slab) + *disable = false + rt, pt := FuzzyMatchV2(cs, false, fwd, chars, pattern, wp, slab) + if rg != rt || !samePos(pg, pt) { + t.Fatalf("mismatch item=%q pat=%q cs=%v fwd=%v wp=%v: general %v %v vs fast %v %v", + chars.ToString(), string(pattern), cs, fwd, wp, rg, pg, rt, pt) + } +} + +// lowerPattern lowercases the pattern for case-insensitive search, matching +// the Algo contract (the pattern is pre-lowercased by BuildPattern). +func lowerPattern(p []rune, cs bool) []rune { + if cs { + return p + } + out := make([]rune, len(p)) + for i, c := range p { + if c >= 'A' && c <= 'Z' { + c += 32 + } + out[i] = c + } + return out +} + +func runExhaustive(t *testing.T, patLen, maxLen int, disable *bool) { + // All patterns of length patLen over the alphabet + var pats [][]rune + var genPat func(cur []rune) + genPat = func(cur []rune) { + if len(cur) == patLen { + pats = append(pats, append([]rune(nil), cur...)) + return + } + for _, c := range equivAlphabet { + genPat(append(cur, rune(c))) + } + } + genPat(nil) + + slab := util.MakeSlab(100*1024, 2048) + buf := make([]byte, 0, maxLen) + var rec func(depth int) + rec = func(depth int) { + chars := util.ToChars(append([]byte(nil), buf...)) + for _, cs := range []bool{false, true} { + for _, fwd := range []bool{true, false} { + for _, wp := range []bool{false, true} { + for _, p := range pats { + compareFastPath(t, &chars, lowerPattern(p, cs), cs, fwd, wp, disable, slab) + } + } + } + } + if depth == maxLen { + return + } + for _, c := range equivAlphabet { + buf = append(buf, c) + rec(depth + 1) + buf = buf[:len(buf)-1] + } + } + rec(0) +} + +func TestFuzzyMatchV2SingleExhaustive(t *testing.T) { + runExhaustive(t, 1, 6, &disableSingle) +} + +func TestFuzzyMatchV2TwoExhaustive(t *testing.T) { + runExhaustive(t, 2, 6, &disableTwo) +} + +func fuzzFastPath(f *testing.F, patLen int, disable *bool) { + f.Add("core_color/view/server.txt", "co") + f.Add("a b", "ab") + f.Add("XyZ/123_abc.def", "z1") + f.Add("aaaaaaaaaaaaaaaaaaaaaaaa", "aa") + slab := util.MakeSlab(200*1024, 4096) + f.Fuzz(func(t *testing.T, input, pat string) { + r := []rune(pat) + if len(r) != patLen { + return + } + for _, c := range r { + if c >= 128 { + return + } + } + chars := util.ToChars([]byte(input)) + if !chars.IsBytes() { + return + } + for _, cs := range []bool{false, true} { + for _, fwd := range []bool{true, false} { + for _, wp := range []bool{false, true} { + compareFastPath(t, &chars, lowerPattern(r, cs), cs, fwd, wp, disable, slab) + } + } + } + }) +} + +func FuzzFuzzyMatchV2Single(f *testing.F) { fuzzFastPath(f, 1, &disableSingle) } +func FuzzFuzzyMatchV2Two(f *testing.F) { fuzzFastPath(f, 2, &disableTwo) }