fzy.lua: optimize main compute() a little

There could be a lot more done if we target luajit only. Or if we port
this to C. But for now let's keep it this way. It's 2-3x faster than
before, and the issue is mostly only on very big inputs, which are not
that common in practice.

Previously computing scores allocated two matrices of lua tables per
call. With hundreds of thousands of items this made fuzzy matching in
console.lua GC-bound.

When only the score is needed, keep two reused rolling matrix rows,
making repeated scoring allocation-free. positions() still keeps all
rows since tracing back the matched positions requires them.

fzy.filter() of 300000 items drops from 1013 ms to 356 ms (on my setup)
for a worst-case single-character needle, and a standalone score() call
no longer allocates at all.
This commit is contained in:
Kacper Michajłow committed 2026-07-23 12:16:59 +02:00
1 parent e4abaaa26b
commit e2eaf31ce2
2 files changed
+78 -41

No files matched your search

+1
View File
@@ -1703,6 +1703,7 @@ set_active = function (active)
matches = {}
item_positions = {}
completion_buffer = {}
fzy.gc()
collectgarbage()
end
+77 -41
View File
@@ -49,6 +49,16 @@ local CHAR_Z = byte("Z")
local CHAR_a = byte("a")
local CHAR_z = byte("z")
-- Reusable memory arrays for repeating filtering calls. Profiling shown that
-- with huge payloads we spend a lot of time allocating and later dealociting,
-- making poor GC work overtime. Keep them allocated. Free them with fzy.gc()
-- after use.
local match_bonus, row_D1, row_M1, row_D2, row_M2 = {}, {}, {}, {}, {}
function fzy.gc()
match_bonus, row_D1, row_M1, row_D2, row_M2 = {}, {}, {}, {}, {}
end
-- Check if `needle` is a subsequence of the `haystack`.
--
-- Usually called before `score` or `positions`.
@@ -80,7 +90,6 @@ function fzy.has_match(needle, haystack, case_sensitive)
end
local function precompute_bonus(haystack)
local match_bonus = {}
local last_char = CHAR_SLASH
for i = 1, #haystack do
local this_char = byte(haystack, i)
@@ -103,53 +112,72 @@ local function precompute_bonus(haystack)
return match_bonus
end
local function compute(needle, haystack, D, M, case_sensitive)
-- Run the dynamic programming loop over the score matrices D (score of the
-- best match ending at this position) and M (best score up to this position).
--
-- `needle` and `haystack` must already be case-folded as desired and both
-- non-empty, with #needle < #haystack <= MATCH_MAX_LENGTH. `haystack_cased`
-- is the original-case haystack, used for the match bonuses.
--
-- Returns the score, and, if `full` is true, also the D and M matrices as
-- freshly allocated arrays of rows, which are needed to trace back the
-- matched positions. Otherwise only two reused rows are kept, making
-- repeated scoring allocation-free.
local function compute(needle, haystack_cased, haystack, full)
local n = #needle
local m = #haystack
-- Note that the match bonuses must be computed before the arguments are
-- converted to lowercase, since there are bonuses for camelCase.
local match_bonus = precompute_bonus(haystack)
local n = string.len(needle)
local m = string.len(haystack)
local match_bonus = precompute_bonus(haystack_cased)
if not case_sensitive then
needle = string.lower(needle)
haystack = string.lower(haystack)
local D, M
if full then
D, M = {}, {}
end
-- Because lua only grants access to chars through substring extraction,
-- get all the characters from the haystack once now, to reuse below.
local haystack_chars = {}
for i = 1, m do
haystack_chars[i] = haystack:sub(i, i)
end
local prev_D, prev_M, cur_D, cur_M = row_D2, row_M2, row_D1, row_M1
for i = 1, n do
D[i] = {}
M[i] = {}
if full then
cur_D, cur_M = {}, {}
D[i], M[i] = cur_D, cur_M
end
local prev_score = SCORE_MIN
local gap_score = i == n and SCORE_GAP_TRAILING or SCORE_GAP_INNER
local needle_char = needle:sub(i, i)
local needle_char = byte(needle, i)
for j = 1, m do
if needle_char == haystack_chars[j] then
if needle_char == byte(haystack, j) then
local score = SCORE_MIN
if i == 1 then
score = ((j - 1) * SCORE_GAP_LEADING) + match_bonus[j]
score = (j - 1) * SCORE_GAP_LEADING + match_bonus[j]
elseif j > 1 then
local a = M[i - 1][j - 1] + match_bonus[j]
local b = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
score = math.max(a, b)
local match = prev_M[j - 1] + match_bonus[j]
local consecutive = prev_D[j - 1] + SCORE_MATCH_CONSECUTIVE
score = match > consecutive and match or consecutive
end
D[i][j] = score
prev_score = math.max(score, prev_score + gap_score)
M[i][j] = prev_score
else
D[i][j] = SCORE_MIN
cur_D[j] = score
prev_score = prev_score + gap_score
M[i][j] = prev_score
if score > prev_score then
prev_score = score
end
cur_M[j] = prev_score
else
cur_D[j] = SCORE_MIN
prev_score = prev_score + gap_score
cur_M[j] = prev_score
end
end
prev_D, prev_M, cur_D, cur_M = cur_D, cur_M, prev_D, prev_M
end
return prev_M[m], D, M
end
-- Compute a matching score.
@@ -164,19 +192,23 @@ end
-- number: higher scores indicate better matches. See also `get_score_min`
-- and `get_score_max`.
function fzy.score(needle, haystack, case_sensitive)
local n = string.len(needle)
local m = string.len(haystack)
local n = #needle
local m = #haystack
if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > m then
return SCORE_MIN
elseif n == m then
return SCORE_MAX
else
local D = {}
local M = {}
compute(needle, haystack, D, M, case_sensitive)
return M[n][m]
end
local haystack_cased = haystack
if not case_sensitive then
needle = string.lower(needle)
haystack = string.lower(haystack)
end
local score = compute(needle, haystack_cased, haystack, false)
return score
end
-- Compute the locations where fzy matches a string.
@@ -195,8 +227,8 @@ end
-- character of `needle` in `haystack`.
-- number: the same matching score returned by `score`
function fzy.positions(needle, haystack, case_sensitive)
local n = string.len(needle)
local m = string.len(haystack)
local n = #needle
local m = #haystack
if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > m then
return {}, SCORE_MIN
@@ -208,9 +240,13 @@ function fzy.positions(needle, haystack, case_sensitive)
return consecutive, SCORE_MAX
end
local D = {}
local M = {}
compute(needle, haystack, D, M, case_sensitive)
local haystack_cased = haystack
if not case_sensitive then
needle = string.lower(needle)
haystack = string.lower(haystack)
end
local score, D, M = compute(needle, haystack_cased, haystack, true)
local positions = {}
local match_required = false
@@ -229,7 +265,7 @@ function fzy.positions(needle, haystack, case_sensitive)
end
end
return positions, M[n][m]
return positions, score
end
-- Apply `has_match` and `positions` to an array of haystacks.