Thuật toán song song & Chính sách thực thi
Tận dụng STL song song C++17 để song song hóa việc sắp xếp, chuyển đổi và giảm thiểu bằng các chính sách thực thi. Hiểu khi nào tính song song có ích, định luật Amdahl và thiết kế nhóm luồng thực tế.
STL song song - Song song dễ dàng
C++17 đã thêm chính sách thực thi vào hầu hết các chức năng. Bằng cách chuyển một chính sách làm đối số đầu tiên, bạn yêu cầu quá trình triển khai song song hóa thuật toán — có thể sử dụng nhiều luồng, SIMD hoặc cả hai — mà không thay đổi bất kỳ mã nào khác.
Đây là cách dễ nhất để thêm tính song song vào C++: thực hiện lệnh gọistd::sort,std::transformhoặcstd::for_eachhiện có của bạn, thêmstd::execution::parvào trước và để quá trình triển khai thực hiện phần còn lại. Tất nhiên, bạn phải đảm bảo các hoạt động của mình an toàn để thực thi đồng thời (không có sự chạy đua dữ liệu trong các vị từ, không có trạng thái có thể thay đổi chung).
Giải thích chính sách thực thi
Có ba chính sách thực thi tiêu chuẩn được xác định trong. Mỗi cái cung cấp những đảm bảo khác nhau về tính song song và vector hóa.
#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 so với std::accumulate
std::accumulatexử lý các phần tử hoàn toàn từ trái sang phải và không thể song song.std::reduce(C++17) cho phép triển khai xử lý các phần tử theo bất kỳ thứ tự nào và kết hợp các kết quả từng phần — cho phép thực thi song song. Phép toán phải kết hợp và giao hoán đểreducetạo ra kết quả chính xác.
#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;
}Khi sử dụng các chính sách thực thi song song, có thể gọi (vị ngữ, hàm biến đổi, toán tử rút gọn) mà bạn truyền vào thuật toán có thể được gọi đồng thời từ nhiều luồng. Nếu khả năng gọi của bạn truy cập vào trạng thái có thể thay đổi được chia sẻ, bạn sẽ phải chạy đua dữ liệu:
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
});Use std::atomic, thread-local accumulators, or better yet, use std::reduce / std::count_if xử lý việc tổng hợp một cách an toàn.
Định luật Amdahl - Khi sự song song giúp ích
Định luật Amdahl nêu rõ rằng tốc độ tăng tốc tối đa từ tính song song bị giới hạn bởi phân số tuần tự của chương trình của bạn. Nếu 10% công việc phải được thực hiện tuần tự thì ngay cả với vô số luồng, tốc độ tăng tốc tối đa là 10 lần.
Tăng tốc = 1 / (S + P/N), trong đó S là phân số tuần tự, P là phân số song song và N là số lượng luồng.
Tính song song cũng có chi phí chung: tạo luồng, đồng bộ hóa, tranh chấp bộ đệm và chia sẻ sai. Đối với các tập dữ liệu nhỏ, chi phí có thể vượt quá lợi ích. Quy tắc ngón tay cái:
- Sắp xếp: Parallelstd::sortthường hỗ trợ các phần tử trên ~100K.
- Transform/for_each: Lợi ích trên ~10K phần tử với công việc không hề tầm thường trên mỗi phần tử.
- Giảm: Lợi ích trên ~100K phần tử hoặc với các hoạt động tốn kém.
- Luôn đo — hồ sơ có và không cóstd::execution::par.
Tổng quan về nhóm chủ đề thực tế
Tiêu chuẩn C++ không cung cấp nhóm luồng (kể từ C++23), nhưng mẫu rất đơn giản: một tập hợp các luồng công việc cố định kéo các tác vụ từ hàng đợi chung. Đây là một bản phác thảo tối thiểu.
#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;
}Ưu tiênstd::execution::parhơn tính năng song song cuộn bằng tay đối với các thuật toán tiêu chuẩn — việc triển khai biết phần cứng của bạn. Luôn xác minh rằng cuộc gọi của bạn không có tình trạng chạy đua dữ liệu và tác dụng phụ. Sử dụngstd::reducethay vìstd::accumulatekhi bạn cần tính song song. Điểm chuẩn với kích thước dữ liệu thực tế; đối với đầu vào nhỏ, tuần tự có thể nhanh hơn. Hãy nhớ rằngpar_unseqcấm gọi bất kỳ thứ gì có được khóa (bao gồm cả cấp phát bộ nhớ trên một số nền tảng).
- Chính sách thực thi C++17 (
seq,par,par_unseq) thêm tính song song vào các thuật toán tiêu chuẩn với những thay đổi mã tối thiểu std::reducelà sự thay thế thân thiện song song chostd::accumulate- hoạt động của bạn phải mang tính kết hợp và giao hoán- Luật của Amdahl hạn chế tốc độ tăng tốc: phần tuần tự trong mã của bạn trở thành nút cổ chai
- Các thuật toán song song có chi phí chung - chỉ có lợi cho các tập dữ liệu đủ lớn hoặc công việc trên mỗi phần tử đắt tiền
- Luôn đảm bảo các lệnh gọi được chuyển đến các thuật toán song song không có tình trạng chạy đua dữ liệu
- Nhóm luồng sử dụng lại một tập hợp luồng cố định để tránh chi phí tạo luồng cho nhiều tác vụ nhỏ
Câu đố — Kiểm tra kiến thức
(15 XP)1. Tại sao`std::accumulate`không thể được sử dụng với các chính sách thực thi?
2. `std::execution::par_unseq`đặt ràng buộc gì đối với khả năng gọi được?
3. Theo định luật Amdahl, tốc độ tăng tốc tối đa là bao nhiêu nếu 20% chương trình là tuần tự?