跳到主要内容

TBB库学习笔记

1. TBB库简介与安装

1.1 TBB库概述:什么是TBB,为何选择TBB

  • TBB(Threading Building Blocks)是一个由Intel开发的高级、跨平台的C++模板库,旨在简化多线程并行编程。TBB提供了一套丰富的数据结构和算法,使开发者能够轻松地编写高效的并行应用程序,而无需直接管理线程。

  • TBB的核心理念是任务并行性,而不是线程并行性。这意味着开发者只需要将问题分解为可并行执行的任务,而TBB的运行时系统会自动将这些任务分配给可用的处理器核心。

  • 为什么选择TBB?

    1. 高级抽象:TBB提供了高级并行编程模式,如并行for循环、流水线等,使并行编程变得更加简单。

    2. 可移植性:TBB可在多种操作系统和处理器架构上运行,包括Windows、Linux、macOS等。

    3. 可扩展性:TBB能够自动适应可用的处理器核心数量,无需手动调整。

    4. 性能优化:TBB内部实现了许多优化技术,如工作窃取算法,以提高并行执行的效率。

    5. 与现有代码的兼容性:TBB可以与现有的串行代码和其他并行编程模型(如OpenMP)无缝集成。

1.2 TBB库的历史与发展

  • TBB的发展历程如下:

    • 2006年:Intel首次发布TBB 1.0版本。

    • 2008年:TBB 2.0发布,引入了并行容器和内存分配器。

    • 2011年:TBB 4.0发布,增加了流图接口,用于表达复杂的数据流和依赖关系。

    • 2017年:Intel将TBB开源,采用Apache 2.0许可证。

    • 2019年:TBB成为oneAPI的一部分,这是Intel的统一编程模型。

    • 2020年:TBB被纳入开源社区oneTBB项目,进一步推动了其发展和应用。

  • 随着多核处理器的普及,TBB在并行编程领域的重要性不断提升,成为许多高性能计算和数据密集型应用的首选工具。

1.3 TBB库的优势与应用场景

TBB的主要优势包括:

  1. 高效的任务调度:TBB使用工作窃取算法来平衡线程间的负载,提高并行效率。
  2. 可组合性:TBB的并行构造可以嵌套使用,允许创建复杂的并行算法。
  3. 缓存友好:TBB的内存分配器和数据结构设计考虑了缓存局部性,提高了性能。
  4. 异常安全:TBB能够正确处理并行执行中的异常,确保资源正确释放。
  5. 丰富的并行模式:提供了多种并行模式,如并行reduce、scan、pipeline等。

TBB的应用场景包括但不限于:

  • 科学计算:例如物理模拟、气候模型等。
  • 图像和视频处理:如实时视频编解码、图像滤波等。
  • 金融分析:如蒙特卡洛模拟、风险分析等。
  • 游戏开发:物理引擎、AI计算等。
  • 大数据处理:并行数据分析和处理。

1.4 TBB库的安装与配置(不同操作系统与编译器)

  • TBB的安装方法因操作系统和编译器而异。以下是几种常见环境的安装步骤:
  1. Windows + Visual Studio:

    • 下载TBB预构建二进制包或使用vcpkg包管理器。
    • 设置环境变量TBB_ROOT指向TBB安装目录。
    • 在Visual Studio项目中,添加TBB include目录和库目录。
    • 链接tbb.lib(release模式)或tbb_debug.lib(debug模式)。
  2. Linux (Ubuntu):

    sudo apt-get update
    sudo apt-get install libtbb-dev
  3. macOS (使用Homebrew):

    brew install tbb
  4. 使用CMake(跨平台): 在CMakeLists.txt中添加:

    find_package(TBB REQUIRED)
    target_link_libraries(your_target TBB::tbb)
  • 配置完成后,可以通过以下简单的C++代码测试TBB是否正确安装:

    #include <iostream>
    #include <tbb/parallel_for.h>
    #include <vector>

    int main() {
    const int size = 1000000;
    std::vector<int> vec(size);

    tbb::parallel_for(0, size, [&](int i) {
    vec[i] = i * 2;
    });

    std::cout << "Parallel computation completed." << std::endl;
    return 0;
    }

1.5 并行计算的理论基础:阿姆达尔定律和古斯塔夫森定律

  • 在使用TBB进行并行编程时,理解一些基本的并行计算理论是非常重要的。
  • 这些理论不仅能帮助我们预测并行化的潜在收益,还能指导我们如何更好地设计和优化并行算法。
  • 下面将介绍三个关键的定律,并讨论它们如何应用于TBB编程实践。

1.5.1 阿姆达尔定律与TBB并行化策略

  • 阿姆达尔定律描述了程序并行化后的理论加速比。其公式为:

image-20240629154652337

  • 其中:

    • S(N) 是使用 N个处理器后的加速比。

    • P 是可以并行化的比例。

    • N 是处理器的数量。

  • 阿姆达尔定律表明,即使将可并行化部分的比例 P 提高,只要存在不可并行化的部分,性能提升就会受限。

  • 在TBB编程中,阿姆达尔定律提醒我们关注程序的串行部分。例如,当使用tbb::parallel_for时,我们应该尽量减少循环体外的串行代码。考虑以下示例:

#include <tbb/parallel_for.h>
#include <vector>

void process_data(std::vector<double>& data) {
// 串行初始化(可能限制总体加速比)
double initial_value = compute_initial_value();

tbb::parallel_for(tbb::blocked_range<size_t>(0, data.size()),
[&](const tbb::blocked_range<size_t>& r) {
for (size_t i = r.begin(); i != r.end(); ++i) {
data[i] = process(data[i], initial_value);
}
});

// 串行终结操作(也可能限制加速比)
post_process(data);
}
  • 在这个例子中,initial_value的计算和post_process操作是串行的,可能会限制整体的加速比。根据阿姆达尔定律,即使parallel_for部分实现了完美的并行,这些串行部分仍会制约总体性能提升。

1.5.2 古斯塔夫森定律与TBB的可扩展性

  • 古斯塔夫森定律考虑了问题规模随处理器数量增加而扩大的情况,其公式为:

image-20240629154720912

  • 其中:

    • S(N) 是使用 N 个处理器后的加速比。

    • P 是可以并行化的比例。

    • N 是处理器的数量。

  • 古斯塔夫森定律更适合于大规模计算场景下的性能评估。

  • 这个定律在TBB中尤为重要,特别是在处理大规模数据时。例如,使用tbb::parallel_reduce处理可变大小的数据集:

#include <tbb/parallel_reduce.h>
#include <vector>

double sum_vector(const std::vector<double>& data) {
return tbb::parallel_reduce(
tbb::blocked_range<size_t>(0, data.size()), 0.0,
[&](const tbb::blocked_range<size_t>& r, double local_sum) {
for (size_t i = r.begin(); i != r.end(); ++i) {
local_sum += data[i];
}
return local_sum;
},
std::plus<double>()
);
}
  • 随着数据规模的增加,我们可以充分利用更多的处理器核心,从而获得接近线性的加速比。这正是古斯塔夫森定律所预测的。

1.6 应用这些定律评估TBB系统性能

  • 在实际的TBB应用中,我们可以结合这两个定律来全面评估和优化系统性能:
  1. 使用阿姆达尔定律确定理论加速上限:
    • 分析代码,识别无法并行化的部分。
    • 估算串行部分的比例,预测最大可能的加速比。
    • 重点优化那些限制整体性能的串行代码段。
  2. 应用古斯塔夫森定律设计可扩展的TBB算法:
    • 设计能够随问题规模增长的并行算法。
    • 使用tbb::scalable_allocator来确保内存分配不会成为瓶颈。
    • 利用TBB的自动负载平衡特性,如work-stealing调度器。
  • 通过这样的分析,我们可以全面评估TBB程序的性能,找出瓶颈,并有针对性地进行优化。这种方法不仅适用于单个算法,也可以扩展到整个TBB应用系统的性能评估和优化。

  • 下面通过一个具体的代码示例来展示如何应用阿姆达尔定律和古斯塔夫森定律来评估TBB系统性能:

#include <tbb/parallel_for.h>
#include <tbb/task_arena.h>
#include <tbb/tick_count.h>
#include <vector>
#include <iostream>
#include <cmath>

// 模拟一些计算密集型工作
void compute_intensive_task(double& value) {
for (int i = 0; i < 1000; ++i) {
value = std::sqrt(value);
}
}

// 串行部分
void serial_work(std::vector<double>& data) {
double sum = 0;
for (const auto& val : data) {
sum += val;
}
data[0] = sum / data.size(); // 模拟一些不可并行化的工作
}

// 并行部分
void parallel_work(std::vector<double>& data) {
tbb::parallel_for(tbb::blocked_range<size_t>(0, data.size()),
[&](const tbb::blocked_range<size_t>& r) {
for (size_t i = r.begin(); i != r.end(); ++i) {
compute_intensive_task(data[i]);
}
});
}

double measure_time(std::function<void()> work) {
tbb::tick_count t0 = tbb::tick_count::now();
work();
tbb::tick_count t1 = tbb::tick_count::now();
return (t1 - t0).seconds();
}

int main() {
const std::vector<size_t> data_sizes = {1000000, 10000000, 100000000};
const std::vector<int> thread_counts = {1, 2, 4, 8, 16};

for (size_t size : data_sizes) {
std::cout << "Data size: " << size << std::endl;
std::vector<double> data(size, 2.0);

double serial_time = measure_time([&]() {
serial_work(data);
for (auto& val : data) compute_intensive_task(val);
});

std::cout << "Serial time: " << serial_time << " seconds" << std::endl;

for (int threads : thread_counts) {
tbb::task_arena arena(threads);
double parallel_time = arena.execute([&]() {
return measure_time([&]() {
serial_work(data);
parallel_work(data);
});
});

double speedup = serial_time / parallel_time;
double parallel_fraction = 1 - (1 / speedup - 1 / threads) / (1 - 1.0 / threads);

std::cout << "Threads: " << threads
<< ", Time: " << parallel_time << " seconds"
<< ", Speedup: " << speedup
<< ", Parallel fraction: " << parallel_fraction << std::endl;
}
std::cout << std::endl;
}

return 0;
}

现在分析这段代码,并解释如何应用阿姆达尔定律和古斯塔夫森定律:

  1. 阿姆达尔定律分析: 我们可以使用观察到的加速比来估算程序的并行分数。根据阿姆达尔定律:image-20240629154848024其中 S(N) 是加速比,N 是线程数,P 是并行分数。我们可以通过变换公式得到:image-20240629154906557在代码中,我们计算了 parallel_fraction,这就是估算的 P 值。

    • 如果 P 接近 1,说明程序有很好的并行性。
    • 如果 P 较小,说明程序受串行部分限制较大。

    通过观察不同线程数下的 P 值,我们可以评估程序的并行效率和潜在瓶颈。

  2. 古斯塔夫森定律分析: 古斯塔夫森定律考虑了问题规模增加的情况。在我们的代码中,我们使用了不同的数据大小(1000000, 10000000, 100000000)来模拟这种情况。 根据古斯塔夫森定律: $S(N) = N - \alpha(N-1)$ 其中 α 是串行分数。我们可以通过观察加速比随数据大小的变化来评估程序的可扩展性:

    • 如果大数据集上的加速比明显优于小数据集,说明程序符合古斯塔夫森定律的预测,具有良好的可扩展性。
    • 如果加速比随数据大小变化不大,可能表明程序存在其他瓶颈,如内存带宽限制。
  3. 性能优化建议: 基于这些分析,我们可以给出以下优化建议:

    • 如果观察到 P 值较小,重点优化 serial_work 函数,尝试将更多工作并行化。
    • 如果大数据集的性能提升不明显,考虑使用 tbb::scalable_allocator 来优化内存分配,或者调整任务粒度以提高缓存利用率。
    • 如果加速比在特定线程数后不再提升,可能需要重新设计算法以提高并行度。
  4. **实际应用: **运行这段代码,观察不同数据大小和线程数下的性能表现。例如:

    • 如果看到 8 线程和 16 线程的性能相近,可能表明程序已经达到了硬件或算法的并行极限。
    • 如果大数据集上的加速比明显优于小数据集,说明程序在处理大规模问题时更有效率,符合古斯塔夫森定律的预测。

通过这种方式,我们可以综合运用阿姆达尔定律和古斯塔夫森定律来全面评估TBB程序的性能,找出瓶颈,并有针对性地进行优化。这种分析方法不仅适用于这个简单的示例,也可以扩展到更复杂的TBB应用系统中。

2. TBB库基础概念与核心组件

2.1 任务(task)与任务组(task_group)

  • 在TBB中,任务(task)是并行执行的基本单位。任务比线程更轻量级,允许更细粒度的并行性。TBB使用任务而不是直接使用线程,这样可以更好地利用系统资源,提高并行效率。

  • 任务通常被封装在函数对象中,例如:

class MyTask {
public:
void operator()() {
// 任务的具体执行内容
}
};
  • 任务组(task_group)是一种用于组织和管理相关任务的机制。它允许您创建一组任务,并等待它们全部完成。使用任务组的基本方式如下:
tbb::task_group g;
g.run(MyTask()); // 添加任务到任务组
g.run([]{ /* 另一个任务 */ }); // 可以使用lambda表达式
g.wait(); // 等待所有任务完成
  • 任务组提供了一种简单的方法来创建和同步一组相关的任务,而无需手动管理它们的生命周期。

2.2 任务调度器(task_scheduler)与工作窃取算法

TBB的任务调度器是一个复杂而高效的系统,它的主要目标是在可用的处理器核心上均匀分配工作负载。

详细工作原理:

  1. 任务池:调度器维护一个全局的任务池,同时每个工作线程也有自己的本地任务队列。
  2. 工作线程:调度器创建与系统处理器核心数量相当的工作线程。
  3. 任务分配:
    • 当新任务被创建时,它们通常被放入创建它们的线程的本地队列。
    • 本地队列使用双端队列(deque)数据结构,允许从两端进行操作。
  4. 工作窃取算法:
    • 当一个线程完成了自己队列中的所有任务,它会尝试从其他线程"窃取"任务。
    • 窃取过程通常遵循以下步骤:
      • a. 选择一个"受害者"线程,通常是队列最长的线程。
      • b. 从受害者线程的队列尾部窃取任务(而线程自己则从队列头部获取任务)。
    • 这种方法减少了竞争,因为每个线程主要操作自己队列的一端。
  5. 负载均衡:工作窃取自动平衡了系统负载,忙碌的线程会被其他线程帮助,从而提高整体效率。
  6. 缓存友好:由于线程主要处理自己队列中的任务,这增加了缓存命中率,提高了性能。
  7. 任务亲和性:调度器尝试将相关的任务分配给同一线程,以提高缓存利用率。
  8. 动态适应:调度器可以根据系统负载动态调整活跃线程的数量。

示例代码(简化的工作窃取算法原理):

class Scheduler {
std::vector<std::deque<Task>> threadQueues;
std::mutex schedulerMutex;

public:
void addTask(Task task, int threadId) {
threadQueues[threadId].push_back(std::move(task));
}

Task getTask(int threadId) {
if (!threadQueues[threadId].empty()) {
Task task = std::move(threadQueues[threadId].front());
threadQueues[threadId].pop_front();
return task;
}

// 尝试窃取
std::lock_guard<std::mutex> lock(schedulerMutex);
for (int i = 0; i < threadQueues.size(); ++i) {
if (i != threadId && !threadQueues[i].empty()) {
Task task = std::move(threadQueues[i].back());
threadQueues[i].pop_back();
return task;
}
}

return Task(); // 空任务,表示没有找到可执行的任务
}
};

2.3 TBB的任务模型

  • TBB的任务模型基于分治策略,(divide-and-conquer),这是一种将大问题分解为小问题的方法。
  • 但它比简单的递归分割更复杂。以下是TBB任务模型的详细解释:
  1. 任务树: TBB使用任务树来组织工作。每个任务可以生成子任务,形成一个树状结构。
  2. 继承关系: TBB定义了一个基础任务类 tbb::task,用户可以继承这个类来定义自己的任务。
  3. 重要方法:
    • execute(): 定义任务的实际工作。
    • spawn(): 创建一个子任务并将其添加到调度器。
    • spawn_and_wait_for_all(): 创建子任务并等待它们完成。
  4. 任务回收: TBB使用引用计数来管理任务的生命周期,自动处理任务的创建和销毁。
  5. 任务分配: 任务可以被分配到不同的线程执行,调度器负责管理这个过程。
  6. 任务亲和性: 相关的任务倾向于在同一线程上执行,以提高缓存效率。

示例代码(使用TBB的任务模型计算斐波那契数列):

class FibTask : public tbb::task {
long n;
long* sum;
public:
FibTask(long n_, long* sum_) : n(n_), sum(sum_) {}

tbb::task* execute() override {
if (n < 2) {
*sum = n;
} else {
long x, y;
FibTask& a = *new(allocate_child()) FibTask(n-1, &x);
FibTask& b = *new(allocate_child()) FibTask(n-2, &y);

set_ref_count(3);
spawn(b);
spawn_and_wait_for_all(a);

*sum = x + y;
}
return nullptr;
}
};

long parallel_fib(long n) {
long sum;
FibTask& a = *new(tbb::task::allocate_root()) FibTask(n, &sum);
tbb::task::spawn_root_and_wait(a);
return sum;
}

2.4 并发容器(concurrent_container):concurrent_vector, concurrent_queue, concurrent_hash_map等

TBB提供了几种线程安全的容器,用于在多线程环境中安全地存储和访问数据:

  1. concurrent_vector: 一个可以并发增长的动态数组。

    tbb::concurrent_vector<int> vec;
    vec.push_back(1); // 线程安全的添加元素
    int element = vec[0]; // 线程安全的访问元素
  2. concurrent_queue: 一个线程安全的先进先出(FIFO)队列。

    tbb::concurrent_queue<int> queue;
    queue.push(1); // 线程安全的入队
    int value;
    bool success = queue.try_pop(value); // 线程安全的出队
  3. concurrent_hash_map: 一个线程安全的哈希表。

    tbb::concurrent_hash_map<int, string> map;
    map.insert(std::make_pair(1, "one")); // 线程安全的插入
    tbb::concurrent_hash_map<int, string>::accessor acc;
    if (map.find(acc, 1)) {
    std::cout << acc->second; // 线程安全的访问
    }

这些容器在内部使用细粒度的锁定机制,以允许多个线程同时访问容器的不同部分,从而提高并发性能。

2.5 同步原语(synchronization primitives):mutex, spin_mutex, atomic等

  • TBB提供了多种同步原语,每种都有其特定的用途和性能特征:

mutex

  • 基于操作系统的互斥量实现

  • 提供互斥访问共享资源的能力

tbb::mutex m;
{
tbb::mutex::scoped_lock lock(m);
// 临界区
}

spin_mutex

  • 使用自旋等待而不是线程挂起
  • 适用于保护短临界区
tbb::spin_mutex sm;
{
tbb::spin_mutex::scoped_lock lock(sm);
// 短临界区
}

atomic

  • 利用硬件级原子操作
  • 提供无锁编程的基础
tbb::atomic<int> counter(0);
counter++;
int value = counter;

reader_writer_lock

  • 允许多个读者同时访问,或一个写者独占访问
  • 适用于读多写少的场景
tbb::reader_writer_lock rwl;
{
tbb::reader_writer_lock::scoped_lock_read lock(rwl);
// 读操作
}
{
tbb::reader_writer_lock::scoped_lock lock(rwl);
// 写操作
}

queuing_mutex

  • 使用队列来管理等待的线程
  • 提供先进先出的公平性
tbb::queuing_mutex qm;
{
tbb::queuing_mutex::scoped_lock lock(qm);
// 临界区
}

critical_section

  • 轻量级的互斥机制
  • 仅在单个进程内有效
tbb::critical_section cs;
{
tbb::critical_section::scoped_lock lock(cs);
// 临界区
}

condition_variable

  • 允许线程等待某个条件成立
  • 通常与mutex配合使用
tbb::mutex m;
tbb::condition_variable cv;
bool ready = false;

// 等待线程
{
tbb::mutex::scoped_lock lock(m);
while (!ready) {
cv.wait(lock);
}
}

// 通知线程
{
tbb::mutex::scoped_lock lock(m);
ready = true;
cv.notify_one();
}
  • 这些同步原语为开发者提供了丰富的工具集,可以根据不同的并发场景选择最适合的同步机制。在使用这些原语时,需要考虑性能、公平性、死锁风险等因素。

3. TBB库基本用法与示例

3.1 parallel_for:并行循环

  • parallel_for 是TBB中最常用的并行算法之一,用于并行执行独立的循环迭代。

  • parallel_for 将一个循环的迭代范围划分为多个子范围,每个子范围由不同的线程并行执行。这种方法特别适用于循环迭代之间没有依赖关系的情况。

  • parallel_for 可以与不同类型的范围(range)结合使用。

range的使用:

  • TBB提供了多种range类型,最常用的是tbb::blocked_range。range定义了要并行处理的数据范围。
  1. 两参数的range: tbb::blocked_range<T>(begin, end)
    • begin:范围的起始
    • end:范围的结束(不包含)
  2. 三参数的range: tbb::blocked_range<T>(begin, end, grain_size)
    • begin:范围的起始
    • end:范围的结束(不包含)
    • grain_size:每个任务处理的最小单元数,用于控制并行粒度

C++示例

#include <tbb/parallel_for.h>
#include <vector>
#include <iostream>

void parallel_for_example() {
std::vector<int> vec(1000000);

// 使用两参数的range
tbb::parallel_for(tbb::blocked_range<size_t>(0, vec.size()),
[&](const tbb::blocked_range<size_t>& r) {
for (size_t i = r.begin(); i != r.end(); ++i) {
vec[i] = i * 2;
}
}
);

// 使用三参数的range,设置grain_size为1000
tbb::parallel_for(tbb::blocked_range<size_t>(0, vec.size(), 1000),
[&](const tbb::blocked_range<size_t>& r) {
for (size_t i = r.begin(); i != r.end(); ++i) {
vec[i] = i * 3;
}
}
);
}
  • 在这个例子中,我们使用parallel_for来并行处理一个vector中的所有元素。tbb::blocked_range用于指定迭代范围,lambda函数定义了每个线程要执行的操作。

其他range

  1. blocked_range2d<T>
blocked_range2d<T>(T row_begin, T row_end, T col_begin, T col_end)
blocked_range2d<T>(T row_begin, T row_end, size_t row_grainsize,
T col_begin, T col_end, size_t col_grainsize)
  • 适用场景:二维数据的并行处理,如矩阵或图像。
tbb::parallel_for(tbb::blocked_range2d<int>(0, height, 32, 0, width, 32),
[&](const tbb::blocked_range2d<int>& r) {
for (int i = r.rows().begin(); i != r.rows().end(); ++i) {
for (int j = r.cols().begin(); j != r.cols().end(); ++j) {
// 处理 matrix[i][j]
}
}
}
);
  1. blocked_range3d<T>
blocked_range3d<T>(T page_begin, T page_end,
T row_begin, T row_end,
T col_begin, T col_end)
blocked_range3d<T>(T page_begin, T page_end, size_t page_grainsize,
T row_begin, T row_end, size_t row_grainsize,
T col_begin, T col_end, size_t col_grainsize)
  • 适用场景:三维数据的并行处理,如三维矩阵或体积数据。
tbb::parallel_for(tbb::blocked_range3d<int>(0, depth, 0, height, 0, width),
[&](const tbb::blocked_range3d<int>& r) {
for (int z = r.pages().begin(); z != r.pages().end(); ++z) {
for (int y = r.rows().begin(); y != r.rows().end(); ++y) {
for (int x = r.cols().begin(); x != r.cols().end(); ++x) {
// 处理 volume[z][y][x]
}
}
}
}
);
  1. counting_iterator
counting_iterator<T>(T value)
  • 适用场景:当需要对一系列连续的整数值进行并行处理时。
tbb::parallel_for_each(
tbb::counting_iterator<int>(0),
tbb::counting_iterator<int>(100),
[](int i) {
// 处理 i
}
);
  1. concurrent_vector_range<T>
concurrent_vector_range<T>(tbb::concurrent_vector<T>& vector)

适用场景:

  • 并行处理tbb::concurrent_vector中的元素。
  • 当vector大小可能在并行操作期间变化时。
tbb::concurrent_vector<int> vec;
// ... 填充 vec ...
tbb::parallel_for_each(
tbb::concurrent_vector_range<int>(vec),
[](int& item) {
// 处理 item
}
);
  1. enumerable_thread_specific_range
enumerable_thread_specific_range<T>(tbb::enumerable_thread_specific<T>& ets)

适用场景:

  • 并行处理线程局部存储(TLS)中的数据。
  • 当每个线程有自己的数据副本,需要汇总或处理这些副本时。
tbb::enumerable_thread_specific<std::vector<int>> ets;
// ... 填充 ets ...
tbb::parallel_for_each(
tbb::enumerable_thread_specific_range<std::vector<int>>(ets),
[](std::vector<int>& thread_local_vec) {
// 处理每个线程的 thread_local_vec
}
);

划分器:

  • parallel_for 还可以使用额外的参数来控制任务划分策略。
  • TBB提供了几种不同的划分器,用于控制并行任务的分配策略。
  • 每种划分器都有其特定的使用场景和优势。

a) auto_partitioner(默认):

  • 自动调整任务的划分,根据工作负载和可用资源动态平衡。
  • 适用于大多数情况,特别是当任务的工作量不均匀或难以预测时。
tbb::parallel_for(range, body, tbb::auto_partitioner());

b) simple_partitioner:

  • 递归地将范围划分为更小的子范围,直到达到一个预定义的最小粒度。
  • 适用于工作负载均匀且可预测的情况。
  • 可能导致创建过多的任务,增加调度开销。
tbb::parallel_for(range, body, tbb::simple_partitioner());

c) static_partitioner:

  • 在算法开始时就将工作划分为固定数量的块。
  • 适用于工作负载非常均匀且处理器数量已知的情况。
  • 优点是最小化调度开销,缺点是缺乏灵活性。
tbb::parallel_for(range, body, tbb::static_partitioner());

d) affinity_partitioner:

  • 尝试将任务分配给之前执行过相同数据的线程,以提高缓存亲和性。

  • 适用于重复执行的并行循环,可以提高缓存命中率。

  • 需要在循环外部定义并重用。

tbb::affinity_partitioner ap;
for (int i = 0; i < 10; ++i) {
tbb::parallel_for(range, body, ap);
}
  • 选择合适的划分器可以根据具体的应用场景和性能需求来决定。通常,auto_partitioner是一个很好的起点,如果发现性能问题,可以尝试其他划分器。

3.2 parallel_reduce:并行归约

  • parallel_reduce 用于并行执行需要累积结果的操作,如求和、求最大值等。

  • parallel_reduce 将问题分解为多个子问题,每个子问题由不同的线程并行计算部分结果,最后将所有部分结果合并得到最终结果。

C++伪码示例

#include <tbb/parallel_reduce.h>
#include <vector>

int parallel_sum(const std::vector<int>& vec) {
return tbb::parallel_reduce(
tbb::blocked_range<size_t>(0, vec.size()), 0,
[&](const tbb::blocked_range<size_t>& r, int local_sum) -> int {
for (size_t i = r.begin(); i != r.end(); ++i) {
local_sum += vec[i];
}
return local_sum;
},
[](int x, int y) -> int {
return x + y;
}
);
}
  • 这个例子展示了如何使用parallel_reduce计算vector中所有元素的和。第一个lambda函数计算每个子范围的局部和,第二个lambda函数定义了如何合并局部结果。

3.3 parallel_scan:并行前缀和

  • parallel_scan 用于计算前缀和(也称为扫描操作),它在保持元素顺序的同时并行执行累加操作。

  • parallel_scan 通过两次遍历实现并行化:第一次自底向上计算部分和,第二次自顶向下传播最终结果。这种方法允许在保持正确顺序的同时实现并行计算。

C++伪码示例

#include <tbb/parallel_scan.h>
#include <vector>

void parallel_prefix_sum(std::vector<int>& vec) {
tbb::parallel_scan(
tbb::blocked_range<size_t>(0, vec.size()),
0,
[&](const tbb::blocked_range<size_t>& r, int sum, bool is_final_scan) -> int {
int temp = sum;
for (size_t i = r.begin(); i != r.end(); ++i) {
temp += vec[i];
if (is_final_scan) {
vec[i] = temp;
}
}
return temp;
},
[](int left, int right) -> int {
return left + right;
}
);
}
  • 这个例子展示了如何使用parallel_scan计算vector的前缀和。is_final_scan参数用于区分第一次和第二次遍历。

3.4 parallel_invoke:并行调用函数

  • parallel_invoke 用于并行执行多个独立的函数或任务。它与任务组(task_group)有一些相似之处,但也有重要的区别。

  • parallel_invoke 将多个函数或任务分配给不同的线程并行执行,当所有任务完成时返回。这对于需要同时执行多个独立操作的场景非常有用。

C++伪码示例

#include <tbb/parallel_invoke.h>
#include <iostream>

void task1() { std::cout << "Task 1\n"; }
void task2() { std::cout << "Task 2\n"; }
void task3() { std::cout << "Task 3\n"; }

void parallel_invoke_example() {
tbb::parallel_invoke(
[]{ task1(); },
[]{ task2(); },
[]{ task3(); }
);
}
  • 在这个例子中,parallel_invoke同时执行三个独立的任务。每个任务可以是一个lambda函数、函数指针或函数对象。

parallel_invoke 与任务组(task_group)的区别:

  1. 使用方式:
    • parallel_invoke:直接传入要执行的函数或lambda表达式。
    • task_group:需要显式创建任务组,然后添加任务,最后等待所有任务完成。
  2. 灵活性:
    • parallel_invoke:适用于固定数量的任务,所有任务在调用时就确定。
    • task_group:更灵活,可以动态添加任务,甚至在执行过程中添加新任务。
  3. 同步点:
    • parallel_invoke:函数返回时,所有任务都已完成。
    • task_group:可以选择何时等待任务完成(使用wait()方法)。
  4. 异常处理:
    • parallel_invoke:如果任何任务抛出异常,会立即传播到调用者。
    • task_group:可以在wait()时捕获异常,提供更细粒度的异常处理。

task_group 示例:

#include <tbb/task_group.h>
#include <iostream>

void task_group_example() {
tbb::task_group g;
g.run([]{ std::cout << "Task 1\n"; });
g.run([]{ std::cout << "Task 2\n"; });
g.run([]{ std::cout << "Task 3\n"; });
g.wait(); // 等待所有任务完成
}

选择使用parallel_invoke还是task_group取决于具体的需求:

  • 对于简单的、固定数量的并行任务,parallel_invoke更加简洁和直观。
  • 对于需要动态添加任务或更细粒度控制的场景,task_group更合适。

3.5 常用算法的并行实现:parallel_sort, parallel_merge等

  • TBB提供了许多常见算法的并行版本,如parallel_sort和parallel_merge。

  • 这些并行算法利用多线程来加速传统的串行算法。例如,parallel_sort使用并行快速排序算法,而parallel_merge并行合并两个已排序的序列。

C++示例(parallel_sort)

#include <tbb/parallel_sort.h>
#include <vector>

void parallel_sort_example(std::vector<int>& vec) {
tbb::parallel_sort(vec.begin(), vec.end());
}
  • 这个简单的例子展示了如何使用parallel_sort对vector进行并行排序。TBB会自动处理并行化的细节,包括任务分配和负载均衡。

3.6 在现有串行代码中引入TBB并行化

  • 将TBB引入现有的串行代码通常遵循以下步骤:

    1. 识别独立的任务或循环:找出代码中可以并行执行的部分。

    2. 选择适当的TBB算法:根据任务的性质选择合适的并行算法。

    3. 重构代码:使用选定的TBB算法替换原有的串行实现。

    4. 测试和优化:验证并行版本的正确性,并进行性能测试和优化。

示例:图像处理中的边缘检测算法

串行版本的边缘检测算法:

#include <vector>
#include <cmath>

struct Pixel { unsigned char r, g, b; };

std::vector<Pixel> detectEdges(const std::vector<Pixel>& image, int width, int height) {
std::vector<Pixel> result(image.size());

for (int y = 1; y < height - 1; ++y) {
for (int x = 1; x < width - 1; ++x) {
int index = y * width + x;

// 计算索贝尔(Sobel)算子
int gx = 0, gy = 0;
for (int j = -1; j <= 1; ++j) {
for (int i = -1; i <= 1; ++i) {
int pixel = (y + j) * width + (x + i);
int value = (image[pixel].r + image[pixel].g + image[pixel].b) / 3;
gx += value * (i * (1 - abs(j)));
gy += value * (j * (1 - abs(i)));
}
}

// 计算梯度强度
int magnitude = std::sqrt(gx * gx + gy * gy);
magnitude = std::min(255, magnitude);

result[index].r = result[index].g = result[index].b = magnitude;
}
}

return result;
}

现在,让我们将这个串行代码转换为使用TBB的并行版本:

#include <vector>
#include <cmath>
#include <tbb/parallel_for.h>
#include <tbb/blocked_range2d.h>

struct Pixel { unsigned char r, g, b; };

std::vector<Pixel> detectEdgesParallel(const std::vector<Pixel>& image, int width, int height) {
std::vector<Pixel> result(image.size());

tbb::parallel_for(tbb::blocked_range2d<int>(1, height - 1, 1, width - 1),
[&](const tbb::blocked_range2d<int>& r) {
for (int y = r.rows().begin(); y != r.rows().end(); ++y) {
for (int x = r.cols().begin(); x != r.cols().end(); ++x) {
int index = y * width + x;

// 计算索贝尔(Sobel)算子
int gx = 0, gy = 0;
for (int j = -1; j <= 1; ++j) {
for (int i = -1; i <= 1; ++i) {
int pixel = (y + j) * width + (x + i);
int value = (image[pixel].r + image[pixel].g + image[pixel].b) / 3;
gx += value * (i * (1 - abs(j)));
gy += value * (j * (1 - abs(i)));
}
}

// 计算梯度强度
int magnitude = std::sqrt(gx * gx + gy * gy);
magnitude = std::min(255, magnitude);

result[index].r = result[index].g = result[index].b = magnitude;
}
}
}
);

return result;
}

解释:

  1. 我们使用了tbb::parallel_for来并行化嵌套的for循环。
  2. tbb::blocked_range2d用于创建二维范围,适合处理图像这样的二维数据。
  3. lambda函数作为并行执行的主体,包含了原来的边缘检测逻辑。
  4. 我们没有使用额外的划分器参数,让TBB使用默认的auto_partitioner。
  5. 注意范围的选择:我们从1到width-1和height-1,以避免边界检查问题。

优化:

  • 尝试不同的划分器,例如:

    tbb::affinity_partitioner ap;
    tbb::parallel_for(tbb::blocked_range2d<int>(1, height - 1, 1, width - 1),
    [&](const tbb::blocked_range2d<int>& r) {
    // ... 函数体 ...
    },
    ap
    );
  • 调整grain_size以找到最佳的并行粒度:

    tbb::parallel_for(tbb::blocked_range2d<int>(1, height - 1, 32, 1, width - 1, 32),
    [&](const tbb::blocked_range2d<int>& r) {
    // ... 函数体 ...
    }
    );
  1. 考虑内存访问模式:评估是否需要使用TBB的并发容器或内存分配器来进一步提高性能。

4. TBB库进阶用法与技巧

4.1 流式编程(flow graph):构建复杂并行工作流

流式编程是TBB提供的一种高级并行编程模型,允许开发者通过构建数据流图来表达复杂的并行算法。这种方法特别适合处理具有复杂依赖关系的任务。

流图由节点和边组成:

  • 节点代表计算单元或数据操作
  • 边表示节点之间的数据流动或依赖关系

TBB提供了多种类型的节点,包括:

  1. function_node:执行用户定义的函数
  2. continue_node:等待前驱节点完成后执行
  3. multifunction_node:可以有多个输出的节点
  4. join_node:等待多个输入后才触发执行
  5. split_node:将输入分发到多个后继节点
#include <tbb/flow_graph.h>

void construct_flow_graph() {
tbb::flow::graph g;

// 创建节点
tbb::flow::function_node<int, int> node1(g, tbb::flow::unlimited, [](int n) { return n * 2; });
tbb::flow::function_node<int, int> node2(g, tbb::flow::unlimited, [](int n) { return n + 1; });
tbb::flow::function_node<int, int> node3(g, tbb::flow::unlimited, [](int n) { return n * n; });

// 连接节点
tbb::flow::make_edge(node1, node2);
tbb::flow::make_edge(node2, node3);

// 启动图的执行
node1.try_put(10);
g.wait_for_all();
}

在这个例子中,我们创建了一个简单的流图,包含三个function_node。数据从node1流向node2,再流向node3,形成一个简单的计算管道。

4.2 流水线(pipeline):实现高效的并行数据处理

  • TBB的流水线是一种强大的并行编程模式,特别适用于需要对大量数据进行多阶段处理的场景。它允许不同的处理阶段并行执行,从而提高整体吞吐量和效率。

流水线的核心概念:

  1. 阶段(Stage):每个阶段代表数据处理的一个步骤。
  2. 令牌(Token):流经流水线的数据单元。
  3. 并行性:不同的令牌可以同时在不同的阶段中处理。

TBB流水线的主要特点:

  • 可以包含任意数量的阶段
  • 支持串行和并行阶段
  • 自动负载均衡
  • 内置的异常处理机制

创建和使用TBB流水线的基本步骤:

  1. 定义流水线阶段
  2. 创建pipeline对象
  3. 添加阶段到pipeline
  4. 运行pipeline
#include <tbb/pipeline.h>
#include <tbb/parallel_pipeline.h>

class InputStage {
public:
void* operator()(tbb::flow_control& fc) const {
static int count = 0;
if (count >= MAX_ITEMS) {
fc.stop();
return nullptr;
}
return new int(count++);
}
};

class ProcessStage {
public:
void* operator()(void* item) const {
int* input = static_cast<int*>(item);
*input *= 2; // 将输入值翻倍
return input;
}
};

class OutputStage {
public:
void operator()(void* item) const {
int* result = static_cast<int*>(item);
std::cout << "Result: " << *result << std::endl;
delete result;
}
};

void run_pipeline() {
tbb::pipeline pipeline;

const size_t MAX_TOKENS = 8; // 最大并行令牌数

InputStage input_stage;
ProcessStage process_stage;
OutputStage output_stage;

pipeline.add_filter(tbb::filter::serial_in_order, input_stage);
pipeline.add_filter(tbb::filter::parallel, process_stage);
pipeline.add_filter(tbb::filter::serial_in_order, output_stage);

tbb::parallel_pipeline(MAX_TOKENS, pipeline);
}
  • 在这个例子中:
  1. InputStage 是一个串行输入阶段,负责生成数据。
  2. ProcessStage 是一个并行处理阶段,对数据进行处理(这里简单地将值翻倍)。
  3. OutputStage 是一个串行输出阶段,负责输出结果。
  • 流水线的工作原理:
  1. InputStage 生成一个令牌(token)并传递给下一阶段。
  2. ProcessStage 可以并行处理多个令牌,提高处理效率。
  3. OutputStage 按顺序输出处理后的结果。

TBB流水线的一些高级用法:

  1. 动态过滤器:可以在运行时动态添加或移除阶段。
tbb::filter_t<void,void> dynamic_filter(tbb::filter::serial_in_order, 
[](tbb::flow_control& fc) -> void* {
// 动态决定是否继续处理或停止
if (some_condition) {
fc.stop();
return nullptr;
}
return produce_next_item();
}
);
pipeline.add_filter(dynamic_filter);
  1. 组合过滤器:将多个简单过滤器组合成一个复杂过滤器。
auto combined_filter = tbb::make_filter<void,void>(
tbb::filter::parallel,
[](tbb::flow_control& fc) -> void* {
void* item = first_stage(fc);
if (item) {
item = second_stage(item);
}
return item;
}
);
pipeline.add_filter(combined_filter);
  1. 使用 tbb::parallel_pipeline 的高级选项:
tbb::parallel_pipeline(
MAX_TOKENS,
pipeline,
tbb::parallel_pipeline_options()
.set_max_throughput(100) // 设置最大吞吐量
.set_max_waiters(5) // 设置最大等待者数量
);

流水线模式的优势:

  1. 提高并行度:不同阶段可以同时处理不同的数据项。
  2. 改善缓存利用:每个阶段可以更好地利用缓存。
  3. 灵活性:易于添加、移除或修改处理阶段。
  4. 自动负载均衡:TBB会自动平衡各阶段的工作负载。

使用TBB流水线时的注意事项:

  1. 合理设置令牌数量:太少可能无法充分利用并行性,太多可能增加内存压力。
  2. 平衡各阶段工作量:尽量使各阶段工作量均衡,避免出现瓶颈。
  3. 正确处理异常:确保在异常发生时正确清理资源。
  4. 考虑数据局部性:设计阶段时考虑数据访问模式,提高缓存效率。

4.3 任务依赖与任务延续

  • TBB提供了强大的机制来处理任务之间的复杂关系,包括任务依赖和任务延续。这些机制使得开发者能够精确控制任务的执行顺序和并行性,从而构建高效的并行算法。

4.3.1 任务依赖

  • 任务依赖允许我们定义任务之间的前置条件关系。TBB提供了多种方式来表达和管理这些依赖关系。
  1. 使用 tbb::task_group
  • task_group 允许我们创建一组相互依赖的任务,并等待它们全部完成。
#include <tbb/task_group.h>

void complex_algorithm() {
tbb::task_group g;

g.run([&] {
// Task A
std::cout << "Executing Task A" << std::endl;
});

g.run([&] {
// Task B
std::cout << "Executing Task B" << std::endl;
});

// 等待Task A和B都完成
g.wait();

// 只有在A和B都完成后,才会执行C
g.run([&] {
// Task C
std::cout << "Executing Task C" << std::endl;
});

g.wait();
}
  1. 使用 tbb::flow::graph
  • 对于更复杂的依赖关系,我们可以使用TBB的流图接口。
#include <tbb/flow_graph.h>

void complex_dependency_graph() {
tbb::flow::graph g;

tbb::flow::function_node<int, int> nodeA(g, tbb::flow::unlimited, [](int n) {
std::cout << "Node A: " << n << std::endl;
return n * 2;
});

tbb::flow::function_node<int, int> nodeB(g, tbb::flow::unlimited, [](int n) {
std::cout << "Node B: " << n << std::endl;
return n + 10;
});

tbb::flow::function_node<int, int> nodeC(g, tbb::flow::unlimited, [](int n) {
std::cout << "Node C: " << n << std::endl;
return n * n;
});

// 建立依赖关系:A -> B -> C
tbb::flow::make_edge(nodeA, nodeB);
tbb::flow::make_edge(nodeB, nodeC);

// 启动图的执行
nodeA.try_put(5);
g.wait_for_all();
}
  1. 使用 tbb::parallel_do
  • 对于动态生成的任务集,可以使用 parallel_do
#include <tbb/parallel_do.h>
#include <vector>

class Task {
public:
Task(int id) : id_(id) {}

void operator()(tbb::parallel_do_feeder<Task>& feeder) const {
std::cout << "Executing Task " << id_ << std::endl;
if (id_ < 5) {
feeder.add(Task(id_ + 1));
}
}

private:
int id_;
};

void dynamic_task_creation() {
std::vector<Task> initial_tasks = {Task(0)};
tbb::parallel_do(initial_tasks.begin(), initial_tasks.end());
}

4.3.2 任务延续

  • 任务延续允许我们定义任务完成后应该执行的后续任务。这种机制特别适用于构建复杂的任务链或任务树。
  1. 使用 tbb::task::enqueue
#include <tbb/task.h>

void task_continuation() {
tbb::task::enqueue([&] {
std::cout << "Task 1" << std::endl;

tbb::task::enqueue([&] {
std::cout << "Task 2 (continuation of Task 1)" << std::endl;

tbb::task::enqueue([&] {
std::cout << "Task 3 (continuation of Task 2)" << std::endl;
});
});
});
}
  1. 使用 tbb::flow::continue_node
  • 对于更复杂的延续关系,可以使用流图的 continue_node
#include <tbb/flow_graph.h>

void complex_continuation() {
tbb::flow::graph g;

tbb::flow::continue_node<int> nodeA(g, [](const tbb::flow::continue_msg&) -> int {
std::cout << "Node A" << std::endl;
return 1;
});

tbb::flow::continue_node<int> nodeB(g, [](const tbb::flow::continue_msg&) -> int {
std::cout << "Node B" << std::endl;
return 2;
});

tbb::flow::continue_node<void> nodeC(g, [](const tbb::flow::continue_msg&) {
std::cout << "Node C" << std::endl;
});

// 建立延续关系:A和B都完成后,执行C
tbb::flow::make_edge(nodeA, nodeC);
tbb::flow::make_edge(nodeB, nodeC);

nodeA.try_put(tbb::flow::continue_msg());
nodeB.try_put(tbb::flow::continue_msg());
g.wait_for_all();
}
  1. 使用 tbb::flow::graphtbb::flow::function_node 构建复杂的任务树:
#include <tbb/flow_graph.h>

void task_tree() {
tbb::flow::graph g;

auto create_node = [&](const char* name) {
return tbb::flow::function_node<int, int>(g, tbb::flow::unlimited,
[name](int n) {
std::cout << "Node " << name << ": " << n << std::endl;
return n + 1;
});
};

auto nodeA = create_node("A");
auto nodeB1 = create_node("B1");
auto nodeB2 = create_node("B2");
auto nodeC = create_node("C");

tbb::flow::make_edge(nodeA, nodeB1);
tbb::flow::make_edge(nodeA, nodeB2);
tbb::flow::make_edge(nodeB1, nodeC);
tbb::flow::make_edge(nodeB2, nodeC);

nodeA.try_put(0);
g.wait_for_all();
}
  • 在这个例子中,我们构建了一个简单的任务树:任务A完成后,B1和B2并行执行,然后C在B1和B2都完成后执行。

4.3.3 高级技巧和注意事项

  1. 避免过度细粒度:任务不应该太小,否则调度开销可能超过并行带来的收益。
  2. 考虑数据局部性:设计任务依赖和延续时,考虑数据访问模式,尽量提高缓存效率。
  3. 使用 tbb::task_arena 控制并发度:
tbb::task_arena limited_arena(4);  // 限制并发度为4
limited_arena.execute([&]{
// 在这个arena中执行的任务最多只会使用4个线程
complex_algorithm();
});
  1. 处理异常:确保在任务中正确处理异常,防止异常导致整个并行结构崩溃。
  2. 避免死锁:在设计复杂的任务依赖关系时,要小心避免循环依赖导致的死锁。
  3. 使用 tbb::task_group_context 进行任务取消:
tbb::task_group_context ctx;
tbb::task_group g(ctx);

g.run([&] {
// 长时间运行的任务
for (int i = 0; i < 1000000; ++i) {
if (ctx.is_group_execution_cancelled())
return;
// 做一些工作
}
});

// 在另一个线程中
ctx.cancel_group_execution(); // 取消任务组的执行
  • 通过这些机制,TBB提供了强大而灵活的工具来处理复杂的任务依赖和延续关系。开发者可以精确控制任务的执行顺序和并行度,从而构建高效、可扩展的并行算法。

4.4 自定义任务调度器

TBB允许开发者自定义任务调度器,以适应特定应用场景的需求。自定义调度器可以控制任务的分配、优先级和执行顺序。

创建自定义调度器的步骤:

  1. 继承tbb::task_scheduler_observer
  2. 重写on_scheduler_entryon_scheduler_exit方法
  3. 在适当的地方注册和注销观察者
class CustomScheduler : public tbb::task_scheduler_observer {
public:
void on_scheduler_entry(bool is_worker) override {
// 初始化线程本地存储或其他资源
}

void on_scheduler_exit(bool is_worker) override {
// 清理资源
}
};

void use_custom_scheduler() {
CustomScheduler cs;
cs.observe(true); // 注册观察者

// 执行并行任务
tbb::parallel_for(0, 100, [](int i) { /* 任务 */ });

cs.observe(false); // 注销观察者
}

4.5 TBB内存分配器

TBB提供了高效的可扩展内存分配器,专门针对多线程环境优化。这个分配器可以显著减少内存分配时的竞争,提高并行程序的性能。

使用TBB内存分配器:

#include <tbb/scalable_allocator.h>
#include <vector>

std::vector<int, tbb::scalable_allocator<int>> vec;
vec.push_back(1);

也可以全局替换默认的内存分配器:

#include <tbb/tbbmalloc_proxy.h>

这将自动替换全局的newdelete操作符,使用TBB的可扩展分配器。

4.6 TBB异常处理机制

  • TBB提供了强大的异常处理机制,可以在并行环境中安全地捕获和处理异常。
try {
tbb::parallel_for(0, 100, [](int i) {
if (i == 50) throw std::runtime_error("Error at 50");
});
} catch (const tbb::captured_exception& e) {
std::cerr << "Caught exception: " << e.what() << std::endl;
}
  • TBB会自动捕获并重新抛出并行区域内的异常,使其能够被外部的try-catch块捕获。

4.7 性能分析与调优工具

TBB提供了多种工具来帮助开发者分析和优化并行程序的性能:

  1. TBB's Task Scheduler Analyzer: 可视化任务调度器的行为,帮助识别负载不均衡和调度开销。
  2. Intel VTune Profiler: 虽然不是TBB的一部分,但它与TBB紧密集成,提供了详细的性能分析报告。
  3. 使用tbb::tick_count进行简单的时间测量:
tbb::tick_count t0 = tbb::tick_count::now();
// 执行并行任务
tbb::tick_count t1 = tbb::tick_count::now();
double elapsed = (t1 - t0).seconds();
  1. TBB的性能调优建议:
    • 适当选择粒度:任务不要太小,避免调度开销超过并行收益
    • 使用tbb::task_arena控制线程数量
    • 避免伪共享,合理设计数据结构
    • 使用TBB的并发容器减少同步开销

5. TBB库高级应用与案例

略,还需要更多时间学习

5.1 TBB库在图像处理中的应用

5.2 TBB库在科学计算中的应用

5.3 TBB库在机器学习中的应用

5.4 TBB库在大规模分布式系统中的应用

5.5 TBB库在其他领域的应用案例

6. TBB库常见问题与解决方案

6.1 死锁与活锁

  • 死锁(Deadlock)和活锁(Livelock)是并发编程中常见的两种问题,它们都会导致程序无法正常进行。

  • 死锁是指两个或多个线程互相等待对方释放资源,导致所有相关线程都无法继续执行的情况。例如:

tbb::mutex mutex1, mutex2;

void thread1() {
tbb::mutex::scoped_lock lock1(mutex1);
// 某些操作
tbb::mutex::scoped_lock lock2(mutex2);
// 更多操作
}

void thread2() {
tbb::mutex::scoped_lock lock2(mutex2);
// 某些操作
tbb::mutex::scoped_lock lock1(mutex1);
// 更多操作
}
  • 在上面的代码中,如果thread1获得了mutex1的锁,同时thread2获得了mutex2的锁,那么两个线程都会永远等待对方释放锁,从而陷入死锁。

  • 解决方案:

  1. 使用TBB的高级并行结构,如parallel_for,减少直接使用互斥量的需求。
  2. 如果必须使用多个互斥量,确保所有线程以相同的顺序获取锁。
  3. 使用tbb::mutex::try_lock()代替直接锁定,在获取锁失败时释放已持有的锁并重试。
  • 活锁是指线程持续改变彼此的状态,导致所有线程都无法向前推进的情况。虽然线程没有被阻塞,但它们无法完成有用的工作。例如:
tbb::atomic<bool> flag1{false}, flag2{false};

void thread1() {
while (true) {
flag1 = true;
if (flag2) {
flag1 = false;
continue;
}
// 执行任务
break;
}
}

void thread2() {
while (true) {
flag2 = true;
if (flag1) {
flag2 = false;
continue;
}
// 执行任务
break;
}
}
  • 在这个例子中,两个线程可能会不断地设置和清除它们的标志,而never能够执行它们的任务。

  • 解决方案:

  1. 引入随机等待时间,打破同步循环。
  2. 使用TBB的任务调度器,如tbb::task_group,让库来管理任务的执行顺序。

6.2 数据竞争

  • 数据竞争是指多个线程同时访问同一内存位置,且至少有一个线程进行写操作的情况。这可能导致不可预测的行为和难以复现的bug。例如:
int shared_counter = 0;

void increment_counter() {
shared_counter++; // 数据竞争!
}

tbb::parallel_for(0, 1000, [&](int i) {
increment_counter();
});

解决方案:

  1. 使用TBB的原子操作:
tbb::atomic<int> shared_counter{0};

void increment_counter() {
shared_counter.fetch_and_add(1);
}
  1. 使用TBB的并发容器,如tbb::concurrent_vector或tbb::concurrent_hash_map。
  2. 使用TBB的互斥量和锁:
tbb::mutex m;
int shared_counter = 0;

void increment_counter() {
tbb::mutex::scoped_lock lock(m);
shared_counter++;
}
  1. 利用TBB的reduction模板:
tbb::atomic<int> shared_counter{0};

tbb::parallel_reduce(tbb::blocked_range<size_t>(0, 1000),
0,
[](const tbb::blocked_range<size_t>& r, int local_sum) -> int {
for (size_t i = r.begin(); i != r.end(); ++i)
local_sum++;
return local_sum;
},
[](int x, int y) -> int {
return x + y;
}
);

6.3 性能瓶颈分析与优化

性能瓶颈分析和优化是并行编程中的关键环节。在使用TBB时,需要考虑多个方面来确保最佳性能。

任务粒度优化

  • 任务粒度是指单个任务执行的工作量。粒度过小会导致调度开销超过并行化带来的收益,而粒度过大则可能导致负载不均衡。

**优化策略: **

a) 使用自动分区器:

tbb::parallel_for(tbb::blocked_range<size_t>(0, n), 
[&](const tbb::blocked_range<size_t>& r) {
for(size_t i=r.begin(); i<r.end(); ++i) {
// 任务逻辑
}
},
tbb::auto_partitioner()
);

b) 手动调整粒度:

const size_t grain_size = 10000; // 根据实际情况调整
tbb::parallel_for(tbb::blocked_range<size_t>(0, n, grain_size),
[&](const tbb::blocked_range<size_t>& r) {
for(size_t i=r.begin(); i<r.end(); ++i) {
// 任务逻辑
}
}
);

负载均衡优化

  • 负载均衡是确保所有处理器核心都得到充分利用的关键。

优化策略:

a) 使用affinity_partitioner:

tbb::affinity_partitioner ap;
tbb::parallel_for(tbb::blocked_range<size_t>(0, n),
[&](const tbb::blocked_range<size_t>& r) {
// 任务逻辑
},
ap
);

b) 动态负载均衡:

tbb::parallel_do(begin(container), end(container), 
[](auto& item) {
// 处理item
}
);

减少同步开销

  • 过度同步可能成为性能瓶颈。

优化策略:

a) 使用原子操作代替互斥锁:

tbb::atomic<int> counter{0};
tbb::parallel_for(0, n, [&](int i) {
counter.fetch_and_add(1);
});

b) 使用并发容器:

cppCopytbb::concurrent_vector<int> vec;
tbb::parallel_for(0, n, [&](int i) {
vec.push_back(i);
});

内存分配优化

  • 频繁的内存分配可能成为瓶颈,尤其在多线程环境中。

*优化策略:

a) 使用TBB的可扩展分配器:

#include <tbb/scalable_allocator.h>
std::vector<int, tbb::scalable_allocator<int>> vec;

b) 实现对象池:

class ObjectPool {
tbb::concurrent_queue<Object*> pool;
public:
Object* get() {
Object* obj;
if (pool.try_pop(obj)) return obj;
return new Object();
}
void put(Object* obj) {
pool.push(obj);
}
};

缓存优化

  • 考虑数据局部性和缓存行对齐可以显著提升性能。

优化策略:

a) 使用tbb::cache_aligned_allocator:

std::vector<int, tbb::cache_aligned_allocator<int>> vec;

b) 数据布局优化:

struct alignas(64) CacheAlignedData {
int data[16]; // 确保结构体大小是64字节(一个缓存行)
};

使用性能分析工具

  • 使用专业工具进行性能分析可以帮助识别瓶颈。

  • 工具推荐:

    • Intel VTune Profiler

    • Valgrind

    • gprof

使用示例(以VTune为例):

  1. 编译时添加调试信息:
g++ -g -O2 myprogram.cpp -o myprogram -ltbb
  1. 使用VTune运行程序:
vtune -collect hotspots ./myprogram
  1. 分析VTune生成的报告,识别热点和瓶颈。
  2. 并行算法选择:选择合适的并行算法可以大幅提升性能。

6.4 TBB程序的调试技巧

调试并行程序可能比调试序列程序更具挑战性。以下是一些有用的技巧:

  1. 使用TBB的调试宏: TBB提供了一些调试宏,如TBB_ASSERT()和__TBB_ASSERT()。
#define TBB_USE_DEBUG 1
#include <tbb/tbb.h>

void some_function() {
TBB_ASSERT(condition, "Error message");
// 函数逻辑
}
  1. 利用线程安全的调试输出: 使用tbb::mutex来保护cout或使用线程安全的日志库。
tbb::mutex cout_mutex;

void debug_print(const std::string& message) {
tbb::mutex::scoped_lock lock(cout_mutex);
std::cout << "Thread " << tbb::this_tbb_thread::get_id() << ": " << message << std::endl;
}
  1. 使用并行调试器: 如Intel Inspector或Valgrind的DRD工具可以帮助检测数据竞争和死锁。
  2. 渐进式并行化: 从串行版本开始,逐步添加并行构造,每次添加后都进行测试。
  3. 使用TBB的exception_handling版本: 这允许异常在线程间正确传播。
#include <tbb/parallel_for.h>
#include <tbb/global_control.h>
#include <iostream>
#include <stdexcept>

int main() {
try {
// 启用TBB的异常传播
tbb::global_control gc(tbb::global_control::exception_handling, 1);

tbb::parallel_for(0, 100, [](int i) {
if (i == 50) {
throw std::runtime_error("Error in thread");
}
});
}
catch (const std::exception& e) {
std::cout << "Caught exception: " << e.what() << std::endl;
}

return 0;
}

6.5 TBB库与其他并行库的比较与选择

TBB是一个强大的并行库,但在某些情况下,其他库可能更适合。以下是TBB与其他常见并行库的比较:

  1. OpenMP:
  • 优点:简单易用,适合数据并行任务。
  • 缺点:任务并行支持有限,不如TBB灵活。
  • 选择建议:对于简单的循环并行化,OpenMP可能更直观。
  1. std:🧵
  • 优点:C++标准库的一部分,跨平台。
  • 缺点:低级API,需要手动管理线程。
  • 选择建议:对于需要精细控制线程的情况,std::thread可能更合适。
  1. Intel oneAPI DPC++:
  • 优点:支持异构计算,可以利用GPU。
  • 缺点:学习曲线较陡,主要针对Intel硬件优化。
  • 选择建议:如果需要利用GPU或其他加速器,考虑使用DPC++。
  1. Boost.Thread:
  • 优点:提供了比std::thread更多的功能。
  • 缺点:需要额外的依赖。
  • 选择建议:如果已经在使用Boost库,Boost.Thread可能是一个好选择。

TBB的优势在于:

  • 高级抽象,易于使用。
  • 优秀的负载均衡和任务调度。
  • 丰富的并行模式和算法。
  • 良好的可扩展性和性能。

选择建议:

  • 对于需要复杂任务并行和细粒度并行的C++项目,TBB通常是最佳选择。
  • 如果项目已经大量使用了TBB,继续使用TBB通常是最合理的。
  • 对于简单的数据并行任务,可以考虑OpenMP。
  • 如果需要跨平台且不想引入额外依赖,可以考虑std::thread。
  • 对于需要利用异构计算的Intel平台项目,可以考虑oneAPI DPC++。

7. TBB库未来展望与总结

略,还需要更多时间学习

7.1 TBB库的发展趋势

7.2 TBB库在C++标准并行库中的地位

7.3 总结与展望

8. 附录

8.1 TBB库常用API参考

太多了,尚未整理

8.2 TBB库相关资源与社区

官方资源:

社区资源:

相关工具:

  • Intel VTune Profiler: 用于分析TBB应用性能
  • Intel Advisor: 帮助识别并行化机会和优化并行代码

8.3 TBB常见问题

太多了,尚未整理