From 558204d87bd4ff3a7be347e4c765c4b9b8f28c52 Mon Sep 17 00:00:00 2001 From: Yi Ming Date: Wed, 22 Apr 2026 23:38:58 +0800 Subject: [PATCH] perf(lsp): clear table by table.clear() #39222 benchmark: https://gist.github.com/ofseed/6224529d77c016c36f7ab2f977059848 local rounds = tonumber(arg[1]) or 1000 local count = tonumber(arg[2]) or 1000 -- Load the table.clear function. local clear = require("table.clear") local function fill(t, n) for i = 1, n do t[i] = i end end local function bench_reassign(n_rounds, n_items) local t = {} local start = os.clock() for _ = 1, n_rounds do t = {} collectgarbage("collect") fill(t, n_items) end return os.clock() - start end local function bench_reassign_no_gc(n_rounds, n_items) local t = {} local start = os.clock() for _ = 1, n_rounds do t = {} fill(t, n_items) end return os.clock() - start end local function bench_clear(n_rounds, n_items) local t = {} local start = os.clock() for _ = 1, n_rounds do clear(t) fill(t, n_items) end return os.clock() - start end -- Warm up LuaJIT before the real benchmark. do local t = {} for _ = 1, 2000 do clear(t) fill(t, count) end end collectgarbage("collect") local reassign_time = bench_reassign(rounds, count) collectgarbage("collect") local reassign_no_gc_time = bench_reassign_no_gc(rounds, count) collectgarbage("collect") local clear_time = bench_clear(rounds, count) print(string.format("rounds=%d count=%d", rounds, count)) print(string.format("t = {} + GC : %.6f s", reassign_time)) print(string.format("t = {} : %.6f s", reassign_no_gc_time)) print(string.format("table.clear : %.6f s", clear_time)) print(string.format("vs + GC : %.2fx", reassign_time / clear_time)) print(string.format("vs no GC : %.2fx", reassign_no_gc_time / clear_time)) benchmark result: rounds=1000 count=1000 t = {} + GC : 0.022469 s t = {} : 0.002570 s table.clear : 0.000387 s vs + GC : 58.06x vs no GC : 6.64x `count` is how many items the table has, and `round` is how many rounds we fill the table, clear, and then refill it. `table = {}` is clear the table by resigning a new empty one, because this script does not run persistently like nvim so GC is not triggered, so I added another extreme control group that manually triggers GC. --- runtime/doc/news.txt | 2 ++ runtime/lua/vim/_core/table.lua | 27 +++++++++++++++++++++ runtime/lua/vim/lsp/_folding_range.lua | 33 +++++++++++++------------- runtime/lua/vim/lsp/codelens.lua | 13 +++++----- test/functional/core/main_spec.lua | 1 + 5 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 runtime/lua/vim/_core/table.lua diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 9f78179c61..ef6adb92d2 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -168,6 +168,8 @@ PERFORMANCE functions, which skips the Vimscript <=> Lua "bridge" (no data conversion/marshalling) entirely, if the `vim.fn` function is called from Lua. +• The table holding LSP data is now cleared using `table.clear`, + thus reducing GC and memory reallocation during each data reset. PLUGINS diff --git a/runtime/lua/vim/_core/table.lua b/runtime/lua/vim/_core/table.lua new file mode 100644 index 0000000000..58833b1c8d --- /dev/null +++ b/runtime/lua/vim/_core/table.lua @@ -0,0 +1,27 @@ +-- Basic shim for LuaJIT's table.new and table.clear. +local has_new, new = pcall(require, 'table.new') +local has_clear, clear = pcall(require, 'table.clear') + +local M = {} + +if not has_new then + ---@diagnostic disable-next-line: unused-local + new = function(narr, nrec) + return {} + end +end + +if not has_clear then + clear = function(tab) + ---@diagnostic disable-next-line: no-unknown + for k in pairs(tab) do + ---@diagnostic disable-next-line: no-unknown + tab[k] = nil + end + end +end + +M.new = new +M.clear = clear + +return M diff --git a/runtime/lua/vim/lsp/_folding_range.lua b/runtime/lua/vim/lsp/_folding_range.lua index e7ceee95db..b35fdfb1ea 100644 --- a/runtime/lua/vim/lsp/_folding_range.lua +++ b/runtime/lua/vim/lsp/_folding_range.lua @@ -1,5 +1,6 @@ local util = require('vim.lsp.util') local log = require('vim.lsp.log') +local tableclear = require('vim._core.table').clear local api = vim.api ---@type table @@ -51,12 +52,13 @@ Capability.all[State.name] = State --- Re-evaluate the cached foldinfo in the buffer. function State:evaluate() - ---@type table" | "<"?]?> - local row_level = {} - ---@type table?>> - local row_kinds = {} - ---@type table - local row_text = {} + local row_level, row_kinds, row_text, row_virt_text = + self.row_level, self.row_kinds, self.row_text, self.row_virt_text + + tableclear(row_level) + tableclear(row_kinds) + tableclear(row_text) + tableclear(row_virt_text) for client_id, ranges in pairs(self.client_state) do for _, range in ipairs(ranges) do @@ -88,11 +90,6 @@ function State:evaluate() end end end - - self.row_level = row_level - self.row_kinds = row_kinds - self.row_text = row_text - self.row_virt_text = {} end --- Force `foldexpr()` to be re-evaluated, without opening folds. @@ -190,10 +187,10 @@ end function State:reset() self.lang = vim.treesitter.language.get_lang(vim.bo[self.bufnr].filetype) - self.row_level = {} - self.row_kinds = {} - self.row_text = {} - self.row_virt_text = {} + tableclear(self.row_level) + tableclear(self.row_kinds) + tableclear(self.row_text) + tableclear(self.row_virt_text) end --- Initialize `state` and event hooks, then request folding ranges. @@ -201,7 +198,11 @@ end ---@return vim.lsp.folding_range.State function State:new(bufnr) self = Capability.new(self, bufnr) - self:reset() + self.lang = vim.treesitter.language.get_lang(vim.bo[self.bufnr].filetype) + self.row_level = {} + self.row_kinds = {} + self.row_text = {} + self.row_virt_text = {} api.nvim_buf_attach(bufnr, false, { -- Reset `bufstate` and request folding ranges. diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 07212e6554..1846d0f752 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -1,5 +1,6 @@ local util = require('vim.lsp.util') local log = require('vim.lsp.log') +local tableclear = require('vim._core.table').clear local api = vim.api local M = {} @@ -99,10 +100,10 @@ end function Provider:clear() self:reset_timer() self.version = nil - self.row_version = {} + tableclear(self.row_version) for _, state in pairs(self.client_state) do - state.row_lenses = {} + tableclear(state.row_lenses) api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) end @@ -130,8 +131,8 @@ function Provider:handler(err, result, ctx) return end - ---@type table - local row_lenses = {} + local row_lenses = state.row_lenses + tableclear(row_lenses) -- Code lenses should only span a single line. for _, lens in ipairs(result or {}) do @@ -140,8 +141,6 @@ function Provider:handler(err, result, ctx) table.insert(lenses, lens) row_lenses[row] = lenses end - - state.row_lenses = row_lenses self.version = ctx.version api.nvim__redraw({ buf = self.bufnr, valid = true, flush = false }) @@ -516,7 +515,7 @@ function M.on_refresh(err, _, ctx) if not provider.timer then provider:request(client_id, function() if api.nvim_buf_is_valid(bufnr) then - provider.row_version = {} + tableclear(provider.row_version) vim.api.nvim__redraw({ buf = bufnr, valid = true, flush = false }) end end) diff --git a/test/functional/core/main_spec.lua b/test/functional/core/main_spec.lua index e95fe80343..b800aa5bf8 100644 --- a/test/functional/core/main_spec.lua +++ b/test/functional/core/main_spec.lua @@ -233,6 +233,7 @@ describe('vim._core', function() 'vim._core.shared', 'vim._core.stringbuffer', 'vim._core.system', + 'vim._core.table', 'vim._core.ui2', 'vim._core.util', 'vim._core.vimfn',