ソフトウェアのパフォーマンスに関する考察

Nelson Elhage
2020/02/02

これまでのキャリアの中で、私はパフォーマンスが決定的な特徴であるプロジェクトに少なくとも三つ取り組んだ: Livegrep, Taktician, Sorbet である (Sorbet については前回の記事で、Livegrep については以前の記事で細かく論じた)。自分が使うツールのパフォーマンスを改善したこともあり、一部は Accidentally Quadratic という私のもう一つのブログにまとまっている。

この記事では、パフォーマンスに優れるソフトウェアを自分で書く中で、そしてパフォーマンスがそれほど優れない数多くのソフトウェアの改善に取り組む中で私が学んだ教訓について考察したい。

パフォーマンスは機能である

「パフォーマンスはツールの機能や特徴と独立する副次的な性質ではない」と私は強く意識するようになった。パフォーマンス──特に、圧倒的に高速なこと──はそれ自身が一つの機能であり、ツールの用途や印象を根本的に変えてしまう。

Sorbet を実際のコードベースに導入したときに Stripe エンジニアから受けたフィードバックや称賛では、Sorbet が速いことが繰り返し指摘された。低速なツール1に満ちたこの世界において、開発者は高速でレスポンシブに感じたツールを心から評価するものだ。

頭の中でならパフォーマンスの価値がよく理解されることもある──レスポンス時間に関する知覚しきい値コンバージョンレートに対するレイテンシの影響を知っていて話題に出すエンジニアは多くいる。しかし一方で、こういった事実が血肉になるまで理解されることはほとんどなく、与えられるのは実際の投資ではなくリップサービスである場合が多い。遅いソフトウェアを嘆き悲しむ人は最近よく見かけるものの、それに対して何かしているチームを見かけるのは稀に感じる──私たちが普段利用するツールは遅くなり続けている。私は自分の経験から、私たちのツールが原因で高速なソフトウェアを書くことはますます困難になっているのは確かであるものの、高速なソフトウェアの作成は依然として十分可能であり、依然として行うだけの十分な価値があると強く信じるようになった。

パフォーマンスはソフトウェアの用途を変える

ユーザーが高速なソフトウェアを好むのは全く当然に思える。何らかのタスクを行うとき、ツールが遅いよりは速い方が使い心地が向上するだろう。

これより明らかでないのは、高速なツールがあるときユーザーはツールの使い方やタスクの片付け方を変えるという事実である。ユーザーはまず間違いなく目標を達成するための戦略を──「このタスクはやめて、他のことをやろう」も含めて──複数持っており、時間の経過とともに高速なツールを頻繁に使うようになっていく。高速なツールを手にしたユーザーはタスクを速く完了できるだけではなく、全く新しい種類のタスクを達成できるようにもなる。私はこの現象を Sorbet と Livegrep の両方ではっきりと目にした。

Sorbet では、開発されつつあるコードに対するフィードバックを素早くユーザーに提示することがプロジェクトの主要な目標の一つとされた。Stripe は高品質でコードカバレッジの高い大規模なテストスイートを持っており、その実行は非常に一貫して 10–15 分の間に終わっていた。Sorbet によって一部のテストやランタイムエラーが削除されるとは予想されていたものの、テストスイートを小さくしたり追加の安全性をプロダクションに提供することは主要な目標ではなかった。その代わり私たちが願っていたのは、開発中のフィードバックループを短縮し、ユーザーに対して実行可能なアクションを示したコードに対するフィードバックを以前よりずっと速く提示することだった。

Stripe のコードベースに含まれる個別のテストを開発環境で実行すると 10–20 秒かそれ以上かかる。Sorbet はコードベース全体をその程度の時間で型検査することに成功したので、Sorbet は多くの開発者にとって自身のコードに対する何らかのフィードバック (typo や API の使い間違いといったすぐに見つけられる種類のエラー報告) を得る最速の手段となった。Sorbet が最速の選択肢なので、Sorbet の開発や導入が始まってすぐにユーザーはコードをチェックする最前線のツールとして Sorbet を利用し始めた。多少質が劣ったとしても、素早く手に入るフィードバックは数分かかって手に入るどんなものよりもずっと重要となる。

実行が速いと、ユーザーは偽陽性のエラー (間違っていない部分を間違っていると Sorbet が指摘すること) に (比較的) 寛容になる。エラーを訂正する方法 (例えば T.must を追加するなど) さえはっきりと示しておけばいい。もし Sorbet の実行時間が CI と同程度──10 分程度──だったとしたら、ユーザーと Sorbet の関係は今と大きく異なったものになるだろう。ユーザーに素早くフィードバックを提供できるというアドバンテージが失われるので、Sorbet は CI で非常に明確なバリューを提供しなければならないだろうし、ユーザーに Sorbet の設定を変更させることほとんどないだろう。

livegrep のユーザーを観察すると、パフォーマンスが持つ同様の利点を見ることができる。livegrep は十分な根拠のある閾値である 100 ms 以内にほとんどの検索に対して応答できることを目標としている。100 ms 以内であれば多くのユーザーは応答が「瞬時」だと認識する。livegrep はそれだけの速さを持つので、ユーザーは他の検索エンジンではほとんど見たことがない対話的な方法で livegrep を利用する: まずクエリを入力して検索結果を得る。その結果が多すぎたり少なすぎたりしたら、結果のリストを元にクエリを編集し、新しい結果を確認する。以上の処理をユーザーは所望の結果が得られるまで繰り返すのである。

私は livegrep の検索処理を正規表現を使って書いたのだが、その理由は「技術的課題として面白そうだから」が主だった──理論上は、構文あるいは意味論を認識する検索の方が livegrep の用途には適しているように思える。しかし、構文を認識するコード検索ツールで livegrep と比べられる程度の速度を持つものを私は見たことがない。また livegrep の対話性によって検索を反復しながらのクエリの改善が簡単になり、新しい力が livegrep にもたらされたことに私は満足している。加えて、多くのエンジニアが既に正規表現にある程度慣れているという事実、そして対話的に実験して結果をリアルタイムに確認できる機能が合わさることで、livegrep は複雑なクエリ構文を使った場合には不可能なほどに非常にアプローチしやすくなっている。

パフォーマンスに対してはプロジェクトのライフサイクルを通じた注力が必要である

パフォーマンスを全く問題にしないことは最近ますます普通になっているように思える。プロジェクトの初期段階では特にそうだ。次のような言葉を聞いたことがあるだろう:

Ruby や Python が「十分速い」と主張する人々からも同じような言葉を聞くことがある。パフォーマンスは後から考えるものでしかなく、速いコードより動作するコードの方がはるかに重要だ、というのが彼らの言い分のようだ。

主流の考え方をまとめれば「まず最も書きやすい形でアプリケーションを書き、正しく動くようになったらプロファイラを有効にしてホットスポットを個別に最適化する。必要なら高速な言語あるいはテクノロジを使った個別のコンポーネントの書き直しだって行う」となるだろう。

これは一般的なアドバイスとしてなら申し分ないのかもしれないと私も思う。一方で、この考え方の欠点を認識し、必要なときは他のパラダイムを採用することが非常に重要だと私は学んだ。特に、この「パフォーマンスは最後に」モデルが真に高速なソフトウェアを生み出すことは (仮にあったとしても) ほとんどないと私は強く信じるようになった (そして先述したように、真に高速なソフトウェアは目指すだけの価値がある目標だと私は思っている)。このモデルが真に高速なソフトウェアを生み出せない理由について、私は次の二つを指摘できる:

アーキテクチャはパフォーマンスに大きく影響する

システムの基礎的なアーキテクチャ──高いレベルの構造、データーフロー、組み立て方──はパフォーマンスに重大な影響をもたらすことがよくある。以前の記事でも議論したように、私たちは Sorbet で「型推論をメソッド内でのみ行う」という設計判断を下した。この設計判断は型システムとユーザーエクスペリエンスに影響を及ぼしただけではなく、Sorbet を格段に単純かつ高速に、そして並列化と差分計算の実装を簡単にした。このアーキテクチャに関する判断は開発に先立ってすぐに下せるものであるものの、それを後から変更するのは非常に難しい。Facebook の Flow チームは Flow と社内コードベースを現在よりローカル中心のモデルに変更する数年単位のリファクタリングを行っている2

真にパフォーマンスに優れるソフトウェアを作りたいなら、開発初期に下される設計・アーキテクチャに関する決断を下すときにパフォーマンスを少なくとも念頭に置く必要がある。さもないと、後になって非常に厄介な状況に陥るだろう。

パフォーマンスはホットスポットだけが全てではない

Sorbet は、他のコンパイラや型推論器と同じく、ごく少量のホットスポットしか持たない: ツールの主要なパスがそれぞれ同程度の実行時間を占め、プロファイラを見るとそれぞれのパスの様々なコードに時間が費やされているのが分かる。この「散らばった」プロファイル結果は、遅い型検査器を作ってホットスポットを最適化して高速化するアプローチが不可能も同然であることを意味する。ホットスポットがそもそも存在しないからだ。

そういったアプローチではなく、Sorbet では以前の記事で議論したような数多くのテクニック──例えばキャッシュを意識して最適化されたデータ構造、あるいは C++ でコードを書くこと──によって本質的に型検査器の全ての行に対して高速化が行われた。この高速化の効果はアプリケーション全体で積み重なっていく。

積み重なるパフォーマンス改善の影響に関して、SQLite 3.8.7 のリリースに関する私の好きな逸話がある。このリリースは、それぞれは 1% に満たないパフォーマンス改善が積み重なることで、前回のリリースより 50% 高速となった。この例はコードベース中の様々な箇所における小さなパフォーマンス改善に注目する利点を示している。それぞれは大きくなくても、積み重なれば大きな改善になる。また、SQLite の開発者は数多くの小さなパフォーマンス改善を後から行ったものの、最初から 1% のパフォーマンスレグレッションをなるべく避けておけばそれだけ、後から行う最適化の仕事も簡単になる。

高パフォーマンスの基礎はアーキテクチャを単純化する

私がもう一つ観察したのは、高いパフォーマンスを持つコアを最初に作ると、既定の機能を実装するとき最終的なソフトウェアプロジェクトのアーキテクチャが格段に単純になるという事実である。

Sorbet を書く前、Stripe は内部向けに書かれた Ruby の静的解析器を持っていた。このツールは Sorbet に実装された解析の非常に小さな部分集合 (グローバル定数の解決) が行えた。このツールは Parser gem を使って Ruby で書かれているために Sorbet より格段に遅く、加えて Ruby の GVL のために並列化が難しかった。このため私たちは fork ベースの並列化を使ったさらに複雑なキャッシュ機構を作ることになった。このツールの実行を実用可能な時間で終わらせるためだけに、である。元々のパフォーマンスに優れる Sorbet はキャッシュ機構を使わなくてもこのツールより高速だったので、内部では単純な並列化を使っている。

ここから一般的な観察が得られる: 遅いシステムのパフォーマンスを改善しようとすると、しばしば複雑性が生み出される。つまり、複雑なキャッシュ機構、分散システム、あるいはインクリメンタルな再計算を正確に行うための追加の記録処理といった形で複雑性がシステムに追加される。こういった機能によってシステムは複雑になり、新しい種類のバグが発生する。加えてオーバーヘッドも加わるので普通の入力に対するパフォーマンスは悪化し、問題はかえって大きくなる。

ツールが最初から高速なとき、実用可能な全体パフォーマンスを達成する上でこういった追加のレイヤーは必要にならない可能性がある。もしそうなら、既定のパフォーマンスを達成するためのシステムはずっと単純になる。

Sorbet に関して言うと、確かに Sorbet はキャッシュを使っており、さらに Sorbet の LSP サーバーはインクリメンタルな再型検査を行うために複雑になってはいるものの、これらのシステムはどちらも私が知っている似たような型検査器より格段に単純になっている。この単純さを私たちが手に入れられたのは、単に元々の Sorbet のパフォーマンスが悪くなかったからというのが主な理由である。仕事を小さくする複雑な処理をせずとも、Sorbet は最初から十分速いので仕事をそのまま行わせれば済む。

Sorbet の興味深い特徴として、Sorbet のキャッシュフォーマットは前方互換性も後方互換性を持たない: Sorbet リリースは自身の git コミットの sha1 を知っており、異なるバージョンで生成されたキャッシュファイルを無視する。この設計判断は、キャッシュフォーマットを非常に単純かつ最小限にでき、開発者は移行や互換性に関して気にする必要がないことを意味する。当然このアプローチの欠点は、リリースのたびにユーザーが (キャッシュが構築されるまでの間) キャッシュが存在しない状態のパフォーマンスを経験することである。キャッシュによって節約されるのは数秒であり、コールドスタートしたとしても実行時間は 20 秒以下なので、互換性の排除がもたらす単純さ (そしてバグの少なさ) とのトレードオフは受け入れられると判断された。これに対して、Dropbox [訳注: コードベースの大部分が Python であることで有名] で MyPy [訳注: Python 向け型検査器] の開発に取り組んだことのある友人からは、キャッシュの無い状態で型検査を実行すると数十分以上かかると聞いたことがある。キャッシュの有無で実行時間がここまで大きく異なると、完全に異なる考え方が必要になる。キャッシュを無効化して構わないタイミングを正確に見きわめる必要が生じるので、開発プロセスに (完全に避けられた可能性もある) 複雑性が加わるだろう。

Stripe のモノレポ [訳注: 組織が保有するコードを全て納めたレポジトリ] が成長を続ければ、Sorbet の生のパフォーマンスに頼るだけでは無理が生じることになるだろう。そのため、将来 Sorbet に現在より高度なキャッシュ機構とインクリメンタル実行の機能が追加される可能性はあると私は考えている: 生のパフォーマンスは万能薬ではない。しかし、規模が一定なら生のパフォーマンスは大きく影響する。本質的に単純な設計を使った現在の Sorbet が現在の規模で問題なく運用される上で生のパフォーマンスがどれだけ有用だったかは軽視されるべきではない。

結論

「私たちはソフトウェアを設計・作成するときにパフォーマンスを軽視している」と私は心の底から信じるようになった。私たちはツールやライブラリを選ぶときに二倍あるいは十倍、ときにはもっと大きなパフォーマンスの低下をそれに見合うだけの利益がもたらされるかどうかを深く考えることもなく何でもないかのように受け入れることに慣れてしまっている。

ソフトウェアは 2020 年でも高速でレスポンシブに作成でき、そのために労力を注ぐことがそれだけの価値を持つ場合は本当に多い。あなたの好きな驚くほど速い、使っていて気持ちがいいと感じるほど速いソフトウェアは何だろうか?


  1. これは、私が思うに、Ruby エコシステムが特にひどい点である。例えば Sorbet がコールドな Stripe のコードベースに対する型検査を終えるまでの間に、Ruby はコードの実行開始はおろか読み込みさえ完了できない。 ↩︎

  2. Flow チームの選択を批判したいわけではないことは明確にしておきたい。彼らの開発したツールは Facebook の内外で大きな成功を収めているのだから、彼らの下した判断は当時十分な理由を持っていたに違いない。また、リンクしたブログ記事の透明性は素晴らしいものだと思う。ただ、Sorbet と Flow の比較はアーキテクチャに関する開発初期の判断が持つ影響を示す分かりやすい例だと私は感じる。 ↩︎

広告