14.1.2 声明性数据并行度(Declarative data parallelism)

728 篇文章 1 订阅
26 篇文章 0 订阅
 

14.1.2 声明性数据并行度(Declarative data parallelism)

 

    声明风格编程背后的主要理念是代码不会指定它应该如何执行。执行由数量很少的基元被提供,比如,LINQ 中的 select 和 where,或者F# 中的 map 和 filter,这些基元的行为可能很复杂。

    在第 1 章中, 我们演示过如何把一个普通的 LINQ 查询,修改成使用 PLINQ 并行运行的查询。我们是用 C# 查询表达式展示的,但是为了理解它是如何工作的,最好检查使用方法调用和 lambda 函数转换后的版本。在这里,我们将使用一个小例子,在后面,会讨论一些更复杂的东西。清单 14.3 计算1 百万至 2 百万之间素数的个数。展示了 C# 使用方法调用的代码,也有 F# 的版本。

 

Listing 14.3 Counting the number of primes (C# and F#)

 

C#

F#

bool IsPrime(int n) {
  if (n <= 1) return false;
  int top = (int)Math.Sqrt(n);
  for (int i = 2; i <= top; i++)
    if (n%i == 0) return false;
  return true;
}

let isPrime(n) =
  let top = int(sqrt(float(n)))
  let rec isPrimeUtil(i) =
    if i > top then true
    elif n % i = 0 then false
    else isPrimeUtil(i + 1)
  (n > 1) && isPrimeUtil(2)

 

// Count the primes (C# version)
var nums = Enumerable.Range(1000000, 2000000);
var primeCount = nums.Where(IsPrime).Count();

// Count the primes (F# version)
let nums = [1000000 .. 3000000]
let primeCount = nums |> List.filter isPrime |> List.length

 

    清单首先是典型的命令式和函数式解决方案,用于测试一个数字是是否素数。我们以不同的方式实现,为了使用最适合每种语言的代码。你肯定已经知道,如果一个数只能被 1 和它本身整除,就是素数。我们测试数字整除,只从 2 到给定数的平方根,因为这就足够了。

    在 C# 中,代码使用命令式的 for 循环来实现。在 F# 中,使用递归函数;尾递归,这是一种高效的实现。还要注意,F# 版本使用了一个关键字,到目前为止还没有看到过的:elif 关键字,这是 else 后面跟着另一个 if 表达式的缩写。

    更有趣的是清单的第二部分。为了计算给定范围内素数的数量,我们只选择是素数的数字,然后计数。在 C# 中,我们生成一个 IEnumerable<int> 类型的整数(nums)范围。LINQ 为这种类型提供了扩展方法 Where 和 Count,所以,我们使用这些来计算计数的结果。在 F# 中,我们显式指定函数。我们将使用一个列表,所以,我们使用来自 List 模块的函数实现这段代码。

    现在,我们修改代码,可以并行运行。在 C# 中,这意味着,添加对 AsParallel 扩展方法的调用。在 F# 中,可以直接访问 LINQ 方法,但是,更适合的方法是使用流运算符。为此,我们将使用几个函数,打包对 .NET PLINQ 类的调用,类似于前一节的 pfor 函数。这些函数是在一个称为 PSeq 的模块中可用。

 

获取 F# 的并行扩展

 

    在我们继续之前,需要获取一个文件,有几个扩展,使得在 F# 中更容易进行并行编程。这个文件包含 PSeq 模块,有一组简单的包装,以及我们将很快会用到的 pseq 计算生成器。像这样的一个文件最终可能成为 F# PowerPack 或 F# 库的一部分,所以,我们不会展示如何实现它。

    现在,你可以从本书的网站下载我们需要的函数。若要从 F# 脚本引用这个文件,可以使用 #load 指令,并指定这个 fs 的文件路径。

 

    清单 14.4 显示了在 C# 和 F# 的并行查询。"最佳测试"函数还没有被重复,因为它不需要更改。

 

Listing 14.4 Counting primes in parallel (C# and F#)

C#F#

var primeCount =
  nums.AsParallel()
            .Where(IsPrime)
            .Count();

let primeCount =
  nums |> PSeq.ofSeq
             |> PSeq.filter isPrime
             |> PSeq.length

 

    F# 示例和我们已经见过的所有其他集合处理的示例是一致的。并行数据处理的函数,符合处理列表和数组的函数相同的模式。这意味着,我们首先必须将数据转换为一种并行数据结构,使用 PSeq.ofSeq(这就像 Array.ofSeq),然后,可以使用各种处理函数。并行数据结构是另一种序列类型,因此,如果我们需要,就可以将其转换为使用 List.ofSeq 函数的函数式列表。

    C# 版本要求更仔细检查,具有讽刺意味的是,因为它的改变要少于 F# 版本。第十二章中,我们看到过,如何实现自定义 LINQ 查询的运算符, PLINQ 使用了类似的技术。AsParallel 方法的返回类型是 ParallelQuery<T>。当 C# 编译器时搜索适当的 Where 方法调用,它找到一个扩展方法叫 Where,它取 ParallelQuery<T> 作为第一个参数值,它宁可这一方法更通用,取 IEnumerable<T>。这个并行的 Where 方法也返回 ParallelQuery<T>,所以,整个链使用这个由 PLINQ 所提供的方法。

 

在 F# Interactive 中测量加速

 

    在第 10 章,当讨论处理列表的函数时,我们要测量性能。若要快速比较我们示例的并行和顺序的版本,可以在 F# Interactive 中使用 #time 指令。一旦我们打开了,就可以选择一个版本,然后,通过按 Alt + Enter 运行它:

 

> #time;;
> nums |> List.filter isPrime |> List.length;;
Real: 00:00:01.606, CPU: 00:00:01.606
val it : int = 70501

> nums |> PSeq.ofSeq |> PSeq.filter isPrime
              |> PSeq.length;;
val it : int = 70501
Real: 00:00:00.875, CPU: 00:00:01.700

 

    Real 时间是操作的用时,可以看到,并行运行这个操作,在一台双核机器上,为我们加速大约 180 到 185%。这是令人印象深刻的,记住,最大理论加速是 200%(在用于测试的双核机器上),当然,我们用来测试的,只是一个玩具的示例。CPU 时间显示在所有的核心上,执行该操作花费的总时间,这就是为什么它比第二种情况中的实际时间要高。

    不幸的是,在 C# 中性能测试,并不容易,因为我们不能使用任何互动的工具。在本章后面,我们会写一些工具函数,来测量已编译的代码的性能。

 

    在声明性数据并行度入门中讨论的最后一个主题,是如何简化 F# 的语法。第十二章中,我们学会了如何写序列表达式,执行计算数字集合。创建一个计算表达式,以并行方式处理序列是很自然的下一步。

 

F# 中并行序列表达式

 

    有关 C# 版本的代码好的事情,是在顺序和并行版本之间切换,实际上就是添加或删除 AsParallel 调用的问题。在 F# 示例中,我们显式使用函数,比如 List.xyz 或 PSeq.xyz,所以,过渡不太平滑。

    如果我们使用序列表达式重写代码,就可以并行化绝大部分的代码,只要按一下键盘。你可以在清单 14.5 中看到这两个版本。

 

Listing 14.5 Parallelizing sequence expressions (F#)

// Sequence expression
seq {
  for n in nums do
    if (isPrime n) then
      yield n }
  |> Seq.length

// Parallel sequence expression
pseq {
  for n in nums do
    if (isPrime n) then
      yield n }
  |> PSeq.length

 

    并行序列表达式由 pseq 值表示,它在 F# 的并行扩展文件中可用。它会改变表达式内部的 for 操作的含义,从顺序版本到并行版本。语法比 C# 查询表达式更加灵活,因为,可以返回多个值,通过使用 yield 和 yield! 关键字,只是性能可能会略低。原因是,F# 编译器处理表达式的方式,不同于 C# 编译器。并行的序列表达式使用计算表达式实现,如第 12 章所见,序列转换的处理代码依赖一个基元型,通常对应于 for 循环。它告诉框架有关算法的信息,要比我们显式使用 PSeq.filter 和 PSeq.map 的少关,所以,当并行化代码时,它可能不聪明。有趣的是,F# 实现 pseq 构造比你想象的要更容易。

 

使用 LINQ 和计算表达式的并行度

 

    在第十二章中,我们实现自己的 LINQ 运算符集,并学会如何用 F# 写计算表达式。这两个概念基于同样的原则:我们实现一组基本运算符,LINQ 查询或 F# 计算表达式然后使用这些运算符运行。

    PLINQ 库实现 C# 查询所语法支持的几乎所有运算符,包括Select、SelectMany、Where、OrderBy,以及其他许多。那么,哪些成员要在 pseq 表达式中实现呢?

    在第十二章,我们看到了可以提供的几个基元,在实施时,当实现计算表达式时,最重要的是 Bind 成员,它对应于 let!,Return 成员用于当我们写 return 时。我们还讨论了有关序列表达式和 for 构造。知道,包含 for 的序列表达式可以转换成对平面映射操作的调用。如果我们想要在计算表达式的内部支持 for,可以实现一个名为 For 的成员。并行的序列表达式的实现,可以使用 SelectMany 运算符,来自并行 LINQ 库,因为,这个运算符在 LINQ 中实现了平面映射。

    还有一些其他基元,我们没看到过,在实现并行的序列表达式时需要,但是,它们都很简单。首先,我们需要支持 yield,它将生成一个值。这可以通过添加 yield 实现,将返回包含一个元素的序列,是作为参数值获取的。因为可以在表达式中有多个 yield,还需要 Combine 成员,它取两个序列,将它们连接成一个序列。最后,是 Zero 成员(它允许我们写 if 条件,而不需要 else 分支),它将返回一个空序列。有关 F# 实现 pseq 计算表达式的更多细节,可以阅读本书的网站。

    并行处理大量数据的声明性代码,可能是函数式编程最具吸引力的方面,因为,它非常容易,而且对于大型的数据集,也能给出很好的结果。但是,通常我们需要并行化更加复杂的计算。在函数式编程中,这些经常会使用不可变的数据结构和递归来写,所以,在下一节,我们要讨论一些更一般的技术。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值