7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C++】スレッドプール自作Part4:ベンチマークとチューニングで限界性能を引き出す

Last updated at Posted at 2025-12-25

C++スレッドプール自作シリーズ

Part1 std::thread Part2 ワークスティーリング Part3 Future/Promise Part4 ベンチマーク
👈 Now

はじめに

ここまでスレッドプールを作ってきたけど、本当に速いの?

今回は実際にベンチマークを取って、ボトルネックを見つけて改善していく。

ベンチマーク環境

項目 スペック
CPU i7-12650H
RAM 32GB DDR4-3600
OS Windows 11
コンパイラ MSVC 19.38 (Release /O2)

ベンチマーク1: スループット測定

空のタスクを大量に処理するスループットを測定。

#include <chrono>
#include <iostream>
#include <atomic>

void benchmark_throughput(auto& pool, int num_tasks, const std::string& name) {
    std::atomic<int> counter{0};
    
    auto start = std::chrono::high_resolution_clock::now();
    
    for (int i = 0; i < num_tasks; ++i) {
        pool.submit([&counter] {
            ++counter;
        });
    }
    
    while (counter.load() < num_tasks) {
        std::this_thread::yield();
    }
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    
    double throughput = static_cast<double>(num_tasks) / duration.count() * 1'000'000;
    
    std::cout << name << ":\n";
    std::cout << "  Time: " << duration.count() / 1000.0 << " ms\n";
    std::cout << "  Throughput: " << static_cast<int>(throughput) << " tasks/sec\n";
}

結果

実装 100万タスク処理時間 スループット
都度スレッド生成 847,234 ms 1,180 tasks/sec
単純な共有キュー 2,341 ms 427,166 tasks/sec
ワークスティーリング 891 ms 1,122,334 tasks/sec
チューニング後 312 ms 3,205,128 tasks/sec

ワークスティーリングで共有キューより約2.6倍、チューニングでさらに約2.8倍速くなった。

ベンチマーク2: レイテンシ測定

タスクを投入してから完了するまでの時間。

void benchmark_latency(auto& pool, int num_samples) {
    std::vector<double> latencies;
    latencies.reserve(num_samples);
    
    for (int i = 0; i < num_samples; ++i) {
        auto start = std::chrono::high_resolution_clock::now();
        
        auto future = pool.submit([] {
            // 最小限の処理
        });
        
        future.get();
        
        auto end = std::chrono::high_resolution_clock::now();
        auto duration = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start);
        latencies.push_back(duration.count());
    }
    
    // 統計計算
    std::sort(latencies.begin(), latencies.end());
    
    double avg = std::accumulate(latencies.begin(), latencies.end(), 0.0) / num_samples;
    double p50 = latencies[num_samples / 2];
    double p99 = latencies[num_samples * 99 / 100];
    double p999 = latencies[num_samples * 999 / 1000];
    
    std::cout << "Latency (ns):\n";
    std::cout << "  Average: " << avg << "\n";
    std::cout << "  P50: " << p50 << "\n";
    std::cout << "  P99: " << p99 << "\n";
    std::cout << "  P99.9: " << p999 << "\n";
}

結果

実装 平均 P50 P99 P99.9
共有キュー 15,234 ns 12,100 ns 89,000 ns 234,000 ns
ワークスティーリング 8,456 ns 6,200 ns 45,000 ns 123,000 ns
チューニング後 3,123 ns 2,100 ns 18,000 ns 56,000 ns

P99.9(最悪ケース)が大幅に改善されてるのがポイント。

チューニング1: False Sharing対策

キャッシュラインを跨ぐデータアクセスは遅い。

┌─────────────────────────────────────────────────────────────┐
│ False Sharing                                               │
│                                                             │
│   64バイトのキャッシュライン                                 │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ counter_a │ counter_b │ ... │                       │   │
│   └─────────────────────────────────────────────────────┘   │
│        ↑            ↑                                       │
│   Thread A     Thread B                                     │
│                                                             │
│   → 別の変数なのにキャッシュの無効化が発生                  │
└─────────────────────────────────────────────────────────────┘

対策: alignas でパディング

struct alignas(64) PaddedAtomic {
    std::atomic<int> value{0};
    // 64バイトにパディングされる
};

// ❌ NG: False Sharingが発生
struct SharedState {
    std::atomic<int64_t> top;      // 同じキャッシュラインに
    std::atomic<int64_t> bottom;   // 入ってしまう
};

// ⭕ OK: 別のキャッシュラインに配置
struct alignas(64) PaddedTop {
    std::atomic<int64_t> value{0};
};

struct alignas(64) PaddedBottom {
    std::atomic<int64_t> value{0};
};

struct SharedState {
    PaddedTop top;
    PaddedBottom bottom;
};

効果

False Sharing対策前: 891 ms
False Sharing対策後: 623 ms  (30%改善)

チューニング2: スピンロック vs mutex

軽量なタスクでは、mutexのオーバーヘッドが目立つ。

class SpinLock {
public:
    void lock() {
        while (flag_.test_and_set(std::memory_order_acquire)) {
            // スピン待ち
            _mm_pause();  // CPUヒント:待ってるよ
        }
    }
    
    void unlock() {
        flag_.clear(std::memory_order_release);
    }

private:
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};

いつスピンロックを使うか

状況 推奨
保持時間が短い(< 1μs) スピンロック
保持時間が長い mutex
競合が少ない スピンロック
競合が激しい mutex

ハイブリッドアプローチ

class HybridLock {
public:
    void lock() {
        // 最初は少しスピン
        for (int i = 0; i < SPIN_COUNT; ++i) {
            if (try_lock()) return;
            _mm_pause();
        }
        
        // それでも取れなければmutexにフォールバック
        mutex_.lock();
        mutex_held_ = true;
    }
    
    void unlock() {
        if (mutex_held_) {
            mutex_held_ = false;
            mutex_.unlock();
        } else {
            flag_.clear(std::memory_order_release);
        }
    }

private:
    bool try_lock() {
        return !flag_.test_and_set(std::memory_order_acquire);
    }
    
    static constexpr int SPIN_COUNT = 100;
    
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
    std::mutex mutex_;
    thread_local static bool mutex_held_;
};

チューニング3: バッチ処理

タスクを1つずつ取り出すのではなく、まとめて取り出す。

std::vector<Task> pop_batch(size_t max_count) {
    std::vector<Task> batch;
    batch.reserve(max_count);
    
    std::lock_guard<std::mutex> lock(queue_mutex_);
    
    while (!tasks_.empty() && batch.size() < max_count) {
        batch.push_back(std::move(tasks_.front()));
        tasks_.pop();
    }
    
    return batch;
}

void worker_loop() {
    while (!stop_) {
        auto batch = pop_batch(16);  // 最大16個まとめて取る
        
        for (auto& task : batch) {
            task();
        }
        
        if (batch.empty()) {
            std::this_thread::yield();
        }
    }
}

効果

個別取り出し: 623 ms
バッチ処理(16): 412 ms  (34%改善)

チューニング4: ローカルキューの活用

タスクの中で新しいタスクを作る場合、グローバルキューに入れるよりローカルキューに入れたほうが速い

// スレッドローカルなタスクリスト
thread_local std::vector<Task> local_queue;

void worker_loop(size_t my_id) {
    while (!stop_) {
        Task task;
        
        // 1. ローカルキューを優先
        if (!local_queue.empty()) {
            task = std::move(local_queue.back());
            local_queue.pop_back();
        }
        // 2. 自分のワークスティーリングキュー
        else if (auto t = queues_[my_id].pop()) {
            task = std::move(*t);
        }
        // 3. 他から盗む
        else {
            // ... steal処理
        }
        
        if (task) {
            task();
        }
    }
}

// タスク内から呼ばれる
void spawn(Task task) {
    local_queue.push_back(std::move(task));
}

効果(再帰的なタスク生成時)

グローバルキューのみ: 1,234 ms
ローカルキュー併用: 523 ms  (58%改善)

チューニング5: NUMA対応

NUMAアーキテクチャでは、メモリの配置が性能に影響。

#ifdef _WIN32
#include <windows.h>

void pin_thread_to_node(int node) {
    GROUP_AFFINITY affinity = {};
    affinity.Group = node;
    affinity.Mask = static_cast<KAFFINITY>(-1);
    SetThreadGroupAffinity(GetCurrentThread(), &affinity, nullptr);
}
#else
#include <numa.h>

void pin_thread_to_node(int node) {
    numa_run_on_node(node);
}
#endif

// 各ワーカーを適切なNUMAノードに配置
void spawn_workers() {
    int nodes = get_numa_node_count();
    int workers_per_node = num_threads_ / nodes;
    
    for (size_t i = 0; i < num_threads_; ++i) {
        int node = i / workers_per_node;
        
        workers_.emplace_back([this, i, node] {
            pin_thread_to_node(node);
            worker_loop(i);
        });
    }
}

効果(2ソケットシステム)

NUMAを考慮しない: 523 ms
NUMA対応: 312 ms  (40%改善)

チューニング6: メモリアロケーター

タスクを作るたびにnewするのは遅い。

// シンプルなオブジェクトプール
template<typename T, size_t BlockSize = 4096>
class ObjectPool {
public:
    T* allocate() {
        if (free_list_) {
            T* obj = free_list_;
            free_list_ = *reinterpret_cast<T**>(obj);
            return obj;
        }
        
        // 新しいブロックを確保
        if (current_offset_ >= BlockSize) {
            blocks_.push_back(std::make_unique<std::array<T, BlockSize>>());
            current_offset_ = 0;
        }
        
        return &(*blocks_.back())[current_offset_++];
    }
    
    void deallocate(T* obj) {
        *reinterpret_cast<T**>(obj) = free_list_;
        free_list_ = obj;
    }

private:
    std::vector<std::unique_ptr<std::array<T, BlockSize>>> blocks_;
    size_t current_offset_ = BlockSize;
    T* free_list_ = nullptr;
};

// 使い方
thread_local ObjectPool<TaskNode> task_pool;

void submit(Task task) {
    TaskNode* node = task_pool.allocate();
    new (node) TaskNode(std::move(task));
    enqueue(node);
}

効果

標準アロケータ: 312 ms
オブジェクトプール: 234 ms  (25%改善)

最終的なスループット比較

最適化 処理時間 改善率
初期実装(共有キュー) 2,341 ms 基準
ワークスティーリング 891 ms 2.6x
+ False Sharing対策 623 ms 3.8x
+ バッチ処理 412 ms 5.7x
+ ローカルキュー 312 ms 7.5x
+ オブジェクトプール 234 ms 10.0x

10倍の高速化を達成!

プロファイリングツール

ボトルネックを見つけるためのツール紹介。

Intel VTune

# Windows
vtune -collect hotspots ./my_threadpool.exe

# 結果をGUIで確認
vtune-gui result_dir

perf (Linux)

# CPU使用率
perf stat ./my_threadpool

# フレームグラフ
perf record -g ./my_threadpool
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

MSVC Profiler

Visual Studioで「分析」→「パフォーマンス プロファイラー」。

実践的なTips

1. スレッド数の決め方

size_t optimal_thread_count() {
    // CPUバウンド: コア数と同じ
    // IOバウンド: コア数の2〜4倍
    
    unsigned int cores = std::thread::hardware_concurrency();
    
    // デフォルトはコア数
    return cores;
}

2. タスクの粒度

細かすぎ: オーバーヘッドが支配的
粗すぎ: 負荷分散が効かない

目安: 1タスク 1〜100μs の処理量

3. 例外安全性

void worker_loop() {
    while (!stop_) {
        try {
            auto task = pop();
            if (task) task();
        } catch (const std::exception& e) {
            // ログに記録
            std::cerr << "Task exception: " << e.what() << "\n";
        } catch (...) {
            std::cerr << "Unknown exception in task\n";
        }
    }
}

まとめ

チューニング項目 効果
False Sharing対策 30%改善
バッチ処理 34%改善
ローカルキュー 58%改善
NUMA対応 40%改善
オブジェクトプール 25%改善

全部やると10倍速くなった

スレッドプールは奥が深い。実際のプロダクションではIntel TBBBoost.Asioなどの実績あるライブラリを使うのもアリだけど、仕組みを理解してるとデバッグや性能改善のときに役立つ

このシリーズで学んだこと:

  1. マルチスレッドの基本(std::thread、mutex、atomic)
  2. ワークスティーリングの原理とLock-freeデータ構造
  3. Future/Promiseパターン
  4. パフォーマンスチューニングの手法

ぜひ自分でも実装して、並列処理の世界を楽しんでみてね。

この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?