Skip to content

Commit a4c449f

Browse files
committed
refactor: speed up parsing/transform with stringbuf/worker
1 parent 114da56 commit a4c449f

4 files changed

Lines changed: 179 additions & 127 deletions

File tree

lua/fzf-lua/lib/stringbuffer.lua

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
---@diagnostic disable
2+
-- https://github.com/neovim/neovim/blob/20e77c5d886af54d1f7b6844cffc11129f579ad9/runtime/lua/vim/_core/stringbuffer.lua
3+
-- Basic shim for LuaJIT's stringbuffer.
4+
-- Note this does not implement the full API.
5+
-- This is intentionally internal-only. If we want to expose it, we should
6+
-- reimplement this a userdata and ship it as `string.buffer`
7+
-- (minus the FFI stuff) for Lua 5.1.
8+
local M = {}
9+
10+
local has_strbuffer, strbuffer = pcall(require, "string.buffer")
11+
12+
if has_strbuffer then
13+
M.new = strbuffer.new
14+
15+
-- Lua 5.1 does not have __len metamethod so we need to provide a len()
16+
-- function to use instead.
17+
18+
--- @param buf vim._core.stringbuffer
19+
--- @return integer
20+
function M.len(buf)
21+
return #buf
22+
end
23+
24+
return M
25+
end
26+
27+
--- @class vim._core.stringbuffer
28+
--- @field private buf string[]
29+
--- @field package len integer absolute length of the `buf`
30+
--- @field package skip_ptr integer
31+
local StrBuffer = {}
32+
StrBuffer.__index = StrBuffer
33+
34+
--- @return string
35+
function StrBuffer:tostring()
36+
if #self.buf > 1 then
37+
self.buf = { table.concat(self.buf) }
38+
end
39+
40+
-- assert(self.len == #(self.buf[1] or ''), 'len mismatch')
41+
42+
if self.skip_ptr > 0 then
43+
if self.buf[1] then
44+
self.buf[1] = self.buf[1]:sub(self.skip_ptr + 1)
45+
self.len = self.len - self.skip_ptr
46+
end
47+
self.skip_ptr = 0
48+
end
49+
50+
-- assert(self.len == #(self.buf[1] or ''), 'len mismatch')
51+
52+
return self.buf[1] or ""
53+
end
54+
55+
StrBuffer.__tostring = StrBuffer.tostring
56+
57+
--- @private
58+
--- Efficiently peak at the first `n` characters of the buffer.
59+
--- @param n integer
60+
--- @return string
61+
function StrBuffer:_peak(n)
62+
local skip, buf1 = self.skip_ptr, self.buf[1]
63+
if buf1 and (n + skip) < #buf1 then
64+
return buf1:sub(skip + 1, skip + n)
65+
end
66+
return self:tostring():sub(1, n)
67+
end
68+
69+
--- @param chunk string
70+
function StrBuffer:put(chunk)
71+
local s = tostring(chunk)
72+
self.buf[#self.buf + 1] = s
73+
self.len = self.len + #s
74+
return self
75+
end
76+
77+
--- @param str string
78+
function StrBuffer:set(str)
79+
return self:reset():put(str)
80+
end
81+
82+
--- @param n? integer
83+
--- @return string
84+
function StrBuffer:get(n)
85+
n = n or self.len
86+
local r = self:_peak(n)
87+
self:skip(n)
88+
return r
89+
end
90+
91+
--- @param n integer
92+
function StrBuffer:skip(n)
93+
self.skip_ptr = math.min(self.len, self.skip_ptr + n)
94+
return self
95+
end
96+
97+
function StrBuffer:reset()
98+
self.buf = {}
99+
self.skip_ptr = 0
100+
self.len = 0
101+
return self
102+
end
103+
104+
function M.new()
105+
return setmetatable({}, StrBuffer):reset()
106+
end
107+
108+
--- @param buf vim._core.stringbuffer
109+
function M.len(buf)
110+
return buf.len - buf.skip_ptr
111+
end
112+
113+
return M

lua/fzf-lua/libuv.lua

Lines changed: 59 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ end
8484
---@field EOL_data? string
8585
---@field process1? boolean
8686
---@field profiler? boolean
87-
---@field use_queue? boolean
8887

8988
---@param opts fzf-lua.SpawnOpts
9089
---@param fn_transform function?
@@ -103,34 +102,31 @@ M.spawn = function(opts, fn_transform, fn_done)
103102
or opts.cmd:match("%s%-%-null")
104103
or opts.cmd:match("%s%-Z"))
105104
and "\0" or "\n"
105+
local EOL_byte = EOL_data:byte()
106106
local output_pipe = assert(uv.new_pipe(false))
107107
local error_pipe = assert(uv.new_pipe(false))
108-
local write_cb_count, read_cb_count = 0, 0
109-
local prev_line_content ---@type string?
108+
local write_cb_count = 0
110109
local handle, pid ---@type uv.uv_process_t, integer
111-
local co = coroutine.running()
112-
local queue = require("fzf-lua.lib.queue").new()
110+
local strbuf = (vim.F.nil_wrap(require)("vim._core.stringbuffer") or
111+
require("fzf-lua.lib.stringbuffer")).new()
113112
local work_ctx
114113

115-
-- Disable queue if running headless due to
116-
-- "Attempt to yield across a C-call boundary"
117-
opts.use_queue = not _G._fzf_lua_is_headless and opts.use_queue
118-
119114
-- cb_write_lines trumps cb_write
120115
---@diagnostic disable-next-line: assign-type-mismatch
121116
if opts.cb_write_lines then opts.cb_write = opts.cb_write_lines end
122117

123118
local can_finish = function()
124119
if not output_pipe:is_active() -- EOF signalled or process is aborting
125-
and read_cb_count == 0 -- no outstanding read_cb data processing
126120
and write_cb_count == 0 -- no outstanding write callbacks
121+
and #strbuf == 0
127122
then
128123
return true
129124
end
130125
end
131126

132127
---@diagnostic disable-next-line: redefined-local
133128
local finish = function(code, sig, from, pid)
129+
os.execute("notify-send " .. write_cb_count)
134130
-- Uncomment to debug pipe closure timing issues (#1521)
135131
-- output_pipe:close(function() print("closed o") end)
136132
-- error_pipe:close(function() print("closed e") end)
@@ -139,7 +135,7 @@ M.spawn = function(opts, fn_transform, fn_done)
139135
if opts.cb_finish then
140136
opts.cb_finish(code, sig, from, pid)
141137
end
142-
queue:clear()
138+
strbuf:reset()
143139
if not handle:is_closing() then
144140
handle:kill("sigterm")
145141
vim.defer_fn(function()
@@ -187,7 +183,7 @@ M.spawn = function(opts, fn_transform, fn_done)
187183
if opts.cb_pid then opts.cb_pid(pid) end
188184

189185
local function write_cb(data)
190-
write_cb_count = write_cb_count + 1
186+
-- write_cb_count = write_cb_count + 1
191187
opts.cb_write(data, function(err)
192188
write_cb_count = write_cb_count - 1
193189
if err then
@@ -204,126 +200,75 @@ M.spawn = function(opts, fn_transform, fn_done)
204200
end
205201

206202
---@param data string data stream
207-
---@param prev string? rest of line from previous call
208-
---@param trans function? line transformation function
209-
---@return table, string? line array, partial last line (no EOL)
210-
local function split_lines(data, prev, trans)
203+
---@return string, string? line array, partial last line (no EOL)
204+
local function split_lines(data)
205+
-- io.stderr:write("[DEBUG] worker init")
206+
if not _G.fzf_lua_worker_init then
207+
_G.fzf_lua_worker_init = true
208+
-- local __FILE__ = assert(debug.getinfo(1, "S")).source:gsub("^@", "")
209+
-- package.path = ("%s/?.lua;"):format(vim.fs.dirname(vim.fs.dirname(__FILE__))) .. package.path
210+
-- require("fzf-lua")
211+
-- pcall(require, "fzf-lua.make_entry")
212+
end
211213
local ret = {}
212214
local start_idx = 1
213215
repeat
214-
local nl_idx = data:find(EOL_data, start_idx, true)
216+
local nl_idx = data:find(EOL_data or "\n", start_idx, true)
215217
if nl_idx then
216218
local cr = data:byte(nl_idx - 1, nl_idx - 1) == 13 -- \r
217219
local line = data:sub(start_idx, nl_idx - (cr and 2 or 1))
218-
if prev then
219-
line = prev .. line
220-
prev = nil
221-
end
222-
if trans then line = trans(line) end
223-
if line then table.insert(ret, line) end
220+
-- if trans then line = trans(line) end
221+
if line then ret[#ret + 1] = line end
224222
start_idx = nl_idx + 1
225-
else
226-
-- assert(start_idx <= #data)
227-
if prev and #prev > 4096 then
228-
-- chunk size is 64K, limit previous line length to 4K
229-
-- max line length is therefor 4K + 64K (leftover + full chunk)
230-
-- without this we can memory fault on extremely long lines (#185)
231-
-- or have UI freezes (#211)
232-
prev = prev:sub(1, 4096)
233-
end
234-
prev = (prev or "") .. data:sub(start_idx)
235223
end
236224
until not nl_idx or start_idx > #data
237-
return ret, prev
238-
end
239-
240-
--- Called with nil to process the leftover data
241-
---@param data string?
242-
local process_data = function(data)
243-
if not data and prev_line_content then
244-
data = prev_line_content .. EOL
245-
prev_line_content = nil
246-
end
247-
if not data then
248-
-- NOTE: this isn't called when prev_line_content is not nil but that's
249-
-- not a problem as the write_cb will call finish once the callback is done
250-
-- since the output_pipe is already in "closing" state
251-
if can_finish() then
252-
finish(0, 0, "[EOF]", pid)
253-
end
254-
return
255-
end
256-
if not fn_transform then
257-
write_cb(data)
258-
else
259-
-- NOTE: cannot use due to "yield across a C-call boundary"
260-
-- if co and not work_ctx then
261-
-- work_ctx = uv.new_work(split_lines, function(lines, prev)
262-
-- coroutine.resume(co, lines, prev)
263-
-- end)
264-
-- end
265-
local nlines, lines = 0, nil
266-
local t_st = opts.profiler and uv.hrtime()
267-
if t_st then write_cb(string.format("[DEBUG] start: %.0f (ns)" .. EOL, t_st)) end
268-
if work_ctx then
269-
-- should never get here, work_ctx is never initialized
270-
-- code remains as a solemn reminder to my efforts of making
271-
-- multiprocess=false a lag free experience
272-
if prev_line_content then uv.queue_work(work_ctx, data, prev_line_content) end
273-
lines, prev_line_content = coroutine.yield()
274-
else
275-
lines, prev_line_content = split_lines(data, prev_line_content,
276-
-- NOTE `fn_transform=true` is used to force line split without transformation
277-
type(fn_transform) == "function" and fn_transform or nil)
225+
ret[#ret + 1] = ""
226+
return table.concat(ret, "\n")
227+
end
228+
229+
work_ctx = uv.new_work(split_lines, write_cb)
230+
231+
local co = coroutine.create(function()
232+
local stop = 0
233+
while true do
234+
local len = #strbuf
235+
local ref = strbuf:ref()
236+
if output_pipe:is_closing() then
237+
if len == 0 then return end
238+
if ref[len - 1] ~= EOL_byte then strbuf:put(EOL_byte) end -- make split_lines happy
239+
write_cb_count = write_cb_count + 1
240+
return work_ctx:queue(strbuf:get())
278241
end
279-
nlines = nlines + #lines
280-
if #lines > 0 then
281-
if opts.cb_write_lines then
282-
write_cb(lines)
283-
else
284-
-- Testing shows better performance writing the entire table at once as opposed to
285-
-- calling 'write_cb' for every line after 'fn_transform', we therefore only use
286-
-- `process1` when using "mini.icons" as `vim.filetype.match` causes a signigicant
287-
-- delay and having to wait for all lines to be processed has an apparent lag
288-
if opts.process1 then
289-
vim.tbl_map(function(l) write_cb(l .. EOL) end, lines)
290-
else
291-
write_cb(table.concat(lines, EOL) .. EOL)
292-
end
242+
local eol = len
243+
for i = len - 1, stop, -1 do
244+
if ref[i] == EOL_byte then
245+
eol = i
246+
break
293247
end
294248
end
295-
if t_st then
296-
local t_e = vim.uv.hrtime()
297-
write_cb(string.format("[DEBUG] finish:%.0f (ns) %d lines took %.0f (ms)" .. EOL,
298-
t_e, nlines, (t_e - t_st) / 1e6))
249+
if eol == len then
250+
stop = len -- no EOL found, wait for more data
251+
coroutine.yield()
252+
else
253+
local data = strbuf:get(eol + 1)
254+
stop = #strbuf
255+
write_cb_count = write_cb_count + 1
256+
work_ctx:queue(data)
299257
end
300258
end
301-
end
259+
if can_finish() then finish(0, 0, "[EOF]", pid) end
260+
end)
302261

303262
local read_cb = function(err, data)
304263
if err then
305-
finish(130, 0, "[read_cb: err]", pid)
306-
return
307-
end
308-
if not data then
309-
-- EOF signalled, we can close the pipe
264+
return finish(130, 0, "[read_cb: err]", pid)
265+
elseif data then
266+
strbuf:put(data)
267+
else -- EOF signalled, we can close the pipe
310268
output_pipe:close()
311269
end
312-
if opts.use_queue then
313-
if data then queue:push(data) end
314-
-- Either we have outstanding data enqueued or the pipe is closing
315-
-- due to the above `output_pipe:close`, in both cases we need to
316-
-- resume the dequeue loop
317-
coroutine.resume(co)
318-
else
319-
read_cb_count = read_cb_count + 1
320-
local process = function()
321-
read_cb_count = read_cb_count - 1
322-
process_data(data)
323-
end
324-
-- Schedule data processing if we're in fast event
325-
-- avoids "attempt to yield across C-call boundary" by using vim.schedule
326-
if vim.in_fast_event() then vim.schedule(process) else process() end
270+
if #strbuf > 0 or output_pipe:is_closing() then
271+
assert(coroutine.resume(co))
327272
end
328273
end
329274

@@ -352,20 +297,6 @@ M.spawn = function(opts, fn_transform, fn_done)
352297
error_pipe:read_start(err_cb)
353298
end
354299

355-
if opts.use_queue then
356-
while not (output_pipe:is_closing() and queue:empty()) do
357-
if queue:empty() then
358-
coroutine.yield()
359-
else
360-
process_data(queue:pop())
361-
end
362-
end
363-
-- process the leftover line from `processs_data`
364-
-- will call `finish` immediately if there's no last line
365-
-- otherwise, finish is called in the write callback
366-
process_data(nil)
367-
end
368-
369300
return handle, pid
370301
end
371302

@@ -628,6 +559,7 @@ M.spawn_stdio = function(opts)
628559
cmd = cmd,
629560
cb_finish = on_finish,
630561
cb_write = on_write,
562+
-- cb_write_lines = on_write_lines,
631563
cb_err = on_err,
632564
process1 = opts.process1,
633565
profiler = opts.profiler,

lua/fzf-lua/make_entry.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
local M = {}
22

3+
-- do return { file = nil } end
34
---@diagnostic disable-next-line: deprecated
45
local uv = vim.uv or vim.loop
56
local path = require "fzf-lua.path"

0 commit comments

Comments
 (0)