Vimのすゝめ改

第3回 なぜ Vim のシンタックスハイライトは遅いのか

2019.07.29

Vim 使いの「ブイ」(仮名)です。Vim のすゝめ改では、現代のテキストエディタについてのあらゆる話題をテーマに Vim の視点から見た話を行います。

今回のテーマは「なぜ Vim のシンタックスハイライトは遅いのか」です。

久しぶりに重い話を扱おうかと思っています。

1 Vim が遅い?

あなたは Vim でファイルを開いていて、「Vim が遅い」と感じたことはないでしょうか。

「Vim が遅い」にもいろいろな原因があり、プラグインが悪さをしていることもあります。しかし、外部のプラグインを入れていない状態でも遅いと感じたのなら、ほぼ「Vimのシンタックスハイライトが遅い」せいです。Vim のシンタックスハイライトを切れば症状は収まるはずです。

Vim のシンタックスハイライトを無効にするには、 syntax off を実行すればよいです。

それではなぜ Vim のシンタックスハイライトは遅いのでしょうか。

実は Vim のシンタックスハイライトというのは正規表現のパターンの固まりとなっています。「シンタックスハイライトが遅い」というのは、「Vim の正規表現処理が遅い」に帰着できるわけです。

だとすると、Vim の正規表現を高速化する方法を考えなくてはいけません。どのようにすれば Vim の正規表現を高速化できるのでしょうか。

2 Vim の二つの正規表現エンジン

以下の issue を見てください。

https://github.com/vim/vim/issues/3937

以下のコマンドの実行が遅いと言われています。

$ time vim -i NONE -u NONE -c "s/^/0/" -c 'echo search("\\%((\\v0|!@!)+0")' -c 'qa!'

この問題の解決方法として上記の issue では set regexpengine = 1 という設定が提案されていますね。'regexpengine' というオプションは Vim の正規表現エンジンを切り替えるためのものです。

Vim には歴史的事情により「バックトラックエンジン」「NFA エンジン」の二つの正規表現エンジンがあります。

もともとは「バックトラックエンジン」のみでしたが、後から「NFA エンジン」が追加され、今では両方の正規表現エンジンによって正規表現は処理されています。

なぜ正規表現エンジンが二つとも追加されているかというと、それぞれのエンジンにより得意な正規表現と不得意な正規表現があるためです。どんなパターンでも高速な万能な正規表現エンジンは存在しないのです。

:help regexpengine を見てみると、この値の初期値は 0 になっています。

                        *'regexpengine'* *'re'*
'regexpengine' 're' number  (default 0)
            global
    This selects the default regexp engine. |two-engines|
    The possible values are:
        0   automatic selection
        1   old engine
        2   NFA engine
    Note that when using the NFA engine and the pattern contains something
    that is not supported the pattern will not match.  This is only useful
    for debugging the regexp engine.
    Using automatic selection enables Vim to switch the engine, if the
    default engine becomes too costly.  E.g., when the NFA engine uses too
    many states.  This should prevent Vim from hanging on a combination of
    a complex pattern with long text.

これは「バックトラックエンジン」「NFA エンジン」の両方を用いて処理します。「NFA エンジン」で処理できないパターンやパフォーマンスが遅いパターンを自動で判別して「バックトラックエンジン」に切り替える仕組みになっているので、本来は両方のエンジンの良いところどりができるはずなのですが、上記 issue を見れば分かる通り自動判別は完璧ではありません。

'regexpengine' の値を 1 に変更することで、正規表現は「バックトラックエンジン」のみを用いて処理するようになります。

3 Vim が不得意なパターン

よく問題となるのは Ruby のファイルです。Ruby は構文が複雑なので、それをシンタックスハイライトするために他と比較して複雑な正規表現が定義されています。これが非常に遅いようです。

他にも Vim が編集している行が極端に長い場合、正規表現のマッチが極端に遅くなるのでパフォーマンスの低下が発生します。これは正規表現でよく使われる .* のような貪欲なマッチだと一度行の最後までマッチしてしまい、その後マッチしなくなるまでバックトラックを繰り返すため、行が長いとバックトラックが大量に発生し正規表現処理が重くなるのだと考えられます。

4 シンタックスハイライトのプロファイル機能

おそらく、ほとんどの Vim ユーザーが知らないと思いますが、Vim には遅いシンタックスハイライトを調査する :syntime コマンドというものが存在します。これを用いることで、遅い正規表現パターンを調査することが可能です。

:help syntime を参照すると使い方は単純明快です。

:syntime on
[ redraw the text at least once with CTRL-L ]
:syntime report

私の手元でも Ruby ファイルを編集した状態で syntime を試してみました。

TOTAL      COUNT  MATCH   SLOWEST     AVERAGE   NAME               PATTERN
0.112071   1115   133     0.000797    0.000101  rubySymbol         \%([{(,]\_s*\)\@<=\%(\h\|[^\x00-\x7F]\)\%(\w\|[^\x00-\x7F]\)*[?!=]\=::\@!
0.016030   1263   415     0.000095    0.000013  rubyConstant       \%(\%(^\|[^.]\)\.\s*\)\@<!\<[[:upper:]]\%(\w\|[^\x00-\x7F]\)*\>\%(\s*(\)\@!
0.015054   1115   133     0.000069    0.000014  rubySymbol         \%([{(,]\_s*\)\@<=[[:space:],{]\%(\h\|[^\x00-\x7F]\)\%(\w\|[^\x00-\x7F]\)*[?!=]\=::\@!
0.013281   983    0       0.000123    0.000014  rubyCapitalizedMethod \%(\%(^\|[^.]\)\.\s*\)\@<!\<\u\%(\w\|[^\x00-\x7F]\)*\>\%(\s*(\)\@=
0.013149   1115   133     0.000061    0.000012  rubySymbol         []})\"':]\@1<!\<\%(\h\|[^\x00-\x7F]\)\%(\w\|[^\x00-\x7F]\)*[!?]\=:[[:space:],]\@=
0.012447   1322   396     0.000056    0.000009  rubyKeywordAsMethod \%(\%(\.\@1<!\.\)\|&\.\|::\)\_s*\%([_[:lower:]][_[:alnum:]]*\|\%(BEGIN\|END\)\>\)
0.011563   1366   384     0.000034    0.000008  rubySymbol         [[:space:],{(]\%(\h\|[^\x00-\x7F]\)\%(\w\|[^\x00-\x7F]\)*[!?]\=:[[:space:],]\@=
0.009774   1031   48      0.000093    0.000009  rubyConditionalExpression \%(\%(^\|\.\.\.\=\|[{:,;([<>~\*/%&^|+=-]\|\<then\s\|\%(\<\%(\h\|[^\x00-\x7F]\)\%(\w\|[^\x00-\x7F]\)*\)\
0.009214   983    0       0.000053    0.000009  rubyUselessLineContinuation \%([.:,;{([<>~\*%&^|+=-]\|%(\%(\w\|[^\x00-\x7F]\)\@1<![?!]\)\s*\zs\\$
0.007968   983    0       0.000100    0.000008  rubyAttribute      \%(\%(^\|;\)\s*\)\@<=attr\>\(\s*[.=]\)\@!
0.007331   956    0       0.000056    0.000008  neosnippetConcealExpandSnippets <`0\|<`\|<{\d\+:\=\%(#:\|TARGET:\?\)\?\|%\w\+(<|
0.007081   983    0       0.000045    0.000007  rubyPredefinedConstant \%(\%(^\|[^.]\)\.\s*\)\@<!\<\%(ARGF\|ARGV\|ENV\|DATA\|STDERR\|STDIN\|STDOUT\|TOPLEVEL_BINDING\)\>\%(\s*
0.006445   983    0       0.000044    0.000007  rubyCharacter      \%(\w\|[^\x00-\x7F]\|[]})\"'/]\)\@1<!\%(?\%(\\M-\\C-\|\\C-\\M-\|\\M-\\c\|\\c\\M-\|\\c\|\\C-\|\\M-\)\=\%
0.006205   983    0       0.000030    0.000006  rubyRepeatExpression \%(\%(^\|\.\.\.\=\|[{:,;([<>~\*/%&^|+=-]\|\%(\<\%(\h\|[^\x00-\x7F]\)\%(\w\|[^\x00-\x7F]\)*\)\@<![!?]\)\
0.006200   983    0       0.000032    0.000006  rubyMultilineComment \%(\%(^\s*#.*\n\)\@<!\%(^\s*#.*\n\)\)\%(\(^\s*#.*\n\)\{1,}\)\@=
0.006119   1048   93      0.000042    0.000006  rubyKeywordAsMethod \<[_[:lower:]][_[:alnum:]]*[?!]
0.006100   983    0       0.000036    0.000006  rubyPredefinedConstant \%(\%(^\|[^.]\)\.\s*\)\@<!\<\%(FALSE\|NIL\|TRUE\)\>\%(\s*(\)\@!
0.006031   983    0       0.000056    0.000006  rubyPredefinedConstant \%(\%(^\|[^.]\)\.\s*\)\@<!\<\%(RUBY_\%(VERSION\|RELEASE_DATE\|PLATFORM\|PATCHLEVEL\|REVISION\|DESCRIPTI
0.005939   1160   195     0.000050    0.000005  rubySymbol         []})\"':]\@1<!:\%(\h\|[^\x00-\x7F]\)\%(\w\|[^\x00-\x7F]\)*\%([?!=]>\@!\)\=

どうやら、Ruby のファイルの場合「シンボル」や「定数」を認識するための正規表現が複雑で遅いようです。個人的には遅い正規表現パターンに \@<=\@<! \@= といったものが使用されているのが気になります。\@ 系のパターンは複雑な処理を要するので遅いです。使用を避けるべきだと思います。Ruby のシンタックスがいくら複雑とはいっても、このようなパターンを導入する必要はないのではないでしょうか。

Vim の正規表現の処理が遅いのはある程度仕方ないとして、シンタックスファイルの改善で対応できる部分もかなりあるのではないかと私は考えています。シンタックスファイルの改善を行うための材料は既に syntime により揃っています。

5 Vim の改善状況

Vim 8.0.0647

https://github.com/vim/vim/commit/06f1ed2

'redrawtime' オプションがシンタックスハイライトに適用されるようになりました。これにより、redrawtime より遅いハイライトはスキップされます。

"slow regex search " 問題について

https://github.com/vim/vim/issues/3937

実をいうと、冒頭の遅い regex パターンがある問題は最新版の Vim ならばもう改善しています。Vim の正規表現エンジンはただ無策に遅いままではないということです。

$ time vim -i NONE -u NONE -c "s/^/0/" -c 'echo search("\\%((\\v0|!@!)+0")' -c 'qa!'
0.02s user 0.01s system 96% cpu 0.034 total

これは regexpengine=1 とほぼ同等の速度のようです。

$ time vim -i NONE -u NONE -c "set regexengine=1" -c "s/^/0/" -c 'echo search("\\%((\\v0|!@!)+0")' -c 'qa!'
0.02s user 0.01s system 95% cpu 0.029 total

6 neovim の改善状況

さて、neovim だとどうでしょうか。neovim と Vim はコアの部分のコードが同一なのでneovim にももちろん同じ問題があります。

https://github.com/neovim/neovim/issues/650

https://github.com/neovim/neovim/issues/650#issuecomment-359299343

Note: 7/10 日現在、最新の neovim でも直っていませんでした。neovim 特有の問題として neovim は Vim のパッチを全て取り込めていないので、Vim に取り込まれている正規表現パフォーマンスの改善の取り込みが遅いからでしょう。

A common performance problem that was seen when the new regex engine was
introduced was lookbehind assertions. Those were enhanced to allow specifying a
maximum number of bytes to look behind instead of being unbounded.

neovim のメンテナによると、Vim/neovim の NFA 正規表現が遅いときは、ほとんどの場合バックトラックが必要な複雑な正規表現を処理しているとの見解があるようです。

This article is made by Vim.

著者プロフィール

v

ブイ。社内では数少ない Vim 使い。ブログ記事の執筆により、社内でのVim の知名度を上げ、Vim を使用する人を増やそうと計画しているらしい。

記事一覧Index