英文原文:Why are some programming languages faster than others?
看看在科学计算方面,Fortran 语言在科学和工程领域经久不衰,讨论的最热烈的一个主题就是性能。
Fortran 语言至今依然非常重要的一个最主要原因是速度快。在 Fortran 中捣弄数字的方式比在其他语言中使用的别的方式要快。能在这个领域和 Fortran 竞争的——C和C++,被广泛使用的原因也是因为他们性能的竞争力极强。
问题是,为什么?是什么让 C++ 和 Fortran 这么快?又是为什么,他们能胜过像 Java 或者 Python 这些其他语言?
解释 VS 编译
对不同编程语言分类的方法有很多,像是依据编程语言偏好的风格分类,或者它们所支持的特性分类等等。侧重性能的话,可以分为编译型语言和解释型语言。
这两种类型的区分并不困难;相反,就像是在一条线上的两端。在一端,是传统的编译型语言,这一类包括 Fortran,C/C++等。在这些语言中,有一个单独的编译平台,专门负责将源代码编译成处理器可以执行的程序。
编译的过程分为以下几步。首先是分析和解析源代码。基本的拼写错误和笔下误会在此时被检测出来。检查过的源代码将用来产生存放在存储器中的表达式,作用同样是检查错误——这一次检查的是语法错误,例如调用了不存在的函数,或者在字符串或文本上进行了算数运算。
然后,存储器中的表达式驱动代码生成器,这一步会生成可执行的代码。为了提高可执行代码的性能,代码优化会遵循一以下的过程来进行:在代码的表达式上进行高水平的优化,在代码生成器的输出上进行低水平的优化。
事实上,随后就可以执行生成的代码。完整的编译过程就是这样。
在对面的一端,就是解释型语言。解释型语言也有类似于编译器那样的解析平台,但是它的代码直接用来驱动程序的运行。
最简单的解释器会将源代码和各种各样编程语言所支持的特性进行匹配——所以解释型语言将会负责添加数字,添加到字符串等函数。随着代码被解析,解释器会匹配到相应的函数并且执行它。程序中产生的变量将存储在表中,通过名字和值的匹配来提供查询。
解释型语言的风格最极端的例子是批处理文件和 shell script。在这些语言中,可执行的代码通常不会构建到解释器中,而是独立的运行。
那么,到底是什么造成了这种性能的不同呢?通常来说,每一次底层间接的调用都会降低性能。例如,添加两个数字最快的方式,是将其存放在处理器的寄存器中,然后使用处理器的添加指令。编译器可以这样做,可以将变量往寄存器里面存并利用处理器的指令。但是解释器却不行,相同的添加动作需要这样来完成:声明两个变量的名称与值,然后调用函数执行添。相应的函数可能对处理器调用了一样的指令添加数字,但是所有这些在调用处理器指令之前的这些工作都让性能变慢了。
模糊的界限
在这两个极端之间还存在着别的选择。例如,很多优秀的解释器表现得像编译器一样:它们执行类似于编译器的步骤,包括生成可以直接执行的代码,但是它们紧接着就将代码执行了(而不是存储在硬盘上供以后执行)。在程序的运行期间,解释器会保留这些可执行的代码,这样当需要特定代码的时候就不用重新编译了,但是程序执行完的时候,可执行代码就被删除了,如果你想再运行这个程序,就不得不重新生成可执行代码。
这种在程序执行时候编译带啊的方法叫做即时编译(Just-in-time,JIT),IE,火狐,Chrome 浏览器的 JavaScript 引擎都使用了这项技术来提高它们的脚本性能。
即时编译一般比传统的解释要高效。然而,却比不上提前编译(Ahead-of-time,AOT)。提前编译可能会慢,因为编译器要花大量的时间来,尽最大的努力来优化代码。它们之所以能够这样做是因为人们大可不必在这段时间苦等着它们完成这项工作。然而,即时编译却是在运行时期,人们守在键盘前面等待程序运行。这就限制了优化代码占用的时间。像是在后台进程优化添加过程或者现在的多核处理器技术在这方面拥有广泛前景。
原则上,即时编译在改变编译环境方面会表现出优势。常规的编译方式必须在某些方面非常保守。例如,微软不能轻易地使用新一代英特尔和 AMD 上最新的 AVX 来编译 Windows,因为 Windows 必须要保证能够在不支持 AVX 的处理器上运行。然而,一个即时编译型的程序就可以,因为它能适应所处的硬件环境,并最大化的利用它。
历史上,即时编译并没有很好地利用现代处理器提供的复杂指令集。的确,就算抛开时间的限制不说,用好像 SSE 或者 AVX 指令集对于提前编译来说都是一个很大的挑战。好在这种情况正在改善,比如 Oracle 的 HotSpot Java 虚拟机就有对这些指令集的早期支持。
另一种应用广泛的技术是使用字节码。基于字节码的平台有 Java,.NET,他们也有传统上的编译,不过编译器不生成可执行文件,而是生成字节码,一种类型的字节码并不是基于硬件环境而设计的,而是基于理想的虚拟机。程序运行的时候,字节码可以被解释或者即时编译。
总体上说,这种基于字节码系统的平台介于编译型和解释型之间。字节码即时编译起来非常简单并且易于优化,相比于解释型语言是一个进步的地方,但优化代码的效果仍比不上编译型。
各种各种介于解释型和编译型中间的选择,提供了广泛的介于解释型和编译型两种阶段之间的选项。
技术上讲,使用编译器和解释器并不是一个语言自身的特点。有一些项目,例如,有人为通常使用编译器的C语言制作一个解释器;JavaScript 也正从简单解释器向复杂的即时编译器过度以便强化其性能。
然而,主流的预编并不会在这两种形式之间来回转换。C++本质上说是提前编译的,Fortran 也是。C#和 Java 大多时候是编译成字节码,运行的时候再即使编译。Python 和 Ruby 通常是解释型。这就产生了一个性能的分级:C++和 Fortran 比 Java 和 C# 快,Java 和 C# 又比 Python 和 Ruby 快。
语言本身的特点
不同的语言种类之间依然存在着很大的性能差异,其中一个主要原因就是它们的受重视度。拿 JavaScript 和 Python 来说,例如:这两个流行的脚本语言都是编译型的。但实际上,JavaScript 却比 Python 要快的多,这并不是因为语言自身的特点——它们的表达式和能力方面不相上下——而是因为像微软,谷歌,莫斯拉这些公司更加重视 JavaScript。
几种类似的语言,投资的不同(或者发展的优先级别不同)是一种语言性能的决定因素。写一个优秀的编译器或者解释器需要花很多精力,而不是每一个语言都值得去这样做。
然而,语言的不同也会对性能有所影响。Fortran 语言的长寿就是一个很好的例子。很长一段时间,相同的两个程序在 Fortran 和C(或者C++)中运行,Fortran 会快一些,因为 Fortran 的优化做的更好。这是真的,就算C语言和 Fortran 的编译器用了相同的代码生成器也是一样。这个不同不是因为 Fortran 的某种特性,事实上恰恰相反,是因为 Fortran 不具备的特性。
数据处理程序经常在很大的数量级上操作数据。内存中的数字会表示某个点在 3D 空间中或者其他的东西。运算通常会在这数据上对每一个元素迭代重复的操作。例如,有这样一个函数:将两个数组中的每一个元素添加对对方的数组中,需要三步:添加两个数组,声明第三个数组,然后进行运算获得结果。
更好的添加数组可以用别的指令来完成,这允许这种各样的优化。例如,可以使用 SSE 或者 AVX 这样的指令集。不再是一个一个地添加元素,而是每一个添加四个,同时,使用 SSE 或者 AVX 指令集来做一个简单的提升四倍性能的优化。这个函数可以应用到多线程:如果你有四核处理器,那么一个核心可以完成分四分之一的添加。这种基于数组的函数提供了一些强力的编译的优化,使得代码在运行很多次的时候,效率有了明显的提升。
C 语言其实并没有让数组作为函数的输入(或者,在这种情况下,输出)。而是使用了指针。指针或多或少地代表了地址,这是C语言的内嵌特点:读出或者写入储存在内存中的地址值。在很多时候,C语言使用指针和数组可以互相转换。对于数组,指针只是代表了元素的首地址。数组的其他元素就在下一个连续的内存中。C语言用内嵌的可以操作内存中的地址值的功能可以获取数组。
指针非常复杂,C程序员使用它们来构建复杂的程序结构,以及紧凑有序的数组。但是这种复杂却造成了编译器优化的麻烦。依然以添加两个数组的函数为例。在C中,可能不需要三个数组作为输入,但是却需要三个指针:两个输入,一个输出。
问题就来了。这些指针可以代替任何内存地址。更重要的是,他们可以重叠。输出数组的内存地址也可以同时是输入数组的。甚至可以部分重叠,输出数组可以覆盖一个输入数组的一半。
这对编译器优化来说是个大问题,因为之前基于数组的优化不再适用。特别的,添加元素的顺序也成问题,如果输出重叠的数组,计算的结果会变得不确定,取决于输出元素的动作是发生在元素被覆盖之前还是之后。
这就意味着我们之前的理想的优化——使用多线程和迭代指令——行不通了。编译器不知道这样做是不是安全的,它处理器源码的时候只好遵从原来的顺序,没有别的。编译器不再有整理代码使之更快的自由了。
这个问题叫做混淆现象,像传统的 Fortran 语言就没有这种问题。因为传统 Fortran 没有指针,它只有非重叠数组。很长时间以来,都允许 Fortran 的编译器(或者程序员)对程序进行强有力的优化,不像在C。巩固了 Fortran 在数据处理方面的霸主地位。
显然对于这种功能的函数,这种指针的灵活性并不是很有用。如果数组重叠,就没有合适的方式来处理数据,所以很不幸,优化在这里就毫无用武之地了。在 1999 年规定的C语言标准(C99),给出了这个问题的答案。在 C99 中,指针可以被指定为不重叠。这时编译器就可以做所有的优化,C99 的这个特性使得C语言(和C++,,因为大多数编译器供应商给了一个类似的功能)成为了像 Fortran 那样的可以优化的语言。
这个混淆现象表明语言的特点会跟优化相关,尤其是很大的、变革性的优化。例如,自动将单线程代码转换成多线程的代码。然而,它也表明这种差异不是永久性的。开发人员希望能够使用C和c++来处理数据,如果一些小的改变能够使它像 Fortran 语言那样快,开发人员就会做这样一些改变。