पैरेलल एल्गोरिदम और एक्ज़ीक्यूशन पॉलिसी
सॉर्टिंग, ट्रांसफ़ॉर्मेशन और रिडक्शन को एक्ज़ीक्यूशन पॉलिसी के साथ पैरेलल करने के लिए C++17 पैरेलल STL का लाभ उठाएँ। समझें कि पैरेललिज़्म कब मदद करता है, Amdahl का नियम, और व्यावहारिक थ्रेड पूल डिज़ाइन।
पैरेलल STL — आसान पैरेललिज़्म
C++17 ने ज़्यादातर फ़ंक्शन में एक्ज़ीक्यूशन पॉलिसी (execution policies) जोड़ीं। पहले आर्गुमेंट के रूप में एक पॉलिसी देकर, आप इम्प्लीमेंटेशन को बताते हैं कि एल्गोरिदम को पैरेलल करे — संभवतः कई थ्रेड, SIMD, या दोनों का उपयोग करके — और बाक़ी कोड में कोई बदलाव नहीं करना पड़ता।
C++ में पैरेललिज़्म जोड़ने का यह सबसे आसान तरीक़ा है: अपने मौजूदा std::sort, std::transform, या std::for_each कॉल में std::execution::par आगे जोड़ें, और बाक़ी इम्प्लीमेंटेशन पर छोड़ दें। बेशक, आपको यह सुनिश्चित करना होगा कि आपके ऑपरेशन समवर्ती निष्पादन के लिए सुरक्षित हों (प्रेडिकेट में कोई डेटा रेस नहीं, कोई साझा म्यूटेबल स्टेट नहीं)।
एक्ज़ीक्यूशन पॉलिसी की व्याख्या
में C++17 की तीन मानक एक्ज़ीक्यूशन पॉलिसी (seq, par, par_unseq) परिभाषित हैं (C++20 में unseq जुड़ी)। हर एक पैरेललिज़्म और वेक्टराइज़ेशन के बारे में अलग गारंटी देती है।
#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 (C++17) इम्प्लीमेंटेशन को एलिमेंट किसी भी क्रम में प्रोसेस करने और आंशिक नतीजों को जोड़ने की अनुमति देता है — जिससे पैरेलल निष्पादन संभव होता है। reduce के सही नतीजे देने के लिए ऑपरेशन साहचर्य (associative) और क्रमविनिमेय (commutative) होना चाहिए।
#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;
}पैरेलल एक्ज़ीक्यूशन पॉलिसी का उपयोग करते समय, एल्गोरिदम को दिया गया कॉलेबल (प्रेडिकेट, ट्रांसफ़ॉर्म फ़ंक्शन, रिडक्शन ऑपरेटर) कई थ्रेड से एक साथ इनवोक किया जा सकता है। अगर आपका कॉलेबल साझा म्यूटेबल स्टेट को एक्सेस करता है, तो आपके पास डेटा रेस है:
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 का उपयोग करें, जो एग्रीगेशन को सुरक्षित रूप से सँभालते हैं।
Amdahl का नियम — पैरेललिज़्म कब मदद करता है
Amdahl का नियम (Amdahl's law) कहता है कि पैरेललिज़्म से मिलने वाली अधिकतम स्पीडअप आपके प्रोग्राम के क्रमिक अंश (sequential fraction) से सीमित होती है। अगर 10% काम क्रमिक रूप से ही करना पड़े, तो अनंत थ्रेड होने पर भी अधिकतम स्पीडअप 10x है।
स्पीडअप = 1 / (S + P/N), जहाँ S क्रमिक अंश है, P पैरेलल अंश है, और N थ्रेड की संख्या है।
पैरेललिज़्म का ओवरहेड भी होता है: थ्रेड बनाना, सिंक्रोनाइज़ेशन, कैश कंटेंशन और फ़ॉल्स शेयरिंग। छोटे डेटा सेट के लिए ओवरहेड फ़ायदे से ज़्यादा हो सकता है। अंगूठे के नियम:
- सॉर्ट: पैरेलल std::sort आमतौर पर ~100K एलिमेंट से ऊपर मदद करता है।
- Transform/for_each: प्रति एलिमेंट गैर-मामूली काम होने पर ~10K एलिमेंट से ऊपर फ़ायदा मिलता है।
- रिड्यूस: ~100K एलिमेंट से ऊपर या महँगे ऑपरेशन के साथ फ़ायदा मिलता है।
- हमेशा मापें — std::execution::par के साथ और उसके बिना प्रोफ़ाइल करें।
व्यावहारिक थ्रेड पूल का अवलोकन
C++ स्टैंडर्ड थ्रेड पूल उपलब्ध नहीं कराता (C++23 तक), लेकिन पैटर्न सीधा है: वर्कर थ्रेड का एक निश्चित समूह साझा क्यू से टास्क निकालता है। यहाँ एक न्यूनतम रूपरेखा है।
#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::accumulate की जगह std::reduce का उपयोग करें। यथार्थवादी डेटा आकार के साथ बेंचमार्क करें; छोटे इनपुट के लिए क्रमिक (sequential) तेज़ हो सकता है। याद रखें कि par_unseq ऐसी किसी भी चीज़ को कॉल करने से रोकता है जो लॉक हासिल करती है (कुछ प्लेटफ़ॉर्म पर मेमोरी एलोकेशन सहित)।
- C++17 एक्ज़ीक्यूशन पॉलिसी (
seq,par,par_unseq) न्यूनतम कोड बदलाव के साथ स्टैंडर्ड एल्गोरिदम में पैरेललिज़्म जोड़ती हैं std::reduce,std::accumulateका पैरेलल-अनुकूल विकल्प है — आपका ऑपरेशन साहचर्य (associative) और क्रमविनिमेय (commutative) होना चाहिए- Amdahl का नियम स्पीडअप को सीमित करता है: आपके कोड का क्रमिक हिस्सा बॉटलनेक बन जाता है
- पैरेलल एल्गोरिदम में ओवरहेड होता है — केवल पर्याप्त बड़े डेटा सेट या महँगे प्रति-एलिमेंट काम के लिए ही फ़ायदेमंद
- यह हमेशा सुनिश्चित करें कि पैरेलल एल्गोरिदम को दिए गए कॉलेबल डेटा रेस से मुक्त हों
- थ्रेड पूल कई छोटे टास्क के लिए थ्रेड बनाने की लागत से बचने हेतु थ्रेड के एक निश्चित समूह को दोबारा इस्तेमाल करते हैं
क्विज़ — अपना ज्ञान परखें
(15 XP)1. `std::accumulate` को एक्ज़ीक्यूशन पॉलिसी के साथ क्यों इस्तेमाल नहीं किया जा सकता?
2. `std::execution::par_unseq` कॉलेबल पर कौन-सी बाधा लगाता है?
3. Amdahl के नियम के अनुसार, अगर किसी प्रोग्राम का 20% क्रमिक है तो अधिकतम स्पीडअप क्या है?