Vim の DirectX を速くした話

Vim の DirectX による描画が速くなった技術的背景を解説します。 というか悔恨録です。

先日の記事 に書いたとおり Vim のカラー絵文字パッチにより、 DirectX (正確には DirectWrite) を用いた画面描画がめちゃくちゃ速くなりました。 その記事にはこんなことを書いていましたね。

そして僕は大きな間違いに気がついた。 詳細を説明はしませんが、一言で言えば「Vimは標準的なWin32アプリではない」

このあたりをちょっと詳細に説明してみようと思います。 なので Vim の話でありながらほとんど Windows の話になります。 しかも無駄に長くなりそうです。(実際なった)

なおこの記事は Vim Advent Calendar 2017 3日めの参加記事です。

遅かった理由

速くなった理由を知るには、その前の遅かった理由を知る必要があるでしょう。 まずはそれを見ていきましょう。

DirectWrite には大きく分けて2つの描画方法があります。 GPUの性能を活かして直接 VRAM へ描画する方法と、 RAM に描いてから VRAM に転送する方法です。 いやこれはざっくりとした説明なので、 正確なことを知ってる人はちょっと目を瞑っていてください。 あ、本当に閉じちゃうと読めなくなっちゃいますね。

まぁわかると思いますけど当然のように後者のほうが遅くなるんですが、 Vim ではその遅い方法を採用していました。 ここには退っ引きならない事情がありました。 いやわかってしまえば「なんだそういうことだったのか」なんですけど、 ほんと…なんていうか…ごめんなさい。

遅い方法を採用した理由

知りたいですか? 知りたいですよね。知りたいと言ってください。

速い方法で実装したら動かなかったんです。 何も表示されなかった、というのが正確でしょうか。 実装できてねーじゃん、というツッコミはそのとおりです。 ドキュメントどおりに作ったつもりが動かなかったんですね。

「マニュアル通りにやっていますというのはアホの言うことだ」 っていう御大将の声が聞こえてきそうです。 なので先へ進みましょう。 つまり、なぜ描画できなかったのかがわかれば、 速い方法で描画できるようになり、先へ進めますね。

Windows と Vim における画面描画 (GDI)

さてここから先を理解するには、 普通の Windows アプリの画面描画がどういう手続きを取るのか、 また Vim がどうしているのかを知る必要があります。

まず Windows において 伝統的な GDI を用いて画面描画をする手続きは以下のようになります。 ちなみに GDI っていうのは Graphics Device Interface の省略ですね。

  1. WM_PAINT イベントを受け取ったら
  2. BeginPaint() を呼び、デバイスコンテキスト(以下HDC)と描画対象領域を取得し
  3. 描画対象領域に対して HDC を用いて描画しまくる
  4. EndPaint() を呼び、HDCを返却して終了
  5. イベントハンドラを抜ける

NodeJS がイベントドリブンだなんだで騒がれる遥か前から、 Windows ではイベントドリブンなプログラムが要求されてました。 Windows に限らずユーザーインタラクションが重要な部分では そうなるものなんですけどね。

重要なポイントは BeginPaint と EndPaint との間で HDC を使って描画するってところです。 こうなっている理由は知りませんが、 ディスプレイドライバが GPU と効率的にやりとりするには こうするのが良いだろうなという推測はできます。

ところが Windows ではこの原則に則らない描画もできてしまいます。 任意のタイミングで HDC を取得して、その HDC を使って描画を要求でき、 実際に描画できてしまいます。 さらに本来ならそうやって取得した HDC は Windows 側に返す必要があるのですが、 実は返さないままアプリ側で保持しちゃっても問題なく描画できちゃったりします。

どうしてそうなっているのかはわかりません。 でもできちゃうものはしかたない。 たとえ Vim がそれを使っていたとしても文句は言えません。

DirectWrite における画面描画

次に DirectWrite における描画手続きを見てみましょう。 以下の通りです。

  1. レンダリング対象 (Render Target: 以下RT) を用意(作成)する
  2. RT の BeginDraw() メソッドを呼ぶ
  3. RT の各種描画メソッドを呼ぶ
  4. RT の EndDraw() メソッドを呼ぶ

こう見ると GDI に似てますね。 うん、じゃあ GDI をそのまま翻訳すれば良いな(安直)。 BeginPaint のところで BeginDraw を呼んで、 EndPaint のところで EndDraw を呼んで、 HDC を使って描画している場所では RT の対応する描画メソッドを叩けば良いな(確信)。 当時の私はこう考えたわけですね。

ここで一つ残念なお知らせがあります。 DirectWrite においては RT の各種描画メソッドは、 BeginDraw を呼んでから EndDraw を呼ぶまでの間しか機能しません。 さらに各描画メソッドはエラーを返しません。というか返せません。 いや返していたとしてもチェックなんてしませんが。

なお DirectWrite の描画手順がこうなっている理由は、 GPUとの通信をまとめて回数を少なくし描画を速くできるように、 ということだと推測されます。 それが GDI に比べて DirectWrite では徹底されているのですね。

なにがあかんかったのか

まず Vim の構造をすっかり忘れて、 前述の置き換えをしただけで BeginDraw と EndDraw が 正しく呼ばれていると思いこんでいました。 もちろん BeginDraw を呼ばないと RT の描画メソッドが機能しないことも知りませんでした。

結果的に「う~ん描画できない~」という苦しい状況の下で、 「おっそうだ!(唐突) メモリに描いて転送すれば描けるんじゃ?」とクッソ適当なハックをでっち上げ、 「できたできた(満足)」と目下の仕事を片付けたことで満足してしまったのです。 結果としてこれが遅い描画のまま Vim の DirectWrite 対応を世に出すことに繋がりました。 穴があったら入りたいですね。 そのまま埋めてしまいましょう。

Vim が普通の Windows アプリだったならば WM_PAINT でまとめて描画するので、 それでも良かったんですけどね。 でも Vim は普通ではなかったのです。しかたないね。

いやほんとすまんかった。

どう変えたのか

さて何が悪かったか気がつけばあとは簡単です。

元々 Vim では DirectWrite を利用するために DWriteContext という コンテキストオブジェクトを導入していました。 先程から出てきている RT などの DirectWrite にまつわるリソースを保持するコンテキストです。 こいつに BeginDraw を呼んだかどうかの状態(モード)を持たせました。

加えて Vim から任意のタイミングで EndDraw を呼べるようにしました。 こうしないと GDI による描画と DirectWrite による描画がかち合って 見た目的に大変なことになります。

あとは描画の度に必要なモード切り替え、 すなわち BeginDraw の呼び出しを行いました。 これにより Vim は晴れて DirectWrite の速い方を用いて描画できるようになったのです。

その後、このモードは 現在開発中のさらに高速化するパッチ においては 以下の3つに分解しています。

  1. GDI による直接描画
  2. DirectWrite による描画
  3. DirectWrite で HDC をシミュレートした描画

モーダルエディタである Vim の中に、 こんな小さな単位で内部モードが導入されているのは なんだか面白いと思います。

まとめ

この事例から学べることを3つほどあげて、 この記事のまとめとしておきましょう。

  1. ドキュメントはよく読みましょう。

    BeginDraw の解説には、一番最初にこう書いてあります。

    描画操作を発行できるのは、BeginDraw を呼び出してから EndDraw を呼び出すまでの間のみです。

    いや僕が初めて見た時にもあったかはわかりませんが。

  2. 初めてのライブラリを使う際には、小さなプログラムを書いて実験しましょう。

    今回は DirectWrite で絵文字を描くほぼミニマムなサンプル を いじくり回すことで突破口が開けました。 このサンプルが当時にあったわけではありませんが、 似たようなものを作って試すことはできたはずです。 それをやっていればこうはならなかったかもしれません。 たぶんね。

  3. 正しくプログラミングしましょう。

    Windows なら Windows、プラットフォームが推奨する方法で プログラムを書きましょう。 そうではないハックを用いてアプリを作れば、 いずれすぐにさらなるハックが必要になります。 その果てに行き着くのが天国なわけはありません。 思い出 の塊と化すのです。 しかもこの思い出、その補正はまず間違いなく悪い方に働きます。

    もう言い訳にしかなりませんが、 仮に Vim が描画を WM_PAINT だけでやっていてくれれば 何も問題は生じませんでした。 さらなる高速化の余地も容易に生まれていたはずです。 いずれそんな形に直せたら良いなぁとは考えています。

以上、Vim Advent Calendar 2017 の3日め記事でした。