fuku.day

@aumy_f, 2024-12-28-zsh:

Ghostty使ってたらzshの起動が遅いのが気になってきた


神速のzinit: M4覚醒 #zsh

Ghosttyが出ましたね。俺はWezTerm入れてたけどあんまり使いこなせてなくてなんかな、と思っていた結果新しいMacBook Proを買ったけどTerminal.appで済ませてしまっていたのでとりあえず入れてみることにした。ネイティブUIということを言っていたので「あーはいはいElectron不使用のことね、でも本来のネイティブUIというのはOSのUIライブラリを使用してそのデザインシステムと一貫した体験提供することこそがだね〜」というくそみたいな説教を始めようとしたらmacOS向けではSwiftを書いてAppKitとSwiftUIを叩いててsecure input APIのようなLinuxで同等の機能がないものにも対応しているということが書いてあって今平謝りモードになっている。HashiCorpという成功をすでに掴んでいることで収益性を気にせずクオリティ高いものを作れるというのよすぎる。俺もなりたすぎ

というGhosttyの話とはあまり関係がないが、Ghosttyを入れてちょっと開けたりしてみて、思った。zshの起動が重すぎる。適当に測ったらなんと600msもかかっている1。Core Web VitalsのINPでいえばPOORの域であり、たいへんよくない2

 time zsh -i -c exit
zsh -i -c exit  0.41s user 0.19s system 96% cpu 0.623 total

というわけでzshを高速化しよう。

これまでのあらすじ

昔から、それこそプログラミング本格的にはじめた高校ぐらいの時期からstarshipというプロンプトを使っていた。Rust製でblazing fastと自称するCLIツールの鏑矢にあたるものかと思うのだが、実はこいつがボトルネックになるシチュエーションがけっこう存在する

これはなんでかというと、starshipにはGitリポジトリの状態、つまり「差分があるかどうか」「pull可能な変更があるかどうか」といったものをプロンプトに表示する機能があるんだが、これが同期的に行われている、すなわち取得を待たないとプロンプトを表示できないから。一方でsindresorhus/pureは非同期的に実装されていて、Gitの状態取得を待たずにプロンプトを出し、取得が完了したらあとからプロンプトに反映するようになっている。git status は大きなリポジトリでは低速化していく(NixOS/nixpkgsだとわかりやすい)3ため、ls するたびにプロンプトが出るのが遅くややダルい。starshipのほうに非同期取得が行われるようになればいいんだけど、根本的な再設計が必要ということでそういう予定はなさそう

というわけでstarshipをやめてsindresorhus/pureに変えた。starshipのが多機能だが、まあなくてもとくに困らないぐらいのラインが多くてな。

ちなみにこの内容をgifつきツイートしています: https://x.com/aumy_f/status/1870521996066635956

zprof

というわけでstarshipの話をブログで消化するのを終えたので時間軸を今に戻しますね。starshipで毎プロンプトが改善されてはいたんですが、しかし起動時間は普通に遅かった。このような場合にはまず計測ですね。zshでは zprof というものが(たぶん)組み込みであるらしい。.zshenv とかに以下を入れて、

zmodload zsh/zprof

.zshrc の末尾にこれを入れておくと、zsh起動時に関数ごとの呼び出し回数と実行時間を見せてくれる。

if (which zprof > /dev/null) ;then
    zprof | less
fi

めんどいので例は貼らない、読み方とかは勘で頼む。基本的に複数回の呼び出しを足した時間で長いのが上に来るようソートされる。呼び出し回数、合計時間、呼び出し1回あたりの平均時間(?)、zsh読み込み全体に対する割合とかがあって、関数が呼んだ関数による時間を含める(time 列、ブラウザdevtoolsでいうtotal time)か否(self 列)かで2種類があるんじゃないかと思う。このデータからなにをいじるべきかはもちろん(CPUを改善するといった筋肉ソリューションも含めて)ケースバイケースで、俺の場合は以下のあたりを最適化した。

1回でいい関数が複数回呼ばれてる

compinit のような読み込みとか有効化系のやつはだいたい1回しか呼ぶ必要がなく、compinit は複数回呼ぶと探索が毎回走るのか普通に100msとか重くなる。俺の環境だと nix-darwin がデフォルトで /etc/zshrccompinit する設定を挿入していたが、これは余計なので切っておく。また、promptinit も挿入してくるが、これもsindresorhus/pureのREADMEを読む限りzinitでのインストールには不要らしいので切っておく。

{ pkgs, ... }: {
  programs.zsh.enableGlobalCompInit = false;
  programs.zsh.promptInit = "";
}

ライブラリの遅延読み込み

zinitにはTurbo Modeというのがあり、非同期的にプラグインとかスクリプトを読み込みできる。特定のディレクトリとかをトリガーにするとかいったフックの自由度もあり、ものによるがうまく使えばだいぶ高速化が期待できる。

ただまあプラグイン類をむやみやたらに遅延読み込みすると普通にぶち壊れる、たとえばazu/ni.zshとかエイリアスとかコマンドを追加する系を遅延させると zsh -i -c とかで command not found とか言われることになる、ので、このプラグインはどのライフサイクルで読み込まれるべきかというのをREADMEとかソース読んで把握し、zinitのオプションとにらめっこして適切なフックタイミングとかを渡してやる必要がある。

ここんところzinitはだいぶシェアが多いのでプラグインやツールがREADMEでzinitでの適切な遅延ロード設定方法を案内していたりして助かる、これを丸パクリするのがよいと思う。

疲れてきたのでくわしいことはコミットとか見てほしい。パクリ元も書いたんで https://github.com/AumyF/dotfiles/commits/master/?since=2024-12-28&until=2024-12-28

結果

 time zsh -i -c exit
zsh -i -c exit  0.03s user 0.03s system 76% cpu 0.082 total

 node -p "623 / 82"
7.597560975609756

7.597560975609756倍速くなる!!!!!!!!!

実際このぐらいの速度になるともはやGhosttyを起動してウィンドウが開いたのを確認してそっちに視線を動かすとかタブを開いてそれを確認するとかいった行為をやってる間にプロンプトがもう出ている。つまり、思考の速度より早くなったのだ。たいへん気分がいいね。

余談

  • Q. M2覚醒じゃないんですか?
    • A. 普通に俺のMacBookってM4なんで嘘になるし。M2x2ってことでどう?
  • Q. 神速のゲノセクト好きなんですか?
    • A. みんなの物語、ボルケニオンと機巧のマギアナが好きです

Footnotes

  1. ばらつきを抑えるために for で10回回すというのもよくやられる。実際おそらくCPUかなんかのキャッシュにより2回目3回目以降で20-40ms程度高速化して安定するのだが、そんなにzshを起動しまくることはないのでキャッシュが効いてモーターのコイルがあたたまっている状態の数値は実際の利用シーンを反映した指標ではなく、これを見て快適か快適じゃないか判断するのは適切ではない、ゆえに単発で撃って測るのがいいと個人的には思う。

  2. そもそもWebの指標をターミナルに持ち込んでいるわけで真剣な議論がしたいわけじゃないけど、ネットワークからリソースを持ってくることが前提のLCPと比べて純粋にインタラクションの速度を見るINPの数値はこういう場面でも十分に機能するんじゃないかと思う

  3. git status を高速化する方法もあるようだが、巨大リポジトリではユーザーインタラクションをブロッキングして許される時間(このあとの最適化の成果とあわせて考えればだいたい100ms以内)には収まらないようだし、環境が違えばもっと遅くなることもありえるわけで、「M4でこの速さなら同期でも耐えられる」とかいった価値判断はおそらく有効には機能しない。要するに、非同期にするべきだと思う