为什么朱莉娅工作得这么好?

2020-10-21 19:09:57

它比其他脚本语言更快,使您可以快速开发Python/MATLAB/R,同时生成与C/Fortran一样快的代码。

为什么不干脆让其他脚本语言变得更快呢?如果朱莉娅能做到,为什么其他人做不到呢?

你如何干预朱莉娅基准来证实这一点?(这对许多人来说是出乎意料的困难!)。

许多人认为Julia速度很快,因为它是实时(JIT)编译的(即,每条语句都是使用编译函数运行的,这些函数要么在使用前编译,要么使用之前缓存的编译)。这就引出了一个问题,即Julia比Python/R的JIT实现提供了什么(而MATLAB默认使用JIT)。这些JIT编译器的优化时间比Julia长得多,所以我们为什么要疯狂地相信Julia很快就优化了所有这些编译器呢?然而,这完全是对朱莉娅的误解。我想以一种非常直观的方式展示朱莉娅的速度,因为她的设计决定。核心设计决策,即通过多分派实现专门化的类型稳定性,使得Julia能够非常容易地将编译器转换成高效的代码,同时也允许代码非常简洁,并且看起来像是一种脚本语言。这将带来一些非常明显的性能提升。

但是我们将在本例中看到Julia并不总是像其他脚本语言那样工作。我们必须了解一些丢失的午餐。理解此设计决策如何影响您必须编码的方式,对于生成高效的Julia代码至关重要。

一般来说,Julia中的数学与其他脚本语言中的数学看起来是一样的。需要注意的一个细节是,这些数字是实数,因为在Float64中,它确实与64位浮点数或C中的Double&34;相同。A Vector{Float64}与C中的Double数组是相同的内存布局,两者都使得与C的互操作变得容易(实际上,在某种意义上,Julia是C&34;之上的一层),并且它可以带来高性能(NumPy数组也是如此)。

A=2+2 b=a/3 c=a?3#\div制表符补全,表示整数除d=4*5 println([a;b;c;d])。

请注意,我在这里展示了Julia的Unicode制表符完成功能。Julia允许使用Unicode字符,这些字符可以通过制表符完成类Latex语句使用。此外,如果后跟变量,则允许不带*的数字乘法。例如,允许使用以下Julia代码:

类型稳定性是指一个方法只能输出一个可能的类型。例如,从*(::Float64,::Float64)输出的合理类型是Float64。不管你给它什么,它都会吐出Float64。这里的情况是多重分派:*运算符会根据它看到的类型调用不同的方法。当它看到浮标时,它会吐出浮标。Julia提供了代码内省宏,这样您就可以看到您的代码实际编译成了什么。因此,Julia不仅仅是一种脚本语言,它还是一种可以让你处理汇编的脚本语言!像许多语言一样,Julia编译成LLVM(LLVM是一种可移植的汇编语言)。

;函数*;位置:int.jl:54定义i64@";julia_*_33751";(i64,i64){顶部:%2=mul i64%1,%0 ret i64%2}。

此输出表示执行了浮点乘法运算,并返回了答案。我们甚至可以看一看这个集合体。

.text;function*{;location:int.jl:54 imulq%rsi,%rdi movq%rdi,%rax retq nopl(%rax,%rax);}。

这表明*函数已经编译成与C/Fortran中完全相同的操作,这意味着它实现了相同的性能(尽管它是在Julia中定义的)。因此,不仅可以将";Close";转换为C,而且可以实际得到相同的C代码。在什么情况下会发生这种情况?

朱莉娅的有趣之处在于,询问发生这种情况的情况并不是正确的问题。正确的问题是,在什么情况下,代码不能编译成像C/Fortran那样高效的代码?这里的关键是类型稳定性。如果函数是类型稳定的,那么编译器可以知道函数中所有点的类型是什么,并智能地将其优化到与C/Fortran相同的程序集中。如果它不是类型稳定的,Julia必须添加昂贵的装箱,以确保在操作之前找到/知道类型。

优点是,当类型稳定时,Julia的函数本质上是C/Fortran函数。因此^(取幂)速度很快。但是,^(::Int64,::Int64)是类型稳定的,那么它应该输出什么类型呢?

这里我们得到一个错误。为了向编译器保证^将返回Int64,它必须抛出错误。如果在MATLAB、Python或R中执行此操作,则不会抛出错误。这是因为这些语言的整个语言并不是围绕类型稳定性构建的。

.text;function^{;location:intfuncs.jl:220 Push q%rax movabq$power_by_ssquing,%rax callq*%rax popq%rcx retq NOP;}。

现在,让我们定义我们自己对整数的求幂。让我们让它像在其他脚本语言中看到的形式一样安全:

.text;function expo{;location:in[8]:2presq%rbx movq%rdi,%rbx;function>;;{;location:operators.jl:286;function<;;{;location:int.jl:49testq%rdx;}}jle L36;location:in[8]:3;function^;{;location:intfuncs.jl:220 movabq$power_by_ssquing,%rax movq%rsi,%rdi movq%rdx;}movq%rax,(%rbx)movb$2,%dl xorl%eax,%eax popq%rbx retq;位置:in[8]:5;函数转换;{;location:num.jl:7;函数类型;{;location:float.jl:60L36:vcvtsi2sdq%rsi,%xmm0,%xmm0;}};位置:in[8]:6;函数^;{;位置:math.jl:780;函数类型;{;位置:float.jl:60 vcvtsi2sdq%rdx,%xmm1,%xmm1 movabq$__power,%rax;}callq*%rax;}vmovsd%xmm0,(%rbx)movb$1,%dl xorl%eax,%eax;位置:in[8]:3 popq%rbx retq nopw%cs:(%rax,%rax);}。

这是一个非常直观的演示,说明了为什么Julia在如何使用类型推理方面比其他脚本语言实现了如此高的性能。

类型稳定性是Julia区别于其他脚本语言的一个重要特性。事实上,朱莉娅的核心理念是这样表述的:

这就是Julia所要做的,所以让我们花一些时间来深入研究它。如果你在函数内部有类型稳定性(也就是说,函数中的任何函数调用也是类型稳定的),那么编译器在每一步都可以知道变量的类型。因此,它可以编译经过充分优化的函数,因为此时的代码与C/Fortran代码本质上是相同的。多分派之所以适用于这个故事,是因为它意味着*可以是类型稳定的函数:它只是对不同的输入有不同的含义。但是,如果编译器可以在调用*之前知道a和b的类型,那么它就知道要使用哪个*方法,因此它知道c=a*b的输出类型。因此,它可以一路向下传播类型信息,知道整个过程中的所有类型,从而实现完全优化。多重分派允许*在您每次使用它时都意味着正确的事情,几乎神奇地实现了这种优化。

我们从中学到了一些东西。首先,为了实现这种级别的优化,您必须具有类型稳定性。这不是大多数语言的标准库中的特色,选择它是为了让用户的体验更容易一些。其次,需要多个分派能够专门化类型的函数,这使得脚本语言语法比看上去更明确。最后,需要一个健壮的类型系统。为了构建类型不稳定的求幂(可能需要),我们需要像转换这样的功能。因此,语言必须设计为具有多个分派的类型稳定,并且以健壮的类型系统为中心,以便在保持脚本语言的语法/易用性的同时实现这种原始性能。您可以将JIT放在Python上,但要真正使其成为Julia,您必须将其设计为Julia。

Julia网站上的Julia基准测试对编程语言的组件进行了速度测试。这并不意味着它正在测试最快的实现。这就是一个重大误解发生的地方。你会让一个R程序员看着斐波纳契计算器的R代码,然后说,哇,那是个可怕的R代码。你不应该在R中使用递归,当然它很慢。然而,斐波那契问题是用来测试递归的,并不是第i个斐波纳契数的最快实现。其他问题也是一样的:测试语言的基本组件,看看它们有多快。

Julia是使用类型稳定函数的多重调度建立的。因此,即使是最早的Julia版本,编译器也很容易将其优化为C/Fortran效率。很明显,几乎在每一种情况下,朱莉娅都很接近C,而不是接近C的地方,实际上有一些细节。第一个是Fibonacci问题,其中Julia是C语言的2.11x,这是因为它是对递归的测试,而Julia没有完全优化递归(但在这个问题上仍然做得非常好!)。用于接收此类问题的最快时间的优化称为尾部调用优化。Julia可以随时添加此优化,尽管他们选择不添加是有原因的。主要原因是:在任何可能进行尾部调用优化的情况下,也可以使用循环。但是循环对于优化也更健壮(有许多递归调用将无法进行尾部调用优化),因此他们希望只推荐使用循环,而不是使用脆弱的TCO。

Julia做不到的其他情况是rand_mat_stat和parse_int测试。然而,这在很大程度上要归功于一种称为边界检查的功能。在大多数脚本语言中,如果试图在数组边界之外编制索引,则会收到错误。默认情况下,Julia将执行此操作:

边界错误:尝试访问索引[4]处的3元数组{Float64,1},堆栈跟踪:[1]setindex!At./array.jl:769[inline][2]test1()at./in[11]:4[3]顶级作用域at in[11]:7。

这为您提供了与C/Fortran相同的不安全行为,但也提供了相同的速度(实际上,如果您将这些添加到基准测试中,它们的速度将接近C)。这是Julia的另一个有趣的特性:默认情况下,它让您拥有脚本语言的安全性,但是在必要时(/在测试和调试之后)关闭这些特性以获得完整的性能。

类型稳定性并不是唯一的必需品。您还需要严格键入。在Python中,您可以将任何内容放入数组。在Julia中,您只能将T类型放入Vector{T}。为了提供一般性,Julia提供了各种非严格形式的类型。最大的例子是任何。任何满足T:<;any的东西(因此而得名)。因此,如果需要,可以创建Vector{any}。例如:

抽象类型的另一种不太极端的形式是UNION类型,它听起来就像这样。例如:

这将只接受浮点数和整数。但是,它仍然是一个抽象类型。在抽象类型上调用的函数不能知道任何元素的类型(因为在本例中,任何元素都可以是浮点型或整型)。因此,通过多分派(知道每一步的类型)实现的优化不再存在。因此,优化没有了,Julia将减慢到其他脚本语言的速度。

这就引出了性能原则:尽可能使用严格的类型化。还有其他优点:严格类型的Vector{Float64}实际上与C/Fortran是字节兼容的,因此C/Fortran程序无需转换就可以直接使用它。

很明显,Julia做出了聪明的设计决定,以实现其性能目标,同时仍然是一种脚本语言。然而,到底失去了什么呢?接下来,我将向您展示来自此设计决策的几个Julia特点,以及Julia提供给您处理这些问题的工具。

我已经展示过的一件事是,Julia给出了很多实现高性能的方法(比如@inbound),但是它们并不是必须要使用的。您可以编写类型不稳定的函数。它将和MATLAB/R/Python一样慢,但您可以做到。在你不需要最佳性能的地方,这是很好的选择。

由于类型稳定性是如此重要,Julia提供了一些工具来检查您的函数是否类型稳定。最重要的是@code_warntype宏。让我们用它来检查类型稳定函数:

Body::Int64│220 1─%1=调用Base.Power_by_Ssquing(_2::Int64,_3::Int64)::Int64│└──返回%1。

请注意,它将函数中的所有变量显示为严格类型。我们的世博会怎么样?

正文::联合{flat64,int64}│╻╷>;2 1─%1=(Base.slt_int)(0,y)::bool│└──转到#3如果不是%1│3 2─%3=π(x,int64)│╻^│%4=Invoke Base.Power_by_Sequing(%3::int64,_3::int64)::int64│└──Return%4│5 3─%6=π(x,int64)││╻类型│%7=(Base.sitofp)(浮动64,%6)::Float64│6│%8=π(%7,Float64)│╻^│%9=(Base.sitofp)(Float64,y)::Float64│││%10=$(EXPR(:FOREIGNCALL,";Llvm.power.f64";,flat64,svec(flat64,flat64),:(:llvmcall),2,:(%8),:(%9),:(%9),::(%8))::flat64│└──返回%10。

请注意,可能的返回是临时的%4和%10,它们是不同的类型,因此返回类型被推断为UNION{Float64,Int64}。要准确跟踪这种不稳定发生的位置,我们可以使用Traceur.jl:

┌警告:X被指定为└@in[8]:2┌警告:X被指定为Float64└@in[8]:5┌警告:博览会返回联盟{flat64,int64}└@in[8]:2

这告诉我们,在第2行x被分配给Int,而在第5行x被分配给Float64,因此它被推断为Union{Float64,Int64}。第5行是我们放置显式转换调用的位置,因此这正好为我们确定了问题所在。

首先,我已经展示了一些函数会出错,而在其他脚本语言中,它们会读懂您的心思。在许多情况下,您会意识到您可以从一开始就使用不同的类型并实现类型稳定性(为什么不直接使用2.0^-5呢?)。但是,在某些情况下,您找不到合适的类型。这可以很容易地通过转换来修复,尽管这样会失去类型稳定性。相反,您必须考虑您的设计,并巧妙地使用多个分派。

因此,假设我们有一个作为向量的{Union{Float64,Int}}。我们可能会遇到这样的情况,我们必须使用a,假设在a的每个元素上,我们必须执行大量的操作。在这种情况下,知道给定元素的类型将带来巨大的性能提升,但由于它位于向量{Union{Float64,Int}}中,因此它们在如下函数中是未知的:

函数foo(Array)for i in Eachindex(Array)val=array[i]#在val结束时执行算法X。

但是,我们可以使用多个派单来解决此问题。我们可以写一份关于元素的派单:

因为要检查类型以进行分派,所以函数INTERN_FOO是严格类型化的。因此,如果INTERN_FOO是类型稳定的,那么我们可以通过允许它在INTERN_FOO中专门化来实现高性能。这就产生了一个一般的设计原则,即如果您正在处理奇数/非严格类型,您可以使用外部函数来处理类型逻辑,同时使用内部函数来处理所有的硬计算,并在仍具有脚本语言的通用功能的情况下实现接近最佳的性能。

朱莉娅的全球赛表现糟糕透顶。不使用全局变量是性能提示中的第一个事实。然而,新上班族没有意识到的是,REPL是全球范围的。要了解原因,请回想一下Julia有嵌套的作用域。例如,如果函数内部有一个函数,则内部函数具有外部函数的所有变量。

在test2中,y是已知的,因为它是在test中定义的。如果y是类型稳定的,那么这一切都可以提供一些高性能的东西,因为test2可以假设y始终是一个整数。但现在看看在最高范围(从而有效地在全局范围内)发生了什么:

因为没有使用分派来专门化badidea,并且我们可以随时更改a的类型,因此badidea不能在编译时添加优化,因为a的类型在编译时是未知的。但是,Julia允许我们将变量指定为常量:

请注意,函数将使用常量的值进行专门化,因此它们在设置后应该保持不变。

这将在尝试进行基准测试时显示出来。最常见的人为错误是新来者以朱莉娅为基准,如下所示:

但是,如果我们将其放入一个函数中,它将会优化(实际上,它会优化掉循环并停留在答案中)。

函数时刻表()a=3.0@time for i=1:4a+=i结束时刻表()#首次编译时刻表()。

这是一个很容易陷入的问题:不要在REPL的全球范围内进行基准测试或计时。始终将对象包装在函数中或将其声明为常量。有一个开发人员线程可以使全局性能不那么糟糕,但是,根据这本笔记本中的信息,您已经可以看到,它永远不会不糟糕,它只会不那么糟糕。

朱莉娅是故意跑得快的。类型稳定性和多重分派是进行Julia编译中所涉及的专门化以使其工作得如此好所必需的。健壮的类型系统需要在如此精细的级别上处理类型,以便在任何可能的情况下都能有效地实现类型稳定,并在不完全可能的情况下管理优化。

这里有一个很好的学习项目:您将如何设计一个新类型的EasyFloats来将MATLAB/Python/R算法构建到Julia中?您将如何设计带有Nas的阵列来模拟R?计算结果的时间,看看与最优的区别是什么。