refactor: new cache

This commit is contained in:
Folke Lemaitre 2023-02-13 10:50:39 +01:00
parent e115f5ec17
commit 983e1c5e34
No known key found for this signature in database
GPG Key ID: 41F8B1FBACAE2040
6 changed files with 237 additions and 177 deletions

View File

@ -6,46 +6,29 @@ local M = {}
---@alias CacheHash {mtime: {sec:number, nsec:number}, size:number} ---@alias CacheHash {mtime: {sec:number, nsec:number}, size:number}
---@alias CacheEntry {hash:CacheHash, modpath:string, chunk:string} ---@alias CacheEntry {hash:CacheHash, modpath:string, chunk:string}
---@class CacheFindOpts
---@field rtp? boolean Search for modname in the runtime path (defaults to `true`)
---@field patterns? string[] Paterns to use (defaults to `{"/init.lua", ".lua"}`)
---@field paths? string[] Extra paths to search for modname
M.VERSION = 1 M.VERSION = 1
M.path = vim.fn.stdpath("cache") .. "/lazy/luac"
M.enabled = false
M.stats = { total = 0, time = 0, index = 0, stat = 0, not_found = 0 }
M.config = { ---@class ModuleCache
enabled = false, ---@field _rtp string[]
path = vim.fn.stdpath("cache") .. "/lazy/luac", ---@field _rtp_key string
local Cache = {
---@type table<string, table<string,true>>
_topmods = {},
_loadfile = loadfile,
} }
M._loadfile = loadfile
M.stats = {
find = { total = 0, time = 0, index = 0, stat = 0, not_found = 0 },
}
---@type string
M._rtp_key = nil
---@type string[]
M._rtp = nil
---@type table<string, table<string,true>>
M._topmods = {}
function M.get_rtp()
if vim.in_fast_event() then
return M._rtp or {}
end
local key = vim.go.rtp
if vim.go.rtp ~= M._rtp_key then
M._rtp = {}
for _, path in ipairs(vim.api.nvim_get_runtime_file("", true)) do
path = M.normalize(path)
-- skip after directories
if path:sub(-6, -1) ~= "/after" then
M._rtp[#M._rtp + 1] = path
end
end
M._rtp_key = key
end
return M._rtp
end
-- slightly faster/different version than vim.fs.normalize -- slightly faster/different version than vim.fs.normalize
-- we also need to have it here, since the cache will load vim.fs -- we also need to have it here, since the cache will load vim.fs
function M.normalize(path) ---@private
function Cache.normalize(path)
if path:sub(1, 1) == "~" then if path:sub(1, 1) == "~" then
local home = vim.loop.os_homedir() local home = vim.loop.os_homedir()
if home:sub(-1) == "\\" or home:sub(-1) == "/" then if home:sub(-1) == "\\" or home:sub(-1) == "/" then
@ -57,97 +40,36 @@ function M.normalize(path)
return path:sub(-1) == "/" and path:sub(1, -2) or path return path:sub(-1) == "/" and path:sub(1, -2) or path
end end
function M.reset(path) ---@private
M._topmods[M.normalize(path)] = nil function Cache.get_rtp()
end if vim.in_fast_event() then
return Cache._rtp or {}
-- index the top-level lua modules for this path
---@return string[]
function M.get_topmods(path)
if not M._topmods[path] then
M.stats.find.index = M.stats.find.index + 1
M._topmods[path] = {}
local handle = vim.loop.fs_scandir(path .. "/lua")
while handle do
local name, t = vim.loop.fs_scandir_next(handle)
if not name then
break
end end
---@cast name string local key = vim.go.rtp
---@cast t "file"|"directory"|"link" if key ~= Cache._rtp_key then
-- HACK: type is not always returned due to a bug in luv Cache._rtp = {}
t = t or vim.loop.fs_stat(path .. "/" .. name).type for _, path in ipairs(vim.api.nvim_get_runtime_file("", true)) do
---@type string path = Cache.normalize(path)
local topname -- skip after directories
if name:sub(-4) == ".lua" then if path:sub(-6, -1) ~= "/after" then
topname = name:sub(1, -5) Cache._rtp[#Cache._rtp + 1] = path
elseif t == "link" or t == "directory" then
topname = name
end
if topname then
M._topmods[path][topname] = true
end end
end end
Cache._rtp_key = key
end end
return M._topmods[path] return Cache._rtp
end
---@param modname string
---@param opts? {rtp:string[], patterns:string[]}
---@return string?, CacheHash?
function M.find(modname, opts)
opts = opts or {}
local start = uv.hrtime()
M.stats.find.total = M.stats.find.total + 1
modname = modname:gsub("/", ".")
local basename = modname:gsub("%.", "/")
local idx = modname:find(".", 1, true)
local topmod = idx and modname:sub(1, idx - 1) or modname
-- OPTIM: search for a directory first when topmod == modname
local patterns = opts.patterns or (topmod == modname and { "/init.lua", ".lua" } or { ".lua", "/init.lua" })
local rtp = opts.rtp or M.get_rtp()
for _, path in ipairs(rtp) do
if M.get_topmods(path)[topmod] then
for _, pattern in ipairs(patterns) do
local modpath = path .. "/lua/" .. basename .. pattern
M.stats.find.stat = M.stats.find.stat + 1
local hash = uv.fs_stat(modpath)
if hash then
M.stats.find.time = M.stats.find.time + uv.hrtime() - start
return modpath, hash
end
end
end
end
-- module not found
M.stats.find.not_found = M.stats.find.not_found + 1
M.stats.find.time = M.stats.find.time + uv.hrtime() - start
end
function M.setup()
M.config.enabled = true
vim.fn.mkdir(vim.fn.fnamemodify(M.config.path, ":p"), "p")
_G.loadfile = M.loadfile
table.insert(package.loaders, 2, M.loader)
end end
---@param name string can be a module name, or a file name ---@param name string can be a module name, or a file name
function M.cache_file(name) ---@private
return M.config.path .. "/" .. name:gsub("[/\\]", "%%") .. ".luac" function Cache.cache_file(name)
end return M.path .. "/" .. name:gsub("[/\\]", "%%") .. ".luac"
---@param h1 CacheHash
---@param h2 CacheHash
function M.eq(h1, h2)
return h1 and h2 and h1.size == h2.size and h1.mtime.sec == h2.mtime.sec and h1.mtime.nsec == h2.mtime.nsec
end end
---@param entry CacheEntry ---@param entry CacheEntry
function M.write(name, entry) ---@private
local cname = M.cache_file(name) function Cache.write(name, entry)
local cname = Cache.cache_file(name)
local f = assert(uv.fs_open(cname, "w", 438)) local f = assert(uv.fs_open(cname, "w", 438))
local header = { local header = {
M.VERSION, M.VERSION,
@ -163,8 +85,9 @@ function M.write(name, entry)
end end
---@return CacheEntry? ---@return CacheEntry?
function M.read(name) ---@private
local cname = M.cache_file(name) function Cache.read(name)
local cname = Cache.cache_file(name)
local f = uv.fs_open(cname, "r", 438) local f = uv.fs_open(cname, "r", 438)
if f then if f then
local hash = uv.fs_fstat(f) --[[@as CacheHash]] local hash = uv.fs_fstat(f) --[[@as CacheHash]]
@ -186,58 +109,49 @@ function M.read(name)
end end
---@param modname string ---@param modname string
function M.loader(modname) ---@private
function Cache.loader(modname)
modname = modname:gsub("/", ".") modname = modname:gsub("/", ".")
local modpath, hash = Cache.find(modname)
local modpath, hash = M.find(modname)
---@type function?, string?
local chunk, err
if modpath then if modpath then
chunk, err = M._load(modname, modpath, { hash = hash }) return Cache.load(modpath, { hash = hash })
end end
return chunk or err or ("module " .. modname .. " not found") return "module " .. modname .. " not found"
end end
---@param modpath string ---@param filename? string
---@return any, string? ---@param mode? "b"|"t"|"bt"
function M.loadfile(modpath) ---@param env? table
modpath = M.normalize(modpath)
return M._load(modpath, modpath)
end
function M.check_loaded(modname)
---@diagnostic disable-next-line: no-unknown
local mod = package.loaded[modname]
if type(mod) == "table" then
return function()
return mod
end
end
end
---@param modkey string
---@param modpath string
---@param opts? {hash?: CacheHash, entry?:CacheEntry}
---@return function?, string? error_message ---@return function?, string? error_message
function M._load(modkey, modpath, opts) ---@private
function Cache.loadfile(filename, mode, env)
filename = Cache.normalize(filename)
return Cache.load(filename, { mode = mode, env = env })
end
---@param h1 CacheHash
---@param h2 CacheHash
---@private
function Cache.eq(h1, h2)
return h1 and h2 and h1.size == h2.size and h1.mtime.sec == h2.mtime.sec and h1.mtime.nsec == h2.mtime.nsec
end
---@param modpath string
---@param opts? {hash?: CacheHash, mode?: "b"|"t"|"bt", env?:table}
---@return function?, string? error_message
---@private
function Cache.load(modpath, opts)
opts = opts or {} opts = opts or {}
if not M.config.enabled then
return M._loadfile(modpath)
end
---@type function?, string?
local chunk, err
chunk = M.check_loaded(modkey)
if chunk then
return chunk
end
local hash = opts.hash or uv.fs_stat(modpath) local hash = opts.hash or uv.fs_stat(modpath)
if not hash then if not hash then
-- trigger correct error -- trigger correct error
return M._loadfile(modpath) return Cache._loadfile(modpath)
end end
local entry = opts.entry or M.read(modkey) ---@type function?, string?
if entry and M.eq(entry.hash, hash) then local chunk, err
local entry = Cache.read(modpath)
if entry and Cache.eq(entry.hash, hash) then
-- found in cache and up to date -- found in cache and up to date
chunk, err = loadstring(entry.chunk --[[@as string]], "@" .. entry.modpath) chunk, err = loadstring(entry.chunk --[[@as string]], "@" .. entry.modpath)
if not (err and err:find("cannot load incompatible bytecode", 1, true)) then if not (err and err:find("cannot load incompatible bytecode", 1, true)) then
@ -246,12 +160,147 @@ function M._load(modkey, modpath, opts)
end end
entry = { hash = hash, modpath = modpath } entry = { hash = hash, modpath = modpath }
chunk, err = M._loadfile(entry.modpath) chunk, err = Cache._loadfile(entry.modpath)
if chunk then if chunk then
entry.chunk = string.dump(chunk) entry.chunk = string.dump(chunk)
M.write(modkey, entry) Cache.write(modpath, entry)
end end
return chunk, err return chunk, err
end end
---@param modname string
---@param opts? CacheFindOpts
---@return string? modpath, CacheHash? hash
function Cache.find(modname, opts)
opts = opts or {}
local start = uv.hrtime()
M.stats.total = M.stats.total + 1
modname = modname:gsub("/", ".")
local basename = modname:gsub("%.", "/")
local idx = modname:find(".", 1, true)
local topmod = idx and modname:sub(1, idx - 1) or modname
-- OPTIM: search for a directory first when topmod == modname
local patterns = opts.patterns or (topmod == modname and { "/init.lua", ".lua" } or { ".lua", "/init.lua" })
local rtp = opts.rtp ~= false and Cache.get_rtp() or {}
if opts.paths then
rtp = vim.deepcopy(rtp)
for _, dir in ipairs(opts.paths) do
rtp[#rtp + 1] = Cache.normalize(dir)
end
end
for p, pattern in ipairs(patterns) do
patterns[p] = "/lua/" .. basename .. pattern
end
for _, path in ipairs(rtp) do
if M.lsmod(path)[topmod] then
for _, pattern in ipairs(patterns) do
local modpath = path .. pattern
M.stats.stat = M.stats.stat + 1
local hash = uv.fs_stat(modpath)
if hash then
M.stats.time = M.stats.time + uv.hrtime() - start
return modpath, hash
end
end
end
end
-- module not found
M.stats.not_found = M.stats.not_found + 1
M.stats.time = M.stats.time + uv.hrtime() - start
end
--- Resets the topmods cache for the path
---@param path string
function M.reset(path)
Cache._topmods[Cache.normalize(path)] = nil
end
function M.enable()
if M.enabled then
return
end
M.enabled = true
vim.fn.mkdir(vim.fn.fnamemodify(M.path, ":p"), "p")
-- selene: allow(global_usage)
_G.loadfile = Cache.loadfile
table.insert(package.loaders, 2, Cache.loader)
end
function M.disable()
if not M.enabled then
return
end
M.enabled = false
-- selene: allow(global_usage)
_G.loadfile = Cache._loadfile
---@diagnostic disable-next-line: no-unknown
for l, loader in ipairs(package.loaders) do
if loader == Cache.loader then
table.remove(package.loaders, l)
end
end
end
-- Return the top-level `/lua/*` modules for this path
---@return string[]
function M.lsmod(path)
if not Cache._topmods[path] then
M.stats.index = M.stats.index + 1
Cache._topmods[path] = {}
local handle = vim.loop.fs_scandir(path .. "/lua")
while handle do
local name, t = vim.loop.fs_scandir_next(handle)
if not name then
break
end
-- HACK: type is not always returned due to a bug in luv
t = t or vim.loop.fs_stat(path .. "/" .. name).type
---@type string
local topname
if name:sub(-4) == ".lua" then
topname = name:sub(1, -5)
elseif t == "link" or t == "directory" then
topname = name
end
if topname then
Cache._topmods[path][topname] = true
end
end
end
return Cache._topmods[path]
end
---@param modname string
---@param opts? CacheFindOpts
---@return string? modpath
function M.find(modname, opts)
local modpath = Cache.find(modname, opts)
return modpath
end
function M.inspect()
local function ms(nsec)
return math.floor(nsec / 1e6 * 1000 + 0.5) / 1000 .. "ms"
end
local props = {
{ "total", M.stats.total, "Number" },
{ "time", ms(M.stats.time), "Bold" },
{ "avg time", ms(M.stats.time / M.stats.total), "Bold" },
{ "index", M.stats.index, "Number" },
{ "fs_stat", M.stats.stat, "Number" },
{ "not found", M.stats.not_found, "Number" },
}
local chunks = {} ---@type string[][]
for _, prop in ipairs(props) do
chunks[#chunks + 1] = { "* " .. prop[1] .. ": " }
chunks[#chunks + 1] = { tostring(prop[2]) .. "\n", prop[3] }
end
vim.api.nvim_echo(chunks, true, {})
end
M._Cache = Cache
return M return M

View File

@ -341,7 +341,7 @@ function M.get_main(plugin)
local normname = Util.normname(plugin.name) local normname = Util.normname(plugin.name)
---@type string[] ---@type string[]
local mods = {} local mods = {}
for modname, _ in pairs(Cache.get_topmods(plugin.dir)) do for modname, _ in pairs(Cache.lsmod(plugin.dir)) do
mods[#mods + 1] = modname mods[#mods + 1] = modname
local modnorm = Util.normname(modname) local modnorm = Util.normname(modname)
-- if we found an exact match, then use that -- if we found an exact match, then use that
@ -452,7 +452,7 @@ end
---@param modname string ---@param modname string
function M.loader(modname) function M.loader(modname)
local modpath = Cache.find(modname, { rtp = Util.get_unloaded_rtp(modname) }) local modpath = Cache.find(modname, { rtp = false, paths = Util.get_unloaded_rtp(modname) })
if modpath then if modpath then
local plugin = Plugin.find(modpath) local plugin = Plugin.find(modpath)
if plugin and modpath:find(plugin.dir, 1, true) == 1 then if plugin and modpath:find(plugin.dir, 1, true) == 1 then
@ -463,7 +463,13 @@ function M.loader(modname)
end end
M.load(plugin, { require = modname }) M.load(plugin, { require = modname })
end end
return Cache._load(modname, modpath) local mod = package.loaded[modname]
if type(mod) == "table" then
return function()
return mod
end
end
return loadfile(modpath)
end end
end end
end end

View File

@ -242,9 +242,11 @@ end
function M.find_root(modname) function M.find_root(modname)
local Cache = require("lazy.core.cache") local Cache = require("lazy.core.cache")
local rtp = vim.deepcopy(Cache.get_rtp()) local modpath = Cache.find(modname, {
vim.list_extend(rtp, M.get_unloaded_rtp(modname)) rtp = true,
local modpath = Cache.find(modname, { rtp = rtp, patterns = { "", ".lua" } }) paths = M.get_unloaded_rtp(modname),
patterns = { "", ".lua" },
})
if modpath then if modpath then
local root = modpath:gsub("/init%.lua$", ""):gsub("%.lua$", "") local root = modpath:gsub("/init%.lua$", ""):gsub("%.lua$", "")
return root return root

View File

@ -34,7 +34,7 @@ function M.setup(spec, opts)
local start = vim.loop.hrtime() local start = vim.loop.hrtime()
-- load module cache before anything else -- load module cache before anything else
require("lazy.core.cache").setup() require("lazy.core.cache").enable()
require("lazy.stats").track("LazyStart") require("lazy.stats").track("LazyStart")

View File

@ -1,4 +1,3 @@
local Cache = require("lazy.core.cache")
local Config = require("lazy.core.config") local Config = require("lazy.core.config")
local Util = require("lazy.util") local Util = require("lazy.util")
local Plugin = require("lazy.core.plugin") local Plugin = require("lazy.core.plugin")
@ -6,12 +5,11 @@ local Loader = require("lazy.core.loader")
local M = {} local M = {}
---@type table<string, CacheHash> ---@type table<string, vim.loop.Stat>
M.files = {} M.files = {}
---@type vim.loop.Timer ---@type vim.loop.Timer
M.timer = nil M.timer = nil
M.root = nil
function M.enable() function M.enable()
if M.timer then if M.timer then
@ -19,7 +17,6 @@ function M.enable()
end end
if #Config.spec.modules > 0 then if #Config.spec.modules > 0 then
M.timer = vim.loop.new_timer() M.timer = vim.loop.new_timer()
M.root = vim.fn.stdpath("config") .. "/lua"
M.check(true) M.check(true)
M.timer:start(2000, 2000, M.check) M.timer:start(2000, 2000, M.check)
end end
@ -32,6 +29,12 @@ function M.disable()
end end
end end
---@param h1 vim.loop.Stat
---@param h2 vim.loop.Stat
function M.eq(h1, h2)
return h1 and h2 and h1.size == h2.size and h1.mtime.sec == h2.mtime.sec and h1.mtime.nsec == h2.mtime.nsec
end
function M.check(start) function M.check(start)
---@type table<string,true> ---@type table<string,true>
local checked = {} local checked = {}
@ -44,7 +47,7 @@ function M.check(start)
local hash = vim.loop.fs_stat(modpath) local hash = vim.loop.fs_stat(modpath)
if hash then if hash then
if M.files[modpath] then if M.files[modpath] then
if not Cache.eq(M.files[modpath], hash) then if not M.eq(M.files[modpath], hash) then
M.files[modpath] = hash M.files[modpath] = hash
table.insert(changes, { file = modpath, what = "changed" }) table.insert(changes, { file = modpath, what = "changed" })
end end

View File

@ -672,12 +672,12 @@ function M:debug()
self:append("Cache.find()", "LazyH2"):nl() self:append("Cache.find()", "LazyH2"):nl()
self:props({ self:props({
{ "total", Cache.stats.find.total, "Number" }, { "total", Cache.stats.total, "Number" },
{ "time", self:ms(Cache.stats.find.time, 3), "Bold" }, { "time", self:ms(Cache.stats.time, 3), "Bold" },
{ "avg time", self:ms(Cache.stats.find.time / Cache.stats.find.total, 3), "Bold" }, { "avg time", self:ms(Cache.stats.time / Cache.stats.total, 3), "Bold" },
{ "index", Cache.stats.find.index, "Number" }, { "index", Cache.stats.index, "Number" },
{ "fs_stat", Cache.stats.find.stat, "Number" }, { "fs_stat", Cache.stats.stat, "Number" },
{ "not found", Cache.stats.find.not_found, "Number" }, { "not found", Cache.stats.not_found, "Number" },
}, { indent = 2 }) }, { indent = 2 })
self:nl() self:nl()
end end