ProTBB (四):设计模式与 ProTBB

目录


并行应用程序中最常见的三种并行层(layers)

  • The message-driven layer
  • The fork-join layer
  • Single Instruction, Multiple Data (SIMD) layer

The message-driven layer

  • 将规模较大的计算结构化,通过消息相互通信
  • 常见模式包括
    • Streaming Graphs
      • Streaming Graph Processing
      • 一种用于处理实时数据流的计算模型
      • 结合了图计算和流式数据处理的概念
      • 将图数据作为一个数据流,遍历图数据中的边执行计算的框架
    • Data Flow Graphs
      • 用 nodes 和 edges 的有向图来描述计算过程
    • Dependency Graphs
      • 表示实体之间的依赖关系
  • TBB 的 Flow Graph 接口支持这种类型的并行

The fork-join layer

  • 面对一个大规模任务,先串行将任务拆分为多个可并行执行的子任务,再进行并行计算 (fork)
  • 当所有并行计算的子任务完成后再继续执行串行部分的逻辑(join)
  • 常见模式包括
    • functional parallelism (task parallelism)
    • parallel loops
    • parallel reductions
    • pipelines
  • TBB 通过 Generic Parallel Algorithms 支持

Single Instruction, Multiple Data (SIMD) layer

  • 通过同时对多个数据元素应用相同的操作来实现的
  • 这种类型的并行性通常使用矢量扩展(例如 AVX、AVX2 和 AVX-512)来实现
    • 这些扩展使用每个处理器核心中可用的矢量单元
    • VS 中有专门设置使用某类矢量扩展的编译选项

并行设计模式

设计并行算法的建议

  • 建议程序员需要通过四个设计空间来开发并行程序
    • 寻找并发性 Finding concurrency
      • TBB 自动做了负载均衡,我们只需要保证获得一个连续的任务
    • 算法结构 Algorithm structures
      • 使用并行设计模式设计并行算法
    • 支撑结构 Supporting structures
      • 从算法设计转入到算法实现
      • 考虑如何组织并行程序以及用于管理共享(尤其是可变)数据的技术
    • 实施机制 Implementation mechanisms
      • 包括线程管理和同步
      • TBB 处理所有线程管理,我们只需要关心更高设计级别的任务
  • 有效的模式
    • 实现并行可扩展性的两个先决条件是良好的数据局部性和避免开销
    • 将算法策略抽象为语义并且实现已被证明在实践中非常有效的模式
      • 这种分离关注的方式使得可以分别推理高层算法设计和底层(通常是特定于机器的)细节
  • Data Parallelism Wins 数据并行是最好的并行算法设计策略
    • 可扩展并行性的最佳总体策略是数据并行性 (data parallelism)
      • 并行性随着问题规模的增长而增强
    • 通常,数据被分成块,每个块都通过单独的任务进行处理
    • 有时是平均分割的, 有时是递归分割的
    • 重要的是更大的数据集会产生更多的任务
    • 一般来说,无论问题是规则的还是不规则的,数据并行都可以应用
    • 数据并行的对立面是功能分解 (functional decomposition)(也称为任务并行 task parallelism)
      • 是一种并行运行不同程序功能的方法
      • 功能分解充其量可以将性能提高一个常数因子
      • 有时,功能分解可以提供满足性能目标所需的额外并行性
        • 但这不应该是主要策略,因为它无法扩展

设计模式

嵌套模式 (Nesting Pattern)

  • 因嵌套支持而得到的结果有两个含义
    • 在选择是否应该调用 TBB 模板时,不需要知道我们是处于"并行区域"还是"串行区域"
      • 由于使用 TBB 只是创建任务,所以不必担心线程的超额订阅
    • 不需要担心调用 TBB 编写的库,以及控制它是否可以使用并行性
  • 嵌套可以被认为是一种元模式 (meta-pattern)
    • 因为它意味着模式可以分层组合
    • 有模块化和可组合性的嵌套模式是在设计 TBB 时思考的关键

Map Pattern

  • 此模式所表达的独立性使其具有很好的可扩展性
  • Map Pattern 是并行编程的最佳模式
    • 将工作划分为统一的独立部分,这些部分并行运行,没有依赖性
    • 这代表了一种常规并行化 (regular parallelization )
      • 称为尴尬并行化 (embarrassing parallelism)
      • Map Pattern 值得在任何可能的情况下使用
        • 因为它允许高效的并行化和高效的矢量化
    • Map Pattern 不涉及各部分之间共享的可变状态
    • 映射函数(独立的工作部分)必须是 "纯粹的",因为它不能修改共享状态
      • std::share_ptr 可能具有共享含义
  • parallel_for 适用于 Map Pattern
  • parallel_invoke 可用于少量映射类型的并行化
    • 但有限的数量不会提供太多可扩展性

工作堆模式 (Workpile Pattern)

  • 一种广义的 Map Pattern
    • 每个实例(映射函数)可以生成更多实例
    • 工作可以添加到要做的 "一堆" 事情中
      • 如,可以用于树的递归搜索,希望生成实例来处理树的每个节点的每个子节点
    • 工作堆模式比映射模式更难矢量化
  • parallel_for_each 适用于工作堆模式

Reduction Patterns (Reduce and Scan) 归约模式(归约和扫描)

  • 可以被认为是一个映射操作

    • 其中每个子任务都会产生一个子结果
    • 需要将其组合起来形成最终的单一答案
  • 使用关联的 "组合器函数" (combiner function) 来组合多个子结果

  • 由于舍入变化,浮点数计算会有精度误差

  • parallel_reduce 适用于 Reduction Pattern

    • parallel_definistic_reduce 更加精确
  • 典型的组合器

    • 加法、乘法、最大值、最小值、以及布尔运算 AND、OR 和 XOR
  • 扫描模式

    • 并行计算前缀
    • 具有固有串行依赖性的场景中非常有用
      • 先拆分数据,并行-reduce,再基于 reduce 结果进行并行-reduce
    • parallel_scan 适用于 Scan Pattern
  • 寻找最大值 parallel_reduce 实现

    cpp 复制代码
     int maxValue = tbb::parallel_reduce(
       tbb::blocked_range<int>(0, a.size()),
       std::numeric_limits<int>::min(),
       [&](const tbb::blocked_range<int>& r, int init) -> int
       {
           for(int i = r.begin(); i != r.end(); ++i)
           {
               init = std::max(init, a[i]);
           }
           return init;
       },
       [](int x, int y) -> int
       {
           return std::max(x, y);
       }
    );

Fork-Join Pattern 分叉会合模式

  • 递归地将问题细分为子部分,并且可用于规则和不规则并行
  • 对于实现分治策略 ( divide-and-conquer strategy)(有时称为模式本身)或分支定界策略 (branch-andbound strategy)(有时也称为模式本身)很有用
  • 分叉会合 (Fork-Join ) 不应与屏障 (barriers) 相混淆
    • 屏障是跨多个线程的同步构造
      • 在屏障中,每个线程必须等待所有其他线程到达屏障才能离开
    • 会合也会等待所有线程到达公共点,但不同之处在于
      • 在屏障之后,所有线程都会继续
      • 但在会合之后,只有一个线程会继续
  • 独立运行一段时间,然后使用屏障进行同步,然后再次独立运行的作业实际上与重复使用映射模式 (map pattern)(中间有屏障)相同
  • 此类程序会受到阿姆达尔定律的处罚,因为时间花在等待而不是工作(序列化)上
    • Amdahl's Law
  • TBB 模板 parallel_invoke、task_group 和 flow_graph 可以实现 fork-join 模式
  • 使用嵌套模式 (nesting),组合 parallel_for 也可以实现 Fork-Join Pattern

分治模式 Divide-and-Conquer Pattern

  • 把一个复杂的算法问题按一定的 "分解" 方法分为等价的规模较小的若干部分,然后逐个解决

  • 再把各部分的解组成整个问题的解

  • Fork-Join 模式可以被认为是一个基础模式,Divide-and-Conquer 是实现 Fork-Join 的一种策略

  • 如果一个问题可以递归地划分为更小的子问题,直到达到可以连续解决的基本情况,则适用 Divide-and-Conquer 模式

    • 可以描述为划分(分区)问题
    • 然后使用映射模式计算分区中每个子问题的解决方案
    • 子问题的所得解决方案被组合起来以给出原始问题的解决方案
  • 当需要 Divide-and-Conquer 时,应首先考虑的 parallel_for 和 parallel_reduce 实现功能

    • 也可以使用 parallel_invoke、task_group 和 flow_graph 实现
  • 快速排序 parallel_invoke 实现

    cpp 复制代码
    auto Serial_Quicksort(QSVector::iterator b, QSVector::iterator e) -> void
    {
        if (b >= e)
        {
            return;
        }
    
        double pivotValue = b->value;
        QSVector::iterator i = b;
        QSVector::iterator j = e - 1;
        while (i != j)
        {
            while (i != j && pivotValue < j->value)
            {
                --j;
            }
            while (i != j && i->value <= pivotValue)
            {
                ++i;
            }
    
            std::iter_swap(i, j);
        }
        std::iter_swap(b, i);
    
        Serial_Quicksort(b, i);
        Serial_Quicksort(i + 1, e);
    }
    
    auto Parallel_Qicksort(QSVector::iterator b, QSVector::iterator e) -> void
    {
        constexpr int cutoff = 100;
        if (e - b < cutoff)
        {
            Serial_Quicksort(b, e);
        }
        else
        {
            double pivotValue = b->value;
            QSVector::iterator i = b;
            QSVector::iterator j = e - 1;
            while (i != j)
            {
                while (i != j && pivotValue < j->value)
                {
                    --j;
                }
                while (i != j && i->value <= pivotValue)
                {
                    ++i;
                }
                std::iter_swap(i, j);
            }
            std::iter_swap(b, i);
            tbb::parallel_invoke(
                [=]() { Parallel_Qicksort(b, i); },
                [=]() { Parallel_Qicksort(i + 1, e); }
            );
        }
    }

Branch-and-Bound Pattern 分支定界模式

  • 是一种非确定性搜索方法
    • 用于在可能有多个答案时找到一个满意的答案
  • 分支 (Branch ) 是指使用并发,定界 (Bound) 是指以某种方式限制计算
      • 迄今为止找到的最佳结果
  • "分支定界" 这个名字来源于这样一个事实
    • 我们递归地将问题划分为多个部分,然后将解决方案限制在每个部分中
  • 与许多其他并行算法不同,分支定界可以带来超线性加速
    • 但是,每当存在多个可能的匹配时,此模式都是不确定的
      • 因为返回哪个匹配取决于每个子集的搜索时间
    • 为了获得超线性加速,需要以有效的方式取消正在进行的任务
  • 搜索问题确实适合并行实现,因为有很多点需要搜索
  • 使用 Branch-and-Bound 策略
    • 不是搜索空间中所有可能的点,而是选择重复地将原始问题划分为更小的子问题
    • 评估到目前为止子问题的具体特征,根据手头的信息设置约束(边界),并消除那些不影响子问题的子问题
    • 不满足约束条件。这种消除通常称为 "修剪 pruning"
      • 边界用于 "修剪" 搜索空间,消除可以证明不包含最佳解决方案的候选解决方案
    • 通过这种策略,可以逐渐减小可行解空间的大小
    • 只需探索一小部分可能的输入组合即可找到最佳解决方案
  • 分支定界是一种非确定性方法,也是非确定性何时有用的一个很好的例子
    • 要执行并行搜索,最简单的方法是对集合进行分区并并行搜索每个子集
      • 考虑这样一种情况,我们只需要一个结果,任何满足搜索条件的数据都是可以接受的
      • 在这种情况下,一旦找到与搜索条件匹配的元素,在任何一个并行子集中的搜索中,就可以取消其他子集中的搜索
  • 首先考虑使用 parallel_for 和 parallel_reduce 实现
    • 也可以使用 parallel_invoke、task_group 和 flow_graph 实现

Pipeline Pattern

  • 管道模式将生产者-消费者关系中的任务连接成常规的、不变的数据流
  • 管道的所有阶段都同时处于活动状态,并且每个阶段都可以维护状态,这些状态可以在数据流经它们时更新
    • 由于 TBB 中的嵌套支持,每个阶段本身都可以具有并行性
  • parallel_pipeline 支持基本管道
  • flow_graph 支持管道和广义管道

Event-Based Coordination Pattern (Reactive Streams 反应式流) 基于事件的协调模式

  • 将生产者消费者关系中的任务与任务之间不规则且可能变化的交互连接起来

并行算法粒度(Granularity)、局部性(Locality)、并行性(Parallelism)和确定性(Determinism)

  • 主要围绕三个问题
    • 粒度
      • 任务所做的工作量
    • 数据局部性
    • 可用的并行性

粒度

  • 将算法完成的工作划分为尽可能多的部分
    • 同时,为了最大限度地减少工作窃取和任务调度的开销
    • 尽量创建尽可能大的任务
    • 由于粒度大小和调度开销是相互对立,因此算法的最佳性能位于中间的某个位置
  • 经验法则
    • TBB 任务的平均时间应大于 1 微秒 (自己的笔记本大于 10 微妙)
      • 可以有效隐藏工作窃取的开销
      • 相当于数千个 CPU 周期
      • 建议使用 10,000 个周期
  • 要记住,并不是每个任务都需要大于 1 微秒

Partitioners

  • TBB 算法还支持分区器
    • 指定算法应如何对其 Ranges 进行分区
  • simple_partitioner
    • 用于递归划分范围,直到其 is_divisible 方法返回 false
  • auto_partitioner
    • 使用动态算法来充分分割 Ranges 以平衡负载
    • 但它不一定像 is_divisible 允许的那样精细地分割 Ranges
    • 可以接受使用粒度 1,并让 auto_partitioner 确定最佳粒度
    • parallel_for、parallel_reduce 和 parallel_scan 的默认分区器类型是 auto_partitioner
  • static_partitioner
    • 尽可能均匀地在工作线程上分配 Ranges,而不可能进一步进行负载平衡
    • 工作分配和到线程的映射是确定性的,仅取决于迭代次数、粒度和线程数量
    • 开销是所有分区器中最低的,因为它不做出动态决策
    • 使用 static_partitioner 还可以改善缓存行为
      • 因为调度模式将在同一循环的执行中重复
    • 严重限制了负载平衡,因此需要谨慎使用
  • affinity_partitioner
    • 结合了 auto_partitioner 和 static_partitioner 并提高缓存关联性
    • 如果在同一数据集上重新执行循环时重用相同的分区器对象
    • affinity_partitioner 与 static_partitioner 一样,最初创建均匀分布,但允许额外的负载平衡
    • 它还保留哪个线程执行该 Ranges 的哪个块的历史记录,并尝试在后续执行中重新创建此执行模式
    • 如果数据集完全适合处理器的缓存,则重复调度模式可以显着提高性能

局部性

Ranges, Partitioners, and Data Cache Performance

  • Ranges 和 Partitioners 可以通过启用缓存无关算法(cacheoblivious algorithms)或启用缓存关联(cache affinity) 来提高数据缓存性能
    • 当数据集太大而无法放入数据缓存时,缓存无关算法非常有用
      • 但如果使用分治(divide and conquer)的方法解决问题,则可以利用算法内的数据重用
    • 相反,当数据集完全适合缓存时,缓存关联性(cache affinity)非常有用
      • 用于将某个 Ranges 的相同部分重复调度到相同的处理器上,以便可以从同一缓存再次访问适合缓存的数据

Cache-Oblivious Algorithms 缓存无关算法

  • 是一种在不依赖硬件缓存参数的情况下实现数据缓存的良好(甚至最佳)使用的算法

    • 该概念类似于循环平铺(loop tiling)或循环阻塞( loop blocking)
    • 但不需要精确的平铺或块大小
  • 忽略缓存的算法通常会递归地将问题划分为越来越小的子问题

    • 在某些时候,这些较小的子问题开始适应机器的缓存
  • 递归细分可能会一直持续到尽可能小的大小,或者可能存在效率的截止点

    • 但该截止点与缓存大小无关,并且通常会创建访问大小远低于任何合理缓存大小的数据的模式
  • 我们使用矩阵转置作为可以从缓存无关实现中受益的算法的示例

    • 为了简单起见,假设四个元素适合我们机器中的一个缓存行
    • 如果缓存足够大,它可以在 a 的第一行转置期间保留 b 中访问的所有缓存行,而无需在 a 的第二行转置期间重新加载这些缓存行
    • 但是,如果它不够大,则需要重新加载这些缓存行,从而导致每次访问矩阵 b 时都会出现缓存未命中
    • 如果只专注于转置矩阵 a 的一小块,然后再转置矩阵 a 的其他块,则可以减少保存需要保留在矩阵 a 中的 b 元素的缓存行数量
      • 缓存以通过 cache-line 重用获得性能提升
  • 这是缓存无关算法的真正威力

    • 我们不需要确切地知道内存层次结构的级别大小
    • 随着子问题变得越来越小,它们会逐渐适应内存层次结构中越来越小的部分,从而提高每个级别的重用性
  • 示例代码

    cpp 复制代码
        template<typename P>
        auto matrix_transposition_oblivious_partitioner_2d(int n, double* a, double* b, int gs) -> double
        {
            tbb::tick_count t0 = tbb::tick_count::now();
            tbb::parallel_for(
                tbb::blocked_range2d<int, int>{0, n, static_cast<size_t>(gs), 0, n, static_cast<size_t>(gs)},
                [n, a, b](const tbb::blocked_range2d<int, int>& r)
                {
                    int ie = r.rows().end();
                    int je = r.cols().end();
                    for (int i = r.rows().begin(); i < ie; ++i)
                    {
                        for (int j = r.cols().begin(); j < je; ++j)
                        {
                            b[j * n + i] = a[i * n + j];
                        }
                    }
                },
                P{}
            );
            tbb::tick_count t1 = tbb::tick_count::now();
            return (t1 - t0).seconds();
        }

Cache Affinity

  • 通过将具有数据局部性但不适合缓存的问题分解为适合缓存的较小问题来提高缓存性能

  • 我们可以使用 affinity_partitioner 或 static_partitioner 来为 TBB 循环算法启用缓存关联

  • Using a static_partitioner

    • static_partitioner 是开销最低的分区器,它可以快速地在其区域中的线程之间提供均匀分布的阻塞 Ranges
    • 由于分区是确定性的,因此当在同一 Ranges 内重复执行一个循环或一系列循环时,它还可以改善缓存行为
    • static_partitioner 禁用了 TBB 库的工作窃取调度方法
    • 它将统一分配 Ranges 的任务推送给工作线程,这样就不必窃取任务
      • 当适用时,static_partitioner 是划分循环的最有效方法
      • 个人理解:将拆分好的任务直接提交到所有参与并行线程的任务区
    • 如果工作负载不均匀或者任何核心超额订阅了额外的线程,则使用 static_partitioner 可能会降低性能
  • auto_partitioner 和 affinity_partitioner 能够重新平衡线程之间的负载

    • 而 static_partitioner 则坚持其最初的均匀但不公平的分配
    • 因此,static_partitioner 几乎专门用于高性能计算 (HPC) 应用程序
  • 示例代码

    cpp 复制代码
        template<typename Partitioner>
        auto cache_affinity(double v, int n, double* a, const Partitioner& p) -> void
        {
            tbb::parallel_for(
                tbb::blocked_range<int>(0, n, 1),
                [v, a](const tbb::blocked_range<int>& r)
                {
                    int ie = r.end();
                    for(int i= r.begin(); i<ie; i++)
                    {
                        a[i] += v;
                    }
                },
                p
            );
        }
        for (int i = 0; i < m;  ++i) 
        {
          cache_affinity(v[i], n, a, tbb::static_partitioner{});
        }

确定性

限制调度程序以实现确定性

  • parallel_definistic_reduce 只接受 simple_partitioner 或 static_partitioner
    • 因为子 Range 的数量对于这两种分区器类型都是确定的
    • 无论有多少线程动态参与执行,以及任务如何映射到线程,
      • parallel_definistic_reduce 算法也始终在给定机器上执行相同的一组拆分和连接操作
    • 而 parallel_reduce 算法可能不会
    • 当在同一台机器上运行时,parallel_deterministic_reduce 将始终返回相同的结果
      • 但这样做会牺牲一些灵活性

调整 TBB 管道:过滤器(Filters)、模式(Modes)和令牌(Tokens)的数量

  • TBB 管道的性能受到粒度、局部性和可用并行性的影响
  • TBB 管道不支持 Ranges 和 Partitioners
  • 用于调整管道的控件包括
    • 过滤器的数量
    • 过滤器执行模式
    • 运行时传递给管道的令牌数量
  • 管道过滤器也是使用执行模式创建的
    • serial_in_order
      • 过滤器一次最多可以处理一个元素项
      • 并且必须按照第一个过滤器生成它们的顺序来处理
    • serial_out_of_order
      • 以任何顺序执行元素项
    • parallel
      • 许在不同的元素项上并行执行
  • 运行时,需要向 TBB 管道提供 max_number_of_live_tokens 参数
    • 该参数限制在任何给定时间允许流过管道的元素项数量

了解平衡管道(Balanced Pipeline)

  • 经验法则也适用于 TBB 管道
  • 在串行管道中,并行性仅来自重叠不同的过滤器
  • 在具有并行过滤器的管道中,还可以通过在不同元素项上同时执行并行过滤器来获得并行性

了解不平衡管道(Imbalanced Pipeline)

  • 在稳定状态下,串行管道受到最慢串行级的限制
  • 如果适当配置了正确数量的令牌,TBB 中实现的管道算法可以产生最佳性能

管道和数据局部性和线程亲和性

  • TBB 管道内置的执行顺序旨在增强时间数据局部性,而无需执行任何特殊操作

可以自定义 Range

  • 如果有需要再看
相关推荐
XZHOUMIN3 分钟前
【MFC】如何读取rtf文件并进行展示
c++·mfc
程序猿阿伟1 小时前
《探索C++在3D重建中的算法与技术要点》
开发语言·c++·自然语言处理
Frank学习路上1 小时前
【Arduino】ESP8266获取NTP时间及连接MQTT实现远程控制示例
c++·mqtt·arduino·esp8266
TANGLONG2222 小时前
【初阶数据结构和算法】八大排序算法之插入排序(直接插入排序、希尔排序及其对比)
java·c语言·数据结构·c++·算法·面试·排序算法
安於宿命2 小时前
【Linux】软硬链接
linux·运维·服务器·c++
冰冰的coco2 小时前
Linux c++常用技术
linux·c++
南桥几晴秋2 小时前
【算法刷题指南】前缀和
c++·算法·前缀和
神仙别闹3 小时前
基于OpenCV(C++)实现二维图像配准工具
c++·人工智能·opencv
~yY…s<#>4 小时前
进程间关系与守护进程
linux·服务器·c语言·c++