Nevertheless, she persisted

Speeding Up My Shell Startup

This material is not original, and is unscientific. You can find my shell config (among other things) at https://github.com/hybras/dotfiles

I opened my terminal this morning, and it seemed to take about half a second to start. Unacceptable for someone likes to start and stop their shells on demand (instead of leaving one open) and keeps multiple shells open at a time (instead of using a multiplexer like tmux or zellij). Every few years, my shell startup scripts either become too slow and/or monstrously complex and I must benchmark and prune them. So here’s the 2022 edition of my spring cleaning (in July).

I’ve been using a new featureful benchmarking tool, hyperfine. I used the following command to measure my startup. I added a single warmup run because I was frequently editing my shell config while benchmarking, which mean my shell plugin manager’s cache kept getting rebuilt. I also benchmarked a shell with no config / default system config by adding --no-rcs to zsh in the following command. This gave me a speedy baseline of under 5 ms. With everything enabled it took 200 ms, which is a big discrepancy. My goal is to get to 120 ms. Depending on the source, this is either a human record or the shortest time to record visual stimulus.

hyperfine --warmup 1 "zsh --login --interactive  -c exit"

Troubleshooting my plugins

By selectively disabling my plugins and my plugin manager I obtained the following timings chart

Plugin / CommandTime
sheldon10 ms
fpath+="$(brew --prefix)/share/zsh/site-functions"10 ms
starship10 ms
mcfly5 ms
zsh-users/zsh-completions5 ms
All other plugins combined< 2 ms
  • I like sheldon. Keeping my plugins in sheldon’s config file has made my shell startup scripts much smaller, readable, and maintainable. I decided to keep using it, because migrating away would be extremely difficult. All other plugins manager are configured using shell scripts.
  • My macos package manager, homebrew, has its own directory for shell completions of installed packages. Homebrew (and its packages) are relocatable, so I was dynamically finding homebrew’s location with brew --prefix. However, in 8 years of usage I have always used homebrew’s default location (which varies between macos architectures/versions, and linux). I inlined the dir (/opt/homebrew) and accept that this may break when I switch operating systems, or if homebrew decides to move.
  • I left mcfly untouched
  • While zsh-users provides many excellent zsh plugins, it turns out I wasn’t using zsh-users/zsh-completions at all. This plugin provides completions for dozens of commands. However, the only command for which it provides completions that I am using is caffeinate, and I use it rarely. I disabled this plugin.

I believed my shell plugins were the source of my slow startup, but all together they accounted for ~40 ms. Significant, but not worth optimizing. Where was the remaining 160 ms coming from?

The remainder

I turned my attention away from my plugins towards the rest of shell startup scripts: profile, env, rc.

  • .zshenv : I am (obviously) only setting environment variables here. This must be speedy and cannot be optimized anyways.
  • .zshrc : I am invoking sheldon, setting some environment variables, setting some zsh options, and loading my shell completions (loading them tells zsh to use them). Loading my completions took 30 ms.

    Many plugin managers load your completions for you. However it seems sheldon does not. Even so, I’m not sure how to optimize this. Perhaps only reloading functions and completions on change instead of on startup? Or caching somehow? Definitely an area of further research.

  • .zprofile : Here I was manually adding some directories to my path (homebrew does not link packages that replace macos provided software (though ironically the things I’m overriding will soon be removed)) and invoking homebrew to add its directories to my shell env $PATH, $MANPATH, $INFOPATH.

    This call to homebrew took 130ms. I finally found a cash cow. Inlining it like I did earlier and setting these variables manually is error prone, but made this step instantaneous (< 2 ms).

Conclusion

Plugin / CommandTime
Sheldon and plugins40 ms (reduced)
brew shellenv130 ms (removed)
Functions and completions30 ms (untouched)

I love homebrew, but goddamn is it slow. It is so much slower than other package managers, and other programs in general. Benchmarking brew --help and brew list (list installed packages) confirms this. I attribute this to brew being a ruby program. Here, the problem was compounded by homebrew not being part of the os (unlike linux package managers like apt, yum, apk), which means I must add it to my environment variables on shell startup. There’s nothing I can do to speed brew up, so I’ll settle for working around it.

What I am curious about it that I doubt my shell startup was always this slow. This implies that at some point I was not calling brew. I must have forgotten to avoid calling it on startup when I switched computers in Summer 2021.

I will not be switching away from homebrew, it is by far the nicest package manager I’ve ever used (I’m a slut for 🔴C🟠O🟡L🟢O🔵R🟣S🟤). It’s not like I have a choice though (the only other macos-specific option is ports). Perhaps a cross platform package manager like nix or flatpak is in my future.

#Dev