【问题标题】:Julia code optimization : is this the time to use SIMD?Julia 代码优化:现在是使用 SIMD 的时候了吗?
【发布时间】:2021-08-23 16:38:19
【问题描述】:

我正在努力优化我的 Julia 代码并使其运行得更快。

我提取了整个代码的一部分,我希望您评估是否存在一些瓶颈,使流程比优化的流程慢。

代码简要说明:

  • array1 n x n 数组,用randn 随机条目初始化
  • array1 将在循环中根据某种算法更新,直到array1 的所有条目都大于-0.8
  • 如果循环未在 100000 次循环内结束,则循环强制结束。

using Random;

function Test()
  
    n=5
    sqn = n^2
    foo1 = circshift(collect(1:n)', (0, 1))
    foo2 = circshift(collect(1:n)', (0, -1))
    foo3 = circshift(collect(1:n), 1)
    foo4 = circshift(collect(1:n), -1)


    # initialize array1 with random entries
    array1 = randn(n,n);
    # initialize array2 with zeros
    array2 = zeros(n,n);

    #println(array1);

    loop = 0
  
    while minimum(array1) < -0.8 && loop < 100000
                loop += 1
                bar = zeros(n, n)
    
                # Use simd?
                for i = 1:sqn
                    bar[i] = max(0, array1[i] - 0.2)
                end
    
                array2 += bar
                
                adding = zeros(n, n)
                # Use simd?
                for i = 1:n
                    for j = 1:n
                        adding[i, j] =
                            (1 / 4) * sum([
                                bar[i, foo1[j]],
                                bar[i, foo2[j]],
                                bar[foo3[i], j],
                                bar[foo4[i], j],
                            ])
                    end
                end
                array1 = array1 - bar + adding
            end
   # println(array1)
   # println(array2)
   # println(loop)

end
Test()


我想我可以在某些for statement 中使用@simd 来节省时间。

有没有更好的写法或更好的算法? 如果您有除SIMD 以外的任何有用信息,我很乐意听到。

任何信息将不胜感激。

【问题讨论】:

  • SIMD 指令对bar[i] = max(0, array1[i] - 0.2) 有很大帮助,如果编译器没有已经为您自动矢量化。 (你希望如此;矢量化相对容易)。理想情况下,还将array2 += bar 工作折叠到该循环中,使用bar 的每个向量作为它的产生。所以 IDK 一个典型的 Julia 编译器是否需要任何帮助来向量化它。如果编译器看到每个元素都写在循环内,adding = zeros(n, n) 初始化有望优化掉。
  • sum() 部分更难:我不了解 Julia,但我认为这是使用一个数组的元素来索引另一个数组,在这种情况下,您需要“收集”指令( AVX2 / AVX -512,或者我认为是 ARM SVE)将向量的元素用作内存中的单独索引。 (如果不这样做,您可以对 bar[foo3[i], j]bar[foo4[i], j] 部分进行矢量化,因为它们都是连续 j 的连续内存。)
  • 您应该做的第一件事是避免在循环中分配,尤其是在内部循环中。 sum([bar[i, foo1[j]], ... 每次创建一个 4 元素数组,它应该只是 bar[i, foo1[j]] + ...。然后在循环外定义一次baradding,并就地更新它们:@. bar = max(0, array1 - 0.2)array2 .+= bar@. array1 = array1 - bar + adding。你可能还想要@inbounds adding[i, j] = ...,这比原来的速度快 10 倍,对我来说。
  • 你也可以写bar[i, mod1(j+1, n)]之类的东西来避免foo1等。它更简单,似乎更快一点。由于mod 不是免费的,您可能会考虑使所有数组略大于所需的数组,以便始终安全地索引bar[i, j+1]。或者在大多数i in 2:n-1, j in 2:n-1(不需要mod)的循环中分别迭代边缘(需要mod)。
  • @ten:如果数组的所有部分都在同一点环绕,则将内部循环分成几部分:循环一个连续的部分,然后在另一部分上执行单独的循环。因此,您在任何循环中都不会有任何条件内容,并且您不需要相同数据的冗余副本,这些副本会花费更多的内存带宽/缓存占用空间。 (顺便说一句,“紧固”的意思是“将两个东西固定在一起”,例如用胶水或螺丝钉。“加速”或“加速”确实意味着“加快速度”。)

标签: arrays algorithm optimization julia simd


【解决方案1】:

是的,尤其是使用 LoopVectorization.jl 时——尽管在进入限制因素之前,我们必须先进行一些其他优化。

首先,使用BenchmarkTools.jl在我的系统(一台旧笔记本电脑)上对您的代码进行一些初始计时

julia> using BenchmarkTools

julia> @benchmark Test()
BechmarkTools.Trial: 31 samples with 1 evaluations.
 Range (min … max):    5.987 μs … 257.525 ms  ┊ GC (min … max):  0.00% … 22.07%
 Time  (median):     234.396 ms               ┊ GC (median):    16.67%
 Time  (mean ± σ):   162.310 ms ± 114.023 ms  ┊ GC (mean ± σ):  18.44% ±  8.84%

  █                                                        ▂     
  █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅█▅██▁▃ ▁
  5.99 μs          Histogram: frequency by time          258 ms <

 Memory estimate: 9.84 KiB, allocs estimate: 70.

在循环仅在几十次评估中短路的情况与在整个 100000 次评估中发生短路的情况之间似乎存在一些明显的双峰性。顺便说一句,这是一个典型的例子,说明为什么尽管有一些传统智慧,但最小时间并不是一个很好的基准测试指标,无论是均值还是(甚至更好)完整的直方图都更好——所以来自BenchmarkTools.jlBenchmarkHistograms.jl 的直方图很棒。

让我们从一些常规优化开始(减少不必要的分配等)

function test()

    n=5
    sqn = n^2
    r = collect(1:n)
    foo1 = circshift(r', (0, 1))
    foo2 = circshift(r', (0, -1))
    foo3 = circshift(r, 1)
    foo4 = circshift(r, -1)


    # initialize array1 with random entries
    array1 = randn(n,n);
    # initialize array2 with zeros
    array2 = zeros(n,n);
    # Initialize temporary arrays used in loops
    bar = zeros(n, n)
    adding = zeros(n, n)

    loop = 0
    while minimum(array1) < -0.8 && loop < 100000
                loop += 1
                fill!(bar, 0)

                # Use simd?
                @inbounds for i = 1:sqn
                    bar[i] = max(0, array1[i] - 0.2)
                end

                @. array2 += bar

                fill!(adding, 0)
                # Use simd?
                @inbounds for i = 1:n
                    for j = 1:n
                        s = bar[i, foo1[j]] + bar[i, foo2[j]] + bar[foo3[i], j] + bar[foo4[i], j]
                        adding[i, j] = 0.25 * s
                    end
                end
                @. array1 = array1 - bar + adding
            end
   return array1, array2, loop
end

如您所见,我将baradding 的分配移到了循环之外——因为它们不会改变大小,我们可以只分配一次,然后在必要时用零重新填充。另一个更改涉及在添加和减去数组时不必要的分配分配。例如,原来的array1 = array1 - bar + adding 是分配的,但是@. array1 = array1 - bar + adding 只是array1 .= array1 .- bar .+ adding 的一种方便的简写,明确表示我们需要逐元素操作,并确保它们都发生在原地(即,没有触发新的堆分配)。我还在for 循环上放了一个@inbounds,并稍微重构了最内层循环中的求和(虽然sum 很快,但为其分配一个全新的数组以在循环的每次迭代中累加是不是,如 cmets 中所述)。

这似乎给了我们大约 10 倍的平均加速,并大大减少了我们的分配和内存使用。

julia> @benchmark test()
BechmarkTools.Trial: 305 samples with 1 evaluations.
 Range (min … max):   1.622 μs … 30.093 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     22.438 ms              ┊ GC (median):    0.00%
 Time  (mean ± σ):   16.429 ms ± 11.530 ms  ┊ GC (mean ± σ):  0.00% ± 0.00%

  █                                           ▁                
  █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▇▄▃▃▄▃▅▅▂▃▃▂▃▂ ▂
  1.62 μs         Histogram: frequency by time        29.4 ms <

 Memory estimate: 1.75 KiB, allocs estimate: 9.

现在是 SIMD。事实证明,仅仅在 for 循环前面添加 @inbounds @simd 并没有出现在时间上,以启用 Julia 的编译器尚未找到的任何优化。 LoopVectorization@turbo 可以,但是:

using Random, LoopVectorization

function test_simd_turbo()

    n=5
    sqn = n^2
    r = collect(1:n)
    foo1 = circshift(r', (0, 1))
    foo2 = circshift(r', (0, -1))
    foo3 = circshift(r, 1)
    foo4 = circshift(r, -1)


    # initialize array1 with random entries
    array1 = randn(n,n);
    # initialize array2 with zeros
    array2 = zeros(n,n);
    # Initialize temporary arrays used in loops
    bar = zeros(n, n)
    adding = zeros(n, n)

    loop = 0
    while minimum(array1) < -0.8 && loop < 100000
                loop += 1
                fill!(bar, 0)

                # Use simd?
                @turbo for i = 1:sqn
                    bar[i] = max(0, array1[i] - 0.2)
                end

                @turbo @. array2 += bar

                fill!(adding, 0)
                # Use simd?
                @turbo for i = 1:n
                    for j = 1:n
                        s = bar[i, foo1[j]] + bar[i, foo2[j]] + bar[foo3[i], j] + bar[foo4[i], j]
                        adding[i, j] = 0.25 * s
                    end
                end
                @turbo @. array1 = array1 - bar + adding
            end
   return array1, array2, loop
end

请注意,您可以使用@turbofor 循环和@. 广播进行SIMD 矢量化,如此处所示。

这使我们的速度提高了大约 1.5 倍,总共比原始实现提高了近 15 倍

julia> @benchmark test_simd_turbo()
BechmarkTools.Trial: 419 samples with 1 evaluations.
 Range (min … max):   1.864 μs … 28.059 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     15.486 ms              ┊ GC (median):    0.00%
 Time  (mean ± σ):   11.946 ms ±  8.034 ms  ┊ GC (mean ± σ):  0.00% ± 0.00%

  █                                                            
  █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▇▇▆▅▄▄▃▄▄▄▃▃▂▁▂▁▁▁▂▁▁▁▁▁▁▃ ▂
  1.86 μs         Histogram: frequency by time        26.3 ms <

 Memory estimate: 1.75 KiB, allocs estimate: 9.

在具有AVX512 向量寄存器的系统上,LoopVectorization.jl 的优势可能会更大(我在这台笔记本电脑上只有 AVX2)。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2019-05-29
    • 2015-12-22
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多