PLINQ 中的加速

本文提供的信息将帮助你编写尽可能高效的 PLINQ 查询,同时仍产生正确的结果。

PLINQ 的主要目的是通过在多核计算机上并行执行查询委托来加快 LINQ to Objects 查询的执行速度。 当源集合中每个元素的处理是独立的时,PLINQ 性能最佳,各个委托之间没有涉及共享状态。 此类操作在 LINQ to Objects 和 PLINQ 中很常见,通常称作“令人愉悦的并行”,因为它们可以轻松地在多个线程上进行调度。 但是,并非所有查询都完全由理想的并行操作组成。 在大多数情况下,查询涉及一些无法并行化的运算符,或减缓并行执行。 即使使用完全令人愉快的并行查询,PLINQ 仍必须对数据源进行分区并计划线程上的工作,并且通常在查询完成时合并结果。 所有这些作都增加了并行化的计算成本;添加并行化的这些成本称为 开销。 若要在 PLINQ 查询中实现最佳性能,目标是最大化那些高度并行的部分,并最小化那些需要开销的部分。

影响 PLINQ 查询性能的因素

以下部分列出了影响并行查询性能的一些最重要的因素。 这些语句本身不足以预测所有情况下的查询性能。 与往常一样,必须测量具有一系列代表性配置和负载的计算机上特定查询的实际性能。

  1. 总体工作的计算成本。

    为了实现加速,PLINQ 查询必须有足够多的适合并行操作来抵消开销。 工作量表示为每个委托的计算成本乘以源集合中的元素数量。 假设作可以并行化,计算成本越大,加速的机会就越大。 例如,如果函数执行需要 1 毫秒,则超过 1000 个元素的顺序查询将花费 1 秒来执行该作,而具有四个核心的计算机上的并行查询可能需要 250 毫秒。 这会加快 750 毫秒的速度。 如果函数需要为每个元素执行一秒,则加速将为 750 秒。 如果委托的代价很高,那么即使源集合中只有少数项,PLINQ 也可能显著加快处理速度。 相反,包含最简单的委托的小型源集合通常不适合执行 PLINQ。

    在以下示例中,queryA 可能是 PLINQ 的合适候选对象,假设其 Select 函数需要大量工作。 queryB 可能不是一个理想的选择,因为在 Select 语句中工作量不足,并行化的开销可能会抵消大部分甚至全部性能提升。

    Dim queryA = From num In numberList.AsParallel()  
                 Select ExpensiveFunction(num); 'good for PLINQ  
    
    Dim queryB = From num In numberList.AsParallel()  
                 Where num Mod 2 > 0  
                 Select num; 'not as good for PLINQ  
    
    var queryA = from num in numberList.AsParallel()  
                 select ExpensiveFunction(num); //good for PLINQ  
    
    var queryB = from num in numberList.AsParallel()  
                 where num % 2 > 0  
                 select num; //not as good for PLINQ  
    
  2. 系统上的逻辑核心数(并行度)。

    这一点是上一部分的必然结果,在具有更多内核的计算机上,适合并行查询运行得更快,这是因为可以在更多并发线程之间划分工作。 加速效果的大小取决于查询工作中可以并行化的百分比。 但是,不要假设所有查询在八核计算机上运行的速度是四核计算机的两倍。 优化查询以获得最佳性能时,请务必在具有各种核心数的计算机上测量实际结果。 此点与点 #1 相关:需要更大的数据集才能利用更大的计算资源。

  3. 操作的数量和类型。

    PLINQ 为需要维护源序列中元素的顺序的情况提供 AsOrdered 运算符。 有一个与订购相关的成本,但这种成本通常是适度的。 GroupBy 和 Join 操作同样会产生开销。 如果允许 PLINQ 按任意顺序处理源集合中的元素,并在它们准备就绪后立即将其传递给下一个运算符,则其性能最佳。 有关详细信息,请参阅 PLINQ 中的订单保留

  4. 查询执行形式。

    如果要通过调用 ToArray 或 ToList 来存储查询的结果,则必须将所有并行线程的结果合并到单个数据结构中。 这涉及到不可避免的计算成本。 同样,如果使用 foreach(Visual Basic 中的 For Each)循环来循环访问结果,工作线程的结果必须串行化到枚举器线程。 但是,如果只想根据每个线程的结果执行一些作,则可以使用 ForAll 方法对多个线程执行此作。

  5. 合并选项的类型。

    可以将 PLINQ 配置为缓冲其输出,以块状或在生成整个结果集后一次性生成输出,也可以在生成单个结果时逐个流式传输这些结果。 前者会导致整体执行时间下降,后者会导致生成的元素之间的延迟降低。 虽然合并选项并不总是对整体查询性能产生重大影响,但它们可能会影响感知的性能,因为它们控制用户必须等待查看结果的时间。 有关详细信息,请参阅 PLINQ 中的合并选项

  6. 分区种类。

    在某些情况下,对可索引源集合的 PLINQ 查询可能会导致工作负载不平衡。 发生这种情况时,可以通过创建自定义分区程序来提高查询性能。 有关详细信息,请参阅 PLINQ 和 TPL 的自定义分区程序

PLINQ 选择顺序模式时

PLINQ 将始终尝试至少像按顺序运行查询一样快地执行查询。 尽管 PLINQ 不了解用户委托的计算成本,或者输入源有多大,但它确实会查找某些查询“形状”。具体而言,它查找查询运算符或运算符的组合,这些运算符通常会导致查询在并行模式下执行速度较慢。 当找到此类形状时,PLINQ 默认回退到顺序模式。

但是,在测量特定查询的性能后,可以确定它实际上在并行模式下运行速度更快。 在这种情况下,可以通过方法使用 ParallelExecutionMode.ForceParallelism 标志 WithExecutionMode 来指示 PLINQ 并行化查询。 有关详细信息,请参阅 如何:在 PLINQ 中指定执行模式

以下列表描述了 PLINQ 默认将在顺序模式下执行的查询形状:

  • 在删除或重新排列了原始索引的排序或筛选运算符后面,包含 Select、已编制索引 Where、已编制索引 SelectMany 或 ElementAt 子句的查询。

  • 包含 Take、TakeWhile、Skip、SkipWhile 运算符且源序列中的索引不是原始顺序的查询。

  • 包含 Zip 或 SequenceEquals 的查询,除非其中一个数据源具有最初排序的索引,而另一个数据源是可索引的(即数组或 IList(T))。

  • 包含 Concat 的查询,除非它应用于可索引数据源。

  • 包含 Reverse 的查询,除非应用于可索引的数据源。

另请参阅