From 3734519e3b4ba1bf19ca772104170b0ef776be46 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Tue, 2 Jan 2024 15:47:55 +0000 Subject: [PATCH] feat(lua): add noref to deepcopy Problem: Currently `deepcopy` hashes every single tables it copies so it can be reused. For tables of mostly unique items that are non recursive, this hashing is unnecessarily expensive Solution: Port the `noref` argument from Vimscripts `deepcopy()`. The below benchmark demonstrates the results for two extreme cases of tables of different sizes. One table that uses the same table lots of times and one with all unique tables. | test | `noref=false` (ms) | `noref=true` (ms) | | -------------------- | ------------------ | ----------------- | | unique tables (50) | 6.59 | 2.62 | | shared tables (50) | 3.24 | 6.40 | | unique tables (2000) | 23381.48 | 2884.53 | | shared tables (2000) | 3505.54 | 14038.80 | The results are basically the inverse of each other where `noref` is much more performance on tables with unique fields, and `not noref` is more performant on tables that reuse fields. --- runtime/doc/lua.txt | 13 +++++- runtime/doc/news.txt | 2 + runtime/lua/vim/diagnostic.lua | 12 ++--- runtime/lua/vim/keymap.lua | 2 +- runtime/lua/vim/lsp.lua | 2 +- runtime/lua/vim/lsp/protocol.lua | 2 +- runtime/lua/vim/shared.lua | 76 ++++++++++++++++---------------- runtime/lua/vim/version.lua | 6 +-- test/benchmark/deepcopy_spec.lua | 58 ++++++++++++++++++++++++ 9 files changed, 122 insertions(+), 51 deletions(-) create mode 100644 test/benchmark/deepcopy_spec.lua diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 9c9a9db175..e01e16b8f4 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -1905,15 +1905,24 @@ vim.deep_equal({a}, {b}) *vim.deep_equal()* Return: ~ (boolean) `true` if values are equals, else `false` -vim.deepcopy({orig}) *vim.deepcopy()* +vim.deepcopy({orig}, {noref}) *vim.deepcopy()* Returns a deep copy of the given object. Non-table objects are copied as in a typical Lua assignment, whereas table objects are copied recursively. Functions are naively copied, so functions in the copied table point to the same functions as those in the input table. Userdata and threads are not copied and will throw an error. + Note: `noref=true` is much more performant on tables with unique table + fields, while `noref=false` is more performant on tables that reuse table + fields. + Parameters: ~ - • {orig} (table) Table to copy + • {orig} (table) Table to copy + • {noref} (boolean|nil) When `false` (default) a contained table is + only copied once and all references point to this single + copy. When `true` every occurrence of a table results in a + new copy. This also means that a cyclic reference can cause + `deepcopy()` to fail. Return: ~ (table) Table of copied keys and (nested) values. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 03e1989e62..d3ace5f33b 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -282,6 +282,8 @@ The following new APIs and features were added. |vim.diagnostic.get()| when only the number of diagnostics is needed, but not the diagnostics themselves. +• |vim.deepcopy()| has a `noref` argument to avoid hashing table values. + ============================================================================== CHANGED FEATURES *news-changed* diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua index a447463dff..897837a5ce 100644 --- a/runtime/lua/vim/diagnostic.lua +++ b/runtime/lua/vim/diagnostic.lua @@ -134,7 +134,7 @@ local function prefix_source(diagnostics) return d end - local t = vim.deepcopy(d) + local t = vim.deepcopy(d, true) t.message = string.format('%s: %s', d.source, d.message) return t end, diagnostics) @@ -146,7 +146,7 @@ local function reformat_diagnostics(format, diagnostics) diagnostics = { diagnostics, 't' }, }) - local formatted = vim.deepcopy(diagnostics) + local formatted = vim.deepcopy(diagnostics, true) for _, diagnostic in ipairs(formatted) do diagnostic.message = format(diagnostic) end @@ -373,7 +373,7 @@ local function get_diagnostics(bufnr, opts, clamp) or d.col < 0 or d.end_col < 0 then - d = vim.deepcopy(d) + d = vim.deepcopy(d, true) d.lnum = math.max(math.min(d.lnum, line_count), 0) d.end_lnum = math.max(math.min(d.end_lnum, line_count), 0) d.col = math.max(d.col, 0) @@ -636,7 +636,7 @@ function M.config(opts, namespace) if not opts then -- Return current config - return vim.deepcopy(t) + return vim.deepcopy(t, true) end for k, v in pairs(opts) do @@ -723,7 +723,7 @@ end --- ---@return table A list of active diagnostic namespaces |vim.diagnostic|. function M.get_namespaces() - return vim.deepcopy(all_namespaces) + return vim.deepcopy(all_namespaces, true) end ---@class Diagnostic @@ -756,7 +756,7 @@ function M.get(bufnr, opts) opts = { opts, 't', true }, }) - return vim.deepcopy(get_diagnostics(bufnr, opts, false)) + return vim.deepcopy(get_diagnostics(bufnr, opts, false), true) end --- Get current diagnostics count. diff --git a/runtime/lua/vim/keymap.lua b/runtime/lua/vim/keymap.lua index bdea95f9ab..8e4e123fe0 100644 --- a/runtime/lua/vim/keymap.lua +++ b/runtime/lua/vim/keymap.lua @@ -44,7 +44,7 @@ function keymap.set(mode, lhs, rhs, opts) opts = { opts, 't', true }, }) - opts = vim.deepcopy(opts or {}) + opts = vim.deepcopy(opts or {}, true) ---@cast mode string[] mode = type(mode) == 'string' and { mode } or mode diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 27c5f7ce7b..b2aa943359 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -1353,7 +1353,7 @@ function lsp.start_client(config) ---@param context? {bufnr: integer} ---@param handler? lsp.Handler only called if a server command function client._exec_cmd(command, context, handler) - context = vim.deepcopy(context or {}) --[[@as lsp.HandlerContext]] + context = vim.deepcopy(context or {}, true) --[[@as lsp.HandlerContext]] context.bufnr = context.bufnr or api.nvim_get_current_buf() context.client_id = client.id local cmdname = command.command diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index df12c36396..35eb0305d7 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -314,7 +314,7 @@ local constants = { } for k, v in pairs(constants) do - local tbl = vim.deepcopy(v) + local tbl = vim.deepcopy(v, true) vim.tbl_add_reverse_lookup(tbl) protocol[k] = tbl end diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index bbbc888727..87ab21a28f 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -9,43 +9,36 @@ ---@diagnostic disable-next-line: lowercase-global vim = vim or {} -local function _id(v) - return v -end - -local deepcopy - -local deepcopy_funcs = { - table = function(orig, cache) - if cache[orig] then - return cache[orig] - end - local copy = {} - - cache[orig] = copy - local mt = getmetatable(orig) - for k, v in pairs(orig) do - copy[deepcopy(k, cache)] = deepcopy(v, cache) - end - return setmetatable(copy, mt) - end, - number = _id, - string = _id, - ['nil'] = _id, - boolean = _id, - ['function'] = _id, -} - -deepcopy = function(orig, _cache) - local f = deepcopy_funcs[type(orig)] - if f then - return f(orig, _cache or {}) - else - if type(orig) == 'userdata' and orig == vim.NIL then - return vim.NIL - end +---@generic T +---@param orig T +---@param cache? table +---@return T +local function deepcopy(orig, cache) + if orig == vim.NIL then + return vim.NIL + elseif type(orig) == 'userdata' or type(orig) == 'thread' then error('Cannot deepcopy object of type ' .. type(orig)) + elseif type(orig) ~= 'table' then + return orig end + + --- @cast orig table + + if cache and cache[orig] then + return cache[orig] + end + + local copy = {} --- @type table + + if cache then + cache[orig] = copy + end + + for k, v in pairs(orig) do + copy[deepcopy(k, cache)] = deepcopy(v, cache) + end + + return setmetatable(copy, getmetatable(orig)) end --- Returns a deep copy of the given object. Non-table objects are copied as @@ -54,11 +47,20 @@ end --- same functions as those in the input table. Userdata and threads are not --- copied and will throw an error. --- +--- Note: `noref=true` is much more performant on tables with unique table +--- fields, while `noref=false` is more performant on tables that reuse table +--- fields. +--- ---@generic T: table ---@param orig T Table to copy +---@param noref? boolean +--- When `false` (default) a contained table is only copied once and all +--- references point to this single copy. When `true` every occurrence of a +--- table results in a new copy. This also means that a cyclic reference can +--- cause `deepcopy()` to fail. ---@return T Table of copied keys and (nested) values. -function vim.deepcopy(orig) - return deepcopy(orig) +function vim.deepcopy(orig, noref) + return deepcopy(orig, not noref and {} or nil) end --- Gets an |iterator| that splits a string at each instance of a separator, in "lazy" fashion diff --git a/runtime/lua/vim/version.lua b/runtime/lua/vim/version.lua index 306eef90d3..0873402e29 100644 --- a/runtime/lua/vim/version.lua +++ b/runtime/lua/vim/version.lua @@ -158,7 +158,7 @@ end function M._version(version, strict) -- Adapted from https://github.com/folke/lazy.nvim if type(version) == 'table' then if version.major then - return setmetatable(vim.deepcopy(version), Version) + return setmetatable(vim.deepcopy(version, true), Version) end return setmetatable({ major = version[1] or 0, @@ -228,7 +228,7 @@ function VersionRange:has(version) version = M.parse(version) elseif getmetatable(version) ~= Version then -- Need metatable to compare versions. - version = setmetatable(vim.deepcopy(version), Version) + version = setmetatable(vim.deepcopy(version, true), Version) end if version then if version.prerelease ~= self.from.prerelease then @@ -298,7 +298,7 @@ function M.range(spec) -- Adapted from https://github.com/folke/lazy.nvim local semver = M.parse(version) if semver then local from = semver - local to = vim.deepcopy(semver) + local to = vim.deepcopy(semver, true) if mods == '' or mods == '=' then to.patch = to.patch + 1 elseif mods == '<' then diff --git a/test/benchmark/deepcopy_spec.lua b/test/benchmark/deepcopy_spec.lua new file mode 100644 index 0000000000..dc8655f19c --- /dev/null +++ b/test/benchmark/deepcopy_spec.lua @@ -0,0 +1,58 @@ +local N = 20 + +local function tcall(f, ...) + local ts = vim.uv.hrtime() + for _ = 1, N do + f(...) + end + return ((vim.uv.hrtime() - ts) / 1000000) / N +end + +local function build_shared(n) + local t = {} + local a = {} + local b = {} + local c = {} + for _ = 1, n do + t[#t + 1] = {} + local tl = t[#t] + for _ = 1, n do + tl[#tl + 1] = a + tl[#tl + 1] = b + tl[#tl + 1] = c + end + end + return t +end + +local function build_unique(n) + local t = {} + for _ = 1, n do + t[#t + 1] = {} + local tl = t[#t] + for _ = 1, n do + tl[#tl + 1] = {} + end + end + return t +end + +describe('vim.deepcopy()', function() + local function run(name, n, noref) + it(string.format('%s entries=%d noref=%s', name, n, noref), function() + local t = name == 'shared' and build_shared(n) or build_unique(n) + local d = tcall(vim.deepcopy, t, noref) + print(string.format('%.2f ms', d)) + end) + end + + run('unique', 50, false) + run('unique', 50, true) + run('unique', 2000, false) + run('unique', 2000, true) + + run('shared', 50, false) + run('shared', 50, true) + run('shared', 2000, false) + run('shared', 2000, true) +end)