其实像C或者其他主流语言一样,在使用变量之前,必须先声明变量的具体类型,而Python则不需要。无论分配什么数据,变量都有类型。然而,我没想到正是这种稳定性让Julia获得了比Python更好的性能。选择Julia的主要原因:它比其他脚本语言快得多,让你像Python/Matlab/R一样快速开发,像C/Fortan一样高效运行。Julia的新手可能会对以下描述有点警惕:为什么其他语言不快一点?Julia可以做,其他语言做不到?你如何解释Julia的速度基准?(很多其他语言也很难?)这听起来像是违反天下没有免费午餐的法则,在其他方面有没有损失?很多人认为Julia之所以快,是因为它使用了JIT编译器,即每条语句在使用之前都使用编译函数进行编译,无论是立即预编译还是之前缓存编译。这就产生了一个问题,Python/R和MATLAB等脚本语言也可以使用JIT编译器,而这些编译器的优化时间甚至比Julia语言还要长。那么为什么我们疯狂地相信Julia语言可以在短时间内比其他脚本语言优化得更多呢?这完全是对Julia语言的误解。在本文中,我们将了解到Julia的速度之所以快,是因为它的设计决策。其核心设计决策:通过多次分派实现类型稳定性是Julia快速编译和高效运行的核心,本文稍后将详细解释它为何如此快速。此外,这个核心决定还允许使用非常简洁的语法,如脚本语言,这可以带来非常显着的性能提升。然而,从这篇文章中我们可以看到,Julia并不总是像其他脚本语言那样,我们需要明白,由于这个核心决定,Julia语言有一些“损失”。了解此设计决策如何影响您的编程方式对于生成Julia代码很重要。要了解差异,让我们简要地看一个数学示例。Julia中的数学运算总而言之,Julia中的数学运算看起来与其他脚本语言中的相同。一个值得注意的细节是,Julia的值是“真数”,与Float64中的64位浮点数或C中的“双精度浮点数”真正相同。一个Vector{Float64}中的内存排列相当于一个C的双精度浮点数组,这使得与C的互操作变得容易(的确,某种意义上Julia是建立在C之上的),并且可以带来高性能(也适用于NumPy数组)。Julia中的一些数学运算:a=2+2b=a/3c=a÷3#\divtabcompletion,meansintegerdivisiond=4*5println([a;b;c;d])output:[4.0,1.33333,1.0,20.0]此外,当后面跟着一个变量时,允许在没有运算符*的情况下进行数值乘法,例如,可以在Julia代码中进行以下计算:α=0.5?f(u)=α*u;?f(2)sin(2π)output:-2.4492935982947064e-16类型稳定性和代码自省类型稳定性,即一个方法只能输出一种类型。例如,从*(::Float64,::Float64)输出的合理类型是Float64。无论你给它什么,它都会返回一个Float64。这里有一个多重分派(Multiple-Dispatch)机制:operator*根据它看到的类型调用不同的方法。当它看到浮点数时,它返回浮点数。Julia提供了代码内省宏,这样你就可以看到你的代码实际编译成什么。所以Julia不仅仅是一种脚本语言,它是一种可以让你处理汇编的脚本语言!与许多语言一样,Julia编译为LLVM(LLVM是一种可移植的汇编语言)。@code_llvm2*5;Function*;Location:int.jl:54definei64@"julia_*_33751"(i64,i64){top:%2=muli64%1,%0reti64%2}这个输出表示浮点乘法是执行并返回答案。我们甚至可以看一下程序集:@code_llvm2*5.text;Function*{;Location:int.jl:54imulq%rsi,%rdimovq%rdi,%raxretqnopl(%rax,%rax);}这意味着*函数被编译为与C/Fortran中完全相同的操作,这意味着它实现相同的性能(即使它是在Julia中定义的)。所以你不仅可以“接近”C的性能,而且你实际上可以获得相同的C代码。那么这种情况是在什么情况下发生的呢?关于Julia的有趣之处在于,我们需要知道代码何时无法像C/Fortran那样高效地编译为操作?这里的关键是类型稳定性。如果函数是类型稳定的,那么编译器就可以知道函数中所有节点的类型,并巧妙地将其优化为与C/Fortran相同的程序集。如果它不是类型稳定的,Julia必须添加昂贵的“装箱”以确保在操作之前找到或明确知道类型。这是Julia和其他脚本语言最关键的区别!好处是Julia的函数在类型稳定时与C/Fortran函数基本相同。所以^(exponentiation)很快,但由于^(::Int64,::Int64)是类型稳定的,它应该输出什么类型?2^5output:322^-5output:0.03125这里我们得到一个错误。编译器必须抛出错误才能保证^返回Int64。如果您在MATLAB、Python或R中执行此操作,它不会抛出错误,那是因为这些语言没有围绕类型稳定性构建的整个语言。当我们没有类型稳定性时会发生什么?让我们看看这段代码:@code_native^(2,5).text;Function^{;Location:intfuncs.jl:220pushq%raxmovabsq$power_by_squaring,%raxcallq*%raxpopq%rcxretqnop;}现在让我们定义整数的求幂它在其他脚本语言中是“安全的”:functionexpo(x,y)ify>0returnx^yelsex=convert(Float64,x)returnx^yendendoutput:expo(genericfunctionwith1method)确保它有效:println(expo(2,5))expo(2,-5)output:320.03125当我们检查这段代码时会发生什么?@code_nativeexpo(2,5).text;Functionexpo{;Location:In[8]:2pushq%rbxmovq%rdi,%rbx;Function>;{;Location:operators.jl:286;Function<;{;Location:int.jl:49testq%rdx,%rdx;}}jleL36;Location:In[8]:3;Function^;{;Location:intfuncs.jl:220movabsq$power_by_squaring,%raxmovq%rsi,%rdimovq%rdx,%rsicalq*%rax;}??movq%rax,(%rbx)movb$2,%dlxorl%eax,%eaxpopq%rbxretq;Location:In[8]:5;Functionconvert;{;Location:number.jl:7;FunctionType;{;位置:浮动。jl:60L36:vcvtsi2sdq%rsi,%xmm0,%xmm0;}};Location:In[8]:6;Function^;{;Location:math.jl:780;FunctionType;{;Location:float.jl:60vcvtsi2sdq%rdx,%xmm1,%xmm1movabsq$__pow,%rax;}callq*%rax;}vmovsd%xmm0,(%rbx)movb$1,%dlxorl%eax,%eax;位置:在[8]:3popq%rbxretqnopw%cs:(%rax,%rax);}这个演示非常直观地演示了为什么Julia使用类型推断来获得比其他脚本语言更高的性能。核心理念:多重分派+类型稳定性=>速度+可读性类型稳定性(TypeStability)是Julia语言区别于其他脚本语言的重要特性。事实上,Julia的核心思想如下:(引用)Multipledispatch允许语言将函数调用分派给类型稳定的函数。这是Julia语言的所有功能开始的地方,因此我们将花一些时间深入研究它。如果函数内部存在类型稳定性,即函数内部的任何函数调用也是类型稳定的,那么编译器在每一步都知道变量的类型。因为此时代码与C/Fortran代码基本相同,所以编译器可以使用所有优化来编译函数。如果乘法运算符*是一个类型稳定的函数,我们可以通过案例来解释多重分派:它因输入表示而异。但是如果编译器在调用*之前知道a和b的类型,那么它就知道使用哪个*方法,所以编译器也知道c=a*b的输出类型。因此,如果类型信息沿着不同的操作传播,那么Julia将知道整个过程的类型,从而实现全面优化。多重分派允许每次使用*来表示正确的类型,并且神奇地允许所有优化。我们可以从中学到很多东西。首先,为了实现这种级别的运行时优化,我们必须具有类型稳定性。这不是大多数编程语言标准库的特性,它只是为了让用户体验更轻松而需要做出的选择。其次,函数类型需要多次分派进行专门化,这使得脚本语言变得“更明确,而不仅仅是更具可读性”。***,我们还需要一个健壮的类型系统。为了构造类型不稳定的指数函数(可能有用),我们还需要转换器等函数。因此,编程语言必须设计成具有多重分派的类型稳定语言,也需要以健壮的类型系统为中心,以在保持底层语言的语法和易用性的同时,实现底层语言的性能。脚本语言。我们可以将JIT嵌入到Python中,但是如果我们需要将它嵌入到Julia中,我们需要真正让它成为Julia设计的一部分。Julia基准Julia网站上的Julia基准测试了编程语言的不同模块,希望能变得更快。这并不意味着Julia基准测试测试了最快的实现,这是我们对它的主要误解。其他编程语言也是如此:测试编程语言的基本构建块,看看它们到底有多快。Julia语言建立在类型稳定函数的多重分派机制之上。因此,即使是最初的Julia也有可以快速优化C/Fortran性能的编译器。很明显,在几乎大多数情况下,Julia的性能都非常接近C。但是还有一些细节并没有真正达到C的性能,首先是斐波那契数列问题,其中Julia花费的时间是C的2.11倍。这主要是因为递归测试,Julia没有完全优化递归操作,但它在这个问题上仍然做得很好。这类递归问题最快的优化方法是Tail-CallOptimization,它可以随时添加到Julia语言中。但Julia没有添加它有几个原因,主要是:任何需要使用Tail-CallOptimization的情况也可以使用循环语句。但是循环对优化更稳健,因为有很多递归无法使用Tail-Call进行优化,所以Julia仍然建议使用循环而不是不太稳定的TCO。在某些情况下,Julia表现不佳,例如rand_mat_stat和parse_int测试。然而,这些主要是由于称为边界检查的功能。在大多数脚本语言中,如果我们对超出索引范围的数组进行索引,程序将报错。Julia语言默认会完成如下操作:functiontest1()a=zeros(3)fori=1:4a[i]=iendendtest1()BoundsError:attempttoaccess3-elementArray{Float64,1}atindex[4]Stacktrace:[1]设置索引!at./array.jl:769[inlined][2]test1()at./In[11]:4[3]top-levelscopeatIn[11]:7但是,Julia语言允许我们使用以下命令关闭边界检测@inbounds宏:functiontest2()a=zeros(3)@inboundsfori=1:4a[i]=iendendtest2()这会给我们带来与C/Fortran相同的不安全行为,但速度相同。如果我们在关闭边界检测的情况下对代码进行基准测试,我们将获得与C相似的速度。这是Julia语言的另一个更有趣的特性:它默认允许与其他脚本语言相同的安全增益,但在特定情况下(测试和调试后)这些功能可以关闭以获得完整的性能。核心概念的一个小扩展:严格类型形式类型稳定性不是唯一的要求,我们还需要严格类型形式。在Python中,我们可以将任何类型的数据放入数组中,但在Julia中,我们只能将类型T放入Vector{T}中。为了提供通用性,Julia语言提供了各种非严格类型。最明显的情况是Any,任何满足T的:a=Vector{Any}(undef,3)a[1]=1.0a[2]="hi!"a[3]=:Symbolicaoutput::3-elementArray{Any,1}:1.0“hi!”:符号抽象类型的一种不太极端的形式是Union类型,例如:a=Vector{Union{Float64,Int}}(undef,3)a[1]=1.0a[2]=3a[3]=1/4aoutput:3-elementArray{Union{Float64,Int64},1}:1.030.25这种情况只接受浮点数和整数值,但它仍然是一个抽象类型.通常,在抽象类型上调用函数并不知道任何元素的具体类型,例如,在上述情况下,每个元素可能是浮点数或整数。因此,通过多次分派进行优化,编译器并不知道每一步的类型。Julia和其他脚本语言一样,因为无法完全优化而变慢。这就是高性能原则:尽可能使用严??格类型。遵循这一原则还有其他好处:严格类型Vector{Float64}实际上与C/Fortran是字节兼容的,因此无需转换就可以直接在C/Fortran程序中使用。高性能的成本显然,Julia语言在作为脚本语言的同时做出了明智的设计决策来实现其性能目标。但是,它到底失去了什么?下一节将展示此设计决策产生的一些Julia功能,以及贯穿Julia语言的一些变通工具。1.可选属性如前所述,Julia将通过多种方式(例如@inbounds)实现高性能,但它们不一定需要使用。我们可以使用类型不稳定的函数,它会变得和MATLAB/R/Python一样慢。如果我们不需要最大性能,我们可以使用这些方便的方法。2.检测类型稳定性因为类型稳定性极其重要,所以Julia语言会提供一些工具来检测函数的类型稳定性,其中最重要的就是@code_warntype宏。下面我们可以检查类型稳定性:@code_warntype2^5Body::Int64│2201─%1=invokeBase.power_by_squaring(_2::Int64,_3::Int64)::Int64│└──return%1注意这表明function所有变量都是严格类型化的,那么expo函数呢?@code_warntype2^5Body::Union{Float64,Int64}│??>21─%1=(Base.slt_int)(0,y)::Bool│└─goto#3ifnot%1│32─%3=π(x,Int64)│?^│%4=invokeBase.power_by_squaring(%3::Int64,_3::Int64)::Int64│└──return%4│53─%6=π(x,Int64)││?Type│%7=(Base.sitofp)(Float64,%6)::Float64│6│%8=π(%7,Float64)│?^│%9=(Base.sitofp)(Float64,y)::Float64│││%10=$(Expr(:foreigncall,"llvm.pow.f64",Float64,svec(Float64,Float64),:(:llvmcall),2,:(%8),:(%9),:(%9),:(%8)))::Float64│└──return%10函数可能返回4%和10%,它们是不同的类型,所以返回的类型可以推断为Union{Float64,Int64}。为了准确追踪不稳定的位置,我们可以使用Traceur.jl:usingTraceur@traceexpo(2,5)┌Warning:xisasignedasInt64└@In[8]:2┌Warning:xisasignedasFloat64└@In[8]:5┌Warning:exporeturnsUnion{Float64,Int64}└@In[8]:2output:32这表明第2行x被调度为整数类型Int,第5行被调度为浮点类型Float64,因此可以推断出类型作为联盟{Float64,Int64}。第5行是显式调用convert函数的地方,因此这为我们确定了问题所在。原文章后面也介绍了如何处理不稳定类型,全局变量Globals性能比较差。想了解更多的读者可以参考原文。结论Julia的设计速度很快。类型稳定性和多重分派对于专门化Julia的编译是必要的,使其非常高效。此外,健壮的类型系统还需要在类型的细粒度级别上正常运行,以尽可能地实现类型稳定性,并在类型不稳定的情况下获得尽可能高的优化。原文链接:https://ucidatascienceinitiative.github.io/IntroToJulia/Html/WhyJulia【本文为机器之心专栏原文翻译,微信公众号“机器之心(id:almosthuman2014)”]点这里,查看该作者更多好文
