Repository navigation
perf: speed up startup with many plugins - #50
Merged
Merged
Conversation
Add eager plugins to 'runtimepath' in one write instead of one :packadd! per plugin. add_packs_to_rtp mirrors add_pack_dir_to_rtp() in runtime.c and leaves layouts it doesn't cover to :packadd!. A differential test checks the result against real :packadd!. Load plugins sharing a filetype together and re-fire FileType once, matching lazy.nvim. Each re-fire repeats the ftplugin, indent and syntax :runtime! scans over all of 'runtimepath', so one per plugin scales with both. A plugin with event = 'FileType' keeps its priority slot by splitting the groups its pattern can match. An ft with no usable filetypes, or one nvim_create_autocmd rejects, now warns instead of aborting setup().
zuqini
force-pushed
the
zpack-startup-perf
branch
from
October 31, 2026 07:19
a99b4b3 to
af7de67
Compare
zuqini
added a commit
that referenced
this pull request
Oct 31, 2026
From the fifth review of this branch: - A load that leaves sourcing to native startup only does so before v:vim_did_init, so a require or build after startup sources plugin/. A plugin's command proxies go before it loads, as in lazy.nvim. - A dependency that throws no longer stops its parent. - after/plugin waits for native startup until VimEnter, where it was sourced twice. - A lazy dependency sources before its eager parent when setup() runs after startup. - A tag vim.pack doesn't read as semver no longer counts as fitting a range, so `:ZPack update` doesn't raise over it. - Version priority is lazy.nvim's: commit, tag, version, sem_version, branch, default. `version = false` keeps a commit, tag or branch. - An import ahead of its plugin's spec finds the module in every session, and a same-module import with an owner looks in the owner. - A plugin removed by an install session's trial resolve is looked at again once another plugin's specs are known. - An owner installed under a name from another of its specs is found without a trial resolve. - A throwing `enabled` is reported even if a trial resolve ran it. Also finishes the merge of #50: the pending-build call lost its argument and the context its `load` field, and an empty pattern list is no trigger since this branch, so its ft test needs a real event beside it.
zuqini
added a commit
that referenced
this pull request
Nov 1, 2026
Started as the second round of startup work after #50 and #51. Review of it found bugs on main that delete plugins, so this now changes behaviour. `docs/tips.md` "Upgrading to 3.0" lists the changes that can affect a working config, and the override below lists every user-visible one. **Startup:** zpack's startup loops run a few hundred times and never again, so LuaJIT spends longer compiling them than they would take to interpret: about 8.4 ms of a 35 ms sampled startup with 150 plugins, 3.3 ms after this. Each module that runs at startup opts its own functions out with `jit.off(true, true)`. User configs and plugins keep the JIT, and it is guarded for non-LuaJIT builds. CPU time in ms, 150 plugins, median of 300 rounds, paired d-cpu with a bootstrap 95% CI, measured under load 7-9 before the last few setup() fixes: | Scenario | lazy.nvim | before review | this | |---|---|---|---| | realistic | 148.5 | 143.5 | 142.4 | | open a file | 205.8 | 189.9 | 190.2 | | all eager | 432.7 | 255.2 | 250.1 | Machine load inflates these numbers. The paired delta, this minus lazy.nvim, is -7.1 [-10.2, -3.7], -16.7 [-20.8, -10.9] and -179 [-187, -172] ms. Against the state before review there is no regression, within A/A noise. **Lost specs:** a failed `vim.pack.add` emptied the registry, and `:ZPack clean` and `:ZPack sync` then deleted every installed plugin without asking. A spec module, import or list entry zpack can't read did the same to what it declares. These now refuse, with a warning at startup and in `:checkhealth`. A bulk `:ZPack update` still runs, with its preview, and says so. An import under a disabled plugin doesn't count, since what it declares goes with the plugin. An `import` whose module is gitignored on this machine needs `enabled`, as `docs/tips.md` shows (zpack_nvim-n5w tracks whether this should become a prompt). **Imports:** a plugin listed without an `import` of its own isn't searched, as in 2.x and unlike lazy.nvim (decision zpack_nvim-587). `{ { 'u/dist' }, { import = 'dist.plugins.extra' } }` now warns and refuses clean, and the warning names the fix: `{ 'u/dist', import = 'dist.plugins.extra' }`, which installs the plugin if needed and imports on the first start. A bare string in a spec list installs as a plugin, where it was reported as invalid and skipped. A `nil` in a spec list no 99A2 longer ends the list: LazyVim's AI extras put `vim.g.ai_cmp and { ... } or nil` mid-list. lazy.nvim stops at it (decision zpack_nvim-77y). A function-form `cond` at an import gets no plugin, as in lazy.nvim, in place of the unreleased stand-in from #51, so one that reads its argument throws and `:ZPack clean` refuses. **Versions:** one `version = true` or `tag = 3` no longer fails every plugin in the `vim.pack.add` call. A soft version that follows the default branch says when a restart is needed to reach a tag, and a plugin restored from the lockfile says when it needs a restart to update. **Setup:** `setup()`'s options no longer count as spec fields. `{ 'u/a', 'u/b', defaults = ... }`, as documented, installed only `u/a` and warned, and an `{ import = ... }` beside the string was skipped; now it's a list. `{ 'u/a', config = fn }` stays one spec. A string is the module to import, in `setup('custom.plugins')` and `{ spec = 'custom.plugins' }`, where it reported an error and `:ZPack clean` deleted that module's plugins. A single spec with no array part, `{ import = 'custom.plugins', defaults = ... }` or `{ url = ..., name = ... }`, is now read as a spec, where it silently fell back to the default import with the same result. **Loading:** an error in a `plugin/` script is reported and the load carries on to `config`. An eager plugin a lazy dependent loads runs `config` once. A function-form trigger that throws leaves its plugin lazy, and one that loaded the plugin doesn't get a proxy over its own key or command. `require` finds the plugin holding a module in a shared namespace. **Structure:** `merge.lua`, `utils.lua` and `import.lua` are split by concern as pure moves, and state only one module reads moves there. `lua/` is 7 lines longer than main, with the fixes above included. The unreachable profiler is gone: `setup({ profiling = ... })` is ignored without a word, and `module_loader`'s `print_profile` and `get_profile` are removed. **Checked against real configs:** LazyVim's spec files (core, all 115 extras, 40 seeded subsets) and my own config, between the state before review and this one, shortly before the last one-line fix: no `lazy=` flips and no `P` or `E` line changes, only the missing-import `W` lines gaining the fix and five DEBUG notes on all-extras going away. Against lazy.nvim's resolver, the plugin set and edges differ only by the harness's `<expr>` placeholders. The suite passes on nightly and v0.12.0. BEGIN_COMMIT_OVERRIDE perf: stop tracing zpack's startup code fix(commands)!: `:ZPack clean` and `:ZPack sync` refuse when a spec can't be read or a plugin failed to add, rather than delete what they can't see fix(import)!: a `nil` in a spec list no longer ends the list, so what follows it installs fix(lazy)!: a plugin whose function-form trigger throws stays lazy instead of loading at startup fix(setup): `setup()`'s options no longer count as spec fields, so specs written beside them all install fix(import): a bare string in a spec list installs as a plugin, rather than being reported as invalid and skipped fix(import): the warning for an `import` whose module isn't found names the fix, the `import` on the plugin's own spec refactor(module_loader)!: remove the unreachable profiler; `setup({ profiling = ... })` is ignored, and `print_profile` and `get_profile` are gone fix(commands): `:ZPack clean` leaves a plugin active this session for next time rather than raising fix(utils): a `version` or `tag` of the wrong type is dropped with a warning instead of failing every plugin fix(validate): warn about a `sem_version` that isn't a semver range, and fall through to `branch` and `defaults.version` fix(pack): a soft version installs from the default branch after another plugin's clone failed fix(pack): restore a locked plugin at its revision when no tag fits its soft version fix(pack): say when a plugin needs a restart to reach a tag or to update fix(pack): read a tag as `vim.pack` does when deciding if one fits fix(loader): report a `plugin/` script's error without failing the load fix(startup): run an eager plugin's `config` once when a lazy dependent loads it fix(lazy): no proxy over what a function-form trigger already loaded fix(merge): removals resting on other removals no longer depend on import order fix(module_loader): load the plugin holding a module in a shared namespace, including a compiled one fix(cmd): keep a numeric first argument on a lazy command's first use fix(import): take a plugin's `lua/` off `package.path` after startup fix(setup): a string is the module to import, in `setup('custom.plugins')` and in `spec`, rather than reported and replaced by the default import fix(setup): a single spec with no array part, such as `{ import = ..., defaults = ... }`, is read as a spec rather than ignored for the default import fix(setup): any other `setup()` argument that isn't a table is reported, and the default import goes on fix(health): warn when a spec was lost or a plugin failed to add docs(tips): add Upgrading to 3.0 END_COMMIT_OVERRIDE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Benchmarked against lazy.nvim with 150 plugins, separate process per sample. CPU ms, median of 60:
.rsfileruntimepathin one write instead of one:packadd!per plugin. Mirrorsadd_pack_dir_to_rtp()and falls back to:packadd!for layouts it doesn't cover, including relativeruntimepathentries. A differential test checks the result against real:packadd!.ft=plugins: plugins sharing a filetype load together and re-fireFileTypeonce, matching lazy.nvim. Previously each plugin re-fired it, repeating theftplugin,indentandsyntaxscans over all ofruntimepath.The LazyVim differential shows no structural or opts changes against
main.Expected behaviour changes
Plugins sharing a filetype now load together and re-fire
FileTypeonce, after the whole group, matching lazy.nvim. Servers attached the same on v0.12.0 and nightly withvim.lsp.enable, legacy lspconfig setup and Mason-stylePATHsetup.Filetype groups (only when two or more lazy plugins share a filetype):
config: a plugin'sconfigno longer sees effects offtplugins orBufReadPre/BufReadPosthandlers from earlier plugins on that filetype, even withdependenciesdeclared. The buffer ends up the same once the re-fire runs, and lazy.nvim behaves the same way. This fails silently. It only worked before when priorities put the other plugin first, and only for the buffer that triggered the load. Read the value from aFileTypehandler or the plugin's ownftplugininstead.ftplugins: withb:did_ftpluginguards and nob:undo_ftplugin, the lowest-priority plugin now wins instead of the highest, which also decides the server if thoseftplugins start one. Withb:undo_ftpluginthe winner is unchanged, and the earlier plugin'sftpluginno longer runs only to be undone.configloads another buffer of that filetype,FileTypehandlers of plugins still pending in the group miss that buffer, so a server started from such a handler doesn't attach there. Declaring the pending plugin as a dependency fixes this.vim.lsp.enableisn't affected.configchanges an unnamed buffer before the re-fire, earlier plugins'FileTypehandlers can skip it. Named files aren't affected.ftpatterns: priority doesn't order different patterns, such as a literal and a glob. With high (ft='rust'), mid (ft='*') and low (ft='rust'), low now loads before mid. A plugin withevent='FileType'between them keeps its slot by splitting the group if its pattern can match that filetype, at the cost of one extra re-fire. Brace patterns like{rust,toml}count as globs; comma-separated strings like'rust,toml'are split into their filetypes and grouped normally.FileTypehandlers andftplugins run once per group rather than once per plugin. Anftpluginthat starts a server now sees settings from later plugins and doesn't run first without them.Other:
ft: anftwith no filetypes ('',{}, a map, a function returning neither a string nor a table) now warns and never loads the plugin fromft;cmd,keysandrequire()can still load it. It used to abortsetup(), except''on v0.12.0, which loaded on any filetype. A patternnvim_create_autocmdrejects ('{') also warns instead of aborting, and is skipped while other filetypes in the list still work. Named keys next to list entries, as in{ 'rust', zig = true }, are ignored with a warning.packpath: eager plugins no longer consultpackpath, so they load where:packadd!would have failed with E919. Lazy plugins still go through:packadd. An extra copy of a plugin elsewhere onpackpathno longer reachesruntimepathfor eager plugins.afterin a path: if any eager plugin's path containsafter(a plugin likevim-afterglow, or a root under/home/rafter), the whole batch falls back to:packadd!, soruntimepathorder is unchanged but those configs get none of the eager savings.rtpedge cases that remain: underfileignorecase, a plugin already inruntimepathunder a differently cased non-ASCII path can be added twice. WithXDG_DATA_HOMEcontaining//, a later:packaddof an eager plugin adds a second entry.OptionSet: handlers forruntimepathfire once per batch rather than once per plugin.Windows: not run there. The eager batch probably falls back to
:packadd!because of backslashes inruntimepath, which matches the old behaviour but gets none of the eager savings.