【问题标题】:Is it a bad idea to create a large amount of tasks for a few available cores only?只为几个可用内核创建大量任务是不是一个坏主意?
【发布时间】:2016-02-11 03:41:07
【问题描述】:

我是使用 TPL 进行并行编程的新手,刚刚听完 TPL 课程。我刚刚写了一小段演示软件来测试我对它的理解。

在问我的一般问题之前,让我解释一下我的并行代码的上下文:(如果无聊,直接跳到问题;-)

上下文:

我首先编写了一个遍历递归代码的顺序递归树(适用于任何已知的两人游戏),然后尝试通过在每个遍历(和评估)节点上创建并发子任务来将其并行化,从而尽可能多地展示并行性. (为每个子节点启动一个子任务)。从当前评估的头节点的每个子节点发出的子树通过启动的并发子任务之一调用的相同递归方法自行评估。

在游戏树中,节点代表游戏位置。当前游戏位置的根节点及其 n 个子节点(在第 2 层树上)通过在根位置为玩家 A 应用 n 次合法移动可以到达的游戏位置。第 3 级节点通过在每个第 2 级位置/节点上应用玩家 B 下的合法移动来表示可到达的位置,依此类推,直到达到给定的最大搜索深度。..

树以深度优先遍历顺序遍历,直到给定的最大搜索深度,其中“叶子”位置将由启发式评估函数评估,该函数返回一个整数值,表示玩家 A 与玩家 B 在此方面的优势游戏位置。这个值越大,玩家 A 的位置就应该越好。

将所有子节点的最佳值报告给它们共同的父节点,(当然如果父节点代表玩家A所打的位置,则最佳子节点是具有最高值的子节点,如果它由玩家 B 播放,最好的子节点是值最低的那个)。

最好的子值最终以向根节点报告其最佳值的第 2 级子节点的索引报告给根节点。当然,这个指数代表玩家 A 的“最佳”移动。这意味着向玩家 A 保证报告值(或更好的值)可以在探索深度(“叶子”位置)达到,无论玩家 B 的走法。

好的,这是众所周知的两人状态博弈的极小极大算法。

您可能还知道这种顺序算法的经典优化,称为 alpha-beta,当我们知道博弈树的一些分支/子分支不会导致有趣的叶子位置时,它们允许修剪它们。

所以我尝试并行化这种递归树遍历,将叶子值报告给根节点,并允许完全取消树遍历(如果用户取消搜索),但也允许取消部分子树任务以实现 alpha-beta修剪必须中止执行某些子节点子树评估的任务,而不会中断在其他子树上工作的任务。

工作成功完成,并行搜索比顺序搜索更快,并且几乎总是以 100% 的速度在所有可用内核上分派工作(根据任务管理器)。我不想用我的源代码让你厌烦。我只想准确地说,我还将 .Net 4.5 异步等待模式应用于我的递归并行化方法,并实现了“一个接一个地等待”模式(实际上是等待 Task.WhenAll())来创建探索子任务从每个子节点发出的子树。 (注意:为了允许子树遍历取消,我必须将取消令牌列表传递给递归异步子树探索方法,每个递归探索级别都会向列表中添加一个新的 CancellationToken)。

现在您已经了解了上下文,下面是我的问题:

  1. 启动搜索将快速创建数百万个任务,评估从每个树节点发出的数百万个子树,但实际上只有几个处理器内核可用于使用几个线程(每个内核一个?)执行这些任务。那么不考虑这一点并为每个必须评估/遍历的子节点/子树创建任务是一个好主意吗?创建比暴露最大并行度所需的任务更多的任务是否不会引起开销?限制启动任务的数量不是更好吗(例如,只为最高级别的节点启动任务,而对最低级别的节点使用纯顺序遍历方法?)或者我们可以通过创建数百万个任务,即使我们知道其中一些(在最深的树节点上启动)执行的工作非常小?

  2. 我没有阻止“通过闭包将循环索引变量传递给启动的任务”问题使用 lambda 表达式参数(如课程中建议的那样),而是通过复制循环索引在启动任务并通过闭包引用此副本之前,本地副本变量中的变量,而不是 lambda 表达式中的索引循环变量。您如何看待这种解决方法?这和传递 lambda 表达式参数一样安全吗?

  3. minimax 算法的 alpha-beta 剪枝优化基于每个子树的顺序遍历。并行遍历使 alpha-beta 修剪效率降低,因为如果子节点的所有并行子树遍历花费大致相同的时间返回,则不会中止任何子树遍历任务,如果它们将被中止,它们将被中止,因为它们都是已经快完成了……这将极大地限制 alpha-beta 优化所带来的收益。在将博弈树遍历与 alpha-beta 优化并行化时,您知道解决此问题的明智策略吗?

【问题讨论】:

  • 不,任务不是线程。另一方面,如果要处理大量数据,则应使用 Data (PLINQ),而不是 Task 并行来划分数据并避免调度任务的开销在线程池上。您还应该检查 .NET 4.6(Vector 类)中的 SIMD 支持,因为它提供了至少 4 倍的性能提升,而没有单核的开销。单个 CPU 操作不是一次在单个浮点上工作,而是在 4 个上工作
  • 是的,任务不是线程。但是,当然,任务是在线程上执行的,创建大量任务,即使是非常小批量的工作(低级节点),也意味着很多上下文更改可能会导致开销。这就是为什么我想知道是否应该通过顺序方法遍历最深的节点/子树。感谢您提供替代建议(PLINQ 和 SIMD),不确定在我的上下文中是否容易实现,但我会考虑一下。
  • 这就是为什么在数据并行中你对数据进行分区然后使用一小组任务来处理它。当您有许多应该同时/异步运行的单独任务时,使用任务并行。但是,在您的情况下,您有很多节点要处理,这使得数据并行是合适的。数据并行算法虽然具有与顺序算法不同的结构(例如在简单循环中除外)。这就像将 FOR 循环更改为 SQL 语句或 MapReduce 作业。
  • 那么有多少任务实际上是“活的”(存在)的? (您可以使用原子递增/递减计数器和高水位线来跟踪它)。
  • 这取决于您所说的“实时”任务,如果我在创建任务之前增加计数器并在此任务完成后减少它(在等待语句之后)(以及捕获 OperationCanceledException由取消的任务引发,为每个仍然等待的任务减少一次计数器)我同时获得数百万个现有任务。 (围绕遍历树的总节点)。

标签: .net parallel-processing task-parallel-library task alpha-beta-pruning


【解决方案1】:

关于“多少任务”的问题实际上是由两件事驱动的:

  • 概念性程序组织
  • 任务开销

从概念上讲,为树中的每个节点分配一项任务很简单。这就是好处。

缺点是使用任务的原因是有效地利用并行性,而不是程序组织。当你的处理器有很多工作要做时,你最终会得到最好的并行化, 与工作相比,管理该工作的开销很小。

您在节点级别选择任务的原因是很容易看到(可以说)有很多节点。 [在您的评论中,您提到深度搜索树的工作单元比您预期的要少!]。另一个原因是你不知道一个节点到底需要多少工作,所以有很多任务意味着处理器可以处理一个,一直工作到完成,然后去获取另一个。如果它们都有一些平均时间,处理器将执行并找到其他工作,而不会浪费大量时间等待其他工作出现。

关键问题是开销。要“拥有”一项任务,有很多开销:必须创建它,用工作填充它,将它放入其他处理器可以获取的工作队列中;其他处理器必须将其从队列中拉出,获取其内容,[不是开销:执行它],最后取消创建它。这些开销中的每一个都需要一些计算机指令,一些内存访问(如果幸运的话,它们是缓存命中)以及通常在处理器之间进行一些昂贵的同步(你不能让所有 CPU 将新任务放入可用的工作队列中)同一时刻)。

我不知道 C# TPL 的工作开销是多少。您可以通过编写模拟任务主体的简单循环并对其进行测量来测量它;然后测量转储到任务中时该循环需要多长时间。我知道您希望最小化开销(我围绕这个想法设计了一种并行编程语言PARLANSE)并最大化您投入任务的工作量。

在你的情况下,我担心节点中的工作(“生成下一步动作并简单地评估它”)并不是真的很多,而且你实际上会失去性能并行。如果您退出并行性,您是否测量过相同程序的运行速度? (PARLANSE 足够快,以至于 one can code a parallel 8 puzzle solver,但生成-移动-评估仍然比开销要多得多)。

当您在任务中没有足够的工作来压倒开销时,标准技巧是在任务中投入更多的工作。

在您的情况下,您可能决定让任务代表 2 层或 3 层搜索;这应该为每个任务创造大量的工作。对于 8 层游戏搜索,您仍然会生成大量任务来保持处理器忙碌。我展示了一个类似的技巧 (coarse-grain-threshold) 来使 parallel Fibonacci 工作,这与人们可以发明的工作/开销比率方案一样糟糕。

在国际象棋世界中众所周知,复杂的棋盘评估就像一个人在棋盘位置进行了 2 层或 3 层搜索一样。因此,您的另一个选择是实施昂贵的电路板评估程序;这将使叶子中的开销变小。

Alpha-beta 版可能会从running out of VM due to "big stacks" and many tasks 中节省您(“仅 1000 个任务”)。产生大量工作的好消息是您有很多可以提供给 CPU。坏消息是,它们可能会在虚拟机中占据 很多 空间,而它们却坐在那里不被执行; MS 的大筹码模型加剧了这一点。即使它不会使您耗尽 VM,它也可能导致您的程序接触大量内存,从而否定处理器缓存的价值。我会担心这个,但对 C# 没有好的建议;我构建了 PARLANSE 来避免这些问题;当它有“足够”的工作时,它会自动限制任务生成。不过,MS 有一些智能 cookie。

【讨论】:

  • 一些特定于 TPL 的说明:Task 不是线程,它没有堆栈(因此在不运行时不会占用太多内存)。 Task 基本上是对线程池工作项的抽象。所以它应该相当便宜,但你确定的所有间接费用仍然存在。
  • Task 的开销太大(相对于每个节点的工作量)时,通常有意义的是生产者-消费者方法:有一个小的常数Tasks (例如,每个 CPU 一个)处理来自线程安全队列的项目。例如,ActionBlock from TPL Dataflow 可以像这样轻松使用。
  • @svick:所以一旦一个任务开始运行,它会完全运行到终止而不停止?如果是这样,它不需要堆栈空间。但是如果一个任务可能会在等待另一个任务时卡住(这发生在 PARLANSE 中,爬过不规则的数据结构),挂起的任务将其本地状态存储在哪里?如果有很多任务可以等待,那么您需要大量空间来存储该本地状态。 MS Tasks 没有这个问题?
  • 是的,我假设Tasks 是独立的。如果他们需要互相等待,您可以同步(task.Wait()task.Result)或异步(await task)。如果使用同步方式,确实会遇到线程池必须不断创建新线程的问题,这意味着可能会耗尽内存。
  • 那么 await-task 是如何避免这种情况的呢?显然,一个任务可以调用许多方法来构建一个深度返回堆栈,然后执行等待任务。不记录调用栈怎么能“等待”呢?
猜你喜欢
  • 2017-01-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-02-01
  • 2011-02-03
相关资源
最近更新 更多