整体比较
如果你是一名数据科学家,你很有可能使用Python或R编程。但是有一个叫Julia的新成员承诺在不影响数据科学家编写代码和与数据交互的情况下拥有c一样的性能。
我将R与Julia进行了比较,展示了Julia是如何为数据科学社区带来全新的编程思维方式的。主要的结论是,有了Julia,您不再需要向量化来提高性能,良好地使用循环可能会提供最好的性能。
在这篇文章中,我将添加Python对比。因为对于数据科学家来说我们使用任何算法最好有现成的实现可用,并且从对算法进行编程使用需要非常的简单。这都是我们需要编写高效代码时所必需的。
线性搜索测试
让我们考虑对未排序的整数向量进行隶属关系测试的问题。
julia>10∈ [71,38,10,65,38] truejulia>20∈ [71,38,10,65,38] false
这个问题可以通过线性搜索解决。该算法遍历输入向量的元素,直到找到要搜索的值(成功搜索)或到达向量的末尾(不成功搜索)为止。目的是判断向量中是否有给定的整数。
为了评估R,Python和Julia中的不同实现,我生成了一个数据集,该数据集包含1.000.000范围从1到2.000.000的唯一整数,并执行了1.000个从1到1.000的所有整数的搜索。搜索成功的可能性约为50%,因此算法将扫描整个向量的一半时间得出搜索不成功的结论。在其余情况下,算法应(平均)需要进行(n + 1)/ 2次评估才能找到元素,其中n为向量的长度。
我通过3次运行CPU时间中值来测量每个实现的性能。这些实验的目的不是为不同的语言和实现制定一个准确的基准。其目的是强调当性能很重要时,语言对数据科学家造成的障碍。
C实现
我用C实现了线性搜索,以了解静态类型编译语言的性能,并设置基线。二进制可执行文件执行1.000搜索花费了0.26秒CPU时间。
// 0.26 secondsintfor_search(constint*vec, intsize, intx) { for (inti=0;i<size;i++) if (vec[i] ==x) return1; return0; }
R实现
我尝试了R中不同风格的测试,从专用操作符(in)到使用循环的类c实现,通过向量化方法。
#0.93secondsin_search<-function(vec, x) x%in%vec#2.68secondsvec_search<-function(vec, x) any(x==vec) #13.33secondsforeach_search<-function(vec, x) { for (vinvec) if (v==x) return (TRUE) FALSE} #21.94secondsfor_search<-function(vec, x) { for (iin1:length(vec)) if (vec[i] ==x) return (TRUE) FALSE}
当我们从in_search移动到for_search时,我们得到了对算法的更高控制。但是在R中,随着控制的增加,性能会下降。使用向量化操作(如vec_search)比遍历元素直到找到匹配的元素要快一个数量级。尽管向量化需要更多的内存和(冗余的)操作,但它还是有回报的。正如预期的那样,其中的专用运算符具有最高的性能和更清晰的代码。
我也尝试了Map-Reduce操作,但没有耐心等到它们完成……如果你追求性能,这不是一个好的方式。
Python实现
说实话,最初的目标是只使用原生函数和原生数据结构,但当使用Python的原生列表时,in操作符比R慢了约10倍。因此,我还特意测试了NumPy数组的结果(它给Python带来了向量化的操作)。CPU时间从9.13秒减少到0.57秒,大约是基准时间的2倍。然而,当转向循环方法时,原生领先了一个数量级……通过使用Numba包添加JIT编译,我给了NumPy第二次机会。Numba有一些限制,但是使用起来很简单:您只需要包含Numba包并标记希望看到已编译JIT的函数(并仔细阅读手册)。使用NumPy + Numba的循环提供了与向量化/专门操作相当(或更好)的性能,但要达到这一点并不容易,因为其中存在一些问题。例如使用Numba在本地列表上执行循环是令人失望的……我再次停止执行,因为要花5分钟才能完成。
importnumpyasnpfromnumbaimportjit#9.13secondswhenvecisanativelist#0.57secondswhenvecisanumpyarraydefin_search(vec, x): returnxinvec#0.57secondswhenvecisanumpyarraydefvec_search(vec, x): returnnp.any(vec==x) #18.70secondswhenvecisanativelist#121.48secondswhenvecisanumpyarray#0.47secondswhenvecisanumpyarraywithnumbadefforeach_search(vec, x): forvinvec: ifv==x: returnTruereturnFalse#40.75secondswhenvecisanativelist#161.36secondswhenvecisanumpyarray#0.55secondswhenvecisanumpyarraywithnumbadeffor_search(vec, x): foriinrange(len(vec)): ifvec[i] ==x: returnTruereturnFalse
Julia实现
在Julia中,我添加了另外两种风格,以展示本地可用功能的多样性和性能。
#0.37secondsin_search(vec, x) =xinvec#0.32secondsff_search(vec, x) =findfirst(el->el==x, vec) !=nothing#0.38secondsmapr_search(vec, x) =mapreduce(el->el==x, |, vec) #1.10secondsvec_search(vec, x) =any(vec .==x) #0.33secondsfunctionforeach_search(vec, x) forvinvecifv==xreturntrueendendfalseend#0.32secondsfunctionfor_search(vec, x) fori=1:length(vec) ifvec[i] ==xreturntrueendendfalseend
除了向量化操作之外,性能与C语言的实现非常接近,仅下降了20%-50%。向量化的性能相当不错,大约是4x C的CPU时间,但在向量化操作上,也减少了大约NumPy的两倍CPU时间。并且对于代码的自由度也非常的好,因为你可以在Julia中编写几乎任何算法!为了在For循环上获得最佳性能,我使用提示告诉编译器不要检查索引是否在数组范围内(inbounds宏),并告诉编译器它在执行迭代的顺序上有额外的自由度(simd宏)。因为不提供这些提示将使循环的性能接近in_search。
整体比较
通过对这个简单问题的结果进行对比,我们发现:
- 在执行方面,Julia的性能几乎与C相当;
- Julia的例外是在编写类似R的矢量化代码时,性能下降了大约3倍。
- 在将JIT编译(Numba)添加到Python时,基于循环的实现接近于Julia的性能。Numba仍然在您的Python代码上施加了约束,这使该选项成为一种折衷;
- 在Python中,最好在原生列表和NumPy数组之间以及何时使用Numba之间进行选择:对于经验不足的人来说,最好的数据结构(性能方面)并不明显,也没有明显的赢家尤其是如果包括了动态添加元素的情况(此处未介绍);
- R不是最快的,但是跟Python差不多:R中最慢的实现比最快的实现慢约24倍,而Python的实现是343x(Julia的3倍多);
- 原生 R总是比原生Python更好。
- 每当您无法避免在Python或R中循环时,基于元素的循环比基于索引的循环更有效。
细节很重要
我可以在这里停止本文,并写出在Julia中编写高效代码的无缝性。尽管如此,细节仍然很重要,程序员需要注意Julia的内部构造。您能猜出最能影响性能的代码行是什么?这是一个提示:您不会在之前提供的任何代码段中找到它…
map(line -> parse(Int, line), eachline(f))
这行代码解析输入文本文件f,该文件每行包含一个数字(请注意,读取文件不属于基准测试的一部分)。那么,这一行代码有何特别之处?简而言之,Julia 的推断:
匿名函数的返回类型(map的第一个参数)(总是)是整数,因此,映射的输出是一个整数数组。
由于Julia知道正在存储整数数组,因此它会分配一个连续的内存块,其中每个项都包含一个整数。这允许有效的读取操作。
我们看看下面的代码:
a= [] forlineineachline(f) push!(a, parse(Int, line)) end
理论上应该是一样的,对吧, 但是:
>typeof(a) Array{Any,1}
句子a = []看起来很方便,它创建了一个Any数组,这意味着可以在该数组的每个元素上存储任何类型的数据。在内部,Julia在内存中存储了一个指针数组,以配合Any提供的灵活性。结果,Julia在处理数组时无法再处理连续的连续内存块。对性能有什么影响?慢大约50到100倍!
修改此代码非常简单:a = Int [](而不是a = [])将完成此工作,因为它指定了元素的类型。
最后
从本文涵盖的所有语言来看,Julia显然是编写高效代码的最简单方法。不过,您仍然需要知道自己在做什么。幸运的是,提供了一些性能提示,可以使您走上正确的道路。