I do have a relatively big bashrc
file. If you look at that file, you will see this line towards the end:
test -f ~/.bashrc_local && source ~/.bashrc_local
I use that ~/.bashrc_local
to keep my personal / work configurations separate.
For quite some time I was bothered by how long it took for Bash to start on my machines. I never measured the actual startup time, but it made me hesitant to open up new terminal windows.
So one day I decided to take care of the slowness.
If you search on the Internet, there aren’t that many tools to help with profiling Bash scripts. One trick that I found is to add the following lines around isolated pieces of shell commands and see what they actually do:
set -v
set -x
#<YOUR COMMANDS>
set +v
set +x
Using this technique, you will get a trace log of what’s going on behind the scenes. With some good old trial-and-error, I found these 2 issues in my case:
- Unnecessary calls to
brew
to get configuration - Eagerly processing all shell completion files
Calling brew
in your bashrc
I love Homebrew. It is an amazing package manager. However, it is written in Ruby and has a quite slow startup time.
To give you an idea of how slow it can be, I will use Hyperfine to measure the average time for calling brew --prefix
. This brew
command gives Homebrew’s install path and you can use it to add your installed packages’ bin
directories to $PATH
, among other things.
On my machine:
$ hyperfine 'brew --prefix'
Benchmark 1: brew --prefix
Time (mean ± σ): 36.7 ms ± 1.1 ms [User: 14.5 ms, System: 14.7 ms]
Range (min … max): 35.2 ms … 41.2 ms 72 runs
That’s 36 ms
just to give us the installation path!
You might think that 36 ms
is not much, but I had several calls to brew --prefix
in my bashrc
to get bin
paths for coreutils
, GNU grep
, and other tools. These all add up to hundreds of milliseconds each time the bashrc
file is sourced by Bash.
Solution
First, I use brew shellenv
to generate environment variables related to Homebrew. One of them is $HOMEBREW_PREFIX
which, as you’ve probably guessed, gives us the installation path. Now instead of calling brew --prefix
, I can use that environment variable.
Even better, I call brew shellenv
only once and store the result in a file ($HOME/.brew_env
). Then in my bashrc
I just source
that file and save some more precious milliseconds!
Here is the relevant part in my bashrc
:
|
|
Eagerly processing all shell completion files
Before starting my journey in optimizing my bashrc
, I suspected that this was the culprit. I wasn’t wrong. This was the second source of slowness.
On macOS, I use Bash Completion package to get shell completion for several commands.
After installing that package, brew
tells you to add this line to your Bash startup file:
[[ -r "/usr/local/etc/profile.d/bash_completion.sh" ]] &&
. "/usr/local/etc/profile.d/bash_completion.sh"
This works fine. The problem is that whenever you install a package with brew
, brew
adds the relevant completion files to $HOMEBREW_PREFIX/etc/bash_completion.d/
folder. Using the line above, bash-completion
package loads all files in the mentioned folder eagerly. This can get very slow if you have installed too many packages.
What we ideally want is to process a completion file on-demand. In other words, when I type tar --
and press Tab, I want the relevant completion file to be processed so Bash can give me the completion options. Fortunately bash-completion
package supports this out of the box.
To get that, first we need to install the HEAD
version of it, because the usual Homebrew version is rather old. To install the HEAD
version:
brew install --HEAD bash-completion@2
(I am using Bash 5.x, so I need to use the @2
version)
Then, I disable the eager loading of those Homebrew-installed completion files:
export BASH_COMPLETION_COMPAT_DIR="/blackhole!"
/blackhole!
can be any non-existing path.
Finally, I ask bash-completion
to process Homebrew-installed completion files lazily:
export BASH_COMPLETION_USER_DIR="$HOMEBREW_PREFIX/etc/bash-completion:$HOME/.local/share/bash-completion"
There are actually two paths specified there. One is for packages that are installed via brew install ...
. The other is for my own custom completion files that I put in ~/.local/share/bash-completion/
.
The ability to add several folders to
BASH_COMPLETION_USER_DIR
is added in recent versions ofbash-completion@2
. That’s why I used--HEAD
when installing it.
The full code looks like this:
|
|
On important note: bash-completion
expects to find a completions
folder inside each folder that’s specified in BASH_COMPLETION_USER_DIR
. That’s the reason for the ln -s
trick up there.