8000 perf: speed up startup with many plugins by zuqini · Pull Request #50 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content

perf: speed up startup with many plugins - #50

Merged
zuqini merged 2 commits into
mainfrom
zpack-startup-perf
Oct 31, 2026
Merged

zuqini merged 2 commits into
mainfrom
zpack-startup-perf

Conversation

@zuqini
@zuqini zuqini commented Oct 31, 2026 •
Copy link
Copy Markdown
Owner

Benchmarked against lazy.nvim with 150 plugins, separate process per sample. CPU ms, median of 60:

Scenario before after lazy.nvim
30 eager, 120 lazy 78.8 71.1 71.0
same, opening a .rs file 120.4 99.9 101.1
all eager 171.8 125.4 205.8
  • Eager plugins: add them to runtimepath in one write instead of one :packadd! per plugin. Mirrors add_pack_dir_to_rtp() and falls back to :packadd! for layouts it doesn't cover, including relative runtimepath entries. A differential test checks the result against real :packadd!.
  • ft= plugins: plugins sharing a filetype load together and re-fire FileType once, matching lazy.nvim. Previously each plugin re-fired it, repeating the ftplugin, indent and syntax scans over all of runtimepath.

The LazyVim differential shows no structural or opts changes against main.

Expected behaviour changes

Plugins sharing a filetype now load together and re-fire FileType once, after the whole group, matching lazy.nvim. Servers attached the same on v0.12.0 and nightly with vim.lsp.enable, legacy lspconfig setup and Mason-style PATH setup.

Filetype groups (only when two or more lazy plugins share a filetype):

  • Buffer state in config: a plugin's config no longer sees effects of ftplugins or BufReadPre/BufReadPost handlers from earlier plugins on that filetype, even with dependencies declared. 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 a FileType handler or the plugin's own ftplugin instead.
  • Guarded ftplugins: with b:did_ftplugin guards and no b:undo_ftplugin, the lowest-priority plugin now wins instead of the highest, which also decides the server if those ftplugins start one. With b:undo_ftplugin the winner is unchanged, and the earlier plugin's ftplugin no longer runs only to be undone.
  • Second buffer during load: if a plugin's config loads another buffer of that filetype, FileType handlers 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.enable isn't affected.
  • Unnamed buffers: if a later plugin's config changes an unnamed buffer before the re-fire, earlier plugins' FileType handlers can skip it. Named files aren't affected.
  • Different ft patterns: 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 with event='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.
  • Fewer repeat runs: FileType handlers and ftplugins run once per group rather than once per plugin. An ftplugin that starts a server now sees settings from later plugins and doesn't run first without them.

Other:

  • Unusable ft: an ft with no filetypes ('', {}, a map, a function returning neither a string nor a table) now warns and never loads the plugin from ft; cmd, keys and require() can still load it. It used to abort setup(), except '' on v0.12.0, which loaded on any filetype. A pattern nvim_create_autocmd rejects ('{') 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 consult packpath, so they load where :packadd! would have failed with E919. Lazy plugins still go through :packadd. An extra copy of a plugin elsewhere on packpath no longer reaches runtimepath for eager plugins.
  • after in a path: if any eager plugin's path contains after (a plugin like vim-afterglow, or a root under /home/rafter), the whole batch falls back to :packadd!, so runtimepath order is unchanged but those configs get none of the eager savings.
  • Unbatched rtp edge cases that remain: under fileignorecase, a plugin already in runtimepath under a differently cased non-ASCII path can be added twice. With XDG_DATA_HOME containing //, a later :packadd of an eager plugin adds a second entry.
  • OptionSet: handlers for runtimepath fire once per batch rather than once per plugin.

Windows: not run there. The eager batch probably falls back to :packadd! because of backslashes in runtimepath, which matches the old behaviour but gets none of the eager savings.

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
zuqini force-pushed the zpack-startup-perf branch from a99b4b3 to af7de67 Compare October 31, 2026 07:19
@zuqini
zuqini merged commit 2966b92 into main Oct 31, 2026
4 checks passed
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
@zuqini
zuqini deleted the zpack-startup-perf branch November 6, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0