C++ アプリケーションのプロファイリングの技術

C++ アプリケーションのプロファイリングの技術

C++ アプリケーションのプロファイリングの技術

導入

パフォーマンスの仕事には、2 つの相反する形の虚栄心が集まります。あるエンジニアは、直感だけで十分で、ホットなコードに対する優れた嗅覚が証拠に取って代わることができると信じたいと考えています。また、あたかも測定ボタンを押すことで混乱が知識に変わったかのように、プロファイラーのスクリーンショット自体が結論であると信じたい人もいます。どちらの本能も魅惑的であり、どちらもダメージを与えます。

C++ でのプロファイリングが貴重なのは、C++ では間違いが起こり得る余地が非常に大きいためです。実際、遅いシステムでは、キャッシュ ミス、ロック競合、アロケータ チャーン、ブランチの多いホット ループ、ベクトル化ブロッカー、またはコピーの多すぎる問題が発生している可能性があります。また、部屋にいる全員が CPU について議論している間、I/O を待機している可能性もあります。結果の計算よりも結果のシリアル化に多くの時間を費やしている可能性があります。スケーリングがうまくいかないのは、アルゴリズムが貧弱だからではなく、コード コメントで警告されていない方法でスレッドが衝突し続けるためである可能性があります。これほど表現力豊かで機械に近い言語では、もっともらしい説明が急速に増えます。

だからこそ、プロファイリングは、パフォーマンスに執着する人のための特殊な活動としてではなく、誠実さの規律として理解されるべきです。それは私たちに、エレガントな物語を慎重な物語に置き換えることを教えます。急いで書き直すスピードが遅くなります。これにより、チームは、問題のわずか 4% であることが判明した問題の改善に 1 週​​間を無駄にする必要がなくなります。そして、それがうまく行けば、議論が芝居じみたものではなくなり、より協力的なものになるため、エンジニアリング文化に驚くほど人道的な影響を及ぼします。プロファイラーは武器ではなく審判となる。

ツールが開く前にプロファイリングが開始される

有用なプロファイリング セッションは、最初のサンプルが収集されるずっと前に始まります。それは、どのような質問に答えようとしているのかを決めることから始まります。 「プログラムが遅いのはなぜですか?」十分な質問となることはほとんどありません。ツールの選択をガイドするには曖昧すぎますし、改ざんするには曖昧すぎます。より良い質問は、より具体的に聞こえます。パーサーの変更後に p99 レイテンシが低下したのはなぜですか? 8 スレッドを超えるとスループットの向上が止まるのはなぜですか?あるマシン クラスの動作が別のマシン クラスよりも悪いのはなぜですか?コードを簡略化すると、負荷時のバイナリが遅くなるのはなぜですか?

質問の質が残りの作業を左右します。症状がリクエスト レイテンシの回帰である場合、代表的なリクエスト パスと、そのレイテンシが発生する場所の明確な定義が必要です。症状がスループットの停滞である場合は、CPU、待機、メモリ帯域幅、または同期が成長を制限しているかどうかを知る必要があります。症状がマシン固有の動作である場合は、ソース コード自体よりも、ハードウェア カウンター、アフィニティ、および展開の違いが重要になる可能性があります。良い質問をするという行為は、すでに最適化の一形態です。なぜなら、それは私たちが間違っても構わないと思う事柄の範囲を狭めるからです。

ここは、多くのチームが密かに妨害行為を行っている場所でもあります。これらは、測定値が劇場になるほどノイズの多い環境で、間違ったバイナリで、おもちゃの入力を使用して、非現実的な負荷の下でプロファイリングを行います。そして、彼らは天文学と気象民間伝承の証拠品質に自信を持って結果を提示します。プロファイラーはそれらを失敗しませんでした。彼らの実験計画は失敗に終わりました。パフォーマンス作業では、厳密さはセットアップラインから始まります。

信頼できる測定環境を構築する

C++ プログラムは、さまざまな条件下でさまざまな個性を明らかにします。デバッグ ビルドは、本番環境とは関係のない理由で恐ろしく遅いように見える場合があります。シンボルのないリリース ビルドは十分に高速に実行される可能性がありますが、表示する必要があるパスが隠れてしまいます。小さな合成入力がキャッシュに完全に収まりすぎて、貧弱な設計がお世辞になる場合があります。熱圧力やバックグラウンドノイズがかかるマシンでは、実際にランダムな干渉を記述しながら、正確に感じられる結果が得られる場合があります。

信頼できる環境は完璧である必要はありませんが、意図的に構築されている必要があります。ユーザーが実際に実行するものに最も近いバイナリを使用してください。デバッグ情報やフレーム ポインターを、ツールが活用できる場所に保管してください。現実的な入力、または少なくとも実際のワークロードの定性的特性を維持する入力 (データ サイズ、分岐の不規則性、競合パターン、割り当て圧力、要求の組み合わせなど) をプログラムに入力します。平均実行時間だけでなく、問題に応じてテール レイテンシー、スループット、ステージ時間、割り当て量、ロック待機、キャッシュ動作、起動時間など、システムにとって重要な出力も測定します。

これをうまくやると深い優しさが生まれます。エンジニアが正直な条件でプロファイリングを行うと、チーム全体がゴーストをめぐる争いから逃れることができます。設定に欠陥があると、誰もが理論を擁護するようになります。適切な設定では、理論はすぐに消えてしまいます。これは、パフォーマンス重視のエンジニアがプロジェクトに提供できる最も費用対効果の高い贈り物の 1 つです。

仕事と待ち時間を区別する方法を学ぶ

最も一般的なプロファイリングの失敗の 1 つは、すべての遅さを CPU の動作であるかのように扱うことです。 C++ エンジニアは、言語が低レベルの思考を招くため、この間違いに特に脆弱です。サービスが遅い場合、私たちは命令、分岐、キャッシュ ライン、インライン化の決定を想像し始めます。時にはその直感がまさに正しいこともあります。また、ロックでの待機、キューでの待機、I/O での待機、過剰に調整されたスレッド プールでの待機、ホット ループが少しきれいになって修復できないリソースでの待機など、システムがほとんど待機している場合もあります。

したがって、優れたプロファイリングは広範囲に始まり、全体像が明確になって初めて微細なものになります。サンプリング プロファイラーは、CPU 時間が実際にどこに費やされているかを発見するのに優れています。トレース ツールは、問題が実際にシーケンス、待機、またはステージのインタラクションにあることを明らかにするのに役立ちます。ヒープおよび割り当てツールは、メモリ ストーリーが他のすべてを汚染しているかどうかを教えてくれます。ハードウェア カウンタは、パスが本当にホットで、ミス、分岐、推測、ベクトル化の品質が注目に値する場合に役立ちます。各ツールは異なる質問をする方法です。問題は、チームが 1 つの質問をし、その答えが別の質問を解決したかのように解釈したときに始まります。

身近な例でこの罠を説明します。パーサーが CPU プロファイルの先頭近くに表示されるとします。せっかちなエンジニアは、パーサーを書き直す必要があると結論付けるかもしれません。ただし、タイムライン ビューでは、パイプラインの残りの部分が頻繁にブロックされ、アクティブな CPU 領域が実際よりも比例して大きく見えるため、パーサーが優勢であるように見える場合があります。別のケースでは、パーサーは実際には高価ですが、割り当てをターゲットに少し変更するだけで、大幅な書き換えを行わずにコストのほとんどが削減されます。プロファイラーの才能は、何を最適化すべきかを 1 つのステップで教えてくれるということではありません。その賜物は、本質的な作品を演劇作品から切り離し続けていることです。

ツールは解釈の習慣ほど重要ではない

エンジニアはよく、あたかも普遍的な正解があるかのように、どのプロファイラーが最適であるかを尋ねます。実際には、次にどのような種類の真実が必要かという質問のほうが適切です。 perf、VTune、Visual Studio のプロファイラー、Tracy、Perfetto、フレーム グラフ、Callgrind、およびヒープ プロファイラーはそれぞれ、現実の異なる表面を照らします。成熟した習慣とは、ツールへの忠誠心ではありません。それは解釈の規律です。

フレーム グラフは、CPU サンプルが蓄積される場所を示すのに優れていますが、それだけではキュー遅延を説明できません。タイムライン ビューは、ステージのインタラクションや待機を表示するのに優れていますが、タイトなループで分岐の予測ミスが発生する理由がわからない場合があります。ヒープ プロファイルは、パス全体を汚染する割り当てチャーンを明らかにする可能性がありますが、スレッド モデルが一貫しているかどうかだけでは解決しません。エンジニアは、ツールの視覚的な魅力を完全な理解と誤解すると危険になります。

これが、プロファイリングが測定に基づいているにもかかわらず、芸術的な側面を持つ理由です。芸術は神秘主義ではありません。それは判断です。それは、ホットスポットがいつプライマリであり、いつセカンダリであるのか、マイクロベンチマークがいつ誠実で、いつ間違った形状の作業を推奨するのか、いつハードウェア カウンターが信頼に値するのか、いつ別の実験を引き起こす必要があるのか​​を知ることです。また、いつ下方への掘り下げをやめて、そもそも測定を醜くしたアーキテクチャを簡素化するべきかを知ることでもあります。

C++ パフォーマンス問題の特徴的な形状

C++ のパフォーマンスの問題は、多くの場合、認識可能なグループに分類されます。明らかに計算に関係するものもあります。つまり、過剰な作業を実行するタイトなループ、不十分なベクトル化、ブランチの多いホット コード、キャッシュとの相互作用が不十分なデータ構造などです。メモリに起因するものもあります。割り当てが多すぎる、所有権パターンが不安定、不必要なコピー、断片化、または CPU がコンピューティングよりも待機する時間が長くなるまでホット データを分散させるレイアウトです。調整の問題としては、無害に見えるロック、ホップを 1 つ追加しすぎたキュー、末尾の動作を悪化させながら平均スループットを向上させる作業盗用設計、またはアーキテクチャの秩序を維持する能力を超えるスレッド数などがあります。

プロファイリングが強力なのは、これらの家族がお互いになりすますことが多いためです。メモリの問題は CPU の問題のように見える場合があります。待機の問題は、アルゴリズムの問​​題のように見えることがあります。ロギング パスは、テール レイテンシー ビューでサービス全体を汚染していることが示されるまで、無関係に見えることがあります。平凡に見えるコピーが重要になるのは、それがリクエスト パスが許容できない 1 つの場所で発生するという理由だけです。測定がなければ、これらのインタラクションを説明するのは簡単ですが、ランク付けするのは困難です。

したがって、優れたプロファイラーは、比例に対する感覚を養います。すべての非効率性が問題になるわけではありません。すべての醜い関数を救う価値があるわけではありません。すべてのクリーンな関数が無害であるわけではありません。このプログラムは、尊厳と緊急性が一致する場所を教えてくれますが、多くの場合、その場所はコードレビュー担当者が最初に指摘した場所ではありません。

誤診のケーススタディ

レコードを取り込み、正規化し、スコアを付け、結果を出力するサービスを想像してください。リリース後、スループットが低下し、p99 レイテンシーが悪化します。この部屋で最初に浮上した理論は、新しい採点ルーチンによって高価な数学が導入されたというものです。 2 番目の理論は、パーサーの分岐が多すぎるというものです。 3 つ目は、ライブラリのアップグレード後にアロケータがリグレッションしたことです。どの理論も、会議では賢明に聞こえるほど説得力があります。

広範な CPU プロファイルは、パーサーとスコアラーの両方が目に見える時間を消費していることを示していますが、完全な遅延回帰を説明するには十分ではありません。タイムライン トレースでは、共有出力ステージ周辺での待機のバーストが明らかになります。ヒープ分析では、リクエスト パスの終わり近くで割り当てとフォーマット作業が繰り返されていることがわかります。スレッドごとのバッファーを保持し、フォーマットを延期する小規模な実験により、待機パターンが崩壊し、驚くべき量のテール レイテンシーが除去されました。その後になって初めて、焦点を絞った CPU プロファイルによって、大きなボトルネックが解消された後に新たに表示されるようになったコピーについて、スコアラーが小規模なクリーンアップを行う価値があることが示されます。

これはありふれた話ですが、だからこそ重要なのです。実際のプロファイリングが、たった 1 人の劇的な悪役で終わることはほとんどありません。多くの場合、通常のコストの積み重ねが明らかになり、それぞれが他のコストによって増幅されます。映画のような修正を期待していたエンジニアは、代わりに、累積、相互作用、および無視された割合を通じて、システムが実際にどのように劣化するかを学びます。この教訓は、今後の調​​査の開始方法を変えるため、単一の高速化よりも価値があります。

チームの習慣としてのプロファイリング

優秀なチームは、プロファイリングを緊急時のみの儀式として扱いません。彼らはそれをレビュー、回帰、大きな設計変更に組み込みます。彼らは代表的なデータセットを保持します。フレーム グラフ、トレース、ベンチマーク アーティファクトを、変更内容の説明とともに保存します。彼らは、提案された簡素化によって割り当て、テール レイテンシ、またはステージ境界が変更されるかどうかを尋ねるのが普通になっています。彼らはパフォーマンスを崇拝するわけではありませんが、あまり大声で話す前に評価するほどパフォーマンスを尊重します。

この習慣は、コードベースの感情的な生活を変えます。プロファイリングによって問題が外部化されるため、エンジニアの防御力が低下します。システムが遅いということは、もはやコードに最後に触れた人に対する非難にはなりません。それは証拠を伴う共有パズルになります。若手エンジニアであっても、名声よりも質問や実験を信頼することを学ぶため、この環境ではより有能になります。このようにして構築されたパフォーマンス文化は、単に速いだけではありません。より穏やかです。

これが、C++ においてプロファイリングの技術が非常に重要である理由です。この言語は優れたシステムを構築する力を与えてくれますが、卓越性は賢さだけからは生まれません。それは、規律ある気づきの行為を繰り返すことで生まれます。プロファイリングは、エンジニアがマシンがずっと言おうとしていたことに気づくための最良の方法の 1 つです。

ハンズオン ラボ: 意図的に非効率なプログラムをプロファイリングする

意図的に少し愚かな小さなプログラムを構築してみましょう。本当のプロファイリング スキルは、間違いが見つけられるほど具体的である場合に最も早く習得できるため、これは便利です。

__コード_0__

#include <algorithm>
#include <chrono>
#include <iostream>
#include <mutex>
#include <random>
#include <string>
#include <thread>
#include <vector>

std::mutex g_lock;

static std::string make_payload(std::mt19937& rng) {
    std::uniform_int_distribution<int> len_dist(20, 120);
    std::uniform_int_distribution<int> ch_dist(0, 25);

    std::string s;
    const int len = len_dist(rng);
    for (int i = 0; i < len; ++i) {
        s.push_back(static_cast<char>('a' + ch_dist(rng)));
    }
    return s;
}

static uint64_t score_payload(const std::string& s) {
    uint64_t total = 0;
    for (char c : s) {
        total += static_cast<unsigned char>(c);
    }
    return total;
}

int main() {
    constexpr size_t N = 400000;
    std::vector<std::string> rows;
    rows.reserve(N);

    std::mt19937 rng{42};
    for (size_t i = 0; i < N; ++i) {
        rows.push_back(make_payload(rng));
    }

    std::vector<uint64_t> out;
    out.reserve(N);

    auto worker = [&](size_t begin, size_t end) {
        for (size_t i = begin; i < end; ++i) {
            auto copy = rows[i];
            std::sort(copy.begin(), copy.end());
            uint64_t value = score_payload(copy);

            std::lock_guard<std::mutex> guard(g_lock);
            out.push_back(value);
        }
    };

    const auto t0 = std::chrono::steady_clock::now();

    std::thread t1(worker, 0, N / 2);
    std::thread t2(worker, N / 2, N);
    t1.join();
    t2.join();

    const auto t1_end = std::chrono::steady_clock::now();
    const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1_end - t0).count();

    std::cout << "done in " << ms << " ms, values=" << out.size() << "\n";
}

このプログラムには、いくつかの古典的なパフォーマンスの香りが含まれています。

  • 文字列のコピーを繰り返す
  • ホットパスでの不必要なソート
  • 出力時の中央ロック競合
  • 割り当ての多い文字列の生成

プロファイリング用にビルドする

Linux の場合:

g++ -O2 -g -fno-omit-frame-pointer -std=c++20 -pthread -o bad_profile main.cpp

MSVC を使用する Windows の場合:

cl /O2 /Zi /std:c++20 main.cpp

最初のプロフィール

Linux の場合:

perf record -g ./bad_profile
perf report

ワークフローの一部である場合は、フレーム グラフを収集します。

注意すべきこと

優れたプロファイルは、システムが神秘的な問題を 1 つも抱えていないことをすぐに示唆するはずです。非常にありふれたエンジニアリングの選択の集合体に苦しんでいます。それは正しい教訓です。

愛好家向けのテストタスク

  1. スレッドごとに 1 つの出力ベクトルを使用して、中央の mutex を削除します。再測定してください。
  2. 不要な std::sort を削除し、必要不可欠ではなく劇的なコストがどれだけかかっているかを確認します。
  3. auto copy = rows[i]; を下位コピーの代替ファイルに置き換えて、プロファイルが期待どおりに変更されるかどうかを検査します。
  4. スレッド数を増やして、スループットが拡大するかどうか、または調整が優先されるかどうかを観察します。
  5. -fno-omit-frame-pointer を使用した場合と使用しない場合で同じプログラムを構築し、スタックの品質を比較します。

これら 5 つの手順を注意深く実行すると、プロファイリング ツールの名前よりもはるかに価値のあることを学ぶことができます。あなたは、悪い理論が測定の前にどのように消滅するかを学んだでしょう。

まとめ

C++ アプリケーションをプロファイリングする技術は、正直であり続ける技術です。

優れたプロファイリングとは、派手なスクリーンショットを収集したり、すべてのハードウェア カウンターを記憶したりすることではありません。それは、正確な質問をすること、現実的な条件で測定すること、CPU 作業を待機から分離すること、メモリの動作を理解すること、そして問題の適切な層に適切なツールを使用することです。

サンプリングを使用して、CPU の広範な真実を見つけます。 トレースを使用して、時間と調整を理解します。割り当て動作が優先される場合は、ヒープ分析を使用します。キャッシュと推測が現実の話になる場合は、ハードウェア カウンター を使用してください。そして何よりも、最適化する前にプロファイルを作成します。

C++ では、この分野がエレガントな高性能エンジニアリングと高価な迷信の違いとなることがよくあります。

参考文献

  1. Linux perf マニュアル ページ: https://man7.org/linux/man-pages/man1/perf.1.html
  2. Linux perf-stat マニュアル ページ: https://man7.org/linux/man-pages/man1/perf-stat.1.html
  3. インテル VTune プロファイラーのドキュメント: https://www.intel.com/content/www/us/en/docs/vtune-profiler/overview.html
  4. Visual Studio プロファイリング機能ツアー: https://learn.microsoft.com/visualstudio/profiling/profiling-feature-tour
  5. トレーシー プロファイラー リポジトリ: https://github.com/wolfpld/tracy
  6. Perfetto のドキュメント: https://perfetto.dev/docs/
  7. Brendan Gregg による炎グラフ: https://www.brendangregg.com/flamegraphs.html
  8. Callgrind マニュアル: https://valgrind.org/docs/manual/cl-manual.html
  9. ヒープトラック リポジトリ: https://github.com/KDE/heaptrack
  10. AddressSanitizer のドキュメント: https://clang.llvm.org/docs/AddressSanitizer.html
Philip P.

Philip P. – CTO

Back to Blogs

接触

会話を始める

明確な線が数本あれば十分です。システム、プレッシャー、そして妨げられた決断について説明してください。 または直接書いてください midgard@stofu.io.

01 What the system does
02 What hurts now
03 What decision is blocked
04 Optional: logs, specs, traces, diffs
0 / 10000