Julia vs MATLAB:为什么我的Julia代码运行得这么慢?

5

我刚开始学习Julia,将我的MATLAB代码逐行翻译成了Julia。我发现Julia代码要慢得多(大约50倍)。原始问题是一个动态规划问题,在其中我插值值函数--插值是代码花费大部分时间的地方。因此,我尝试制作一个最小化的示例代码,展示性能差异。需要注意的重要事项是,这是插值的样条逼近,并且网格最好是不规则的,即不等间距的。MATLAB代码:

tic
spacing=1.5;
Nxx = 300; 
Naa = 350;
Nalal = 200; 
sigma = 10;
NoIter = 500;

xx=NaN(Nxx,1);
xmin = 0.01;
xmax = 400;
xx(1) = xmin;
for i=2:Nxx
    xx(i) = xx(i-1) + (xmax-xx(i-1))/((Nxx-i+1)^spacing);
end

f_U = @(c) c.^(1-sigma)/(1-sigma);  
W=NaN(Nxx,1);
W(:,1) = f_U(xx);

xprime = ones(Nalal,Naa);
for i=1:NoIter
     W_temp = interp1(xx,W(:,1),xprime,'spline');
end
toc

Elapsed time is 0.242288 seconds.

Julia代码:

using Dierckx
function performance()

const spacing=1.5 
const Nxx = 300 
const Naa = 350
const Nalal = 200 
const sigma = 10 
const NoIter = 500 

xx=Array(Float64,Nxx)
xmin = 0.01
xmax = 400
xx[1] = xmin
for i=2:Nxx
    xx[i] = xx[i-1] + (xmax-xx[i-1])/((Nxx-i+1)^spacing)
end

f_U(c) =  c.^(1-sigma)/(1-sigma)
W=Array(Float64,Nxx,1)
W[:,1] = f_U(xx)

W_temp = Array(Float64,Nalal,Naa)

interp_spline = Spline1D(xx,W[:,1])
xprime = ones(Nalal,Naa)
for i=1:NoIter
    for j=1:Naa
         for k=1:Nalal
         W_temp[k,j] = evaluate(interp_spline, xprime[k,j])
         end
    end
end

end

@time(performance())
4.200732 seconds (70.02 M allocations: 5.217 GB, 4.35% gc time)

我将W定义为2维数组,因为在原问题中它是一个矩阵。我研究了不同的插值包,但对于不规则网格和样条线选项不多。MATLAB的interp1显然不可用。
我的问题是,如果我编写Julia代码并且它与MATLAB给出相同的结果,那么Julia应该通常更快。但显然情况并非如此,所以您需要在编码时留意一些细节。虽然我不是程序员,但我当然会尽力而为,但我想知道我是否在这里犯了一些明显的错误,这些错误可以轻松修复,或者是否经常需要注意我的Julia编码 - 因为那样的话,学习它可能不值得。同样,如果我可以使Julia更快(我相当确定我可以,例如分配看起来有点大),我也可以使MATLAB更快。我对Julia的希望是,对于类似的代码,它将比MATLAB运行得更快。
在一些关于时间的评论之后,我还运行了此代码:
using Dierckx

tic()
const spacing=1.5 
const Nxx = 300 
const Naa = 350
const Nalal = 200 
const sigma = 10 
const NoIter = 500 

xx=Array(Float64,Nxx)
xmin = 0.01
xmax = 400
xx[1] = xmin
for i=2:Nxx
     xx[i] = xx[i-1] + (xmax-xx[i-1])/((Nxx-i+1)^spacing)
end

f_U(c) =  c.^(1-sigma)/(1-sigma)
W=Array(Float64,Nxx,1)
W[:,1] = f_U(xx)

W_temp = Array(Float64,Nalal,Naa)

interp_spline = Spline1D(xx,W[:,1])
xprime = ones(Nalal,Naa)
for i=1:NoIter
    for j=1:Naa
         for k=1:Nalal
         W_temp[k,j] = evaluate(interp_spline, xprime[k,j])
         end
     end
 end
 toc()

elapsed time: 
7.336371495 seconds

即使速度慢了很多,嗯...

另一个编辑:在这种情况下消除一个循环实际上会使它更快,但仍无法与MATLAB相比。 代码:

function performance2()

const spacing=1.5 
const Nxx = 300 
const Naa = 350
const Nalal = 200 
const sigma = 10 
const NoIter = 500 

xx=Array(Float64,Nxx)
xmin = 0.01
xmax = 400
xx[1] = xmin
for i=2:Nxx
    xx[i] = xx[i-1] + (xmax-xx[i-1])/((Nxx-i+1)^spacing)
end

f_U(c) =  c.^(1-sigma)/(1-sigma)
W=Array(Float64,Nxx,1)
W[:,1] = f_U(xx)

W_temp = Array(Float64,Nalal,Naa)

interp_spline = Spline1D(xx,W[:,1])
xprime = ones(Nalal,Naa)
for i=1:NoIter
    for j=1:Naa
         W_temp[:,j] = evaluate(interp_spline, xprime[:,j])
    end
 end

 end

@time(performance2())
1.573347 seconds (700.04 k allocations: 620.643 MB, 1.08% gc time)

另一个编辑,现在通过循环迭代相同的次数:

function performance3()

const spacing=1.5 
const Nxx = 300 
const Naa = 350
const Nalal = 200 
const sigma = 10 
const NoIter = 500 

xx=Array(Float64,Nxx)
xmin = 0.01
xmax = 400
xx[1] = xmin
for i=2:Nxx
    xx[i] = xx[i-1] + (xmax-xx[i-1])/((Nxx-i+1)^spacing)
end

f_U(c) =  c.^(1-sigma)/(1-sigma)
W=Array(Float64,Nxx,1)
W[:,1] = f_U(xx)

W_temp = Array(Float64,Nalal,Naa)
W_tempr = Array(Float64, Nalal*Naa)

interp_spline = Spline1D(xx,W[:,1])
xprime = ones(Nalal,Naa)
xprimer = reshape(xprime, Nalal*Naa)

for i=1:NoIter
        W_tempr = evaluate(interp_spline, xprimer)
end

W_temp = reshape(W_tempr, Nalal, Naa)
end

tic()
performance3()
toc()

elapsed time: 
1.480349334 seconds

然而,与MATLAB相比并不可比。顺便说一下,在我的实际问题中,我轻松地运行了5万次循环,并访问更大的xprime矩阵,尽管不确定那部分是否有影响。


2
我在Julia代码中看到了三重循环,而在Matlab中只有一个循环。这引起了我的注意:)通常在高级高性能语言中避免使用循环是一件好事情。 - Andras Deak -- Слава Україні
5
显而易见的错误之一是你的Julia代码将编译和执行代码的时间计算在内。 - user2271770
2
@marky2k,谢谢你提供的链接,很有趣的功能。我将和Adriaan一样,不再对Julia做进一步的假设:) - Andras Deak -- Слава Україні
1
运行时仍然不可比较,因为您在Matlab中使用样条NoIter次进行评估,在Julia中使用NoIter * Naa次。消除一个循环使它更快,因为您做了更少的工作。在得出任何结论之前,请使算法相同。 - Isaiah Norton
2
这是另一个例子,Matlab教给你的不仅仅是一门编程语言。 - percusse
显示剩余18条评论
1个回答

15

因为我也在学习Julia,所以我尝试了可能加速OP代码的方法(为了我的练习!)。看起来瓶颈实际上是底层Fortran代码。为了验证这一点,我首先将OP的代码重写为最小形式,如下:

using Dierckx

function perf()

    Nx = 300 

    xinp = Float64[ 2pi * i / Nx for i = 1:Nx ]
    yinp = sin( xinp )

    interp = Spline1D( xinp, yinp )

    Nsample = 200 * 350

    x = ones( Nsample ) * pi / 6
    y = zeros( Nsample )

    for i = 1:500
        y[:] = evaluate( interp, x )
    end

    @show y[1:3]  # The result should be 0.5 (= sin(pi/6))
end

@time perf()
@time perf()
@time perf()

问题的大小保持不变,而输入的x和y坐标被更改,以便结果显而易见(0.5)。在我的机器上,结果是

y[1:3] = [0.49999999999999994,0.49999999999999994,0.49999999999999994]
  1.886956 seconds (174.20 k allocations: 277.414 MB, 3.55% gc time)
y[1:3] = [0.49999999999999994,0.49999999999999994,0.49999999999999994]
  1.659290 seconds (1.56 k allocations: 269.295 MB, 0.39% gc time)
y[1:3] = [0.49999999999999994,0.49999999999999994,0.49999999999999994]
  1.648145 seconds (1.56 k allocations: 269.295 MB, 0.28% gc time)

为了简洁起见,我将从现在开始省略y[1:3] (我已经确认在所有情况下所得到的y[1:3]都是正确的)。如果我们用copy!(y,x)替换evaluate(),结果会变成:

  0.206723 seconds (168.26 k allocations: 10.137 MB, 10.27% gc time)
  0.023068 seconds (60 allocations: 2.198 MB)
  0.023013 seconds (60 allocations: 2.198 MB)

基本上所有的时间都花在了evaluate()上。现在看看这个例程的原始代码,我们可以看到它调用了Fortran中的splev(),而这个函数又调用了fpbspl()(两者最初来自于Netlib)。这些例程相当古老(编写于约1990年),似乎并不适合当前的计算机优化(例如,有很多IF分支,向量化可能很困难...)。因为代码的“向量化”不是一件轻松的事情,所以我尝试使用OpenMP进行并行化。修改后的splev()如下,其中输入点被简单地分成了线程:
      subroutine splev(t,n,c,k,x,y,m,e,ier)
c  subroutine splev evaluates in a number of points x(i),i=1,2,...,m
c  a spline s(x) of degree k, given in its b-spline representation.
(... same here ...)

c  main loop for the different points.
c$omp parallel do default(shared)
c$omp.firstprivate(arg,ier,l1,l,ll,sp,h) private(i,j)
      do i = 1, m

c  fetch a new x-value arg.
        arg = x(i)
c  check if arg is in the support
        if (arg .lt. tb .or. arg .gt. te) then
            if (e .eq. 0) then
                goto 35
            else if (e .eq. 1) then
                y(i) = 0
                goto 80
            else if (e .eq. 2) then
                ier = 1
                ! goto 100        !! I skipped this error handling for simplicity.
            else if (e .eq. 3) then
                if (arg .lt. tb) then
                    arg = tb
                else
                    arg = te
                endif
            endif
        endif

c  search for knot interval t(l) <= arg < t(l+1)
 35     if ( t(l) <= arg .or. l1 == k2 ) go to 40
        l1 = l
        l = l - 1
        go to 35
  40    if ( arg < t(l1) .or. l == nk1 ) go to 50
        l = l1
        l1 = l + 1
        go to 40

c  evaluate the non-zero b-splines at arg.
  50    call fpbspl(t, n, k, arg, l, h)

c  find the value of s(x) at x=arg.
        sp = 0.0d0
        ll = l - k1

        do 60 j = 1, k1
          ll = ll + 1
          sp = sp + c(ll)*h(j)
  60    continue
        y(i) = sp

 80     continue

      enddo
c$omp end parallel do
 100  return
      end

现在使用 gfortran -fopenmp 重新构建包,并在上面调用 perf(),会得到以下结果:

$ OMP_NUM_THREADS=1 julia interp.jl
  1.911112 seconds (174.20 k allocations: 277.414 MB, 3.49% gc time)
  1.599154 seconds (1.56 k allocations: 269.295 MB, 0.41% gc time)
  1.671308 seconds (1.56 k allocations: 269.295 MB, 0.28% gc time)

$ OMP_NUM_THREADS=2 julia interp.jl
  1.308713 seconds (174.20 k allocations: 277.414 MB, 5.14% gc time)
  0.874616 seconds (1.56 k allocations: 269.295 MB, 0.46% gc time)
  0.897505 seconds (1.56 k allocations: 269.295 MB, 0.21% gc time)

$ OMP_NUM_THREADS=4 julia interp.jl
  0.749203 seconds (174.20 k allocations: 277.414 MB, 9.31% gc time)
  0.446702 seconds (1.56 k allocations: 269.295 MB, 0.93% gc time)
  0.439522 seconds (1.56 k allocations: 269.295 MB, 0.43% gc time)

$ OMP_NUM_THREADS=8 julia interp.jl
  0.478504 seconds (174.20 k allocations: 277.414 MB, 14.66% gc time)
  0.243258 seconds (1.56 k allocations: 269.295 MB, 1.81% gc time)
  0.233157 seconds (1.56 k allocations: 269.295 MB, 0.89% gc time)

$ OMP_NUM_THREADS=16 julia interp.jl
  0.379243 seconds (174.20 k allocations: 277.414 MB, 19.02% gc time)
  0.129145 seconds (1.56 k allocations: 269.295 MB, 3.49% gc time)
  0.124497 seconds (1.56 k allocations: 269.295 MB, 1.80% gc time)

# Julia: v0.4.0, Machine: Linux x86_64 (2.6GHz, Xeon2.60GHz, 16 cores)

因此,缩放似乎非常容易(但如果我在使用OpenMP方面犯了大错误,请让我知道...)。如果上述结果是正确的,则意味着使用这个Fortran代码需要8个线程才能匹配OP机器上interp1()的速度。但好消息是,即使是串行运行,Fortran代码也可能有改进的空间。无论如何,OP的程序(在最终形式中)似乎正在比较基础插值例程的性能,即Matlab中的interp1()与Fortran中的splev()


这是一个很好的答案。所以事实上我比较的不是Julia,而是多线程与单线程(苹果与橙子)!很高兴知道这点。感谢您的努力! - marky2k
虽然我专注于Fortran部分,但我真的认为Ismael的第一个编辑部分很有帮助,所以让我们记住这些要点...(我也在阅读手册,但是它的篇幅相当大!) - roygvib
@roygvib 这非常有趣。由于您似乎在Fortran方面相当熟练,因此也许由于Dierckx例程已经存在数十年,并且遵循古老的惯用语 - -fPIC -O3可能不足以使gfortran挤出每一个指令级并行性?而且我猜我的时间观察很可能是巧合:如果内置函数支持多线程,则Matlab文档将说明 - 警告开发人员潜在的线程不安全代码?无论如何,看到Julia的首选插值例程可能更快还是有点令人失望的。 - luffe
1
@luffe 我不是这方面的专家,但你尝试过除了 Dierckx 之外的这些包吗?GridInterpolations, ApproXD, Interpolations, Grid,还可以参考 Julia 的问题 #2557。Julia 还很年轻,给它时间! :D - HarmonicaMuse

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