在Julia中,对数组求和比逐个对变量求和要慢。

5

好的,最近我在进行一系列测试。我有一个MC模拟,在其中有几个变量(20个),将它们全部放入一维数组中会让阅读变得更容易。

但我遇到了一个问题,我需要在每次迭代中对变量求和,而且这个模拟要运行很多次迭代,所以我遇到了这个问题(缩小为7个变量):

function sumtest1(N)
    s=0.0
    a=1.0
    b=2.0
    c=3.0
    d=4.0
    e=5.0
    f=6.0
    g=7.0
    for i = 1:N
        s += (a+b+c+d+e+f+g)
    end
    return s
end

function sumtest2(N)
    s=0.0
    A=[1.0,2.0,3.0,4.0,5.0,6.0,7.0]
    for i = 1:N
        s += sum(A)
    end
    return s
end

@time sumtest1(1_000_000_000)
elapsed time: 0.998272756 seconds (96 bytes allocated)

@time sumtest1(1_000_000_000)
elapsed time: 7.522198967 seconds (208 bytes allocated)

这是正常现象吗?还是我做错了什么?我真的很想将我的变量索引化,因为其他原因太长而无法解释,但这种性能损失是我无法接受的。

1个回答

11

我们来看一下正在执行sumtest1的LLVM代码:

julia> @code_llvm sumtest1(10^9)

define double @julia_sumtest1_21391(i64) {
top:
  %1 = icmp sgt i64 %0, 0
  %2 = select i1 %1, i64 %0, i64 0
  %3 = icmp eq i64 %2, 0
  br i1 %3, label %L3, label %L.preheader

L.preheader:                                      ; preds = %top
  %4 = icmp sgt i64 %0, 0
  %smax = select i1 %4, i64 %0, i64 0
  br label %L

L:                                                ; preds = %L, %L.preheader
  %lsr.iv = phi i64 [ %smax, %L.preheader ], [ %lsr.iv.next, %L ]
  %s.0 = phi double [ %5, %L ], [ 0.000000e+00, %L.preheader ]
  %5 = fadd double %s.0, 2.800000e+01
  %lsr.iv.next = add i64 %lsr.iv, -1
  %6 = icmp eq i64 %lsr.iv.next, 0
  br i1 %6, label %L3, label %L

L3:                                               ; preds = %L, %top
  %s.1 = phi double [ 0.000000e+00, %top ], [ %5, %L ]
  ret double %s.1
}

这可能很难理解,但在循环的主体中有一件事情格外突出,L

  %5 = fadd double %s.0, 2.800000e+01

每次迭代都会将预先计算的常数28.0加到累加器s中。编译器可以知道您从未更改任何局部变量,因此它知道每次都添加相同的总和。循环必须执行的唯一原因是重复浮点加法不完全等同于乘法。如果将所有局部变量更改为整数,其中重复加法完全等同于乘法,则循环将完全被消除:

julia> @time sumtest1_int(10^9)
  0.000005 seconds (6 allocations: 192 bytes)
28000000000

julia> @code_llvm sumtest1_int(10^9)

define i64 @julia_sumtest1_int_21472(i64) {
top:
  %1 = icmp slt i64 %0, 1
  br i1 %1, label %L3, label %L.preheader

L.preheader:                                      ; preds = %top
  %2 = icmp sgt i64 %0, 0
  %.op = mul i64 %0, 28
  %3 = select i1 %2, i64 %.op, i64 0
  br label %L3

L3:                                               ; preds = %L.preheader, %top
  %s.1 = phi i64 [ 0, %top ], [ %3, %L.preheader ]
  ret i64 %s.1
}

在 Julia 中,这大致翻译为:

sumtest1_int(N) = N < 1 ? 0 : ifelse(N > 0, N*28, 0)

那是有些冗余的,因为可以将代码简化为 ifelse(N > 1, N*28, 0) (也可以简化为只有 28N,因为不关心 N 的负值),但这仍然比执行循环要快得多。

sumtest2 函数无法被轻松地分析或优化。它需要证明数组 A 永远不会改变,这相当困难。所以编译器别无选择,必须做所有工作,这当然比不做更慢。在您的模拟中,使用局部变量仍可能比将值存储在数组中快,但也可能不是。您必须测量执行某些更难完全优化掉的代码才能确定。


谢谢您提供详细的答案,我从中学到了很多。我意识到在尝试简化函数以便在这里发布时,正如您所解释的那样,我将其轻描淡写了。实际情况是,在每次迭代中至少有1个(通常更多)变量会发生改变,没有一个是恒定不变的。如果是这种情况,情况会有所不同吗?我会进行测试,并稍后发布结果。 - epx
2
没问题。如果循环中发生任何稍微复杂的变化,编译器不太可能进行这种优化。局部变量仍然比数组中的插槽具有优势:数组需要在某个地方存储在内存中;而局部变量可以存在于CPU寄存器中。 - StefanKarpinski
是的,你说得对。我测试了一下,在循环内部仅生成一个随机数“r”,并将其加到第一个函数中的每个局部变量上,时间从1秒增加到6秒...这仍然比原来的第二个函数快,即使没有生成随机数...这有点让人困扰,因为我还想通过排序变量来提高其他计算的速度,但如果变量是局部的,我不知道该如何做。但没关系,我会继续学习。再次感谢回答。 - epx
您可以使用一小组明确的比较和交换来排序固定数量的本地变量。 - StefanKarpinski
@StefanKarpinski 我已经在几个 Julia 版本中尝试了 sumtest1,但都没有进行这种乘法优化(它们成功将循环每次缩减为加上 28.0)。你的 Julia 是否仍然可以实现此功能?如果可以,请问你使用的是哪个版本? - Dan Getz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接