あなたのzshは何ms?zsh-benchで測ってから起動高速化

tmuxでの画面分割などの「新しいシェルを開くとき」にモサッとするのが気になり、zshの起動速度を改善しました。

起動を高速化するにあたり「速度をどう測定すればいいのか」「どこまでの改善を目標とするかの決め方」「どうやって高速化したのか」を備忘録として残しておきます。

## 今何ms?

体感でもよいのですが、せっかくなら改善の前後で指標をメモしておいたほうが分かりやすいので測定します。

よくある方法だと「forで数回起動して平均を取る」「hyperfineを使う」などを見かけます。

今回はzshの専用のベンチマーク測れるzsh-benchを使いました。

### zsh-benchとは

zsh-benchは「ログインシェルの起動」「コマンドを入力してシェルが反応するまでの時間」などを測定できるベンチマークCLIです。

いきなりですが、筆者の改善前の速度を見てみましょう。

Terminal window
./zsh-bench
==> benchmarking login shell of user eetann ...
creates_tty=0
has_compsys=1
has_syntax_highlighting=1
has_autosuggestions=1
has_git_prompt=1
first_prompt_lag_ms=124.680
first_command_lag_ms=626.930
command_lag_ms=29.023
input_lag_ms=4.279
exit_time_ms=424.227
名前内容
first_prompt_lag_msシェルの起動〜最初にプロンプトが表示されるまでの時間
first_command_lag_msシェルの起動〜最初にコマンドを実行するまでの時間(※)
command_lag_ms空のコマンドラインで Enter を押してから次のプロンプトが表示されるまでの時間
input_lag_msキー入力から、実際に対応文字が表示されるまでの時間
exit_time_mszsh -lic "exit"にかかった時間

※シェル起動中にlsとか入力しても、実際に実行されるまではちょっとラグがありますよね?あれです。

こうしてみると単に早くするといってもいくつかの数値があると分かりました。
今回はタイトルどおり「起動」にかかわるfirst_prompt_lagfirst_command_lagに絞って改善します。

## 「速い」って何ms?

「高速化しよう」と言っても、どこまで速くなれば満足するのか決めないと キリがない です。
究極をいえば「一切カスタマイズせず.zshrcも捨てる!」となりますが、現実的ではありません。

そこでhuman-benchの出番です。human-benchを使うと 前述のラグをシミュレート できます。

### human-benchの使い方

human-benchはzsh-benchと同じリポジトリの中にあるのでスクリプトを叩くだけで使えます。デフォルトでは、ラグ0です。

Terminal window
./human-bench

ラグ0なのでサクサクです。

#### ラグを指定してみる

オプションでラグを指定できます。

Terminal window
./human-bench --first-prompt-lag-ms 500 --first-command-lag-ms 3000

最初のlsは2回目のlsと比べると遅いのが分かります。

#### 同じ種類のラグをランダムして違いを体感

同じ種類のラグを複数指定すれば、指定した値の中からランダムにシミュレートされます。

Terminal window
./human-bench --first-prompt-lag-ms 0 --first-prompt-lag-ms 500

実際にシミュレートしてみると結構違うのが分かります。

これを何回か値を変えてみて「 このぐらいのラグなら実質0と体感変わらないな 」となれば、それが目標値となります。

### 数値の参考値

とはいえ、参考値もほしいですよね。次の表は、zsh-benchのREADMEに書かれているzsh-bench作者が「0と変わらないな」と判断した値です。

種類ms
first_prompt_lag_ms50
first_command_lag_ms150
command_lag_ms10
input_lag_ms20

シミュレートし続けてゲシュタルト崩壊した人はこの表を参考にしてみましょう。

## 自分が試した高速化の例

測定方法と目標が決まったらいよいよ高速化です。

※ここから先は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% p10k
12) 1 0.47 0.47 1.68% 0.04 0.04 0.14% prompt_powerlevel9k_setup
13) 1 0.42 0.42 1.49% 0.04 0.04 0.13% prompt_powerlevel9k_teardown
14) 1 0.43 0.43 1.52% 0.01 0.01 0.03% _p9k_setup
15) 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_toolbox
17) 2 0.01 0.00 0.02% 0.01 0.00 0.02% _p9k_restore_special_params
18) 1 0.01 0.01 0.02% 0.01 0.01 0.02% _p9k_init_ssh

参考:22.35 The zsh/zprof Moduleman 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-pagesmanのビュワーはNeovimを使うようになったため
zsh-history-substring-search履歴をたどるのは通常の上下キーやzeno(fzf)の履歴補完で十分なため
zghq+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_load

add-zsh-hook -d precmdで一度読み込んだら削除してます。

その他のプラグインは、dotfilesのリンクだけ置いておきます。

  • fzf
  • zeno
    • プラグイン本体に遅延読み込みが実装される前に独自で実装
    • これから設定する人はREADME見て公式に合わせたほうがよい(筆者もいずれやりたい)

#### プラグインは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"
fi
fi
unset _zcompdump

## 最終的な結果

そんなわけで最終結果は次のとおりです。

==> benchmarking login shell of user eetann ...
creates_tty=0
has_compsys=1
has_syntax_highlighting=1
has_autosuggestions=1
has_git_prompt=1
first_prompt_lag_ms=42.633
first_command_lag_ms=201.335
command_lag_ms=27.856
input_lag_ms=4.435
exit_time_ms=84.751
指標BeforeAfter割合参考値
first_prompt_lag_ms124.68042.633-65.8%50
first_command_lag_ms626.930201.335-67.9%150
command_lag_ms29.02327.856-4.0%10
input_lag_ms4.2794.435+3.6%20
exit_time_ms424.22784.751-80.0%無し

input_lag_msだけ増えてますが単位がmsなのでほぼ変化無しで、他は高速化できました。

体感や参考値と比べるとfirst_command_lag_msだけまだ改善したいなと思いますが、これは別の機会にやろうと思います。


以上、zshの速度改善でした。コミット細かくして都度ベンチマークをコミットメッセージに入れておけばよかったなぁと反省です。

今まで速度改善する時は特に目標とする値を決めてなかったのですが、zsh-benchのおかげでそれが明確になったのはよかったです。(それはそれとして、目標値を突破しても盆栽として速度改善は面白いので不定期でやりたい)