perf(lsp): request only changed portions of the buffer in changetracking (#17118)

This commits introduces two performance improvements in incremental sync:

* avoiding expensive lua string reallocations on each on_lines call by requesting
only the changed chunk of the buffer as reported by firstline and new_lastline
parameters of on_lines
* re-using already allocated tables for storing the resulting lines to reduce the load on
the garbage collector

The majority of the performance improvement is from requesting only changed chunks
of the buffer.

Benchmark:

The following code measures the time required to perform a buffer edit to
that operates individually on each line, common to plugins such as vim-commentary.

    set rtp+=~/.config/nvim/plugged/nvim-lspconfig
    set rtp+=~/.config/nvim/plugged/vim-commentary
    lua require('lspconfig')['ccls'].setup({})
    function! Benchmark(tries) abort
        let results_comment = []
        let results_undo = []
        for i in range(a:tries)
            echo printf('run %d', i+1)
            let begin = reltime()
            normal gggcG
            call add(results_comment, reltimefloat(reltime(begin)))
            let begin = reltime()
            silent! undo
            call add(results_undo, reltimefloat(reltime(begin)))
            redraw
        endfor
        let avg_comment = 0.0
        let avg_undo = 0.0
        for i in range(a:tries)
            echomsg printf('run %3d: comment=%fs undo=%fs', i+1, results_comment[i], results_undo[i])
            let avg_comment += results_comment[i]
            let avg_undo += results_undo[i]
        endfor
        echomsg printf('average: comment=%fs undo=%fs', avg_comment / a:tries, avg_undo / a:tries)
    endfunction
    command! -bar Benchmark call Benchmark(10)

All text changes will be recorded within a single undo operation. Both the
comment operation itself and the undo operation will generate an on_lines event
for each changed line. Formatter plugins using setline() have also been found
to exhibit the same problem (neoformat, :RustFmt in rust.vim), as this function
too generates an on_lines event for every line it changes.

Using the neovim codebase as an example (commit 2ecf0a4)
with neovim itself built at 2ecf0a4 with
CMAKE_BUILD_TYPE=Release shows the following performance improvement:

src/nvim/lua/executor.c, 1432 lines:
  - baseline, no optimizations:             comment=0.540587s undo=0.440249s
  - without double-buffering optimization:  comment=0.183314s undo=0.060663s
  - all optimizations in this commit:       comment=0.174850s undo=0.052789s

src/nvim/search.c, 5467 lines:
  - baseline, no optimizations:             comment=7.420446s undo=7.656624s
  - without double-buffering optimization:  comment=0.889048s undo=0.486026s
  - all optimizations in this commit:       comment=0.662899s undo=0.243628s

src/nvim/eval.c, 11355 lines:
  - baseline, no optimizations:             comment=41.775695s undo=44.583374s
  - without double-buffering optimization:  comment=3.643933s undo=2.817158s
  - all optimizations in this commit:       comment=1.510886s undo=0.707928s

Co-authored-by: Dmytro Meleshko <dmytro.meleshko@gmail.com>
This commit is contained in:
Michael Lingelbach
2022-01-17 08:19:33 -08:00
committed by GitHub
parent 3906b2d4fc
commit 9055ec5792

View File

@@ -344,6 +344,7 @@ do
state.buffers[bufnr] = buf_state state.buffers[bufnr] = buf_state
if use_incremental_sync then if use_incremental_sync then
buf_state.lines = nvim_buf_get_lines(bufnr, 0, -1, true) buf_state.lines = nvim_buf_get_lines(bufnr, 0, -1, true)
buf_state.lines_tmp = {}
buf_state.pending_changes = {} buf_state.pending_changes = {}
end end
end end
@@ -403,11 +404,40 @@ do
---@private ---@private
function changetracking.prepare(bufnr, firstline, lastline, new_lastline) function changetracking.prepare(bufnr, firstline, lastline, new_lastline)
local incremental_changes = function(client, buf_state) local incremental_changes = function(client, buf_state)
local curr_lines = nvim_buf_get_lines(bufnr, 0, -1, true)
local prev_lines = buf_state.lines
local curr_lines = buf_state.lines_tmp
local changed_lines = nvim_buf_get_lines(bufnr, firstline, new_lastline, true)
for i = 1, firstline do
curr_lines[i] = prev_lines[i]
end
for i = firstline + 1, new_lastline do
curr_lines[i] = changed_lines[i - firstline]
end
for i = lastline + 1, #prev_lines do
curr_lines[i - lastline + new_lastline] = prev_lines[i]
end
if tbl_isempty(curr_lines) then
-- Can happen when deleting the entire contents of a buffer, see https://github.com/neovim/neovim/issues/16259.
curr_lines[1] = ''
end
local line_ending = buf_get_line_ending(bufnr) local line_ending = buf_get_line_ending(bufnr)
local incremental_change = sync.compute_diff( local incremental_change = sync.compute_diff(
buf_state.lines, curr_lines, firstline, lastline, new_lastline, client.offset_encoding or 'utf-16', line_ending) buf_state.lines, curr_lines, firstline, lastline, new_lastline, client.offset_encoding or 'utf-16', line_ending)
-- Double-buffering of lines tables is used to reduce the load on the garbage collector.
-- At this point the prev_lines table is useless, but its internal storage has already been allocated,
-- so let's keep it around for the next didChange event, in which it will become the next
-- curr_lines table. Note that setting elements to nil doesn't actually deallocate slots in the
-- internal storage - it merely marks them as free, for the GC to deallocate them.
for i in ipairs(prev_lines) do
prev_lines[i] = nil
end
buf_state.lines = curr_lines buf_state.lines = curr_lines
buf_state.lines_tmp = prev_lines
return incremental_change return incremental_change
end end
local full_changes = once(function() local full_changes = once(function()