ParallelAccelerator.jl 简介

2016 年 3 月 1 日 | Lindsey Kuper

英特尔实验室的高性能脚本团队最近发布了 ParallelAccelerator.jl,这是一个用于高性能、高级 数组式编程 的 Julia 包。ParallelAccelerator 的目标是使高级数组式程序在 Julia 中尽可能高效地运行,而程序员需要付出的额外努力最少。在本篇文章中,我们将介绍 ParallelAccelerator 包,并通过一些示例演示如何使用它来加速 Julia 中一些典型的数组式程序。

简介

理想情况下,高级数组式 Julia 程序应该在高性能并行硬件上尽可能高效地运行,程序员需要付出的额外努力最少,并且性能应该接近 C 或 C++ 专家实现的性能。ParallelAccelerator 主要是通过以下三种方式来实现这一目标。

ParallelAccelerator 包提供的主要面向用户的特性是一个名为@acc的 Julia 宏,它是“加速”的缩写。使用@acc对函数或代码块进行注释,可以指定您希望编译为优化原生代码的 Julia 程序部分。以下是一个使用@acc对函数进行注释的示例

julia> using ParallelAccelerator

julia> @acc f(x) = x .+ x .* x
f (generic function with 1 method)

julia> f([1,2,3,4,5])
5-element Array{Int64,1}:
2
6
12
20
30

在幕后,ParallelAccelerator 本质上是一个编译器(本身用 Julia 实现),它拦截@acc注释函数的常规 Julia JIT 编译过程。它将@acc注释的代码编译为 C++ OpenMP 代码,然后可以使用外部 C++ 编译器(如 GCC 或 ICC)将其编译为原生库。(此中间 C++ 生成步骤对于 ParallelAccelerator 的设计来说并不是必需的,因为编译器可以针对 Julia 本身即将推出的原生线程后端。[1]) 在 Julia 端,ParallelAccelerator 生成一个代理函数,它调用该原生库,并用对代理函数的调用替换对@acc注释函数(如上面示例中的f)的调用。

稍后我们将更详细地介绍 ParallelAccelerator 针对的并行模式以及 ParallelAccelerator 编译器的工作原理,但在介绍之前,让我们先看一下代码和一些性能结果。

快速预览结果:Black-Scholes 期权定价基准

让我们看看如何使用 ParallelAccelerator 来加速一个经典的高性能计算基准:一个实现 Black-Scholes 公式 的期权定价。以下代码是 Black-Scholes 公式的 Julia 实现。

function cndf2(in::Array{Float64,1})
    out = 0.5 .+ 0.5 .* erf(0.707106781 .* in)
    return out
end

function blackscholes(sptprice::Array{Float64,1},
                      strike::Array{Float64,1},
                      rate::Array{Float64,1},
                      volatility::Array{Float64,1},
                      time::Array{Float64,1})
    logterm = log10(sptprice ./ strike)
    powterm = .5 .* volatility .* volatility
    den = volatility .* sqrt(time)
    d1 = (((rate .+ powterm) .* time) .+ logterm) ./ den
    d2 = d1 .- den
    NofXd1 = cndf2(d1)
    NofXd2 = cndf2(d2)
    futureValue = strike .* exp(- rate .* time)
    c1 = futureValue .* NofXd2
    call = sptprice .* NofXd1 .- c1
    put  = call .- futureValue .+ sptprice
end

function run(iterations)
    sptprice   = Float64[ 42.0 for i = 1:iterations ]
    initStrike = Float64[ 40.0 + (i / iterations) for i = 1:iterations ]
    rate       = Float64[ 0.5 for i = 1:iterations ]
    volatility = Float64[ 0.2 for i = 1:iterations ]
    time       = Float64[ 0.5 for i = 1:iterations ]

    tic()
    put = blackscholes(sptprice, initStrike, rate, volatility, time)
    t = toq()
    println("checksum: ", sum(put))
    return t
end

这里,blackscholes 函数接受五个参数,每个参数都是一个Float64数组。run 函数初始化这五个数组并将它们传递给blackscholes,它与cndf2(累积正态分布)函数(它被调用)一起执行了多个计算,包括对数组进行逐点加法(.+)、减法(.-)、乘法(.*)和除法(./)。不需要理解 Black-Scholes 公式的细节;要注意的是,代码中我们正在执行大量的逐点数组算术。使用 Julia 0.4.4-pre 在具有 8 GB 内存的 4 核 Ubuntu 14.04 桌面机器上,当run函数被调用时,其参数为 40,000,000(这意味着我们正在处理 4000 万个元素的数组),它大约需要 11 秒才能运行。

julia> @time run(40_000_000)
checksum: 8.381928525856283e8
 12.885293 seconds (458.51 k allocations: 9.855 GB, 2.95% gc time)
11.297714183

这里,run返回的11.297714183blackscholes调用本身返回所需的时间。@time报告的12.885293秒略长一些,因为它表示整个run调用运行的时间。

代码中众多逐点数组运算使它非常适合使用 ParallelAccelerator 来加速(我们将在稍后进行详细讨论)。这样做只需要对代码进行一些小的修改:我们使用using ParallelAccelerator导入 ParallelAccelerator 库,然后将cndf2blackscholes函数包装在@acc块中,如下所示

using ParallelAccelerator

@acc begin

function cndf2(in::Array{Float64,1})
    out = 0.5 .+ 0.5 .* erf(0.707106781 .* in)
    return out
end

function blackscholes(sptprice::Array{Float64,1},
                      strike::Array{Float64,1},
                      rate::Array{Float64,1},
                      volatility::Array{Float64,1},
                      time::Array{Float64,1})
    logterm = log10(sptprice ./ strike)
    powterm = .5 .* volatility .* volatility
    den = volatility .* sqrt(time)
    d1 = (((rate .+ powterm) .* time) .+ logterm) ./ den
    d2 = d1 .- den
    NofXd1 = cndf2(d1)
    NofXd2 = cndf2(d2)
    futureValue = strike .* exp(- rate .* time)
    c1 = futureValue .* NofXd2
    call = sptprice .* NofXd1 .- c1
    put  = call .- futureValue .+ sptprice
end

end

run的定义保持不变。添加了@acc包装器后,我们现在获得了更好的性能

julia> @time run(40_000_000)
checksum: 8.381928525856283e8
  4.010668 seconds (1.90 M allocations: 1.584 GB, 2.06% gc time)
3.503281464

这次,blackscholes在约 3.5 秒内返回,整个run调用在约 4 秒内完成。这已经是一个进步,但对于后续的run调用,我们还会获得更好的结果

julia> @time run(40_000_000)
checksum: 8.381928525856283e8
  1.418709 seconds (158 allocations: 1.490 GB, 8.98% gc time)
1.007861068

julia> @time run(40_000_000)
checksum: 8.381928525856283e8
  1.410865 seconds (154 allocations: 1.490 GB, 7.93% gc time)
1.012813958

在随后的调用中,run在约 1 秒内完成,整个调用大约需要 1.4 秒。造成这种额外改进的原因是,ParallelAccelerator 已经编译了blackscholescndf2函数,并且在后续运行中不需要再次编译。

这些结果是在普通台式机上收集的,但我们可以进一步扩展。下图报告了blackscholes在 1 亿个元素的数组上运行所需的时间,这次是在一台具有 128 GB 内存的 36 核机器上运行的[2]

Benchmark results for plain Julia and ParallelAccelerator implementations of the Black-Scholes formula

上图的前三根条形图显示了 ParallelAccelerator 使用不同线程数的性能结果。由于 ParallelAccelerator 将 Julia 编译为 OpenMP C++,因此我们可以使用OMP_NUM_THREADS环境变量来控制代码运行的线程数。在这里,当OMP_NUM_THREADS设置为 18 时,blackscholes在 0.27 秒内运行;当使用 36 个线程(与机器上的核心数匹配)时,运行时间降至 0.16 秒。第三根条形图显示了OMP_NUM_THREADS设置为 1 时的 ParallelAccelerator 结果,其运行时间约为 3 秒。为了比较,最右边的条形图显示了“普通 Julia”的结果,即没有@acc的代码版本,它运行大约需要 21 秒。

由于 Julia(尚未)拥有原生多线程支持,因此最右边的条形图中显示的普通 Julia 结果只针对一个线程。但值得注意的是,即使在只有一个内核运行的情况下,ParallelAccelerator 实现的 Black-Scholes 也比普通 Julia 快大约 7 倍。造成这种加速的原因是 ParallelAccelerator(尽管它的名字如此!)不仅仅是并行化代码。ParallelAccelerator 编译器能够消除数组边界检查和中间数组分配带来的许多运行时开销。在此基础上,再加上并行的优势,我们能够做得更好,与普通 Julia 相比,总共加速了 100 多倍。

为了了解 ParallelAccelerator 是如何实现这一点的,我们将更详细地讨论 ParallelAccelerator 处理的并行模式,然后我们将更仔细地研究 ParallelAccelerator 编译器管道。

并行模式

ParallelAccelerator 通过识别源代码中的隐式并行模式并将其显式化来工作。这些模式包括映射归约数组推导模板

映射

正如我们在上面的 Black-Scholes 示例中所看到的,Julia 中的.+.-.*./运算都是逐点数组运算,它们以输入数组作为参数并生成输出数组。ParallelAccelerator 将这些逐点数组运算转换为数据并行的映射运算。(有关它可以并行化的所有逐点数组运算的完整列表,请参阅 ParallelAccelerator 文档。)此外,ParallelAccelerator 将数组赋值转换为就地映射运算。例如,将a = a .* b分配给ab数组,将映射.*ab上,并使用结果就地更新a。对于标准映射和就地映射,一旦我们确定输入数组和输出数组的大小相同,ParallelAccelerator 就可以避免任何数组边界检查。

归约

归约运算以数组作为参数,并通过使用一个关联且可交换的运算将数组的所有元素组合起来,从而生成一个标量结果。ParallelAccelerator 将 Julia 函数minimummaximumsumprodanyall转换为数据并行的归约运算,前提是它们是在数组上调用的。

数组推导

Julia 支持 数组推导,这是一种构建数组的便捷且简洁的方式。例如,在上面的 Black-Scholes 示例中初始化五个输入数组的表达式都是数组推导。更复杂的例子是,以下avg函数(来自 Julia 手册)接受一个长度为n的一维输入数组x,并使用数组推导构建一个长度为n-2的输出数组,其中每个元素都是原始数组中对应元素与其两个相邻元素的加权平均值

avg(x) = [ 0.25*x[i-1] + 0.5*x[i] + 0.25*x[i+1] for i = 2:length(x) - 1 ]

ParallelAccelerator 也可以将类似这样的推导并行化:简而言之,ParallelAccelerator 可以将数组推导转换为首先分配一个输出数组,然后执行一个就地映射的代码,该映射可以并行地写入输出数组的每个元素。

数组推导不同于映射和归约运算,因为它们涉及显式的数组索引。但是,只要推导主体中(for之前的部分)没有副作用,就可以在 Julia 中并行化数组推导。[3] ParallelAccelerator 使用一种保守的静态分析来尝试识别和拒绝推导中的副作用操作。

模板

除了映射、归约和推导之外,ParallelAccelerator 还针对第四种并行模式:模板计算。模板计算根据称为模板的固定模式更新数组的元素。实际上,上面的avg推导示例也可以被认为是一种模板计算,因为它根据每个元素的相邻元素来更新数组的内容。然而,模板计算不同于 ParallelAccelerator 针对的其他模式,因为 Julia 中没有内置的面向用户的语言特性专门用于表示模板计算。因此,ParallelAccelerator 引入了一种新的面向用户的语言结构,名为runStencil,用于在 Julia 中表示模板计算。接下来,我们将通过一个示例来说明runStencil的工作原理。

示例:使用 runStencil 模糊图像

让我们考虑一个使用 高斯模糊 模糊图像的模板计算。图像表示为一个包含像素的二维数组。为了模糊图像,我们将每个输出像素的值设置为对应输入像素的值与其相邻输入像素值的特定加权平均值。通过重复此过程多次,我们可以得到越来越模糊的图像。[4]

以下代码在 Julia 中实现高斯模糊。它对一个 Float32 的二维数组进行操作:源图像的像素。使用例如来自 Images.jl 库的 load 函数,然后调用 convert 来获取一个 Array{Float32,2} 类型的数组,很容易获得这样的数组。(为了简单起见,我们假设输入图像是一个灰度图像,所以每个像素只有一个值,而不是红、绿、蓝值。但是,对 RGB 像素使用相同的方法是直接的。)

function blur(img::Array{Float32,2}, iterations::Int)
    w, h = size(img)
    for i = 1:iterations
      img[3:w-2,3:h-2] =
           img[3-2:w-4,3-2:h-4] * 0.0030 + img[3-1:w-3,3-2:h-4] * 0.0133 + img[3:w-2,3-2:h-4] * 0.0219 + img[3+1:w-1,3-2:h-4] * 0.0133 + img[3+2:w,3-2:h-4] * 0.0030 +
           img[3-2:w-4,3-1:h-3] * 0.0133 + img[3-1:w-3,3-1:h-3] * 0.0596 + img[3:w-2,3-1:h-3] * 0.0983 + img[3+1:w-1,3-1:h-3] * 0.0596 + img[3+2:w,3-1:h-3] * 0.0133 +
           img[3-2:w-4,3+0:h-2] * 0.0219 + img[3-1:w-3,3+0:h-2] * 0.0983 + img[3:w-2,3+0:h-2] * 0.1621 + img[3+1:w-1,3+0:h-2] * 0.0983 + img[3+2:w,3+0:h-2] * 0.0219 +
           img[3-2:w-4,3+1:h-1] * 0.0133 + img[3-1:w-3,3+1:h-1] * 0.0596 + img[3:w-2,3+1:h-1] * 0.0983 + img[3+1:w-1,3+1:h-1] * 0.0596 + img[3+2:w,3+1:h-1] * 0.0133 +
           img[3-2:w-4,3+2:h-0] * 0.0030 + img[3-1:w-3,3+2:h-0] * 0.0133 + img[3:w-2,3+2:h-0] * 0.0219 + img[3+1:w-1,3+2:h-0] * 0.0133 + img[3+2:w,3+2:h-0] * 0.0030
    end
    return img
end

在这里,为了计算输出图像中像素的值,我们使用相应的输入像素以及它所有的相邻像素,深度为从输入像素向外两个像素——所以,24 个邻居。总共有 25 个像素值需要检查。我们将所有这些像素值加在一起,每个值都乘以一个权重——在本例中,对于最角像素,权重为 0.0030;对于中心像素,权重为 0.1621;对于所有其他像素,权重介于两者之间——总和就是输出像素的值。在图像的边界,我们没有足够的相邻像素来计算输出像素值,所以我们简单地跳过这些像素,不为它们分配值。[5]

注意,blur 函数显式地循环遍历迭代次数,即对图像应用模糊的次数,但它没有显式地循环遍历图像中的像素。相反,代码是用数组风格编写的:它只对数组 img 执行一次赋值,使用范围 3:w-23:h-2 来避免对图像的边界进行赋值。在一个 大型灰度输入图像 上,大小为 7095 x 5322 像素,这段代码运行 100 次迭代大约需要 10 分钟。

使用 ParallelAccelerator,我们可以获得更好的性能。让我们看一下使用 runStencilblur 版本

@acc function blur(img::Array{Float32,2}, iterations::Int)
    buf = Array(Float32, size(img)...)
    runStencil(buf, img, iterations, :oob_skip) do b, a
       b[0,0] =
            (a[-2,-2] * 0.003  + a[-1,-2] * 0.0133 + a[0,-2] * 0.0219 + a[1,-2] * 0.0133 + a[2,-2] * 0.0030 +
             a[-2,-1] * 0.0133 + a[-1,-1] * 0.0596 + a[0,-1] * 0.0983 + a[1,-1] * 0.0596 + a[2,-1] * 0.0133 +
             a[-2, 0] * 0.0219 + a[-1, 0] * 0.0983 + a[0, 0] * 0.1621 + a[1, 0] * 0.0983 + a[2, 0] * 0.0219 +
             a[-2, 1] * 0.0133 + a[-1, 1] * 0.0596 + a[0, 1] * 0.0983 + a[1, 1] * 0.0596 + a[2, 1] * 0.0133 +
             a[-2, 2] * 0.003  + a[-1, 2] * 0.0133 + a[0, 2] * 0.0219 + a[1, 2] * 0.0133 + a[2, 2] * 0.0030)
       return a, b
    end
    return img
end

这里,我们再次有一个名为 blur 的函数——现在用 @acc 注解——它接受与原始代码相同的参数。这个版本的 blur 分配了一个新的二维数组,称为 buf,它与原始的 img 数组大小相同。buf 的分配之后是调用 runStencil。让我们仔细看看 runStencil 的调用。

runStencil 具有以下签名

runStencil(kernel :: Function, buffer1, buffer2, ..., iteration :: Int, boundaryHandling :: Symbol)

blur 中,对 runStencil 的调用使用 Julia 的 do 块语法来传递函数参数,所以 do b, a ... end 块实际上是 runStencil调用的第一个参数。do 块创建一个匿名函数,它绑定变量 ba。传递给 runStencil 的参数 buffer1, buffer2, ... 成为匿名函数的参数。在本例中,我们将两个缓冲区 bufimg 传递给 runStencil,因此匿名函数接受两个参数。

除了匿名函数和两个缓冲区之外,runStencil 还接受另外两个参数。第一个参数是我们要运行模板计算的迭代次数。在本例中,我们简单地传递给 bluriterations 参数。最后,runStencil 的最后一个参数是一个符号,它指示如何处理模板边界。这里,我们使用 :oob_skip 符号,它是 “out-of-bounds skip” 的缩写。这意味着当输入索引超出范围时——例如,在输入像素是图像的两个像素边界上的像素之一的情况下,并且没有足够的相邻像素来计算输出像素值——我们简单地跳过对输出像素的写入。这与原始版本的 blur 中的仔细索引具有相同的效果。

最后,让我们看一下传递给 runStencildo 块的主体。它包含一个对 b 的赋值,使用从 a 计算的值。正如我们所说,这里的 ba 分别是 bufimg:我们新分配的缓冲区和原始图像。这里的代码与 blur 的原始实现类似,但是在这里我们使用的是相对索引而不是绝对索引到数组中。b[0,0] 中的索引 0,0 不指向 b 的任何特定元素,而是指向一个可以被认为是遍历 b 所有元素的游标的当前位置。在赋值的右侧,a[-2,-1] 指向 a 中相对于 a0,0 元素向左两个元素、向上一个元素的元素。通过这种方式,我们可以比 blur 的原始版本更简洁地表达模板计算,并且我们不必担心像以前那样为边界处理获得正确的索引,因为 :oob_skip 参数告诉 runStencil 处理边界所需的一切。

最后,在 do 块的末尾,我们返回 a, b。它们被绑定为 b, a,但是我们以相反的顺序返回它们,以便在模板的每次迭代中,我们将使用已经模糊的缓冲区作为另一轮模糊的输入。这将持续到我们指定的迭代次数。因此,在使用 runStencil 时,不需要为模板迭代编写显式的 for 循环;只需要传递一个参数,说明应该执行多少次迭代。

因此,runStencil 使我们能够编写比普通 Julia 更简洁的代码,正如我们对语言扩展的期望。但是,runStencil 真正发光的地方在于它所带来的性能。下图比较了普通 Julia 和 ParallelAccelerator 实现的 blur 的性能结果,两者都在前面提到的 7095x5322 源图像上运行了 100 次迭代,使用与之前的 Black-Scholes 基准测试相同的机器运行。

Benchmark results for plain Julia and ParallelAccelerator implementations of Gaussian blur

最右边的列显示了普通 Julia 的结果,使用上面显示的第一个 blur 实现。左边三列显示了使用 runStencil 的 ParallelAccelerator 版本的结果。正如我们所看到的,即使只在一个线程上运行,ParallelAccelerator 也能够实现大约 15 倍的加速:从大约 600 秒到大约 40 秒。在 36 个线程上运行可以提供超过 26 倍的额外并行加速,导致总加速比普通单线程 Julia 提高近 400 倍。

ParallelAccelerator 编译器架构概述

既然我们已经讨论了 ParallelAccelerator 加速的并行模式,并看到了一些代码示例,那么让我们看看 ParallelAccelerator 编译器是如何工作的。

标准的 Julia JIT 编译器将 Julia 源代码解析为 Julia 抽象语法树 (AST) 表示。它对 AST 执行类型推断,然后将 AST 转换为 LLVM IR,最后生成本地汇编代码。ParallelAccelerator 在 AST 级别拦截此过程。它为我们上面讨论的并行模式引入了新的 AST 节点。然后,它对生成的 AST 进行各种优化。最后,它生成可以用外部 C++ 编译器编译的 C++ 代码。下图显示了 ParallelAccelerator 编译过程的概述

The ParallelAccelerator compiler pipeline

正如该博客的许多读者所知,Julia 对 检查和操作自己的 AST 有很好的支持。它内置的 code_typed 函数将在 Julia 的类型推断完成之后返回任何函数的 AST。这对 ParallelAccelerator 非常方便,ParallelAccelerator 能够使用 code_typed 的输出作为其编译器的第一阶段的输入,这个阶段称为 “域变换”。域变换阶段生成 ParallelAccelerator 的域 AST 中间表示。

域 AST 类似于 Julia 的 AST,只是它为它识别的并行模式引入了新的 AST 节点。我们将这些节点统称为 “域节点”。域变换阶段用域节点替换 AST 的某些部分。

域变换阶段之后是并行变换阶段,它用 “parfor” 节点替换域节点,每个节点代表一个或多个嵌套的并行 for 循环。循环融合也发生在并行变换阶段。我们将并行变换的结果称为并行 AST。[6]

编译器将并行 AST 代码传递给编译器的最后一个阶段,CGen,它生成 C++ 代码并将 parfor 节点转换为 OpenMP 循环。最后,外部 C++ 编译器创建可执行文件,该文件链接到 OpenMP 和一个用 C 编写的用于管理数组在 Julia 和 C++ 之间来回传输的小型数组运行时组件。

注意事项

ParallelAccelerator 在这个阶段仍然是一个概念验证。用户应该注意两个可能阻碍有效使用 ParallelAccelerator 的问题。这些问题分别是:第一,包加载时间;第二,ParallelAccelerator 能够处理的 Julia 程序的限制。我们将依次讨论这两个问题。

包加载时间

由于 ParallelAccelerator 是一个大型的 Julia 包(毕竟它是一个编译器),所以 using ParallelAccelerator 运行需要很长时间(在 4 核台式机上可能需要 20 或 25 秒)。这种长时间的停顿不是 ParallelAccelerator 编译你的 @acc 注解代码所花费的时间;而是 Julia 编译 ParallelAccelerator 本身所花费的时间。在这段初始停顿之后,第一次调用 @acc 注解函数将产生短暂的编译停顿(这次来自 ParallelAccelerator 编译器,而不是 Julia 本身),可能需要几秒钟。对同一个函数的后续调用将不会产生编译停顿。

让我们看看这些编译停顿在实践中是什么样子的。ParallelAccelerator 包附带了一些 示例程序,它们打印计时信息,包括本帖中显示的 Black-Scholes高斯模糊 示例。所有示例都打印了对 @acc 注解函数的两个调用的计时信息:第一个是使用微不足道的参数的 “预热” 调用,用于测量编译时间;第二个是更现实的调用。在每个示例打印的输出中,更现实的调用的计时信息以字符串 "SELFTIMED" 为前缀,而预热调用的计时信息以 "SELFPRIMED" 为前缀。让我们运行 Black-Scholes 示例并使用 time shell 命令对它计时

$ time julia ParallelAccelerator/examples/black-scholes/black-scholes.jl
iterations = 10000000
SELFPRIMED 1.766323497
checksum: 2.0954821257116848e8
rate = 1.9205394841503927e8 opts/sec
SELFTIMED 0.052068703

real	0m26.454s
user	0m31.027s
sys	0m0.874s

这里,我们在 4 核台式机上对 Black-Scholes 运行了 10,000,000 次迭代。26.454 秒的总墙钟时间主要包括 using ParallelAccelerator 运行所花费的时间。一旦完成,Julia 报告了大约 1.8 秒的 SELFPRIMED 时间,这主要由 ParallelAccelerator 编译 @acc 注解代码所花费的时间决定,最后,对于该问题大小,SELFTIMED 时间约为 0.05 秒。

随着 Julia 编译速度的提高,我们预计包加载时间将不再是 ParallelAccelerator 的问题。

编译器限制

ParallelAccelerator 只能处理 Julia 语言功能的一个有限子集,并且它只支持 Julia 的 `Base` 库函数的一个有限子集。换句话说,您还不能将 `@acc` 注解放到任意的 Julia 代码上,并期望它能立即变得更快。本文中的示例展示了目前支持哪些类型的程序;欲了解更多信息,请查看 ParallelAccelerator 示例的完整集合。但是,如果 ParallelAccelerator 无法编译 `@acc` 注解函数中的某些代码,它将简单地回退到在普通 Julia 下运行该函数。因此,无论 ParallelAccelerator 是否能加速您的代码,您的代码都将运行。

`@acc` 注解函数可能无法编译的一个原因是,ParallelAccelerator 尝试递归地编译 `@acc` 注解函数调用的所有 Julia 函数。因此,如果 `@acc` 注解函数进行多次 Julia 库调用,ParallelAccelerator 将尝试编译这些函数,以及它们调用的所有 Julia 函数等等。如果调用链中的任何代码包含 ParallelAccelerator 目前不支持的功能,ParallelAccelerator 将无法编译原始的 `@acc` 注解函数。因此,最好从用 `@acc` 注解小型(但代价高昂)的计算内核开始,而不是将整个程序包装在 `@acc` 块中。ParallelAccelerator 文档 中提供了更多关于我们不支持哪些 Julia 功能以及原因的详细信息。

这些限制解释了为什么 ParallelAccelerator 提供的性能改进并非 Julia 的默认设置。支持所有 Julia 将是一项重大的工作;然而,在许多情况下,ParallelAccelerator 无法支持特定 Julia 功能或 `Base` 中的函数并没有根本性的原因,支持它只是需要意识到它对用户来说是一个问题,并投入必要的工程力量来解决它。因此,当您遇到 ParallelAccelerator 无法处理的代码时,请 提交错误报告

结论

在本文中,我们介绍了 ParallelAccelerator.jl,这是一个用于加速数组风格的 Julia 程序的包。它通过识别源代码中的隐式并行模式,并将它们编译成高效的显式并行可执行文件来工作,在此过程中去除了许多高级数组风格编程的常见开销。

ParallelAccelerator 是一个处于早期阶段的开源项目,我们热烈鼓励来自 Julia 社区的评论、问题、错误报告 和贡献。我们欢迎所有人的参与,我们尤其感兴趣 ParallelAccelerator 如何用于加速真实世界的 Julia 程序。

[1] 从 Julia 0.5 开始,Julia 将拥有自己的原生线程支持,这意味着 ParallelAccelerator 可以针对 Julia 的原生线程,而不是为并行生成 C++ OpenMP 代码。我们已经开始着手实现一个基于原生线程的后端用于 ParallelAccelerator,但我们目前默认仍以 C++ 为目标。

[2] 详细的机器和基准测试规格:我们使用一台搭载两个英特尔至强 E5-2699 v3 处理器(2.3 GHz),每个处理器拥有 18 个物理核心,以及 128 GB 内存的机器,运行 CentOS 6.7 Linux 发行版。我们使用英特尔 C++ 编译器 (ICC) v15.0.2,带有“-O3”选项来编译生成的 C++ 代码。Julia 版本为 0.4.4-pre+26。显示的结果是三次运行的平均值(我们对每个版本的基准测试运行五次,并丢弃第一次和最后一次运行)。

[3] 在 Julia 中,不可能在推导的输出数组的正文中索引该数组。(`avg` 示例只索引输入数组,而不是输出数组。)因此,不需要对写入输出数组进行任何边界检查。但是,我们仍然需要对从输入数组的读取进行边界检查(例如,在 `avg` 示例中,如果我们写了 `0.25*x[i-2]`,那将越界),因此我们无法像对映射操作那样避免推导中所有数组边界检查。

[4] 在实践中,我们可能不会对图像应用连续的高斯模糊,而是应用一个更大范围的高斯模糊,正如 维基百科所述,这种方式在计算效率方面至少与连续应用高斯模糊相同。但是,我们将在这里使用它作为可以迭代的模板计算的示例。

[5] 高斯模糊的更复杂实现可能会执行更精细的边界处理方式,只使用边界处可用的像素。

[6] “域 AST” 和 “并行 AST” 的命名灵感来自 Delite 编译器框架 中的域 IR 和并行 IR。