رفتن به محتوا

الگوریتم‌های موازی و سیاست‌های اجرا

از STL موازی ‎C++‎17 برای موازی‌سازی مرتب‌سازی، تبدیل و کاهش با سیاست‌های اجرا بهره ببرید. درک کنید چه زمانی موازی‌سازی کمک می‌کند، قانون Amdahl و طراحی عملی استخر نخ.

STL موازی — موازی‌سازی آسان

‎C++‎17 سیاست‌های اجرا (execution policies) را به اکثر توابع اضافه کرد. با ارسال یک سیاست به عنوان اولین آرگومان، به پیاده‌سازی می‌گویید الگوریتم را موازی‌سازی کند — احتمالاً با استفاده از چندین نخ، SIMD یا هر دو — بدون تغییر هیچ کد دیگری.

این آسان‌ترین روش برای افزودن موازی‌سازی به ++C است: فراخوانی موجود std::sort، std::transform یا std::for_each خود را بگیرید، std::execution::par را به ابتدا اضافه کنید و بگذارید پیاده‌سازی بقیه کار را انجام دهد. البته باید مطمئن شوید عملیات شما برای اجرای همروند امن هستند (بدون مسابقه داده در محمول‌ها، بدون حالت مشترک قابل تغییر).

توضیح سیاست‌های اجرا

سه سیاست اجرای استاندارد در تعریف شده‌اند. هر کدام تضمین‌های متفاوتی درباره موازی‌سازی و بردارسازی (vectorization) فراهم می‌کنند.

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 در مقابل std::accumulate

std::accumulate عناصر را به‌طور اکید از چپ به راست پردازش می‌کند و نمی‌تواند موازی‌سازی شود. std::reduce (سی‌پلاس‌پلاس ۱۷) به پیاده‌سازی اجازه می‌دهد عناصر را به هر ترتیبی پردازش و نتایج جزئی را ترکیب کند — که اجرای موازی را ممکن می‌سازد. عملیات باید شرکت‌پذیر (associative) و جابجایی‌پذیر (commutative) باشد تا 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 (محمول، تابع تبدیل، عملگر کاهش) که به الگوریتم ارسال می‌کنید ممکن است به‌طور همزمان از چندین نخ فراخوانی شود. اگر callable شما به حالت مشترک قابل تغییر دسترسی داشته باشد، مسابقه داده دارید:

int count = 0;
std::for_each(std::execution::par, v.begin(), v.end(),
    [&count](int x) {
        if (x > 0) ++count;  // مسابقه داده! چندین نخ count را افزایش می‌دهند
    });

از std::atomic، انباشتگرهای محلی نخ، یا بهتر از آن از std::reduce / std::count_if که تجمیع را به‌صورت امن انجام می‌دهند استفاده کنید.

قانون Amdahl — چه زمانی موازی‌سازی کمک می‌کند

قانون Amdahl بیان می‌کند که حداکثر افزایش سرعت ناشی از موازی‌سازی توسط بخش ترتیبی برنامه شما محدود می‌شود. اگر ۱۰٪ کار باید به‌صورت ترتیبی انجام شود، حتی با نخ‌های بی‌نهایت، حداکثر افزایش سرعت ۱۰ برابر است.

افزایش سرعت = 1 / (S + P/N)، که S بخش ترتیبی، P بخش موازی و N تعداد نخ‌ها است.

موازی‌سازی همچنین سربار دارد: ایجاد نخ، هم‌زمان‌سازی، رقابت کش و اشتراک‌گذاری کاذب. برای مجموعه داده‌های کوچک، سربار می‌تواند از مزیت بیشتر باشد. قوانین سرانگشتی:

- مرتب‌سازی: std::sort موازی معمولاً بالای ~۱۰۰هزار عنصر کمک می‌کند.
- Transform/for_each: بالای ~۱۰هزار عنصر با کار غیرساده به ازای هر عنصر مفید است.
- Reduce: بالای ~۱۰۰هزار عنصر یا با عملیات پرهزینه مفید است.
- همیشه اندازه‌گیری کنید — با و بدون std::execution::par پروفایل بگیرید.

مروری بر استخر نخ عملی

استاندارد ++C استخر نخ (thread pool) فراهم نمی‌کند (تا ‎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 را بر موازی‌سازی دست‌ساز ترجیح دهید — پیاده‌سازی سخت‌افزار شما را می‌شناسد. همیشه تأیید کنید که callable شما فاقد مسابقه داده و عوارض جانبی است. وقتی به موازی‌سازی نیاز دارید به جای std::accumulate از std::reduce استفاده کنید. با اندازه‌های واقعی داده بنچمارک بگیرید؛ برای ورودی‌های کوچک، ترتیبی ممکن است سریع‌تر باشد. به یاد داشته باشید که par_unseq فراخوانی هر چیزی که قفل بگیرد (از جمله تخصیص حافظه در برخی پلتفرم‌ها) را ممنوع می‌کند.

نکات کلیدی
  • سیاست‌های اجرای ‎C++‎17 (seq، par، par_unseq) موازی‌سازی را با حداقل تغییرات کد به الگوریتم‌های استاندارد اضافه می‌کنند
  • std::reduce جایگزین سازگار با موازی‌سازی برای std::accumulate است — عملیات شما باید شرکت‌پذیر و جابجایی‌پذیر باشد
  • قانون Amdahl افزایش سرعت را محدود می‌کند: بخش ترتیبی کد شما تبدیل به گلوگاه می‌شود
  • الگوریتم‌های موازی سربار دارند — فقط برای مجموعه داده‌های به‌اندازه کافی بزرگ یا کار پرهزینه به ازای هر عنصر مفید هستند
  • همیشه اطمینان حاصل کنید callable‌هایی که به الگوریتم‌های موازی ارسال می‌شوند فاقد مسابقه داده هستند
  • استخرهای نخ مجموعه ثابتی از نخ‌ها را بازاستفاده می‌کنند تا از هزینه ایجاد نخ برای وظایف کوچک زیاد جلوگیری شود

آزمون — دانش خود را بسنجید

(15 امتیاز)

1. چرا `std::accumulate` نمی‌تواند با سیاست‌های اجرا استفاده شود؟

2. `std::execution::par_unseq` چه محدودیتی بر callable قرار می‌دهد؟

3. طبق قانون Amdahl، اگر ۲۰٪ برنامه ترتیبی باشد، حداکثر افزایش سرعت چقدر است؟