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.
This commit is contained in:
Lewis Russell 2024-01-02 15:47:55 +00:00 committed by Lewis Russell
parent a064ed6229
commit 3734519e3b
9 changed files with 122 additions and 51 deletions

View File

@ -1905,15 +1905,24 @@ vim.deep_equal({a}, {b}) *vim.deep_equal()*
Return: ~ Return: ~
(boolean) `true` if values are equals, else `false` (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 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. in a typical Lua assignment, whereas table objects are copied recursively.
Functions are naively copied, so functions in the copied table point to 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 the same functions as those in the input table. Userdata and threads are
not copied and will throw an error. 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: ~ 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: ~ Return: ~
(table) Table of copied keys and (nested) values. (table) Table of copied keys and (nested) values.

View File

@ -282,6 +282,8 @@ The following new APIs and features were added.
|vim.diagnostic.get()| when only the number of diagnostics is needed, but |vim.diagnostic.get()| when only the number of diagnostics is needed, but
not the diagnostics themselves. not the diagnostics themselves.
• |vim.deepcopy()| has a `noref` argument to avoid hashing table values.
============================================================================== ==============================================================================
CHANGED FEATURES *news-changed* CHANGED FEATURES *news-changed*

View File

@ -134,7 +134,7 @@ local function prefix_source(diagnostics)
return d return d
end end
local t = vim.deepcopy(d) local t = vim.deepcopy(d, true)
t.message = string.format('%s: %s', d.source, d.message) t.message = string.format('%s: %s', d.source, d.message)
return t return t
end, diagnostics) end, diagnostics)
@ -146,7 +146,7 @@ local function reformat_diagnostics(format, diagnostics)
diagnostics = { diagnostics, 't' }, diagnostics = { diagnostics, 't' },
}) })
local formatted = vim.deepcopy(diagnostics) local formatted = vim.deepcopy(diagnostics, true)
for _, diagnostic in ipairs(formatted) do for _, diagnostic in ipairs(formatted) do
diagnostic.message = format(diagnostic) diagnostic.message = format(diagnostic)
end end
@ -373,7 +373,7 @@ local function get_diagnostics(bufnr, opts, clamp)
or d.col < 0 or d.col < 0
or d.end_col < 0 or d.end_col < 0
then then
d = vim.deepcopy(d) d = vim.deepcopy(d, true)
d.lnum = math.max(math.min(d.lnum, line_count), 0) 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.end_lnum = math.max(math.min(d.end_lnum, line_count), 0)
d.col = math.max(d.col, 0) d.col = math.max(d.col, 0)
@ -636,7 +636,7 @@ function M.config(opts, namespace)
if not opts then if not opts then
-- Return current config -- Return current config
return vim.deepcopy(t) return vim.deepcopy(t, true)
end end
for k, v in pairs(opts) do for k, v in pairs(opts) do
@ -723,7 +723,7 @@ end
--- ---
---@return table A list of active diagnostic namespaces |vim.diagnostic|. ---@return table A list of active diagnostic namespaces |vim.diagnostic|.
function M.get_namespaces() function M.get_namespaces()
return vim.deepcopy(all_namespaces) return vim.deepcopy(all_namespaces, true)
end end
---@class Diagnostic ---@class Diagnostic
@ -756,7 +756,7 @@ function M.get(bufnr, opts)
opts = { opts, 't', true }, opts = { opts, 't', true },
}) })
return vim.deepcopy(get_diagnostics(bufnr, opts, false)) return vim.deepcopy(get_diagnostics(bufnr, opts, false), true)
end end
--- Get current diagnostics count. --- Get current diagnostics count.

View File

@ -44,7 +44,7 @@ function keymap.set(mode, lhs, rhs, opts)
opts = { opts, 't', true }, opts = { opts, 't', true },
}) })
opts = vim.deepcopy(opts or {}) opts = vim.deepcopy(opts or {}, true)
---@cast mode string[] ---@cast mode string[]
mode = type(mode) == 'string' and { mode } or mode mode = type(mode) == 'string' and { mode } or mode

View File

@ -1353,7 +1353,7 @@ function lsp.start_client(config)
---@param context? {bufnr: integer} ---@param context? {bufnr: integer}
---@param handler? lsp.Handler only called if a server command ---@param handler? lsp.Handler only called if a server command
function client._exec_cmd(command, context, handler) 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.bufnr = context.bufnr or api.nvim_get_current_buf()
context.client_id = client.id context.client_id = client.id
local cmdname = command.command local cmdname = command.command

View File

@ -314,7 +314,7 @@ local constants = {
} }
for k, v in pairs(constants) do for k, v in pairs(constants) do
local tbl = vim.deepcopy(v) local tbl = vim.deepcopy(v, true)
vim.tbl_add_reverse_lookup(tbl) vim.tbl_add_reverse_lookup(tbl)
protocol[k] = tbl protocol[k] = tbl
end end

View File

@ -9,43 +9,36 @@
---@diagnostic disable-next-line: lowercase-global ---@diagnostic disable-next-line: lowercase-global
vim = vim or {} vim = vim or {}
local function _id(v) ---@generic T
return v ---@param orig T
end ---@param cache? table<any,any>
---@return T
local deepcopy local function deepcopy(orig, cache)
if orig == vim.NIL then
local deepcopy_funcs = { return vim.NIL
table = function(orig, cache) elseif type(orig) == 'userdata' or type(orig) == 'thread' then
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
error('Cannot deepcopy object of type ' .. type(orig)) error('Cannot deepcopy object of type ' .. type(orig))
elseif type(orig) ~= 'table' then
return orig
end end
--- @cast orig table<any,any>
if cache and cache[orig] then
return cache[orig]
end
local copy = {} --- @type table<any,any>
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 end
--- Returns a deep copy of the given object. Non-table objects are copied as --- 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 --- same functions as those in the input table. Userdata and threads are not
--- copied and will throw an error. --- 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 ---@generic T: table
---@param orig T Table to copy ---@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. ---@return T Table of copied keys and (nested) values.
function vim.deepcopy(orig) function vim.deepcopy(orig, noref)
return deepcopy(orig) return deepcopy(orig, not noref and {} or nil)
end end
--- Gets an |iterator| that splits a string at each instance of a separator, in "lazy" fashion --- Gets an |iterator| that splits a string at each instance of a separator, in "lazy" fashion

View File

@ -158,7 +158,7 @@ end
function M._version(version, strict) -- Adapted from https://github.com/folke/lazy.nvim function M._version(version, strict) -- Adapted from https://github.com/folke/lazy.nvim
if type(version) == 'table' then if type(version) == 'table' then
if version.major then if version.major then
return setmetatable(vim.deepcopy(version), Version) return setmetatable(vim.deepcopy(version, true), Version)
end end
return setmetatable({ return setmetatable({
major = version[1] or 0, major = version[1] or 0,
@ -228,7 +228,7 @@ function VersionRange:has(version)
version = M.parse(version) version = M.parse(version)
elseif getmetatable(version) ~= Version then elseif getmetatable(version) ~= Version then
-- Need metatable to compare versions. -- Need metatable to compare versions.
version = setmetatable(vim.deepcopy(version), Version) version = setmetatable(vim.deepcopy(version, true), Version)
end end
if version then if version then
if version.prerelease ~= self.from.prerelease 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) local semver = M.parse(version)
if semver then if semver then
local from = semver local from = semver
local to = vim.deepcopy(semver) local to = vim.deepcopy(semver, true)
if mods == '' or mods == '=' then if mods == '' or mods == '=' then
to.patch = to.patch + 1 to.patch = to.patch + 1
elseif mods == '<' then elseif mods == '<' then

View File

@ -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)