On-Demand Loading for your .zshrc or .bashrc

Recently, I’ve been trying to make my coding environment snappier, and one thing I was never happy with was how slow my .zshrc is.

Now, don’t get me wrong, I’m not one of those people using oh-my-zsh with a ton of plugins and seeing 15-second waits for my shell to start… but I do want a new tab to be ready in a second or less.

So, I slapped zmodload zsh/zprof onto the top of my .zshrc, opened a new tab, and ran zprof | less …and 50% of the wait was in sourcing virtualenvwrapper, which I don’t feel like reinventing.

Time to take a lesson from the improvements I’ve been making to my .vimrc. Specifically, the { 'on': ['CommandA', 'CommandB'] } option hanging off the end of various lines for my plugin loader.

A little experimentation later and I came up with this construct:

function init_virtualenvwrapper {
    # Don't do anything if it's already loaded
    type virtualenvwrapper_workon_help &>/dev/null && return

    # ------------------------------------------------
    # normal stuff to load virtualenvwrapper goes here
    # ------------------------------------------------
}

for cmd in workon mkproject mkvirtualenv; do
    function $cmd {
        unset -f "$0"
        init_virtualenvwrapper
        "$0" "$@"
    }
done

For those not familiar with shell scripting, I’ll clarify.

For each shell function or command that I want to trigger deferred loading, I create a function with the same name that does the following:

  1. “Delete” itself so it won’t interfere with what virtualenvwrapper is going to set up. (You want to do this first to avoid removing what virtualenvwrapper just created)
  2. Call the virtualenvwrapper setup code to load the real command.
  3. init_virtualenvwrapper starts by checking for some side-effect of having been run before and exits early if that’s the case. (This keeps mkproject from re-doing what workon already did, or vice-versa.)
  4. Call the actual command and pass through any arguments.

Doing this means that:

  1. Your .zshrc or .bashrc startup time only pays the price for declaring a few shell functions. (And, if that gets too heavy for some reason, you could move init_virtualenvwrapper into another file and source it on demand.)
  2. Your first call to a wrapped command like workon will take longer. (eg. if it was adding two seconds to your shell start time, then your first call to it will take two seconds longer.)
  3. Subsequent calls to that or any other command sharing the same init_virtualenvwrapper will be as quick as usual.

Unfortunately, this design is actually Zsh-specific, which sucks for me because this is a file I share between .zshrc and .bashrc:

  1. Bash doesn’t support using a variable for a function name, so you can’t use a for loop. You’ll just get `$cmd': not a valid identifier.
  2. In my testing, functions didn’t set $0 in bash, so this will actually execute bash "$@", bringing you back to where you started, while zsh doesn’t set the FUNCNAME array variable that bash uses.

So, if you want to support both, here’s the most concise form I was able to put together:

function init_virtualenvwrapper {
    local _cmdname="$1"
    shift
    unset -f "$_cmdname"

    # Don't do anything if it's already loaded
    if ! type virtualenvwrapper_workon_help &>/dev/null; then
            # ----------------------------------------
            # normal stuff to load virtualenvwrapper
            # ----------------------------------------
    fi

    "$_cmdname" "$@"
}
# }}}

function workon {
    init_virtualenvwrapper "${FUNCNAME[0]:-$0}" "$@"
}
function mkproject {
    init_virtualenvwrapper "${FUNCNAME[0]:-$0}" "$@"
}
function mkvirtualenv {
    init_virtualenvwrapper "${FUNCNAME[0]:-$0}" "$@"
}

Anyway, I hope this helps to inspire anyone else who’s suffering from slow shell startup times.

UPDATE: And now, shortly after writing that, I discover that someone else went to the trouble of using eval to provide a nice API on top of this trick and put it up on GitHub as sandboxd. From that name, I can see why i didn’t find it before.

CC BY-SA 4.0 On-Demand Loading for your .zshrc or .bashrc by Stephan Sokolow is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.

This entry was posted in Geek Stuff. Bookmark the permalink.

2 Responses to On-Demand Loading for your .zshrc or .bashrc

  1. SomePeer says:

    You could also check out the `zinit` plugin manager, which provides a way to postpone the sourcing of your plugins and doing so in the background (i.e. it provides similar features to your vim plugin manager’s lazy loading).

    It does have some cognitive overhead to get it working just so, but maybe it will be of use to you (or someone else reading this post).

    • Under normal circumstances, that’d be true, but my zshrc is completely hand-written and aggressively optimized. Just adding zinit would be a step backward before I’ve even added anything for it to load.

      I’ve got it to the point where, with a warm cache, zprof reports compinit -C taking between 77% and 96.6% of .zshrc‘s runtime… and -C tells compinit to just load the cached completion dumpfile without checking if it’s stale to minimize disk I/O.

      For cold-cache starts, I’m using "${(%):-"%D{%s}"}" to query the wall time without invoking a subprocess and skipping my usual fortune command if it takes 2 seconds or more to get from the top of my .zshrc to the bottom. (I couldn’t find a way to get sub-second resolution without invoking a subprocess.)

      Still, I’m sure it’ll be useful to someone else, so thanks for mentioning it.

Leave a Reply

Your email address will not be published. Required fields are marked *

By submitting a comment here you grant this site a perpetual license to reproduce your words and name/web site in attribution under the same terms as the associated post.

All comments are moderated. If your comment is generic enough to apply to any post, it will be assumed to be spam. Borderline comments will have their URL field erased before being approved.