C++ のコンテナ・スレッド・-O2 を実測で比べる

C++講座6。Apple clang 21 で実行:vector・list・deque・map・unordered_map、reserve と SSO、ThreadSanitizer でデータ競合、アトミック対ミューテックス、-O0 対 -O2 を実測。

シリーズ:C++講座 6/6(上級)。 テンプレートが書けて STL も使える人(第5回)で、これから C++ のコードを速くしたり、複数コアで動かしたりする人向けです。読み終えると、計算量だけでなく実測でコンテナを選び、reserve と小さな文字列の最適化(SSO)で見えない確保を避け、ThreadSanitizer でデータ競合を捕まえ、ミューテックス・アトミック・「共有しない」を使い分け、デバッグビルドが 30 倍遅くなる理由を説明できるようになります。

基本情報技術者試験やデータ構造の講義では「連結リストは挿入が O(1) で速い」と習います。間違いではありませんが、今の CPU で測ると話がだいぶ変わります。「アトミックはロックフリーだから速い」「テストは -O0 で十分」も同じです。この回では、そうした定説を1つずつ今のマシンで確かめます。以下はすべて Apple Silicon Mac(M1 Pro、ハードウェアスレッド 8)、macOS 26、Apple clang 21.0.0 + libc++ で、特に断りがなければ clang++ -std=c++23 -Wall -Wextra -pedantic -O2、シード固定・中央値で実行しました。GCC・MSVC・Linux・Valgrind は動かしていません。別の CPU では比率は変わりますが、理由は変わりません。

5つのコンテナ、4つの作業

標準のシーケンスコンテナと連想コンテナを int で測りました。キーは 0..N-1 を std::mt19937(シード 42)でシャッフルしたもの、検索キーは同じ生成器の続きから取ったランダム値で、時間はすべて 5 回の中央値です。

template <class F> double med_ms(F f) {
    std::vector<double> t;
    for (int r = 0; r < 5; ++r) {
        auto a = clk::now(); f(); auto b = clk::now();
        t.push_back(std::chrono::duration<double, std::milli>(b - a).count());
    }
    std::sort(t.begin(), t.end());
    return t[2];
}
// e.g. med_ms([&] { sink = std::accumulate(l.begin(), l.end(), 0LL); });
作業 vector deque list
push_back 100 万回 0.69 ms 0.98 ms 25.04 ms
100 万要素の合計 0.05 ms 0.49 ms 1.15 ms
l.sort() 後の合計(同じノード、順序がばらばら) — — 61.31 ms
先頭に 2 万回挿入 11.08 ms 0.04 ms 0.48 ms
ランダムな位置に 2 万回挿入(たどる+挿入) 5.74 ms — 439.65 ms
作業(キー 100 万個) map unordered_map ソート済み vector + lower_bound
構築 378.06 ms 34.17 ms 20.15 ms(コピー+ソート)
ランダム検索 100 万回 378.19 ms 10.66 ms 45.37 ms
棒グラフ。100 万回の検索に std::map は 378.19 ミリ秒、ソート済み vector と lower_bound は 45.37 ミリ秒、std::unordered_map は 10.66 ミリ秒。
int キー 100 万個から 100 万回のランダム検索(mt19937、シード 42)。5 回の中央値、-O2、Apple clang 21 / libc++、Apple Silicon Mac(M1 Pro)。

数字が言っていること:

  • list はほぼ全敗。 push_back のたびにノードを確保します(25 ms 対 0.7 ms)。合計は、ノードが確保順に並んでいる間は vector の 23 倍「程度」でしたが、l.sort() でつなぎ替えた後は同じ合計が 61 ms(別の回では 52.7 ms)になりました。1歩ごとに予測できないアドレスへ飛び、キャッシュを外すからです。連結リストの記事の利点と欠点を数字にするとこうなります。
  • 途中への挿入は vector が 77 倍速い。 list::insert は O(1) でも、位置を探すのはどちらも O(n) で、1万個のノードをたどるのは 40 KB の連続した int を memmove するよりはるかに遅いのです。
  • 先頭への挿入だけは deque の出番:0.04 ms 対 vector の 11 ms。vector は毎回全要素をずらします。
  • 検索は unordered_map が map の 35 倍速い。 map は赤黒木で、1回の検索で 20 段ほどポインタをたどります。ソート済み vector の二分探索はその中間で、構築時間とメモリでは勝ちます。

Java の ArrayList と LinkedList でも同じ話になります。Java コレクションの実測も参照してください。

判断図。キーで値を検索するか。はいなら既定は unordered_map、読み取り中心ならソート済み vector、順序付きの走査が必要なときだけ map。いいえなら、先頭への挿入が多いか。はいなら deque、それ以外は vector。list は灰色で、すでにイテレータを持っているときだけ使う。
この回の実測にもとづくコンテナの選び方。vector から始め、測った理由があるときだけ他に移る。

reserve と見えない確保

libc++ の vector は、空から push_back すると容量を倍々に増やします。int を 100 万回 push_back する間の operator new の呼び出し回数を数えました。

vector<int> 1M push_back, reserve=0: allocations=21 final capacity=1048576
vector<int> 1M push_back, reserve=1: allocations=1 final capacity=1000000

確保 21 回と、伸びていくバッファのコピー 20 回、対して1回。1,000 万回の push_back では 5.0〜5.9 ms が 3.1〜3.3 ms になりました(7 回の中央値、2回実行)。倍々の伸長がもともと安いので、ここでの reserve の効果は 10 倍ではなく 1.6 倍です。効くのは要素のムーブが高いときや、使わない 48,576 個分の領域を無駄にしたくないときです。

小さな文字列の最適化:23 文字目のコスト

std::string は短い文字列をオブジェクトの中に直接持ち、長いときだけヒープを確保します。これが SSO(small-string optimization) です。「短い」とは何文字までか、確保回数で調べました。

std::printf("sizeof(std::string)=%zu  capacity of empty string=%zu\n",
            sizeof(std::string), std::string().capacity());
for (int len : {0, 15, 22, 23, 24, 100}) {
    n_alloc = 0;
    std::string s(len, 'x');
    std::printf("len %3d: allocations=%ld capacity=%zu\n", len, n_alloc, s.capacity());
}
sizeof(std::string)=24  capacity of empty string=22
len   0: allocations=0 capacity=22
len  15: allocations=0 capacity=22
len  22: allocations=0 capacity=22
len  23: allocations=1 capacity=24
len  24: allocations=1 capacity=31
len 100: allocations=1 capacity=103

libc++ の文字列は 24 バイトで、22 文字まではヒープに触れません。reserve 済みの vector に文字列を 100 万個作ると、22 文字で 5.9 ms、23 文字で 17.7 ms(ウォームアップ1回の後の 5 回の中央値。22 文字で 8.2 ms の回もありました)。1文字でコストが3倍です。第4回のコピー計測で 32 文字の文字列が1つずつ確保していた理由もこれです。libstdc++(GCC)は 32 バイトで 15 文字まで内蔵です(ここでは動かしていません)。

スレッド、std::async、TSan で捕まえるデータ競合

std::thread は関数を新しい OS スレッドで動かし、破棄する前に join() が必要です。C++20 の std::jthread はデストラクタで自動的に join します(この libc++ で動きました)。std::async はタスクを動かして std::future を返し、get() は結果を待ち、タスク内の例外を投げ直します。

auto f = std::async(std::launch::async, [&] { return sum_range(v, 0, h); });
long long right = sum_range(v, h, v.size());
long long total = f.get() + right;
async task on another thread: 1
total=32000004000000 expected=32000004000000 hw threads=8
deferred task on main thread: 1

std::launch::async は明示しましょう。std::launch::deferred ではタスクが get() の時点で呼び出し元のスレッドで動き、「async なのに並列にならない」という定番のバグになります。

そして並行処理の講義が必ず最初に扱うバグです。2つのスレッドが普通の int を加算します。

#include <thread>
#include <cstdio>
int counter = 0;
void work(){ for(int i=0;i<100000;i++) counter++; }
int main(){
  std::thread a(work), b(work);
  a.join(); b.join();
  std::printf("counter=%d (expected 200000)\n", counter);
}
$ clang++ -std=c++23 -O0 race.cpp -o race0 && ./race0      (5 runs)
counter=102379 (expected 200000)
counter=102276 (expected 200000)
counter=118794 (expected 200000)
counter=102211 (expected 200000)
counter=101185 (expected 200000)
$ clang++ -std=c++23 -O2 race.cpp -o race2 && ./race2      (3 runs)
counter=200000 (expected 200000)

-O0 では加算の半分が消えました。counter++ は読み込み・加算・書き込みの3つで、スレッド同士が上書きし合います。-O2 では毎回正しい値でした。コンパイラがループを counter += 100000 1回に変え、競合の窓がごく小さくなったからです。バグは残っています。 データ競合は未定義動作で、オプティマイザはそう変えてよいのです(-O で変わる未定義動作は C言語講座 第5回にもっとあります)。ThreadSanitizer はどちらでも見つけます。

$ clang++ -std=c++23 -g -O1 -fsanitize=thread race.cpp -o race_tsan && ./race_tsan
==================
WARNING: ThreadSanitizer: data race (pid=42472)
  Write of size 4 at 0x000104434000 by thread T2:
    #0 work() race.cpp:4 (race_tsan:arm64+0x10000072c)
  Previous write of size 4 at 0x000104434000 by thread T1:
    #0 work() race.cpp:4 (race_tsan:arm64+0x10000072c)
  Location is global 'counter' at 0x000104434000 (race_tsan+0x100008000)
  Thread T2 (tid=26070715, running) created by main thread at:
    #4 main race.cpp:6 (race_tsan:arm64+0x100000778)
SUMMARY: ThreadSanitizer: data race race.cpp:4 in work()
==================
counter=200000 (expected 200000)
ThreadSanitizer: reported 1 warnings

Apple Silicon の macOS でも Apple clang の TSan は動きます。TSan ビルドでも合計は正しく出ていて、本当のことを言ったのはレポートだけでした。この Mac では TSan のバイナリはその後終了コード 134 で終わったので、CI のスクリプトは失敗として扱えます。TSan と ASan は1つのビルドに同居できません(clang++: error: invalid argument '-fsanitize=address' not allowed with '-fsanitize=thread')。ビルドを2つ作ります。

アトミック・ミューテックス・共有しない、を実測

競合を直す方法を比べます。4 スレッドがそれぞれ共有カウンタに 1 を 1,000 万回足し(合計 4,000 万)、5 回の中央値。スレッドは std::jthread です。

// mutex
run_threads([&](int) { for (int i = 0; i < PER; ++i) { std::lock_guard<std::mutex> g(m); ++n; } });
// atomic
run_threads([&](int) { for (int i = 0; i < PER; ++i) n.fetch_add(1); });
run_threads([&](int) { for (int i = 0; i < PER; ++i) n.fetch_add(1, std::memory_order_relaxed); });
// count locally, publish once
run_threads([&](int) {
    long long local = 0;
    for (int i = 0; i < PER; ++i) { ++local; asm volatile("" : "+r"(local)); }  // barrier: keep the loop
    n += local; });
方式 4 スレッド × 1,000 万 1 スレッド × 4,000 万
加算ごとに std::mutex 1,221.45 ms 352.89 ms
std::atomic の fetch_add(seq_cst) 1,566.97 ms 274.29 ms
fetch_add(1, memory_order_relaxed) 647.83 ms 86.87 ms
ローカルに数えて最後に1回アトミック加算 3.39 ms 12.47 ms
1 スレッド、普通の long long 12.45 ms 12.54 ms
棒グラフ。既定の順序のアトミック fetch_add は 1,566.97 ミリ秒、加算ごとのミューテックスは 1,221.45、relaxed の fetch_add は 647.83、共有なしの1スレッドは 12.45、スレッドごとのローカル集計を最後に1回足す方式は 3.39 ミリ秒。
4 スレッド × 1,000 万回、1つのカウンタを加算。5 回の中央値、-O2、Apple clang 21 / libc++、Apple Silicon Mac(M1 Pro)。

5つとも結果はちょうど 40,000,000 でした。大事な点:

  • 激しく競合すると、ミューテックスが既定のアトミックに勝った。 1スレッドならアトミックのほうが速い(274 ms 対 353 ms)のに、4スレッドが1つのキャッシュラインを叩くと、fetch_add のたびにそのラインがコア間を行き来します。ミューテックスでは1つのスレッドが何回か続けて加算できました。「アトミックは常に速い」は誤りです。
  • 本当のコストは共有そのもの。 共有カウンタを使う4スレッドは、共有しない1スレッドの 52〜126 倍遅くなりました。ローカルに数えて最後に1回足す方式は1スレッドより 3.6 倍速く、コアを本当に使えたのはこれだけでした。
  • memory_order_relaxed は、ほかのデータの同期に使わない純粋なカウンタなら正しい選択です。「カウントを見た後で」ほかのデータを読むスレッドがあるなら既定の順序が必要です。

原則:スレッド間で共有するものをできるだけ減らし、残ったものはミューテックスで守り、アトミックは単一のフラグやカウンタに使います。

-O0 対 -O2:デバッグビルドは速さについて嘘をつく

同じファイルを2回ビルドし、int 1,000 万個(rng() % 1000、シード 42)、5 回の中央値です。

コード -O0 -O2 比
素の for ループで合計 25.58 ms 0.81 ms 32 倍
std::accumulate 40.48 ms 0.68 ms 60 倍
views::filter | views::transform で合計 328.39 ms 33.63 ms 10 倍
同じ if を書いた素のループ 113.80 ms 1.19 ms 96 倍
std::sort(既定の <) 122.37 ms 91.13 ms 1.3 倍
ラムダ付き std::sort 646.83 ms 271.77 ms 2.4 倍
unordered_map::find × 1,000 万 281.38 ms 9.43 ms 30 倍

教訓は3つです。1つ目、STL のコードはインライン化が前提です。 -O0 では operator[] もイテレータの ++ もラムダも本物の関数呼び出しになり、std::accumulate は -O2 の 60 倍遅くなりました。C++ の速さをデバッグビルドで判断してはいけません。2つ目、既定の比較の std::sort はほとんど変わりませんでした。libc++ が最適化済みのものをライブラリに同梱しているからです(第5回)。ラムダの比較はあなたのフラグでコンパイルされます。3つ目、ranges は自動的にタダではありません。 -O2 で filter | transform は 33.6 ms、同じ条件の素のループは 1.2 ms で、後者はコンパイラが分岐のないベクトル命令に変えていました。ホットパスでビューを使う前に測りましょう。キャッシュの効果や lldb での調査は C言語講座 第6回にあります。

よくある間違い → 実際に起きたこと → 直し方

間違い 起きたこと 直し方
「挿入が速いから」と std::list ランダム挿入で vector の 77 倍遅い vector。list はイテレータを持っているときだけ
検索に何となく std::map unordered_map の 35 倍遅い unordered_map、読み取り中心ならソート済み vector
スレッド間で普通の int を共有 -O0 で加算が消え、-O2 では正しく見える std::atomic かミューテックス。-fsanitize=thread で確認
全スレッドが1つのアトミックを叩く 1,567 ms、ミューテックスより遅い スレッドごとにローカル集計し、最後に1回合わせる
起動ポリシーなし、または deferred の std::async 呼び出し元のスレッドで動いた std::launch::async
デバッグビルドで速さを測る リリースの 30〜96 倍遅い -O2 で測り、シンボル用に -g を付ける
ホットループで 22 文字を少し超える文字列 22 文字の3倍遅い キーを短く、std::string_view、文字列のインターン

正直な限界・バージョンの注意

  • 実行環境は Apple clang 21.0.0 と libc++(_LIBCPP_VERSION 220106)、M1 Pro 1台、macOS 26 だけです。GCC/libstdc++、MSVC、Linux、Valgrind(helgrind)は動かしていません。比率は CPU のキャッシュとコア数で変わり、10% 程度までの差は実行ごとのばらつきの範囲です。
  • この Mac の steady_clock は約 42 ns 刻みなので、1マイクロ秒未満の時間は載せていません。
  • バージョン:std::thread・std::async・std::mutex・std::atomic は C++11、std::jthread は C++20(ここでは __cpp_lib_jthread 201911)。std::hardware_destructive_interference_size(C++17)は定義されていました。std::execution::par の並列アルゴリズムはこの libc++ にはなく、import std; は unknown type name 'import' で失敗しました(このツールチェーンには標準ライブラリのモジュールが付いていません)。
  • SSO の上限 22 文字、伸長率 2 倍、コンパイル済みの std::sort は libc++ の実装の話です。
  • TSan の終了コード 134 はここで観測した値です。ドキュメント上は、競合を報告すると 0 以外で終わるのが既定です。

練習問題

1. race.cpp を、どの最適化レベルでも 200000 を出し、TSan が何も報告しないように直してください。変更は最小で。

2. 実行せずに、libc++ で各ループのヒープ確保回数を予想し、回数を数える operator new で確かめてください。

for (int len : {10, 30}) {
    std::vector<std::string> v;
    v.reserve(1000);
    for (int i = 0; i < 1000; ++i) v.push_back(std::string(len, 'x'));
}

解答

1. カウンタをアトミックにします。

#include <atomic>
std::atomic<int> counter{0};
void work(){ for(int i=0;i<100000;i++) counter++; }
// print with counter.load()

-fsanitize=thread 付きで counter=200000 (expected 200000)、警告なし、終了コード 0 でした。std::atomic<int> の counter++ はアトミックな fetch_add です。速さを求めるなら、実測の節のローカル集計版がさらに上です。

2. 10 文字は 1 回(reserve したバッファだけ。10 文字は SSO のバッファに収まる)。30 文字は 1001 回(バッファ+文字列ごとに1回)。push した一時オブジェクトは vector にムーブされ、ヒープ上の文字列のムーブはポインタを渡すだけです。reserve なしだと 30 文字で 1011 回、バッファが 1, 2, 4, …, 1024 と伸びる分の 11 回が増えます。

シリーズのまとめ

C++講座はここまでです。第1回から読んできたなら、サニタイザ付きでビルドとデバッグができ、RAII とスマートポインタで資源を持ち、テンプレートのエラーを読み、性能の判断を実測で裏付けられるようになっているはずです。新しく学ぶ人に勧めるなら第1回からどうぞ。

よくある質問

途中への挿入は std::list のほうが std::vector より速い?

手元の計測では逆でした。ランダムな位置への int 2 万回の挿入(位置までたどる時間込み)は std::vector 5.74 ms、std::list 439.65 ms(5 回の中央値、-O2、Apple Silicon M1 Pro)。list の insert 自体は O(1) ですが、位置を探すのに平均1万個のポインタをたどる必要があり、vector は連続メモリを memmove でずらすだけです。list はイテレータをすでに持っているときだけ使います。

macOS の Apple clang で ThreadSanitizer は使える?

使えます。Apple Silicon の macOS 26、Apple clang 21 で clang++ -fsanitize=thread -g が動きました。2つのスレッドが普通の int を加算する例で「WARNING: ThreadSanitizer: data race」と両方のスタックトレースと行番号が出ました。TSan なしでは -O0 ビルドが 200000 のはずが 102379 などを出し、-O2 ビルドは正しい合計を出してバグを隠しました。

std::atomic は std::mutex より常に速い?

いいえ。4 スレッドがそれぞれ1つの共有カウンタを 1,000 万回加算すると、ミューテックス 1221 ms、アトミックの fetch_add 1567 ms、memory_order_relaxed の fetch_add 648 ms でした。1スレッドならアトミックのほうが速い(4,000 万回で 274 ms 対 353 ms)。本当の解決は共有しないことで、各スレッドがローカルに数えて最後に1回足す方式は 3.4 ms でした。

  • #C++
  • #上級
  • #パフォーマンス
  • #STLコンテナ
  • #スレッド
  • #ThreadSanitizer

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑