Macで新しいターミナルのウィンドウを開くと、入力できるようになるまで数秒待たされる。同じdotfilesを使っているLinuxの開発機(この記事では forge と呼びます)では一瞬で出るのに、です。

気持ち悪いので中身を点検したところ、起動の遅さ自体は設定ファイルのミスが原因ですぐ片付きました。ところが直したあとで、もっと大きな無駄が毎コマンド発生していた ことに気づきます。プロンプトです。

この記事は、その犯人が vcs_info だと分かるまでと、自前の関数に置き換えて3倍速にするまでの記録です。

まず起動時間を計測する

体感の話をしても仕方がないので、実測から入ります。

zsh -l -i -c exit

これをログインシェルとして何度か回して中央値を取ると、Macは 173ms、forgeは 43ms でした。4倍の差です。

原因はすぐ見つかりました。.zshrc の冒頭でこう書いていたのです。

source ~/.bashrc
source ~/.bash_profile

zshからbash用の設定を読んでいて、その .bash_profile の中にも .zshrc の中にも、同じSDKMANの初期化行が入っていました。1回の起動でSDKMANが2回読み込まれていた わけです。SDKMANの初期化スクリプトは22本のシェルスクリプトを順に読むので、これだけで往復のコストがそのまま2倍になります。

forgeが速かった理由も同時に判明しました。forgeには ~/.sdkman が存在せず、この行が両方とも空振りしていただけだったのです。

bash用ファイルのsourceをやめ、SDKMANを遅延ロードにして、Macは 48ms まで落ちました。

直した先に、もっと遅いものがあった

さて、ここからが本題です。

起動が48msになったのに、git管理下のディレクトリでは相変わらずキビキビしない。プロンプトの右端に出しているgitの情報を疑って、それだけを計測しました。

zsh -fc '
zmodload zsh/datetime
autoload -Uz vcs_info
zstyle ":vcs_info:*" enable git
zstyle ":vcs_info:git:*" check-for-changes true
cd ~/dev/projects/blog
vcs_info                                    # ウォームアップ
t0=$EPOCHREALTIME; repeat 10 vcs_info; t1=$EPOCHREALTIME
printf "%.1f ms\n" $(( (t1-t0)*100 ))'

結果は Mac で 110ms、forge で 13ms

起動が48msなのに、コマンドを1回打つたびに110ms払っている。しかも起動は1回きりですが、こちらは打つたびに毎回 です。優先順位を完全に取り違えていました。

vcs_infoとは何か

vcs_info はzshに標準で付いてくる、プロンプトにバージョン管理の状態を出すための部品です。autoload -Uz vcs_info で読み込み、プロンプト表示直前に走る precmd フックから呼ぶと、$vcs_info_msg_0_ にブランチ名などを詰めてくれます。

私の右プロンプトの [git][blog][main] という表示は、これが作っていました。

重要なのは、git専用ではない ということです。git・hg・svn・bzr・cvs・p4など14種類のバージョン管理システムを、同じ書式指定で扱えるように作られています。これが強みであり、そのまま遅さの原因でもありました。

gitを何回起動しているか数える

推測ではなく計測したいので、git に偽物をかぶせて回数を数えました。これは他の場面でも使える手なので書いておきます。

PATHの先頭に置くディレクトリを作り、ログを取ってから本物に渡すだけのラッパーを仕込みます。

mkdir -p /tmp/wrap
REAL=$(which git)
cat > /tmp/wrap/git <<EOF
#!/bin/sh
echo "\$@" >> /tmp/gitcalls.log
exec $REAL "\$@"
EOF
chmod +x /tmp/wrap/git

あとはPATHを差し替えて1回だけ vcs_info を走らせ、ログを見ます。

: > /tmp/gitcalls.log
PATH=/tmp/wrap:$PATH zsh -f -c '
  autoload -Uz vcs_info
  zstyle ":vcs_info:*" enable git
  zstyle ":vcs_info:git:*" check-for-changes true
  cd ~/dev/projects/blog
  vcs_info'
cat /tmp/gitcalls.log

出てきたのがこれです。

rev-parse --git-dir
rev-parse --show-toplevel
symbolic-ref HEAD
rev-parse --is-inside-work-tree
diff --no-ext-diff --ignore-submodules=dirty --quiet --exit-code
rev-parse --quiet --verify HEAD
diff-index --cached --quiet --ignore-submodules=dirty HEAD

7回

汎用の仕組みなので「まずどのバージョン管理システムかを判定し、バックエンドを選び、必要な情報を一つずつ問い合わせる」という素直な作りになっていて、1問1答でgitを起動していました。設計としては正しい。ただ、それが高くつく環境があったというだけです。

macOSではプロセス起動が一桁高い

その「高くつく環境」がmacOSでした。プロセスを1回起動するコストを両方で測ります。

Macforge(Linux)
/usr/bin/true を1回2.8 ms0.5 ms
git rev-parse を1回16.8 ms1.0 ms

16倍です。
7回 × 約16ms で、ちょうど110msになる。計算が合いました。

ここまで来て、この調査で出てきた遅さがほとんど同じ原因だったことに気づきます。SDKMANが22本のスクリプトを読むのがMacでだけ致命的だったのも、gcloudの補完定義の読み込みに27msかかっていたのも、全部「回数が同じでも1回が高い」という一点に帰着していました。

Linuxではプロセスをばんばん起動する書き方が許されます。macOSでは許されない。シェル設定を両方で共有していると、この差が片方でだけ表面化します。

同じ表示を、gitの起動2回で作る

vcs_info が7回聞いているものを、こちらは2回で済ませます。方針は3つだけです。

1. 問い合わせをまとめる

git rev-parse は引数を並べれば複数の答えを一度に返します。リポジトリのルートと .git の場所は1回で取れる。

git rev-parse --show-toplevel --absolute-git-dir

2. gitを起こさずに済むものはファイルを読む

ブランチ名は .git/HEAD に書いてあります。ref: refs/heads/main のような1行か、detached HEADなら生のSHAです。読むだけならプロセス起動は要りません。

rebaseやmergeの最中かどうかも同じで、.git/rebase-merge/.git/MERGE_HEAD があるかを見れば分かります。

3. 変更の有無は1回のstatusで両方見る

vcs_info は「ステージ済みがあるか」と「未ステージがあるか」を別々のgitに聞いていました。git status --porcelain は1行ごとに2文字のコードを返すので、1回で両方判定できます。

書いたものがこれです。

# psvar: 1=リポジトリ名 2=ブランチ 3=未ステージ印 4=ステージ済み印 5=進行中の操作
__git_prompt() {
  psvar=()
  local -a loc
  loc=(${(f)"$(git rev-parse --show-toplevel --absolute-git-dir 2>/dev/null)"})
  (( $#loc == 2 )) || return
  local top=$loc[1] gitdir=$loc[2] branch='' action=''

  # 進行中の操作は .git 配下の有無で分かる(git を起こさない)
  if [[ -d $gitdir/rebase-merge ]]; then
    action='rebase-i'
    [[ -r $gitdir/rebase-merge/head-name ]] && branch=${"$(<$gitdir/rebase-merge/head-name)":t}
  elif [[ -d $gitdir/rebase-apply ]]; then
    action='rebase'
    [[ -r $gitdir/rebase-apply/head-name ]] && branch=${"$(<$gitdir/rebase-apply/head-name)":t}
  elif [[ -f $gitdir/MERGE_HEAD ]];       then action='merge'
  elif [[ -f $gitdir/CHERRY_PICK_HEAD ]]; then action='cherry-pick'
  elif [[ -f $gitdir/REVERT_HEAD ]];      then action='revert'
  elif [[ -f $gitdir/BISECT_LOG ]];       then action='bisect'
  fi

  # ブランチ名も .git/HEAD を読むだけ
  if [[ -z $branch && -r $gitdir/HEAD ]]; then
    local head="$(<$gitdir/HEAD)"
    if [[ $head == ref:* ]]; then branch=${head##*/}; else branch="#${head[1,7]}"; fi
  fi

  # 1回の status で staged(+) と unstaged(-) を両方判定
  local staged='' unstaged='' l
  for l in ${(f)"$(git status --porcelain 2>/dev/null)"}; do
    [[ $l[1] == '?' ]] && continue          # 未追跡は印を付けない(vcs_info の既定に揃える)
    [[ $l[1] != ' ' ]] && staged='+'
    [[ $l[2] != ' ' ]] && unstaged='-'
  done
  psvar=("${top:t}" "$branch" "$unstaged" "$staged" "$action")
}

プロンプトへの差し込み方

結果を psvar という配列に入れているのは、zshのプロンプト書式から %1v %2v … で直接参照できるからです。%(1V. あるとき . ないとき .) で「配列のN番目が空でなければ」という条件分岐も書けます。

precmd() { __git_prompt }
RPROMPT='%(1V.%F{5}[%fgit%F{5}]%F{5}[%f%1v%F{5}]%F{5}[%F{2}%2v%(5V.%F{3}|%F{1}%5v.)%F{5}]%f%F{1}%3v%f%F{6}%4v%f.)'

git管理外のディレクトリでは psvar が空になるので、%(1V....) の条件で右プロンプトごと消えます。

表示が本当に同じか確かめる

見た目が同じでも中身が違っていたら意味がないので、テスト用のリポジトリを作って状態を作り分け、vcs_info と並べて出力を突き合わせました。

確認したのは9状態です。

  • コミットのない空リポジトリ
  • クリーン
  • 未追跡ファイルのみ
  • ステージ済みあり
  • ステージ済み+未ステージ
  • detached HEAD
  • マージ衝突中
  • rebase中
  • git管理外のディレクトリ

結果、8つは完全に一致。detached HEADのときだけ差が出ましたvcs_infoheads/main(直前にいたブランチ名)を出すのに対し、自作版は短縮SHAの #a1b2c3d を出します。

これは意図して残しました。detachedしている以上、そのブランチにはもういないわけで、SHAのほうが実態に合っていると判断したからです。

結果

Macforge
vcs_info(変更前)110 ms13 ms
自作関数(変更後)36 ms4 ms

3倍。git の起動が7回から2回になったぶんが、そのまま乗っています。

おまけ:/usr/bin/git はXcodeの取次ぎ役だった

計測の途中で、もう一つ気になるものが出てきました。

ls -la /usr/bin/git
# -rwxr-xr-x  78 root  wheel  118928  /usr/bin/git

ハードリンクが78本。これはgit本体ではなく、Xcodeのコマンドラインツール群を取り次ぐためのシム(薄いラッパー)です。本体は別の場所にいます。

両方を直接叩いて比べました。

rev-parsestatus --porcelain
/usr/bin/git(シム経由)16.8 ms22.8 ms
コマンドラインツールの本体を直接6.8 ms12.4 ms

取次ぎだけで毎回10ms損している。プロンプトはgitを2回叩くので、ここだけで20ms。brew install git してPATHの手前にHomebrew版を置けば、プロンプトはさらに半分近くになり、手で打つgitコマンドも全部10msずつ速くなる計算です。

まとめ

  • ターミナルの遅さは、起動よりもプロンプト(毎コマンド)のほうが効いていることがある
  • vcs_info は14種類のバージョン管理システムに対応した汎用の仕組みで、そのぶんgitを7回起動する
  • macOSはプロセス起動がLinuxの16倍高い。回数を減らす のがそのまま効く
  • ブランチ名も進行中の操作も .git 配下のファイルを読むだけで分かる。gitを起こす必要はない
  • 置き換えたら、必ず状態を作り分けて出力を突き合わせる。速いが間違っているプロンプトは最悪

「推測するな、計測せよ」を地で行く調査でした。
体感で「Macは遅い」と言っているうちは何も直りませんが、1回のプロセス起動が16msだと分かった瞬間に、直すべき場所は全部見えます。