Перейти к содержимому

Параллельные алгоритмы и исполнительная политика

Использование параллельного STL C++17 для параллелизации сортировки, преобразования и сокращения с политикой исполнения. Поймите, когда параллелизм помогает, закон Амдала и практический дизайн бассейна потоков.

Параллельный STL — легкий параллелизм

C++17 добавил политики исполнения к большинству функций . Проводя политику в качестве первого аргумента, вы говорите реализации распараллеливать алгоритм — потенциально используя несколько потоков, SIMD или оба — без изменения какого-либо другого кода.

Это самый простой способ добавить параллелизм в C++: возьмите существующий вызов std::sort, std::transform или std::for_each, предоставьте std::execution::par и позвольте реализации сделать все остальное. Конечно, вы должны убедиться, что ваши операции безопасны для одновременного выполнения (без гонок данных в предикатах, без общего изменяемого состояния).

Политика исполнения объяснена

В определены три стандартные политики исполнения. Каждый из них дает различные гарантии параллелизма и векторизации.

execution_policies.cpp
#include <algorithm>
#include <execution>
#include <vector>
#include <iostream>
#include <chrono>

int main() {
    std::vector<int> data(10'000'000);
    std::iota(data.begin(), data.end(), 0);

    // Sequential — same as no policy
    auto t1 = std::chrono::high_resolution_clock::now();
    std::sort(std::execution::seq, data.begin(), data.end(),
              std::greater<>{});
    auto t2 = std::chrono::high_resolution_clock::now();

    // Parallel — may use multiple threads
    std::sort(std::execution::par, data.begin(), data.end());
    auto t3 = std::chrono::high_resolution_clock::now();

    // Parallel + vectorized — threads AND SIMD
    std::sort(std::execution::par_unseq, data.begin(), data.end(),
              std::greater<>{});
    auto t4 = std::chrono::high_resolution_clock::now();

    auto ms = [](auto a, auto b) {
        return std::chrono::duration_cast<std::chrono::milliseconds>(b - a).count();
    };
    std::cout << "seq: " << ms(t1, t2) << "ms\n";
    std::cout << "par: " << ms(t2, t3) << "ms\n";
    std::cout << "par_unseq: " << ms(t3, t4) << "ms\n";
    return 0;
}

std::reduce vs. std::acumulate

std::accumulate обрабатывает элементы строго слева направо и не может быть распараллелен. std::reduce (C++17) позволяет осуществлять обработку элементов в любом порядке** и комбинировать частичные результаты, обеспечивая параллельное выполнение. Операция должна быть ассоциативной и коммутативной для reduce для получения правильных результатов.

reduce_demo.cpp
#include <numeric>
#include <execution>
#include <vector>
#include <iostream>

int main() {
    std::vector<double> data(1'000'000, 1.0);

    // Sequential accumulate — always left-to-right
    double sum1 = std::accumulate(data.begin(), data.end(), 0.0);

    // Parallel reduce — may split and combine in any order
    double sum2 = std::reduce(std::execution::par,
                              data.begin(), data.end(), 0.0);

    // Transform-reduce: parallel dot product
    std::vector<double> a(1'000'000, 2.0);
    std::vector<double> b(1'000'000, 3.0);
    double dot = std::transform_reduce(
        std::execution::par,
        a.begin(), a.end(), b.begin(),
        0.0);  // default: multiply + add

    std::cout << "accumulate: " << sum1 << "\n";
    std::cout << "reduce:     " << sum2 << "\n";
    std::cout << "dot product: " << dot << "\n";  // 6'000'000
    return 0;
}
Подводный камень

При использовании параллельных политик исполнения callable (предикат, функция преобразования, оператор восстановления), который вы передаете алгоритму, может одновременно вызываться из нескольких потоков. Если вы получаете доступ к общему изменяемому состоянию, у вас есть гонка данных:

int count = 0;
std::for_each(std::execution::par, v.begin(), v.end(),
    [&count](int x) {
        if (x > 0) ++count;  // DATA RACE! Multiple threads increment count
    });

Используйте std::atomic, резьбо-локальные аккумуляторы или, еще лучше, используйте std::reduce / std::count_if, которые безопасно обрабатывают агрегацию.

Закон Амдала — Когда параллелизм помогает

Закон Амдала гласит, что максимальное ускорение от параллелизма ограничено последовательной фракцией вашей программы. Если 10% работы нужно делать последовательно, то даже при бесконечных нитях максимальное ускорение 10х.

Скорость = 1/ (S + P/N), где S — последовательная дробь, P — параллельная дробь, а N — число потоков.

Параллелизм также имеет накладные расходы : создание потоков, синхронизация, борьба с кэшем и ложный обмен. Для небольших наборов данных накладные расходы могут превышать выгоду. Правила большого пальца:

- Сорт : Параллельный std::sort обычно помогает выше ~100K элементов.
Трансформация/для каждого: Преимущество выше ~ 10K элементов с нетривиальной работой на элемент.
Уменьшите : Преимущества выше ~100K элементов или с дорогостоящими операциями.
measure — профиль с std::execution::par и без него.

Практический обзор Thread Pool

Стандарт C++ не обеспечивает пул потоков (как у C++23), но шаблон прост: фиксированный набор рабочих потоков вытягивает задачи из общей очереди. Вот минимальный эскиз.

thread_pool.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <functional>
#include <vector>

class ThreadPool {
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;

public:
    explicit ThreadPool(size_t n) {
        for (size_t i = 0; i < n; ++i) {
            workers_.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock lock(mtx_);
                        cv_.wait(lock, [this] {
                            return stop_ || !tasks_.empty();
                        });
                        if (stop_ && tasks_.empty()) return;
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    task();  // run outside the lock
                }
            });
        }
    }

    void enqueue(std::function<void()> task) {
        {
            std::lock_guard lock(mtx_);
            tasks_.push(std::move(task));
        }
        cv_.notify_one();
    }

    ~ThreadPool() {
        {
            std::lock_guard lock(mtx_);
            stop_ = true;
        }
        cv_.notify_all();
        for (auto& w : workers_) w.join();
    }
};

int main() {
    ThreadPool pool(4);
    for (int i = 0; i < 20; ++i) {
        pool.enqueue([i] {
            std::cout << "Task " << i << " on thread "
                      << std::this_thread::get_id() << "\n";
        });
    }
    // pool destructor waits for all tasks
    return 0;
}
Лучшая практика

Предпочтите std::execution::par ручному параллелизму для стандартных алгоритмов — реализация знает ваше оборудование. Всегда проверяйте, что ваш телефон свободен от гонок данных и побочных эффектов. Используйте std::reduce вместо std::accumulate, когда вам нужен параллелизм. Контрольный показатель с реалистичными размерами данных; для небольших входов последовательный может быть быстрее. Помните, что par_unseq запрещает звонить во все, что приобретает блокировку (включая выделение памяти на некоторых платформах).

Ключевые выводы
  • Политики выполнения C++17 (seq, par, par_unseq) добавляют параллелизм к стандартным алгоритмам с минимальными изменениями кода
  • std::reduce - это параллельная замена std::accumulate - ваша операция должна быть ассоциативной и коммутативной.
  • Закон Амдала ограничивает ускорение: последовательная часть кода становится узким местом.
  • Параллельные алгоритмы имеют накладные расходы — они полезны только для достаточно больших наборов данных или дорогостоящей работы с элементами.
  • Всегда следите за тем, чтобы звонки, передаваемые параллельным алгоритмам, были свободны от рас данных.
  • Поточные пулы повторно используют фиксированный набор потоков, чтобы избежать затрат на создание потоков для многих небольших задач.

Тест — проверьте свои знания

(15 XP)

1. Почему `std::accumulate` не может быть использован с политикой исполнения?

2. Какое ограничение размещает `std::execution::par_unseq` на телефоне?

3. Согласно закону Амдала, каково максимальное ускорение, если 20% программы последовательны?