ProTBB (三):API (二)


TBB 的可组合性

  • 三种常见组合(Composability)类型
    • Nested Composition
    • Concurrent Composition
    • Serial Composition

Nested Composition

  • 在一个并行算法内部执行另一个并行算法
  • 有效处理嵌套并行性是 TBB 设计的主要目标
  • 目的是增加额外的并行性

Concurrent Composition

  • 指并行算法的执行在时间上重叠
  • 无论是在两个不同的进程中还是在同一个进程的两个不同线程中,两个循环的并发组合是指循环 1 的并行实现与循环 2 的并行实现同时执行

Serial Composition

  • 一个接一个地执行,而不会在时间上重叠
  • 两个构造之间没有干扰
  • 应用程序必须从一个并行构造转换到下一个构造
  • 性能可能会受到两个构造之间共享资源的影响

TBB Thread Pool 和 Task Arenas

  • 支持可组合性的两个特性
    • global thread pool
    • task arena
  • 示例
    • 应用程序有一个主线程,默认线程池和默认单个 arena
    • 四核系统上(P=4)
    • TBB 设置一个应用线程(主线程)和 P-1 个工作线程的线程池
    • 启动后线程池中的线程处于休眠状态
    • 每个使用 TBB 的应用程序线程(主线程)都被赋予自己的 task arena,以将其工作与其他应用程序线程的工作隔离开来
    • 在等待算法完成时,主线程可以参与执行 arena 中生成的任务
    • 当主线程获得 task arena,并第一次生成任务时,全局线程池中休眠的工作线程将被唤醒并迁移到主线程的 task arena 中
      • task arena 有槽位,其中一个槽位预留给主线程,其他槽位等待任务调度器将可用线程依次放入其他槽位中
    • 当工作线程加入到 task arena 中,其调度程序可以参与执行由该 task arena 中的其他线程生成的任务
    • 也可以生成可被连接到该 task arena 的其他线程调度程序看到和窃取的任务
    • 一旦 task arena 被完全占用,任务的生成就不会唤醒在全局线程池中等待的其他线程
      • 所以,如果不想所有线程为当前主线程服务,可以设置少量的槽位
    • 当工作线程空闲且在当前 task arena 中找不到更多工作任务时,它会返回到全局线程池中
      • 此时,如果有可用的工作线程,工作线程可以加入需要工作线程的其他 task arena
    • task arena 可以为主线程保留多个插槽,或者根本不为主线程保留插槽
      • 主线程可以填充任何插槽
      • 而从全局线程池迁移到 task arena 的线程无法填充为主线程保留的插槽
    • 无论应用程序多么复杂,始终只有一个全局线程池
      • 有在初始化时更改分配给全局线程池的线程数的功能
      • 也可以动态更改
    • 每个应用程序线程(主线程)也有自己的隐式 task arena
      • 线程无法从另一个 task arena 中的线程窃取任务
      • 默认情况下,会有效隔离不同应用程序线程需要完成的工作

TBB 任务调度器(Task Dispatcher)

  • The local deque 用于在 TBB 中实现工作窃取调度策略
  • 每当线程生成新任务(即,使其可用于 task arena 执行)时,该任务都会被放置在其任务调度程序的本地双端队列的头部
  • 稍后,当完成当前正在处理的任务并需要执行新任务时,会尝试从本地双端队列的头部获取工作,获取最近生成的任务
  • 但是,如果任务调度程序的本地双端队列中没有可用的任务,它会通过随机选择其 task arena 中的另一个工作线程来寻找非本地工作
  • 我们将选定的线程称为受害者,因为调度程序计划从中窃取任务
  • 如果受害者的本地双端队列不为空,调度程序将从受害者线程的本地双端队列尾部取出一个任务
  • 任务调度器可以执行三类任务
    • 执行任务
    • 获取此线程生成的任务
    • 窃取任务
  • TBB 调度程序是用户级、非抢占式任务调度程序
  • OS 线程调度程序要复杂得多
    • 因为它不仅需要处理调度算法,还需要处理线程抢占、线程迁移、隔离和安全
  • 当我们研究并发执行时,我们需要考虑单进程并发(当并行算法由同一进程中的不同线程同时执行时)和多进程并发
    • TBB 库每个进程都有一个全局线程池
    • 但不跨进程共享线程池

控制线程数量

控制线程数量的接口 global_control

  • 调度程序(The scheduler)
    • 引用全局线程池和至少一个 task arena
    • 一旦构建了 TBB 调度程序,就可以向其中添加其他 task arena,从而增加调度程序的引用计数
      • 随着 task arena 被销毁,会减少调度程序的引用计数
    • 如果最后一个 task arena 被销毁,TBB 调度程序(包括全局线程池)也会被销毁
      • 重新调度 TBB 任务都需要构建新的 TBB 调度程序
    • 一个进程中永远不会有多个 TBB 调度程序处于活动状态
  • 硬线程限制(The hard thread limit)
    • 操作系统上逻辑核心数为 P
    • 对于 P <= 64 的平台,硬线程限制为 256,对于 64 < P <= 128 的平台,硬线程限制为 4P,对于 P > 128 的平台,硬线程限制为 2P
  • 软线程限制(The soft thread limit)
    • 是应用程序中 global_control 对象可以请求线程的数量
    • 可使用 global_control 对象更改软线程限制
      • 否则,软线程限制由创建调度程序的线程初始化
  • 默认软线程限制
    • 如果没有 global_control 对象设置显式软线程限制,则软线程限制将初始化为 P-1,其中 P 是平台的硬件并发数(hardware concurrency)
  • global_control 对象
    • global_control 对象在其生命周期内影响 TBB 调度程序可以使用的工作线程数量的软线程限制
    • 在任何时间点,软线程限制都是动态的
    • 如果在创建新的 global_control 对象时有其他活跃的 global_control 对象,则请求所有 global_control 中 max_concurrency_limit 值中的最小值
    • 当 global_control 对象被销毁时,如果被销毁的对象是限制 max_concurrency_limit 的值,则软线程限制可能会增加
    • 创建 global_control 对象不会初始化 TBB 调度程序
      • 也不会增加调度程序上的引用计数
    • 当最后一个 global_control 对象被销毁时,软线程限制被重置为默认值
  • task_arena 对象
    • 创建一个不与特定主线程关联的显式 task arena
    • 底层 task arena 不会在构造函数期间立即初始化,而是在第一次使用时延迟初始化
    • 如果一个线程在该线程初始化其自己的隐式 task arena 之前将任务生成或排队到显式 task arena 中
      • 此操作类似于该线程首次使用 TBB 调度程序
        • 包括其隐式 task arena 的默认初始化以及软线程限制的设置

设置线程数的最佳方法

  • 假设现在正在支持四个逻辑核心的系统上运行
    • TBB 库默认情况下将创建三个工作线程
    • 并且任何默认 task arena 中都会有四个槽,其中一个槽为主线程保留
  • 使用多个 arenas,每个 arena 有不同的槽位来影响 TBB 放置工作线程
    • 有多个 task arena
      • 出现这种情况的最常见方式是程序有多个应用线程
      • 这些线程中的每一个都是主线程,并且具有其自己的隐式 task arena
      • 每个主线程也可以拥有多个 task arena,可以为主线程创建显示的 task_arena
      • 无论以何种方式配置,在应用程序中如果最终获得多个 task_arena,工作线程都会根据它们所拥有的插槽数量按比例迁移到 task_arena 中
        • 并且线程只考虑有任务可供执行的 task arena
  • 一个总共有 3 个 task arena 的示例
    • 两个为主线程(主线程和线程 t) 创建的 task_arena 和一个显式创建的 task_arena(a)
          int  n = 10 * p;
          auto mp = tbb::global_control::max_allowed_parallelism;
          int  nt = 2 * p;
          tbb::global_control gc(mp, nt);
          //设置 arena 中的槽数
          tbb::task_arena a(3 * nt / 4);
      
          //线程 t 是第一个使用 parallel_for 生成任务的线程
          //因此它创建了 TBB 调度程序和全局线程池
          //全局线程池是用默认数量的工作线程初始化的
          std::thread t([=]()
          {
              //使用 global_control 临时限制可用线程数
              //使用 global_control 控制可用于填充 Arena 插槽的线程数量
              tbb::global_control gc1(mp, nt / 4);
              tbb::parallel_for(0, n, [](int) { do_work("std::thread pfor", 0.01); });
      
          });
          //线程 t 和 task arena a 中的 parallel_for 算法的执行可能会重叠
          a.execute([=]() 
          {
              tbb::parallel_for(0, n, [](int) { do_work("arena pfor", 0.01); });
          });
      
          tbb::parallel_for(0, n, [](int) { do_work("main pfor", 0.01); });
          t.join();
      
    • 示例系统中只有四个逻辑核心,因此 TBB 用三个线程初始化全局线程池
    • 全局线程池中的三个线程在这三个 task arena 之间进行分配
    • 哪个 arena 获得的线程较少是由库自行决定
      • 当其中一个 arena 没有可执行工作时,线程就可以迁移到另一个 arena 以帮助完成那里的剩余工作
  • 如果想更改可用于填充插槽的线程数,可以使用 global_control
    • global_control 对象用于影响调度程序使用的全局参数;
  • 使用 global_control 临时限制可用线程数
    • 当 global_control 对象被销毁时,将使用剩余的 global_control 对象重新计算软线程限制

何时不需要控制线程数

  • 最好避免使用 global_control 对象
  • 建议库不要弄乱全局参数,而只将其留给主程序处理
  • 允许插件的应用程序的开发人员应该清楚地与插件编写者沟通应用程序的并行执行策略是什么
    • 以便他们可以适当地实现他们的插件
  • 设置工作线程的堆栈大小
    • global_control 类也可用于设置工作线程的堆栈大小
      • lobal_control 构造函数接受参数和值
      • 如果参数是 thread_stack_size,则该对象会更改全局堆栈大小参数的值
      • 与 max_allowed_pa​​ralleism 值不同,全局 thread_stack_size 值是请求值的最大值
      • 线程的堆栈必须足够大,可以容纳在其堆栈上分配的所有内存
        • 包括其调用堆栈上的所有局部变量
        • 在决定需要多少堆栈时,我们必须考虑任务主体中的局部变量
        • 还要考虑任务树的递归执行
        • 特别是如果使用任务阻塞实现了基于 task 的算法(task 已经弃用)
      • 正确的堆栈大小取决于应用程序,但没有好的经验法则可以分享

弄清楚哪里出了问题

  • 当应用程序变得更加复杂时,task_arena 类可以帮助用户管理应用程序中的隔离
  • global_control 类使用户可以更好地控制库使用的全局参数,以进一步管理复杂性
  • 不幸的是,这些功能并不是作为一个有凝聚力的设计的一部分一起创建的
    • 结果是,当在我们之前概述的场景之外使用时,它们的行为有时可能不直观

工作隔离

  • 建立隔离是一把双刃剑
    • 我们能够控制参与不同 task arena 的线程数量,以便优先执行某些任务
    • 或者使用 TBB 库中的钩子将线程固定到特定核心以优化局部性
    • 但显式的 task arena 使线程更难参与当前分配的 arena 之外的工作
  • 虽然 task arena 也可用于创建隔离来解决正确性问题
    • 但有较高的开销
    • 不太适合用于这类问题

工作隔离确保正确性

  • 如果应用程序使用嵌套并行性
    • TBB 库可能会窃取任务,导致执行顺序可能超出开发人员的预期
  • 决定何时需要工作隔离
    • 是否使用嵌套并行性。如果没有,则不需要隔离
    • 线程重新进入外层并行任务是否安全
      • 向线程存储本地值、重新获取该线程已获取的互斥体
      • 同一线程不应再次使用的其他资源
      • 上述两条都可能导致问题
      • 如果返回安全,则不需要隔离
    • 需要隔离
      • 嵌套并行必须在隔离区域内调用

使用 this_task_arena::isolate 创建隔离区域

  • 可以使用 this_task_arena 命名空间中的 isolate 函数
    • 在隔离区域内,如果线程因为必须等待而变得空闲,则只允许窃取从其自己的隔离区域内生成的任务
    • 示例代码
          std::mutex m;
          const int p = tbb::this_task_arena::max_concurrency();
          tbb::parallel_for(0, p, [&m](int i)
          {
              constexpr int n = 1000;
              std::scoped_lock l{ m };
              tbb::this_task_arena::isolate(
                  [i, n]()
                  {
                      tbb::parallel_for(0, n, [](int j)
                      {
                          do_work();
                      });
                  }
              );
          });
      
  • this_task_arena::isolate 的主要属性,如下
    • 隔离仅限制线程进入或加入隔离 arena
      • 隔离 arena 之外的工作线程可以执行任何任务
      • 包括在隔离 arena 中生成的任务
    • 当没有隔离的线程执行在隔离 arena 中生成的任务时
      • 它会加入该 task arena 并变为隔离,直到任务完成
    • 在隔离 arena 内等待的线程无法处理在其他隔离 arena 中生成的任务
      • 如果隔离 arena 内的线程进入嵌套的隔离 arena,则它无法处理来自外部隔离 arena 的任务

工作隔离会导致自身的正确性问题

  • 不能不加区别地实行工作隔离
    • 这会对性能产生影响
    • 如果使用不当,工作隔离本身可能会导致死锁
  • 在一个隔离 arena 中调用等待接口的任务在等待时无法参与在不同隔离 arena 中生成的任务
    • 如果有足够多的线程卡在这样的位置时,应用程序可能会耗尽线程,并且前进进度将停止
  • 在 task_group 中生成了 M 个任务。但每次生成都发生在不同的隔离 arena 内
    • 会有问题

即使在安全的情况下,工作隔离也并非免费

  • 除了潜在的死锁问题之外,从性能角度来看,工作隔离也不是免费的
  • 因此,即使可以安全使用,我们也需要慎重地使用
  • 从隔离 arena 内窃取的线程会产生更多开销

用任务 arena 进行隔离:一把双刃剑

  • 当线程寻找要做的工作时,工作隔离限制了线程的选项
  • 仅为了确保正确性而使用类 task_arena 而不是 isolate 函数来创建隔离几乎没有任何意义
  • 可以使用 max_concurrency 参数设置 arena 中线程的槽总数
  • 并使用 reserved_for_masters 参数专门为主线程保留的槽数量
  • 当线程调用 task_arena 的 execute 方法时,会尝试作为主线程加入 arena
  • 为不同 arena 设置最大线程数
        constexpr int n = 1000;
        tbb::task_arena ta2{ 2 };
        //使用6个线程是认为 ta6 中的任务更重要
        tbb::task_arena ta6{ 6 };
        tbb::parallel_invoke(
            [&]()
            {
                ta2.execute(
                    [&]()
                    {
                        tbb::parallel_for(0, n, [](int i) { do_work("ta2"); });
                    }
                );
            },
            [&]()
            {
                ta6.execute([&]() {
                tbb::parallel_for(0, n, [](int i) { do_work("ta6"); });
                });
            }
        );
    
  • 使用 isolate 和使用 task_arena 创建隔离之间的主要区别
    • 要使用隔离来帮助正确性
    • 使用 task_arena 主要是为了提高性能
  • 显式的 task_arena 是一把双刃剑
    • 它让我们控制参与工作的线程,但也在它们之间建立了一堵很高的墙
    • 当线程离开由 isolate 创建的隔离 arena 时,它可以自由地参与执行其 arena 中的任何其他任务
    • 一个线程在显式的 task_arena 中没有工作要做时,它必须返回到全局线程池
      • 然后找到另一个有工作要做并且有空槽的 arena

不要试图使用 task_arena 来创建工作隔离以确保正确性

  • 拥有许多 task arena 并在它们之间迁移线程根本不是一种有效的负载均衡方法
  • 如果有许多迭代,我们将在每个外部任务中创建和销毁 task_arenas
    • 如果有四个工作线程,它们将在 task arena 之间四处寻找工作
    • 使用隔离函数处理这种情况
    • 个人理解
      • 创建显示 arena 和工作隔离之间的取舍

任务映射 Thread-to-Core 和 Task-to-Thread

Thread-to-Core 映射

  • 所有主要操作系统都提供允许用户设置亲和力的接口
    • Linux 上的 pthread_setaffinity_np 或 sched_setaffinity
    • Windows 上的 SetThreadAffinityMask
  • TBB 库不会自动将这些线程关联到特定的内核
  • 通常最好不要将线程与核心关联起来
  • 如果想要强制 TBB 线程与核心具有亲和力
    • 使用 Task_Scheduler_Observer 观察调度程序
    • TBB 库不提供设线程置亲和性的抽象接口

Task-to-Thread 映射

  • 使用 affinity_partitioner

总结

  • 不建议设置 Thread-to-Core 映射
  • Task-to-Thread 映射建议使用 affinity_partitioner

任务优先级

支持 TBB 任务类中的非抢占优先级

  • TBB 库定义了三个优先级
    • priority_normal
    • priority_low
    • priority_high
  • 一般来说,TBB 先执行优先级较高的任务,然后再执行优先级较低的任务
    • 但有一些注意事项
      • TBB 任务是由 TBB 线程非抢占式执行的
      • 一旦任务开始执行,它将执行直至完成
  • TBB 库不提供任何用于设置线程优先级的高级抽象
  • 关键经验法则(Critical Rule of Thumb)
    • 不要为同一 arena 中运行的线程设置不同的优先级
  • 关于对任务优先级的支持,还有一些其他重要的限制需要提及
    • 更改可能不会立即在所有线程上生效
      • 即使存在较高优先级的任务,一些较低优先级的任务也可能开始执行
    • 工作线程可能需要迁移到另一个 arena 才能访问最高优先级的任务
      • 这可能需要时间。一旦迁移,可能会导致某些 arena(没有高优先级任务)没有工作线程
    • 由于主线程无法迁移,因此主线程将保留在这些 arena 中,并且它们本身不会停止
      • 即使它们的优先级较低,它们也可以继续执行自己的 arena 中的任务

不使用 TBB task 支持实现优先级

  • 使用 task_group 和 concurrent_priority_queue
    • 当需要完成一项工作时,会采取两个操作
      • 将工作描述推送到共享队列中
      • 在 task_group 中生成一个包装器 task,该包装器 task 将从 task_group 中弹出并执行一个子任务
  • 示例代码
        tbb::concurrent_priority_queue<WorkItem> q;
        tbb::task_group g;
        for(int i=0; i<16; i++)
        {
            q.push(WorkItem{ i });
            g.run([&q]()
                {
                    WorkItem w;
                    if(q.try_pop(w))
                    {
                        w.do_work();
                    }
                });
        }
        g.wait();
    

取消任务和异常处理

  • task::self().cancel_group_execution() 的作用
    • task::self() 返回对调用线程正在运行的最里面任务的引用
    • cancel_group_execution()
      • 该成员函数不仅取消调用任务,还取消属于同一组的所有任务
        tbb::parallel_for(
            tbb::blocked_range<int>{0, n},
            [&](const tbb::blocked_range<int>& r)
            {
                for(int i=r.begin(); i!=r.end(); i++)
                {
                    if(data[i] == -2)
                    {
                        index = i;
                        tbb::task::current_context()->cancel_group_execution();
                        break;
                    }
                }
            }
        );
    
  • 任务取消 TGC
    • 每个任务都属于一个且唯一的一个 task_group_context
      • 称为 TGC
    • TGC 代表一组可以取消或设置优先级的任务
          tbb::task_group_context tg;
          tbb::parallel_for(
              tbb::blocked_range<int>{0, n},
              [&](const tbb::blocked_range<int>& r)
              {
                  for (int i = r.begin(); i != r.end(); i++)
                  {
                      if (data[i] == -2)
                      {
                          index = i;
                          tg.cancel_group_execution();
                          break;
                      }
                  }
              },
              tg
          );
      
    • 当一个任务触发整个 TGC 的取消时
      • 队列中等待的任务将被最终确定而不会运行
      • 但已经运行的任务不会被 TBB 调度程序取消
    • 如果还想取消正在运行的任务,每个任务可以使用以下两种替代方案之一进行取消
      • if(task::self().group()->is_group_execuion_cancelled()) { return; }
      • if(task::self().is_cancelled) { return; }
  • 并行查询(可取消)示例
    int grainsize = 100;
    tbb::task_group g;
    auto serialSearch(long begin, long end) -> void
    {
        for(int i=begin; i<end; i++)
        {
            if(data[i] == -2)
            {
                myIndex = i;
                g.cancel();
                break;
            }
        }
    }
    
    auto parallel_search(long begin, long end) -> void
    {
        if((end-begin) < grainsize)
        {
            return serialSearch(begin, end);
        }
        else
        {
            long mid = begin + (end - begin) / 2;
            g.run([&]() { parallel_search(begin, mid); });
            g.run([&]() { parallel_search(mid, end); });
        }
    }
    
  • 异常处理示例
    try
    {
        tbb::parallel_for(0, N, 1, [&](int i)
            {
                tbb::task_group_context root{ tbb::task_group_context::isolated };
                tbb::parallel_for(0, N, 1, [&](int j)
                    {
                        data3[i][j] = true;
                    });

                throw "oops";
            });
    }
    catch (...)
    {
        std::cout << "An exception captured " << std::endl;
    }

流程图 (Flow Graphs)

优化粒度 (Granularity) 、局部性 (Locality) 和并行性 (Parallelism)

节点(Node)粒度

  • TBB 流程图不支持 Ranges 和 Partitioners
  • 经验法则
    • 流程图节点的执行时间应至少为 1 微秒
    • 相当于数千个 Cpu 周期
    • 建议使用 10,000 个周期
  • 如果节点太小该怎么办
    • 如果流图中的某些节点小于建议的 1 微秒阈值,则有三种选择
      • 如果该节点对应用程序的总执行时间没有显着影响,则不执行任何操作
        • 在这些情况下,设计的清晰度可能胜过所获得的任何无关紧要的效率
      • 将节点与周围其他节点合并以增加粒度
        • 如果合并不会改变图的语义,则可以选择将节点合并在一起
      • 使用轻量级执行策略
        • 示例代码
          • _Chapter17.cpp
          • all_lightweight_polic
        • 在构造节点时,可以通过模板参数将节点更改为使用轻量级执行策略
          • 该策略表明节点的主体包含少量工作
          • 如果可能的话,应该在没有调度任务开销的情况下执行
        • 共有三种轻量级策略可供选择
          • queuing(非 lightweight 策略)
            • 除 async_node 之外的所有节点的默认策略
            • 如果消息到达节点,但并发阻止立即生成新的主体任务
              • 则该消息将被缓冲,直到可以合法生成主体任务为止。
          • rejecting(非 lightweight 策略)
            • 如果消息到达节点,但并发阻止立即生成新的主体任务
              • 则对 try_put 的调用将返回 false,并且不会缓冲该消息
              • 此策略可用于阻止前一个 input_node 生成新值
          • lightweight
            • 表示节点主体包含少量工作
            • 如果可能,应在不产生调度任务开销的情况下执行
            • 通常,主体在调用 try_put 期间执行
          • queuing_lightweight
            • 是 async_node 的默认策略
            • 如果消息到达节点,但并发阻止立即应用正文,则消息将被缓冲
            • 直到可以将正文应用于消息为止
            • 由于这是轻量级策略,因此通常不会生成任务来执行正文。
          • rejecting_lightweight
            • 如果消息到达节点,但并发阻止立即应用主体
            • 则对 try_put 的调用将返回 false,并且不会缓冲该消息
            • 如果执行主体是合法的,则在对 try_put 的调用中执行主体
        • 除了 input_node 之外,所有函数节点都支持轻量级策略
        • 轻量级节点可能不会生成任务
          • 而是在调用线程的上下文中立即在 try_put 内部执行
          • 这意味着消除了生成的开销
            • 但其他线程没有机会窃取任务,因此并行性受到限制
    • 通过合并节点或使用轻量级策略来解决粒度问题可以减少开销
      • 但也会限制可扩展性

局部性

  • 由于可以从流程图中的局部性中受益
    • 因此应考虑数据大小,甚至将数据分成更小的部分
    • 示例代码
      • _Chapter17.cpp
      • data_locality_demo
  • 可以将可组合性分解为三个期望
    • 正确性(绝对)
    • 使用能力(实践)
    • 性能(作为期望)
  • 选择最佳消息类型并限制发送的消息数量
    • 当允许消息进入图中
      • 或者当通过流程图沿着多个路径分割消息时进行复制时,会消耗更多的内存
    • 除了担心局部性之外,可能还需要限制内存增长
    • 当消息传递到数据流图中的节点时,它可以被复制到该节点的内部缓冲区中
      • 如果串行节点需要推迟任务的生成,它会将传入消息保存在队列中,直到可以合法生成任务来处理它们为止
      • 如果在流程图中传递非常大的对象,这种复制可能会很昂贵
      • 如果可能的话,最好传递指向大对象的指针而不是对象本身
    • unique_ptr 和 share_ptr,对于简化流图中指针传递的对象的内存管理非常有用
      • shared_ptr 将正确处理引用计数的递增和递减
      • 但需要确保正确使用 edge 来防止访问指向的对象出现任何潜在的竞争条件
    • 示例代码
      • _Chapter17.cpp
      • function_node_with_serial_unlimited_demo
    • 管理流程图中的资源消耗有三种常见方法
      • 使用 limiter_node
        • limiter_node 维护通过它的消息的内部计数
          • 发送到 limiter_node 上的 decrement port 的消息会减少计数
            • 从而允许其他消息通过
          • 如果计数等于节点的阈值,则任何到达其输入端口的新消息都会被拒绝
        • 示例代码
        • _Chapter17.cpp
        • unlimited_node_demo
      • 使用并发限制
        • 还可以通过节点的并发限制来限制资源消耗
        • 示例代码
          • _Chapter17.cpp
          • function_node_with_rejecting_demo
      • 使用令牌传递模式
        • 可以使用令牌和 reserving join_node 创建一个类似的系统
        • 示例代码
          • _Chapter17.cpp
          • join_node_node_demo

并行性(使用 Task Arenas)

  • 流程图使用的默认 Arenas
    • 当构造 tbb::flow::graph 对象时,图对象捕获构造该对象的线程 arena 的引用
      • 每当生成任务来执行图中的工作时,任务都会在此 arena 中生成
      • 而不是在导致任务生成的线程的 arena 中生成
    • TBB 流程图的结构不如 TBB 并行算法
      • 对于 TBB 流图,可能有一个或多个主线程显式地将消息放入同一图中
      • 如果与这些交互相关的任务在每个主线程的 Arenas 中生成,则图中的某些任务将与同一图中的其他任务隔离
        • 这很可能不是我们想要的行为
      • 因此,所有任务都生成到一个单独的 arena,即构造图对象的线程的 arena
  • 更改流程图使用的 Arenas
    • 可以通过调用图的 reset() 函数来更改图使用的任务 Arena
      • 这会重置图,包括重新捕获任务 arena
    • 示例代码
      • _Chapter17.cpp
      • changing_arena_demo
  • 设置线程数、线程到核心的亲和性等
  • 使用 task_scheduler_observer 对象将线程固定到特定 Task Arenas 的核心上
    • 然后将该 arena 与流程图关联

使用流程的图的忠告

建议

  • 使用嵌套并行
    • 就像管道一样,如果流程图使用并行(flow::unlimited)节点,则它可以具有很大的可扩展性
      • 但如果它有串行节点,则会限制可扩展性
    • 增加扩展的一种方法是在 TBB 流图节点内使用嵌套并行算法
      • 应该尽可能使用嵌套并行性
  • 在需要时使用 join_node、sequencer_node 或 multifunction_node 重新建立流程图中的顺序
    • 在数据流图中建立顺序有三种常见方法
      • 使用键匹配 join_node
      • 使用 sequencer_node
        • sequencer_node 可以重新建立消息的顺序
        • 但它需要我们分配序列号,并且还需要为 sequencer_node 提供一个主体
          • 以便可以从传入消息中获取该序列号
        • 示例代码
          • _Chapter17.cpp
          • sequencer_node_demo
      • 使用 multifunction_node
        • 可以在其任何输出端口上输出零个或多个消息
        • 由于不强制为每个传入消息输出一条消息,因此它可以缓冲传入消息并保存
          • 直到满足某些用户定义的排序约束
        • 示例代码
          • _Chapter17.cpp
          • multifunction_node_demo
  • 使用 Isolate 函数实现嵌套并行
    • _Chapter17.cpp
      • isolate_demo
  • 在流程图中使用取消和异常处理
    • 每个流程图使用单独的 task_group_context
      • 当实例化一个图对象时,可以将一个显式的 task_group_context 传递给构造函数
        • tbb::task_group_context tgc;
        • `` tbb::flow::graph g{tgc};; `
      • 如果不将其传递给构造函数,则会创建一个默认对象
    • 取消流程图
      • 如果想取消流程图,可以使用 task_group_context 取消
        • tgc.cancel_group_excution();
      • 图中还有一个辅助函数,可直接检查图的状态
        • g.is_cancelled()
      • 如果需要取消一个图,但没有对其 task_group_context 的引用,可以从任务中获取
        • tbb::task::self().cancel_group_execution();
    • 取消后重置流程图
      • 如果一个图被取消,无论是直接取消还是由于异常,我们需要重置该图
        • g.reset()
      • 然后才能再次使用它
      • 这会重置图的状态
        • 清除内部缓冲区,将边放回其初始状态
    • 异常处理示例
      • _Chapter17.cpp
        • exceptione_demo
  • 使用 try_put 进行跨图通信
    • 个人理解:由于设置优先级弃用加之尽量避免两个图的通信,该示例略
      • 但是如果确实需要跨图进行通信怎么办 (上一条)
        • 最不危险的选项是显式调用 try_put 将消息从一个图中的节点发送到另一个图中的节点
    • 当图相互通信时需要非常小心
  • 使用 composite_node 封装节点组
    • 需要创建一个继承自 tbb::flow::composite_node 的新类才能使用
    • 组合了来自 source1 和 source2 的两个输入,并使用令牌传递方案来限制内存消耗示例
      • _Chapter17.cpp
      • composite_node_demo
    • 虽然创建继承自 tbb::flow::composite_node 的新类型一开始可能会有顾虑
      • 但使用此接口可以产生更具可读性和可重用性的代码
      • 特别是当流程图变得更大、更复杂时

不建议

  • 不建议使用 Multifunction 节点代替嵌套并行
    • 避免大量的窃取分配工作
  • 不建议在不同图中的节点之间建立边

流程图分析工具

  • 地址
    • https://software.intel.com/en-us/articles/intel-advisor-xe-release-notes

Async Nodes

  • 使用方式
        auto async_node() -> void
        {
            tbb::flow::graph g;
            bool n = false;
            tbb::flow::input_node<int> inNode{
                g,
                [&](tbb::flow_control& fc) -> int
                {
                    if(!n)
                    {
                        std::cout << "Async ";
                        n = true;
                        return 10;
                    }
                    else
                    {
                        fc.stop();
                        return {};
                    }
                }
            };
    
            AsyncActivity asyncAct;
            using ActivityNodeT = tbb::flow::async_node<int, int>;
            using GateWayT = ActivityNodeT::gateway_type;
    
            ActivityNodeT aNode{
                g, tbb::flow::unlimited,
                [&asyncAct](int const& input, GateWayT& gateway)
                {
                    asyncAct.run(input, gateway);
                }
            };
    
            tbb::flow::function_node<int> outNode{
                g, tbb::flow::unlimited,
                [](const int& aNum)
                {
                    std::cout << "Bye! Received: " << aNum << '\n';
                }
            };
    
            tbb::flow::make_edge(inNode, aNode);
            tbb::flow::make_edge(aNode, outNode);
    
            inNode.activate();
            g.wait_for_all();
        }
    

为何以及何时使用 async_node

  • TBB 通常配置与逻辑核心一样多的工作线程
  • 在用户级任务中调用阻塞函数不仅会阻塞该任务
    • 还会阻塞操作系统管理的工作线程处理该任务
  • 如果每个核心都有一个工作线程,并且其中一个线程被阻塞
    • 则相应的核心可能会空闲
    • 在这种情况下,就无法充分利用硬件
  • 当 async_node 任务(通常是 lambda)完成时
    • 负责该任务的工作线程会切换到处理流程图中的其他待处理任务
    • 工作线程就不会阻塞而留下空闲核心

OpenCL 示例

  • 将接受以下假设
    • 为了利用零复制缓冲区策略来减少设备之间数据移动的开销,假设 OpenCL 1.2 驱动程序可用
    • 并且存在一个对 CPU 和 GPU 都可见的公共内存区域
      • 集成 GPU 通常支持
    • 对于新进的异构芯片,OpenCL 2.0 也可用
    • 在这种情况下,可以利用 SVM(共享虚拟内存)
  • 为了减少流图节点的参数数量,从而提高代码的可读性,指向三个数组 A、B 和 C 的 CPU 和 GPU 视图的指针是全局可见的
    • 变量 vsize 也是全局的
  • 为了跳过 TBB 不太相关的内容,所有 OpenCL 代码已封装到单个函数 opencl_initialize() 中
    • 该函数负责获取平台,选择 GPU 设备
    • 创建 GPU 上下文和命令队列,读取 OpenCL 内核的源代码,编译它以创建内核
    • 并初始化三个缓冲区存储数组 A、B 和 C 的 GPU 视图
    • 由于 AsyncActivity 还需要命令队列和程序处理程序,因此相应的变量,队列和程序也是全局的
    • 使用了 cl2.hpp OpenCL C++ 头文件
  • 示例代码
    • _Chapter18.cpp
    • async_node_opencl_demo
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值