あなたのzshは何ms?zsh-benchで測ってから起動高速化
tmuxでの画面分割などの「新しいシェルを開くとき」にモサッとするのが気になり、zshの起動速度を改善しました。
起動を高速化するにあたり「速度をどう測定すればいいのか」「どこまでの改善を目標とするかの決め方」「どうやって高速化したのか」を備忘録として残しておきます。
## 今何ms?
体感でもよいのですが、せっかくなら改善の前後で指標をメモしておいたほうが分かりやすいので測定します。
よくある方法だと「forで数回起動して平均を取る」「hyperfineを使う」などを見かけます。
今回はzshの専用のベンチマーク測れるzsh-benchを使いました。
### zsh-benchとは
zsh-benchは「ログインシェルの起動」「コマンドを入力してシェルが反応するまでの時間」などを測定できるベンチマークCLIです。
いきなりですが、筆者の改善前の速度を見てみましょう。
./zsh-bench==> benchmarking login shell of user eetann ...creates_tty=0has_compsys=1has_syntax_highlighting=1has_autosuggestions=1has_git_prompt=1first_prompt_lag_ms=124.680first_command_lag_ms=626.930command_lag_ms=29.023input_lag_ms=4.279exit_time_ms=424.227| 名前 | 内容 |
|---|---|
| first_prompt_lag_ms | シェルの起動〜最初にプロンプトが表示されるまでの時間 |
| first_command_lag_ms | シェルの起動〜最初にコマンドを実行するまでの時間(※) |
| command_lag_ms | 空のコマンドラインで Enter を押してから次のプロンプトが表示されるまでの時間 |
| input_lag_ms | キー入力から、実際に対応文字が表示されるまでの時間 |
| exit_time_ms | zsh -lic "exit"にかかった時間 |
※シェル起動中にlsとか入力しても、実際に実行されるまではちょっとラグがありますよね?あれです。
こうしてみると単に早くするといってもいくつかの数値があると分かりました。
今回はタイトルどおり「起動」にかかわるfirst_prompt_lag・first_command_lagに絞って改善します。
## 「速い」って何ms?
「高速化しよう」と言っても、どこまで速くなれば満足するのか決めないと キリがない です。
究極をいえば「一切カスタマイズせず.zshrcも捨てる!」となりますが、現実的ではありません。
そこでhuman-benchの出番です。human-benchを使うと 前述のラグをシミュレート できます。
### human-benchの使い方
human-benchはzsh-benchと同じリポジトリの中にあるのでスクリプトを叩くだけで使えます。デフォルトでは、ラグ0です。
./human-benchラグ0なのでサクサクです。
#### ラグを指定してみる
オプションでラグを指定できます。
./human-bench --first-prompt-lag-ms 500 --first-command-lag-ms 3000最初のlsは2回目のlsと比べると遅いのが分かります。
#### 同じ種類のラグをランダムして違いを体感
同じ種類のラグを複数指定すれば、指定した値の中からランダムにシミュレートされます。
./human-bench --first-prompt-lag-ms 0 --first-prompt-lag-ms 500実際にシミュレートしてみると結構違うのが分かります。
これを何回か値を変えてみて「 このぐらいのラグなら実質0と体感変わらないな 」となれば、それが目標値となります。
### 数値の参考値
とはいえ、参考値もほしいですよね。次の表は、zsh-benchのREADMEに書かれているzsh-bench作者が「0と変わらないな」と判断した値です。
| 種類 | ms |
|---|---|
| first_prompt_lag_ms | 50 |
| first_command_lag_ms | 150 |
| command_lag_ms | 10 |
| input_lag_ms | 20 |
シミュレートし続けてゲシュタルト崩壊した人はこの表を参考にしてみましょう。
## 自分が試した高速化の例
測定方法と目標が決まったらいよいよ高速化です。
※ここから先はAIにやらせた内容がほとんどで、提案された内容を自分で調べつつ備忘録として残しておきます。
### ボトルネックを探しておく
zprofモジュールを読み込み、zprofを実行するとプロファイリング結果を表示してくれます。
zmodload zsh/zprof
# 既存の設定
zprof次のようにかかった時間順に並びます。
※実行結果をメモし忘れたため「改善後」の結果です。
num calls time self name----------------------------------------------------------------------------------- 1) 1 14.34 14.34 50.82% 13.86 13.86 49.11% (anon) [/Users/eetann/.zsh/plugins/powerlevel10k/powerlevel10k.zsh-theme:50] 2) 1 8.57 8.57 30.37% 6.29 6.29 22.27% (anon) [/Users/eetann/.cache/p10k-instant-prompt-eetann.zsh:3] 3) 1 4.16 4.16 14.75% 4.16 4.16 14.75% compinit 4) 1 2.28 2.28 8.08% 1.74 1.74 6.18% _p9k_preinit 5) 8 0.54 0.07 1.93% 0.54 0.07 1.93% add-zsh-hook 6) 1 0.55 0.55 1.94% 0.50 0.50 1.78% (anon) [/Users/eetann/.p10k.zsh:22] 7) 1 0.40 0.40 1.41% 0.40 0.40 1.41% (anon) [/Users/eetann/.cache/p10k-instant-prompt-eetann.zsh:599] 8) 1 0.36 0.36 1.26% 0.36 0.36 1.26% is-at-least 9) 1 0.54 0.54 1.90% 0.14 0.14 0.50% gitstatus_start_p9k_10) 1 0.08 0.08 0.27% 0.08 0.08 0.27% (anon) [/Users/eetann/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh:460]11) 1 0.04 0.04 0.15% 0.04 0.04 0.15% p10k12) 1 0.47 0.47 1.68% 0.04 0.04 0.14% prompt_powerlevel9k_setup13) 1 0.42 0.42 1.49% 0.04 0.04 0.13% prompt_powerlevel9k_teardown14) 1 0.43 0.43 1.52% 0.01 0.01 0.03% _p9k_setup15) 1 0.01 0.01 0.03% 0.01 0.01 0.03% (anon) [/Users/eetann/.cache/p10k-instant-prompt-eetann.zsh:151]16) 1 0.01 0.01 0.02% 0.01 0.01 0.02% _p9k_init_toolbox17) 2 0.01 0.00 0.02% 0.01 0.00 0.02% _p9k_restore_special_params18) 1 0.01 0.01 0.02% 0.01 0.01 0.02% _p9k_init_ssh参考:22.35 The zsh/zprof Module(man 1 zshmodulesの内容)
### zshプラグインマネージャーを削除
zshプラグインマネージャーとしてOh My Zshを使ってましたが、次のように自前実装に変更しました。
- Oh My Zshの設定削除
- プラグインを直接
source - 遅延読み込みはAIに実装してもらう
- プラグインのインストール・アップデートの管理はnixを使う
#### Oh My Zshの設定削除
Oh My Zsh関係の設定を消しました。plugins=とかoh-my-zsh.sh読み込みなどです。
export ZSH=$HOME/.oh-my-zsh
ZSH_THEME="powerlevel10k/powerlevel10k"plugins=( colored-man-pages zsh-autosuggestions zsh-completions fast-syntax-highlighting zsh-history-substring-search fzf)if command -v docker &>/dev/null; then plugins+=( docker docker-compose )fi
source $ZSH/oh-my-zsh.sh#### プラグインを直接source
プラグインは直接sourceするよう変更しました。
source "$HOME/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"また、これを機に本当に使っているプラグインだけに絞りました。
残したのは次のプラグインたちです。
一方、消したやつも書いておきます。
| 名前 | 消した理由 |
|---|---|
| colored-man-pages | manのビュワーはNeovimを使うようになったため |
| zsh-history-substring-search | 履歴をたどるのは通常の上下キーやzeno(fzf)の履歴補完で十分なため |
| z | ghq+zeno(fzf)の組み合わせ、や本当によく行くディレクトリはエイリアスを付けるようにしたため(※) |
※次のように、zという名前を受け継いだエイリアスしてます。
alias zd='cd ~/dotfiles'alias zdev='cd ~/ghq/dev/'alias zeetann='cd ~/ghq/github.com/eetann/'alias zhome='cd ~/.nb/home/'#### 遅延読み込みはAIに実装してもらう
Claude Codeにプラグインの遅延読み込みを実装してもらいました。
##### そもそも遅延読み込み不要なやつがある
まず一番盲点だったのが「そもそもプラグイン側で遅延読み込みを実装しているパターン」でした。
たとえばzsh-autosuggestionsはプラグイン側でprecmd発火時に読み込まれるように実装されています。
# autosuggestions(プラグイン自身がprecmd経由で遅延初期化するためそのままsource)source "$HOME/.zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"##### フックで遅延読み込み
fast-syntax-highlightingはprecmd発火時の読み込みにしました。
function _fsh_lazy_load() { add-zsh-hook -d precmd _fsh_lazy_load source "$HOME/.zsh/plugins/fast-syntax-highlighting/fast-syntax-highlighting.plugin.zsh"}
add-zsh-hook precmd _fsh_lazy_loadadd-zsh-hook -d precmdで一度読み込んだら削除してます。
その他のプラグインは、dotfilesのリンクだけ置いておきます。
#### プラグインはnixで管理
プラグインのインストール・アップデートはnixで管理するようにしました(実装)。
nixは学習コストが高くて腰が重かったんですが、「Claudeに任せられる」という話をXやvim-jpのSlackでちらほら見かけていたので任せてみました。
シェル起動時の「更新しますか?」の表示が出ることも無くなり、スッキリしました。
### evalもキャッシュ
evalの箇所も遅くなりがちです。筆者の場合はmiseでevalしてました。
eval "$(mise activate zsh)"この初期化処理を遅延しつつバージョンごとにキャッシュしています。
# mise activateの遅延ロード(precmd one-shot)# 起動時ではなく最初のプロンプト表示直前に初期化するif type mise > /dev/null; then _mise_lazy_activate() { precmd_functions=(${precmd_functions:#_mise_lazy_activate}) local _mise_ver=${$(mise version 2>/dev/null)%% *} local _mise_cache="${HOME}/.cache/zsh/mise-activate.${_mise_ver}.zsh" if [[ ! -f "$_mise_cache" ]]; then mise activate zsh > "$_mise_cache" fi source "$_mise_cache" } precmd_functions+=(_mise_lazy_activate)fi最新のキャッシュされた中身見てみるとmise自体がprecmdとか使っていたため、遅延はもしかしたらいらないかも?(まだ遅延有り無しで検証できてません)
### compinitのキャッシュ
続いて、補完で必要なcompinitをキャッシュするようにしました。
実装にはuma-chanさんのZsh compinit の仕組みを理解して起動時間を短縮するを参考にしました。タイトルどおり仕組みから解説して、キャッシュ有り無しで実行時間も計測しているためcompinitが気になる人はまずこの記事を読むのをオススメします。
# compinit最適化: zcompdumpが1日以内なら-C(再スキャンをスキップ)autoload -Uz compinit_zcompdump="${ZSH_CACHE_DIR}/zcompdump"if [[ -f "$_zcompdump" ]] && (( $(date +%s) - $(stat -f %m "$_zcompdump") < 86400 )); then compinit -C -d "$_zcompdump"else compinit -d "$_zcompdump" # 再生成時にコンパイルして次回以降の読み込みを高速化 # 複数ペイン同時起動時の競合を回避するためロックファイルで排他制御 local _lockfile="${_zcompdump}.lock" if (set -o noclobber; echo $$ > "$_lockfile") 2>/dev/null; then zcompile "$_zcompdump" rm -f "$_lockfile" fifiunset _zcompdump## 最終的な結果
そんなわけで最終結果は次のとおりです。
==> benchmarking login shell of user eetann ...creates_tty=0has_compsys=1has_syntax_highlighting=1has_autosuggestions=1has_git_prompt=1first_prompt_lag_ms=42.633first_command_lag_ms=201.335command_lag_ms=27.856input_lag_ms=4.435exit_time_ms=84.751| 指標 | Before | After | 割合 | 参考値 |
|---|---|---|---|---|
| first_prompt_lag_ms | 124.680 | 42.633 | -65.8% | 50 |
| first_command_lag_ms | 626.930 | 201.335 | -67.9% | 150 |
| command_lag_ms | 29.023 | 27.856 | -4.0% | 10 |
| input_lag_ms | 4.279 | 4.435 | +3.6% | 20 |
| exit_time_ms | 424.227 | 84.751 | -80.0% | 無し |
input_lag_msだけ増えてますが単位がmsなのでほぼ変化無しで、他は高速化できました。
体感や参考値と比べるとfirst_command_lag_msだけまだ改善したいなと思いますが、これは別の機会にやろうと思います。
以上、zshの速度改善でした。コミット細かくして都度ベンチマークをコミットメッセージに入れておけばよかったなぁと反省です。
今まで速度改善する時は特に目標とする値を決めてなかったのですが、zsh-benchのおかげでそれが明確になったのはよかったです。(それはそれとして、目標値を突破しても盆栽として速度改善は面白いので不定期でやりたい)