monolithic kernel

chezmoi のテンプレート機能を使ってシェルの起動を高速化する

最近の CLI ツールには、.bashrc.zshrc などで読み込むべきシェルスクリプトをツール自身が出力してくれるものがある。

eval "$(rtx activate zsh)"
eval "$(starship init zsh)"

利用者からすると設定ファイルをシンプルにできて、ツールの開発者からすると内容をあとから自由にコントロールでき、あと率直におしゃれだと感じる一方で、コマンドを実行することによるオーバーヘッドも気になるところ。ただ、だからと言ってコマンドの出力をベタ書きするというのもメンテナンス性が損なわれるのでやりたくない。

これに対して、すぐに思いつくのは出力されるスクリプトをキャッシュする作戦だろう。コマンドの実行結果を事前にファイルに書き出しておけば、シェルを起動するときはファイルを読み込むだけでよくなる。

ただ、キャッシュ機構を用意するのもそれなりに面倒である。それに、あまりキャッシュのためのコードが増えるようだと、ベタ書きに対する優位性が揺らいでくる。

そういうわけこれまで着手していなかったのだが、最近導入した chezmoi のテンプレート機能を使うことで、簡単に実現できることに気付いた。

テンプレート中で output 関数を使うと、コマンドを実行して出力された内容を埋め込んだファイルを配置できる。

starship.zsh.tmpl
{{- output "starship" "init" "zsh" "--print-full-init" | trim -}}
rtx.zsh.tmpl
{{- output "rtx" "activate" "zsh" | trim -}}

テンプレートは chezmoi apply のたびにレンダリングし直される。すなわち、コマンドも再実行されるため、ベタ書きしたときのようにメンテナンスが面倒になることもない。ただし、コマンドが冪等であるかは事前に確認しておくこと。

これだけで、chezmoi の宣言的な設定の枠組みの中で、コマンドの出力をキャッシュする機構を実現できた。しかし、効果が無ければそもそもやる必要がない。

ぶっちゃけ、冒頭で例に挙げた starshiprtx は、Rust で書かれているので起動が割と速かったりする (それでも1コマンドあたり数 ms くらい変わる)。一方で、brew shellenv はシェルスクリプトで実装されているので、毎回立ち上げるとそこそこオーバーヘッドがある。

rtx activate zshbrew shellenv の実行時間を比べてみると以下のような感じ。M1 MacBook Air のメモリ 16GB、GPU 8コアのモデルにて実行した。

# rtx activate zsh の実行時間
% for i in $(seq 1 10); do time (rtx activate zsh > /dev/null); done
... 0.01s user 0.01s system 109% cpu 0.011 total
... 0.00s user 0.01s system 111% cpu 0.009 total
... 0.00s user 0.01s system 115% cpu 0.008 total
... 0.00s user 0.01s system 118% cpu 0.008 total
... 0.00s user 0.00s system 113% cpu 0.007 total
... 0.00s user 0.00s system 111% cpu 0.007 total
... 0.00s user 0.00s system 122% cpu 0.006 total
... 0.00s user 0.00s system 100% cpu 0.006 total
... 0.00s user 0.00s system 109% cpu 0.006 total
... 0.00s user 0.00s system 115% cpu 0.005 total
# brew shellenv の実行時間
% for i in $(seq 1 10); do time (brew shellenv > /dev/null); done
... 0.00s user 0.01s system 67% cpu 0.016 total
... 0.00s user 0.01s system 75% cpu 0.012 total
... 0.01s user 0.01s system 64% cpu 0.022 total
... 0.00s user 0.01s system 75% cpu 0.013 total
... 0.00s user 0.01s system 72% cpu 0.014 total
... 0.00s user 0.01s system 74% cpu 0.012 total
... 0.00s user 0.01s system 76% cpu 0.016 total
... 0.00s user 0.01s system 78% cpu 0.015 total
... 0.00s user 0.01s system 70% cpu 0.015 total
... 0.00s user 0.01s system 78% cpu 0.014 total

実際に brew shellenv を実行する場合と実行結果をキャッシュした場合を比較すると、以下のようになる。まっさらな環境で試すのが面倒で既存のコードのうち Homebrew 部分のみ書き換えての比較なので、何倍速いみたいな言い方はできないが、どれくらい差があるかはわかるはず。

# 直接 brew shellenv を呼び出した場合の zsh の起動時間
 for i in $(seq 1 10); do time (zsh -i -c exit); done
... 0.02s user 0.03s system 68% cpu 0.083 total
... 0.02s user 0.02s system 87% cpu 0.046 total
... 0.02s user 0.02s system 87% cpu 0.042 total
... 0.02s user 0.02s system 88% cpu 0.037 total
... 0.02s user 0.02s system 87% cpu 0.037 total
... 0.02s user 0.02s system 87% cpu 0.037 total
... 0.02s user 0.02s system 86% cpu 0.038 total
... 0.02s user 0.02s system 88% cpu 0.037 total
... 0.02s user 0.02s system 88% cpu 0.036 total
... 0.02s user 0.02s system 87% cpu 0.037 total
# brew shellenv の結果をキャッシュした場合の zsh の起動時間
% for i in $(seq 1 10); do time (zsh -i -c exit); done
... 0.02s user 0.02s system 91% cpu 0.047 total
... 0.02s user 0.01s system 93% cpu 0.032 total
... 0.01s user 0.01s system 93% cpu 0.027 total
... 0.01s user 0.01s system 92% cpu 0.027 total
... 0.01s user 0.01s system 93% cpu 0.027 total
... 0.01s user 0.01s system 91% cpu 0.026 total
... 0.01s user 0.01s system 94% cpu 0.027 total
... 0.01s user 0.01s system 93% cpu 0.026 total
... 0.01s user 0.01s system 93% cpu 0.027 total
... 0.01s user 0.01s system 91% cpu 0.026 total
homebrew.zsh
export HOMEBREW_NO_ANALYTICS=1
export HOMEBREW_NO_AUTO_UPDATE=1

eval "$(/opt/homebrew/bin/brew shellenv)"
homebrew.zsh.tmpl
export HOMEBREW_NO_ANALYTICS=1
export HOMEBREW_NO_AUTO_UPDATE=1

{{- $prefix := "" -}}
{{- $prefix = "/opt/homebrew" -}}
{{- output (joinPath $prefix "/bin/brew") "shellenv" | trim -}}

{{- end -}}

これだけ差が出るのであれば、個人的にはやる価値はあったかなと思う。実際には、macOS と Linux に両対応するためのコードも入ってきて、それを静的に解決できるか動的に解決するかの違いも生じるので、さらに差は大きくなる (OS ごとの差分の吸収だけ chezmoi のテンプレートに寄せて brew shellenv を呼び出す中間的な選択肢もあるが)。

今もシェルスクリプトでやっていた処理を Rust や Go のプログラムに委譲する流れだが、究極的にはそれらの呼び出し元のシェルスクリプトもすべてなくなって、全部 WebAssembly とかになったら面白そうだなと思った。そういうのすでにあるんだろうか。

なお、今回試している dotfiles 全体は以下で見られる。

https://github.com/mono0x/dotfiles/tree/0c1f0c5e8c121050bb7d7e4d41891f34b6629ef2

この状態での起動時間も参考までに載せておく。

% for i in $(seq 1 10); do time (zsh -i -c exit); done
... 0.02s user 0.02s system 86% cpu 0.050 total
... 0.02s user 0.01s system 92% cpu 0.031 total
... 0.01s user 0.01s system 93% cpu 0.027 total
... 0.01s user 0.01s system 94% cpu 0.027 total
... 0.01s user 0.01s system 93% cpu 0.027 total
... 0.01s user 0.01s system 92% cpu 0.026 total
... 0.01s user 0.01s system 90% cpu 0.028 total
... 0.01s user 0.01s system 93% cpu 0.027 total
... 0.01s user 0.01s system 90% cpu 0.027 total
... 0.01s user 0.01s system 94% cpu 0.026 total

Related articles