8000 perf: stop tracing zpack's startup code by zuqini · Pull Request #52 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content

perf: stop tracing zpack's startup code - #52

Merged
zuqini merged 1 commit into
mainfrom
zpack-startup-perf-2
Nov 2, 2026
Merged

zuqini merged 1 commit into
mainfrom
zpack-startup-perf-2

Conversation

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

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.

Paired d-cpu in ms, 150 plugins, median with bootstrap 95% CI, 400 rounds in shuffled order, measured at 1e6b70e under load 6-12, against lazy.nvim and against f5767ae, the state before the import search and the clean prompt:

Scenario this minus lazy.nvim this minus f5767ae A/A
realistic -4.1 [-6.6, -2.1] +1.3 [-1.1, +3.0] +1.1 [-0.9, +3.9]
open a file -6.4 [-8.6, -4.5] -0.7 [-2.6, +0.8] -2.0 [-3.5, -0.2]
all eager -122 [-128, -118] +1.1 [-1.8, +4.8] -0.7 [-3.0, +1.7]

Machine load inflates these numbers, so only the paired deltas mean anything. Rechecked at fe3803e under load 6-9, 400 rounds, against lazy.nvim: realistic -5.0 [-6.8, -3.6], open a file -7.2 [-8.5, -5.1], all eager -116 [-121, -111]. At the final state: realistic -5.3 [-6.5, -4.1], open a file -7.4 [-9.5, -5.4].

None of the benches above imports from a listed plugin. With AstroNvim's community shape added to the realistic one, { 'AstroNvim/astrocommunity' } beside { import = 'astrocommunity.pack.lua' }, zpack pays +5.9 [+4.5, +7.0] ms per start over putting the import on the plugin's spec (a trial resolve and a second pass), which leaves it level with lazy.nvim on that shape: -0.1 [-1.4, +1.3] (fe3803e, load 7). An import no plugin has costs +4.3 [+3.3, +5.4] ms.

Clean and sync: 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. Both now list what they would delete and ask with a vim.fn.confirm that defaults to No, so a headless run deletes nothing, and :ZPack! clean and :ZPack! sync skip it for scripts. defaults.confirm = false still only means don't ask before installing. Sync asks once its update has finished, and skips clean if the update raises, as when interrupted. Every problem is reported at startup, and setup() with no spec and no lua/plugins reports nothing, since nothing was written. :ZPack sync updates even when a spec can't be read: a plugin with pin = true in that spec moves, its version or tag holds, and :ZPack restore doesn't undo the move unless the lockfile is in git.

Imports: an import with no plugin of its own that your config and 'runtimepath' don't have is looked for in the listed plugins that stay and are named after the module's top level, by name or repository, installing what's missing, so { { 'u/dist' }, { import = 'dist.plugins.extra' } } and AstroNvim's template shape import on the first start and later ones, where 2.x imported nothing. lazy.nvim tries those first and then every other plugin; zpack stops there, so a missing gitignored module such as plugins.machine_local doesn't install every listed plugin from inside the import, and the warning says to put the import on its plugin's spec. Unlike lazy.nvim it finds a plugin listed after the import and doesn't read a disabled one. 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 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, which reports the error and skips the import.

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 matches a module's exact top-level name against each plugin's lua/, and loads the plugin holding the file, so require('dap.ui.widgets') no longer loads nvim-dap-ui by name. A plugin is no longer loaded because its name resembles a module it doesn't provide.

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 128 lines shorter 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 (the live one too, through 1e6b70e, with identical runtime state at startup), between the state before review and this one, and again for the import search and the prompt at 1e6b70e: 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. The prompt in :ZPack clean and :ZPack sync changed no line of that output. Against lazy.nvim's resolver, the plugin set and edges differ only by the harness's <expr> placeholders. The final state against 013dbbe (before the last import changes) gives the same result, with only the missing-import hint text changing, and the live config's output is byte-identical. 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 list what they would delete and ask, defaulting to No; ! skips the prompt

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): an import with no plugin of its own finds its module in a listed plugin named after it, installing it if missing, on the first start

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(module_loader)!: require no longer loads a plugin whose name resembles a module it doesn't provide; a plugin registering a module from plugin/ needs a trigger of its own

fix(setup): setup() with no spec and no lua/plugins, or one holding only dotfiles, is quiet, as in 2.x

fix(hooks): a module brought in by an in-session update is found without a restart

refactor(ft): an event = 'FileType ...' plugin closes every open ft group, at the cost of one more FileType re-fire there

docs(tips): add Upgrading to 3.0
END_COMMIT_OVERRIDE

@zuqini
zuqini force-pushed the zpack-startup-perf-2 branch from 0929d23 to 5d67966 Compare November 1, 2026 21:06
- fix(commands)!: `:ZPack clean` and `:ZPack sync` list what they would delete and ask, defaulting to No; `!` skips the prompt
- 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): an `import` with no plugin of its own finds its module in a listed plugin named after it, installing it if missing, on the first start
- 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(module_loader)!: `require` no longer loads a plugin whose name resembles a module it doesn't provide; a plugin registering a module from `plugin/` needs a trigger of its own
- fix(setup): `setup()` with no `spec` and no `lua/plugins`, or one holding only dotfiles, is quiet, as in 2.x
- fix(hooks): a module brought in by an in-session update is found without a restart
- refactor(ft): an `event = 'FileType ...'` plugin closes every open `ft` group, at the cost of one more `FileType` re-fire there
- docs(tips): add Upgrading to 3.0
@zuqini
zuqini force-pushed the zpack-startup-perf-2 branch from 1d2c923 to ffa1492 Compare November 2, 2026 19:11
@zuqini
zuqini merged commit 936649e into main Nov 2, 2026
4 checks passed
@zuqini
zuqini deleted the zpack-startup-perf-2 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