遅いターミナルを使うには人生は短すぎる
(mijndertstuij.nl)- 一日中使うターミナルの速度は作業効率を左右し、新しいタブを開く・タイピング・自動補完のわずかな遅延が1日に何百回も積み重なると非効率になる
- 完全に読み込まれたインタラクティブシェルが自動補完・構文ハイライト・自動提案・fzf・direnvを含んでいても約30ミリ秒で起動し、新しいタブも即座に開くよう改善
- 最大の秘訣は oh-my-zsh や prezto のようなフレームワークやプラグインマネージャを使わないことで、プラグイン3個だけを直接 git clone して
.zshrcで source している compinitのキャッシュ、遅延読み込み (lazy-loading)、非同期プロンプト、GPU アクセラレーション対応ターミナルなどで起動・プロンプト・入力遅延をすべて最小化- ほとんどの最適化は何かを追加することではなく不要なものを取り除くことであり、実際によく使うものだけを意図的に追加する節度が重要
高速なターミナルが必要な理由
- ほぼすべての作業がターミナル内で行われ、Git・kubectl・tmux・サーバーへの ssh 接続などを一日中使っている
- それだけ頻繁に使うツールは速くあるべきで、新しいタブを開く・文字入力・Tab 補完での遅延を1日に何百回も体感する
- こうした微細な遅延が積み重なる状況は千の切り傷による死 (death by a thousand cuts) のようなもの
シェル起動速度の測定結果
- 改善後、シェルは約30ミリ秒で起動し、測定コマンドとして
for i in {1..5}; do /usr/bin/time zsh -i -c exit; doneを使用 - 自動補完、構文ハイライト、自動提案、fzf、direnv をすべて含む完全なインタラクティブシェルが30fps の単一フレームより短い時間で読み込まれる
- 大規模な最適化プロジェクトがあったわけではなく、長年にわたりシェルを最小限かつ高速に保ってきた習慣の結果
- すべての設定は dotfiles リポジトリ で公開されている
フレームワークなし
- 最大の利点は存在しないものから生まれ、oh-my-zsh・prezto・プラグインマネージャを使わない
- oh-my-zsh の何百ものプラグインやテーマのうち約 5% しか使わないのに、残り 95% に対する時間と計算資源のコストをシェルを開くたびに支払うことになる
- プラグインマネージャはその上に追加のオーバーヘッドを加える
- 使っているのは正確に3つのプラグインだけで、インストールスクリプトが一度 git clone したあと
.zshrcで source するfzf-tab,zsh-autosuggestions,zsh-syntax-highlighting- 起動時に依存関係の解決を行うプラグインマネージャはなく、すでにディスク上にあるファイルを source することには実質的にコストがない
自動補完のキャッシュ
compinitは一般的な.zshrcで最もコストの高い処理のひとつで、デフォルトではシェルを開くたびにすべての自動補完ファイルに対するセキュリティ監査を実行する- 解決策は、キャッシュ (
.zcompdump) が 24 時間より古い場合にのみ完全実行し、それ以外は-Cでチェックをスキップすること- glob 修飾子
#qNmh-24は「存在し、過去 24 時間以内に変更された」ことを意味する - 完全な
compinitは1日1回だけ実行し、それ以外の時間はキャッシュ済みの読み込みを使う
- glob 修飾子
遅延読み込み (Lazy-loading)
- nvm はシェル起動速度低下の最も悪名高い原因のひとつで、起動時にすぐ source すると0.5秒は簡単に追加されうる
- すべてのシェルで nvm が必要なわけではなく、
nvmを入力したときだけ必要なので、最初の使用時に自分自身を置き換える関数で包む- 最初の
nvm呼び出しでスタブを削除し、本物の nvm を source したうえで(--no-useにより node バージョン解決も防ぐ)引数を渡す
- 最初の
- kubectl の自動補完も同じ方式で、
kubectlバイナリを呼び出して補完スクリプトを生成するため、実際に最初に実行されたあとにだけ読み込む eval "$(tool init zsh)"を.zshrcに入れるよう案内するすべてのツールは、起動時にプロセスを fork して出力を評価するため、遅延読み込みの候補になる- direnv と fzf は高速で頻繁に使うため即時読み込みのままにし、実際によく使うものが何かを厳密に判断する必要がある
ノンブロッキングなプロンプト
git statusを同期的に実行するプロンプトは、ある程度大きなリポジトリで遅延が発生し、これは Enter を押すたびに感じられるため遅い起動より悪いこともある- pure を使っており、プロンプトは即座に描画し、git 情報は準備でき次第非同期で埋める
- zsh 組み込みの
vcs_infoに一時的に置き換えてみたが、pure の非同期動作のほうが良かった - 自分でプロンプトに非同期の git status を実装することもできるが、pure がその用途向けにうまく包んでくれる
ターミナルエミュレータ自体
- シェル起動は半分の話にすぎず、エミュレータ自体が入力遅延を追加する
- GPU アクセラレーション対応のネイティブターミナル Ghostty を使っており、設定はたった 7 行
tmux new -A -s mainのエイリアス (t) と組み合わせることで、新しいターミナルウィンドウが既存セッションへすぐ戻るようにしている
自分のシェル性能を測る方法
- 時間がどこに使われているかは自分のターミナルで測定でき、確認すべき遅延は起動時間、プロンプト遅延、入力遅延の3つ
- 基本的な測定は
time zsh -i -c exitを数回実行することで、最初の実行はコールドキャッシュのため常に遅い- 100ms 未満なら問題なく、50ms 未満なら素晴らしく、500ms 以上なら手を入れる余地がある
- 正確な統計には hyperfine を使用:
hyperfine --warmup 3 'zsh -i -c exit' - zsh 組み込みのプロファイラを活用
.zshrcの先頭にzmodload zsh/zprof、末尾にzprofを入れると、時間がどこに使われたかを並べた表を出力する- 上位項目は通常
compinit、nvm.shの source、eval "$(...)"で、最上位の項目から修正して再実行を繰り返す - 完了後はその 2 行を削除する
- zprof で足りなければ、タイムスタンプで起動全体を追跡する:
zsh -ixc exit 2>&1 | ts -i '%.s' | sort -rn | head -20- または
PS4='+%D{%s.%6.}: 'を設定してzsh -ixc exit 2> startup.logを実行し、行間の大きなジャンプを確認する
- または
- 起動は速くてもプロンプトの再描画が遅い場合があり、最大の git リポジトリに
cdして Enter を押し、次のプロンプトが出るまでに遅延があれば、プロンプトが同期処理をしているということ- 非同期プロンプトに切り替えるか、Git 機能を外すという選択肢がある
まとめ
- ほとんどの最適化は何かを削ぎ落とすことに関するものであり、意図的に行動し、実際に使うものだけを追加することが重要
- そうすれば1日に開く何十ものセッションがすべて即座に開き、ターミナルは待たされるアプリケーションではなく、頭脳の拡張のように感じられるツールになる
- 一日中使うツールにおいて、この速度は妥協できない
- 上記のすべての設定は dotfiles リポジトリ で公開されている
1件のコメント
Lobste.rsの意見
厳密に言えば、たいていはターミナルではなくシェルのことを指している
デフォルトがまともなツールを使うのがよく、だからfishを使えばよい
矢印キーで選べるモダンなタブ補完のような機能が最初から入っているのがよく、個人のマシンではまだZSHを使っているが、それはNix設定とhome managerをいじる時間がないからだ
妥当なデフォルトと高速な内蔵補完がありつつ、bashベースのツールを捨てたり書き直したりしなくてよいシェルだといい
ノンブロッキングプロンプトやOpenGLベースのターミナルのような取り組みが、xtermで
PS1="\W: "程度しか使わないことより本当に価値があるのか、ときどき疑問に思うしかも非常に高速で「標準」であるという利点もあり、残っているバグもたいていは些細か、その中で動くプログラム側が正常動作として扱う可能性が高い
なのでまたxtermを使うようになった
zshの起動は本来非常に速く、遅くなるのはユーザーが遅くしたときだけだ
理解していないものを大量に入れなければよく、「ミニマル」と呼びながらプロンプトを作るたびに何百ものコマンドを実行するライブラリもこれに含まれる
私のzsh設定は90年代からごくゆっくり発展してきた数百行のもので、すべての行を理解しており、なぜそこにあるのかも分かっている
特別に高速化しようとしたことはないが、それでも20msで起動し、遅くなりそうな愚かな変更をしたらすぐ気づいて直せる
time zsh -i -c exitのような壊れたベンチマークがいまだによく使われているのが嫌だ完全に間違った対象を測っており、一部のzshプラグインマネージャは実際のシェル起動遅延を犠牲にしてまでこの役に立たない指標に最適化していた
zsh-benchには、このベンチマークがなぜ無意味なのかを説明するセクションがある: https://github.com/romkatv/zsh-bench#how-not-to-benchmark
zsh-benchが測る最初のプロンプト遅延や入力遅延のような指標のほうがずっと有用だ
GPUアクセラレーション付きターミナルのバグの話かと思ったが、そうではなくてよかった
補完キャッシュはよいヒントで、Macの会社支給マシンでzshを使っているが、新しいタブを開こうと考えただけでビーチボールが出るので助けになってほしい
kubectl補完の場合、遅い部分が補完生成なのか読み込みなのか気になるし、前者ならファイルに保存してから読み込めば起動時間が短くなるのか気になる
jjではそうしていて、jjへ移行する際にgit statusを実行するプロンプトは捨てた投稿者が自分の時間も示してくれていたら、私の0.287秒が平均的なのか遅いほうなのか分かったのにと思う
その後測ってみると、ほぼ空の
.bashrcは0.007秒、skimキーバインド後は0.043秒、mise後は0.115秒、jj補完後は0.186秒、/etc/bashrcまで読むと0.294秒で、改善の余地がありそうだtime shell -c exitテストでは私のものは約50msだった他人のLinux環境を使うときにいちばん腹立たしいのは、あちこちにある無駄なアニメーションだ
自分のコンピュータではショートカットキーを押すとターミナルウィンドウがほぼ即座に開き、ときどきウィンドウとプロンプトの間に短いちらつきが見えるだけだ
だから新しいウィンドウを開いてシェルで何かして閉じるまでの、エンドツーエンド全体のテストが重要で、
time mytermのあとウィンドウでCtrl+Dを押して閉じると常に0.120秒未満だった無駄なアニメーションやコンポジットをなくせば可能になることは多く、2つのスプレッドシートの差分を見るときも2つのウィンドウを最大化してからウィンドウを巻き上げるショートカットで素早く切り替えると、差がすぐ分かった
WindowsでExcelのアニメーション付きで同じことをやるのは散漫すぎる
空の設定でも
zsh -i -c exitが平均129.8msで、設定全体でも約250msとかかる時間は似たようなものだcompinitキャッシュで平均5msほど削れはしたが、補完が欠けることがあるので、それだけの労力をかける価値は大きくないと思う
最近zshの起動がほとんど止まったように遅く感じられ、原因は正確には突き止められていないが、compinitがクリティカルパスの大半を占めることは確認した
記事で提案されていた方法とほぼ同じようにキャッシュを実装して遅さを解消し、すてきなglob qualifierを見て自分のやり方も改善すべきだと感じた
そんな機能が可能だとは知らず、正直ちょっと怪しい機能にも見えるが、それでも使うつもりだ
これまでは対象パスを作るとき、比較的ぶっきらぼうな
date -Id方式を使っていたzshのように完全なプログラミング言語で設定できるツールはよく、作者がキャッシュ機能を追加しなくても自分で実装できる
20年近くzshを使ってきたが、フレームワークやプラグインマネージャを使ったことは一度もなく、こうしたものは主にスタイリングのために使われているように見える
私はコンピューティング環境の見た目を気にしないので運がよく、自作のプロンプトも基本的で小さく情報量はあるがまったく派手ではなく、黒背景のデフォルトのターミナルテーマを使っている
複数のシェルインスタンスが並列で同じことをすることもあり、tmuxで実習用の並列インスタンスを立ち上げるときによく起きた
また複数ホスト、特にコンテナ間でホームディレクトリを共有することもあり、結局ロックファイルと期限切れチェック、
zcompileの条件処理まで入れた方式に落ち着いた残念ながらfish設定も徐々に同じ方向へ流れていったようで、月曜の休憩時間にプロファイリングして、遅延ロード手法が実際に自分のケースで有効か確認するつもりだ
遅くなる時間の大半はStarshipのgitモジュールのせいだと思うが、遅延ロードできそうなエイリアスやヘルパー関数もかなりある
Emacsではかなり前からバックグラウンドのステージングシェルをあらかじめ初期化している
ターミナルを開くというのは、そのバッファ用に新しいウィンドウを開いて名前を変えることで、次回のためにシェルを再準備するスレッドをforkする
だから起動遅延がない
以前reptyrでEmacs外の解決策も無理やり作ろうとした記憶があるが、結局そちらを使い続けることはなく、理由はよく覚えていない
https://github.com/nelhage/reptyr
同じように調べていたら、
zsh-abbrが起動時間を約100ms食っていると分かったが、その程度なら構わないあちこちで10msずつ削ることはできても、失う機能を考えると割に合わないように思える
起動時間約300msでやっていくつもりだし、十分速いし、ターミナルを連続して開きまくったり即座にタイピングしなければならない場面もあまりない
それでも記事はよく、
hyperfineを知ることができたし、zshの起動ファイルをいくつか見直すきっかけになったおかげで長く先延ばしにしていたzshrcの修正をやって、いまは80msまで下がってとても満足している
私の人生は遅いターミナルを受け入れられるほど十分に長いし、ときにはターミナルがもっと遅ければいいのにと思うことさえある
たとえばrootコンソールで実行前にデフォルトで5秒の遅延を入れておき、タイプミスをCtrl+Cで取り消す時間があったなら、反抗的だった若いころの何日かは節約できたかもしれない