菜单

是如何提升

2020年5月8日 - 前端排行
是如何提升

背景

HHVM 是 Facebook 开发的高性能 PHP
虚拟机,宣称比官方的快9倍,我很好奇,于是抽空简单了解了一下,并整理出这篇文章,希望能回答清楚两方面的问题:

HHVM

HHVM是什么?

HHVM(HipHop
VM)是Fackbook推出用于在执行PHP代码的虚拟机,是一个PHP的JIT编译器,具有产生快速代码和及时编译的优点。

HHVM能干什么?

HHVM脚本主要应用服务器端脚本和命令行脚本两大领域,专注于服务器端脚本,如收集表单数据、生成动态页面、发送接受COOKIE等。

HHVM为什么比ZendEngine快?

HHVM是Facebook开发的高性能PHP虚拟机,宣称比官方Zend快9倍。

PHP使用的Zend虚拟机(VM),首先会先将PHP代码编译成二进制指令opcode,然后逐条执行,每条opcode指令都对应一个C函数。对于PHP的用户函数、运行时局部变量、常量会存在一个Hashtable中。

执行一次C函数的开销

例如:在PHP中执行1000w次累加

<?php
$sum = 0;
// 发生1000w次C函数调用
for($i=0; $i<10000000; $i++){
  $sum += $i;
}

若编译为机器码情况是什么样的呢?

主频2.0GHZ的CPU每秒执行20亿次指令,函数调用则1秒只能运行1000W次。

因此,编译为机器码执行语言如C、C++、Golang…,或拥有JIT的语言如Java、NodeJS、LuaJIT、HHVM…,单从指令执行角度上看至少比PHP快几十倍。

对于字符串处理、JSON编码解码、iconv编码解码、数组操作等,
PHP比C++、Java慢吗?

在PHP中此类操作都是C扩展函数完成的,性能与编译型语言一致。

PHP到底比编译型语言慢的原因在哪里呢?

PHP代码中用户函数、类、对象操作等。

运算密集型 vs IO密集型

运算密集型程序指的是需大量执行内存复制操作、循环、运行指令等,瓶颈在CPU上,提升性能的解决方案就是提升CPU硬件配置、改进算法、提升语言/工具的执行性能。对于此类程序,PHP性能问题很明显,执行相同的逻辑,比C/C++慢几十倍甚至百倍,这是不可接受的。

IO密集型程序瓶颈在IO等待,例如HTTP请求执行100ms后返回,其中90ms查询数据库,8ms读写文件,
那么无论C/C++还是PHP,请求响应时间总是100ms左右,语言性能优化只有2ms的空间。

如何优化PHP呢

Zend的执行过程可分为两个环节

优化opcode可编码重复解析PHP与静态编译优化,由于PHP的动态性,这种优化方式是有局限,乐观估计可提升20%的性能。

优化opcode架构本身,工作量大投入产生比不高。

优化opcode执行,Zend解释器interpreter在读到opcode后会根据不同opcode调用不同函数(switch),在函数中执行语言相关的操作。优化空间不大。

优化Zend执行性能,对于函数调用的开销,通过inline
threading来优化,其原理如C中的inline关键字。

更快的虚拟机

HHVM 为什么更快,原因是JIT。

JIT操作本身是耗时的,对于简单程序或许比interpreter慢。HHVM的发展就是不断优化、优化、在优化。

4166m金沙 1

HHVM是如何超过HPHPc

什么是JIT,如何实现一个JIT?

动态语言中基本都会有一个eval(),作用是传入一段字符串来执行。JIT做着类似的事,不过它要拼接的不是字符串,而是不同平台下的机器码,然后执行。在JIT中更重要的优化是根据类型来生成特定的指令,从而减少指令数量和条件判断。

类型推导

JIT的关键是猜测类型,变量的类型要是老是变就很难优化。HHVM工程师考虑在PHP语法上做手脚,加上类型的支持,推出Hack。

<?hh
class Point
{
  // 使用静态类型可让HHVM更好的优化性能,不过这也意味着和PHP语法不兼容。
  public float $x,$y;
  public function __construct(float $x, float $y)
  {
    $this->x = $x;
    $this->y = $y;
  }
}

HHVM提升PHP执行性能

HHVM生成和执行PHP的在中间字节码,执行时通过JIT(Just In
Time即时编译,软件优化技术,指在运行时才会去编译字节码为机器码)转换为机器码执行。JIT将大量重复执行的字节码在运行时编译为机器码,达到提高执行效率的目的。通常触发JIT的条件是代码或函数被多次重复调用。

什么是字节码?

4166m金沙 2

字节码

ZendEngine做法是先编译为opcode,逐条执行,每条指令对应的是C语言级别的函数。

HHVM服务器最开始的少数请求会比其余的慢,因为它必须在执行PHP和Hack代码之前将它们编译成机器码,这个效果是非常明显的,所以你不应当立即把一个新设置的HHVM服务器应用到生产环境中。你应该先发送一些人工模拟的请求到这个HHVM服务器上,对它进行热身。
事实上,服务器启动的时候,并不会编译任何代码。初始的请求就是在HHVM的字节码解释器下运行的。原理就是:对于一个web服务器来说,最初的几个请求是不寻常的。在这个期间,开始了初始化,还对缓存进行填充等等。对这些代码路径的编译对整体性能的表现是非常糟糕的,因为一旦对服务器进行了预热,这些过程是不会被经常调用的。HHVM还利用这些请求,来收集一些代码所用到的数据类型分析的工作。所以它可以稍后更加有效地进行编译。你可以使用选项
hhvm.jit_profile_4166m金沙,interp_requests 来调整这个门槛。
对于发送预热请求,颗通过命令行或其它类似的地方,简单地使用 curl
这个命令功能。为了得到最好的结果:
使用你希望在产品中看到的,能够代表最常见的请求的混合集合。例如,如果你期待所有对这个产品的请求中的40%都是到达
index.php 的,那么你的 40% 的预热请求都 应该是到 index.php 的请求。
避免并行发送多个预热请求,若你真的并行发送了多个请求,那么并不会出现什么问题。单对于JIT编译器来说,若没有同时工作在多个请求上的话,它往往能够生成更好的代码。
最终,你最好有个进程脚本用于服务器热身,这样的话,颗在命令行里仅仅执行一个命令就可以做到热身了。但是在最初期的时候,你还需要一些人工的参与,要实际计算出用于热身的请求数量是非常微妙的,
这主要取决于你的程序本身。

你会怎么做?

在讨论 HHVM 实现原理前,我们先设身处地想想:假设你有个 PHP
写的网站遇到了性能问题,经分析后发现很大一部分资源就耗在 PHP
上,这时你会怎么优化 PHP 性能?

比如可以有以下几种方式:

方案1几乎不可行,十年前 Joel 就拿 Netscape
的例子警告过,你将放弃多年的经验积累。尤其是像
Facebook 这种业务逻辑复杂的产品,PHP
代码实在太多了,据称有2千万行(引用自 [PHP on the Metal with
HHVM]),修改起来的成本恐怕比写个虚拟机还大,而且对于一个上千人的团队,从头开始学习也是不可接受的。

方案2是最保险的方案,可以逐步迁移,事实上 Facebook
也在朝这方面努力了,而且还开发了 Thrift 这样的 RPC 解决方案。Facebook
内部主要使用的另一个语言是 C++,从早期的 Thrift
代码就能看出来,因为其它语言的实现都很简陋,没法在生产环境下使用。

目前在 Facebook 中据称 PHP:C++ 已经从 9:1 增加到 7:3
了,加上有
Andrei Alexandrescu 的存在,C++ 在 Facebook
中越来越流行。但这只能解决部分问题,毕竟 C++ 开发成本比 PHP
高得多,不适合用在经常修改的地方,而且太多 RPC 的调用也会严重影响性能。

方案3看起来美好,实际执行起来却很难,一般来说性能瓶颈并不会很显著,大多是不断累加的结果,加上
PHP
扩展开发成本高,这种方案一般只用在公共且变化不大的基础库上,所以这种方案解决不了多少问题。

可以看到,前面3个方案并不能很好地解决问题,所以 Facebook
其实没有选择的余地,只能去考虑 PHP 本身的优化了。

更快的 PHP

既然要优化 PHP,那如何去优化呢?在我看来可以有以下几种方法:

PHP 语言层面的优化是最简单可行的,Facebook
当然想到了,而且还开发了 XHProf 这样的性能分析工具,对于定位性能瓶颈是很有帮助的。

不过 XHProf 还是没能很好解决 Facebook
的问题,所以我们继续看,接下来是方案2。简单来看,Zend
的执行过程可以分为两部分:将 PHP 编译为 opcode、执行 opcode,所以优化
Zend 可以从这两方面来考虑。

优化 opcode 是一种常见的做法,可以避免重复解析
PHP,而且还能做一些静态的编译优化,比如 Zend Optimizer
Plus,但由于 PHP
语言的动态性,这种优化方法是有局限性的,乐观估计也只能提升20%的性能。另一种考虑是优化
opcode
架构本身,如基于寄存器的方式,但这种做法修改起来工作量太大,性能提升也不会特别明显(可能30%?),所以投入产出比不高。

另一个方法是优化 opcode 的执行,首先简单提一下 Zend 是如何执行的,Zend
的 interpreter(也叫解释器)在读到 opcode 后,会根据不同的 opcode
调用不同函数(其实有些是
switch,不过为了描述方便我简化了),然后在这个函数中执行各种语言相关的操作(感兴趣的话可看看深入理解
PHP
内核这本书),所以
Zend 中并没有什么复杂封装和间接调用,作为一个解释器来说已经做得很好了。

想要提升 Zend
的执行性能,就需要对程序的底层执行有所解,比如函数调用其实是有开销的,所以能通过 Inline
threading 来优化掉。它的原理就像
C 语言中的 inline
关键字那样,但它是在运行时将相关的函数展开,然后依次执行(只是打个比方,实际实现不太一样),同时还避免了
CPU 流水线预测失败导致的浪费。

另外还可以像 JavaScriptCore 和 LuaJIT 那样使用汇编来实现
interpreter,具体细节建议看看 Mike
的解释。

但这两种做法修改代价太大,甚至比重写一个还难,尤其是要保证向下兼容,后面提到
PHP 的特点时你就知道了。

开发一个高性能的虚拟机不是件简单的事情,JVM
花了10多年才达到现在的性能,那是否能直接利用这些高性能的虚拟机来优化 PHP
的性能呢?这就是方案3的思路。

其实这种方案早就有人尝试过了,比如 Quercus 和
IBM 的 P8,Quercus 几乎没见有人使用,而
P8 也已经死掉了。Facebook
也曾经调研过这种方式,甚至还出现过不靠谱的传闻 ,但其实
Facebook 在 2011 年就放弃了。

因为方案3看起来美好,但实际效果却不理想,按照很多大牛的说法(比如 Mike),VM
总是为某个语言优化的,其它语言在上面实现会遇到很多瓶颈,比如动态的方法调用。关于这点在 Dart
的文档中有过介绍,而且据说
Quercus 的性能与 Zend+APC 比差不了太多([来自The HipHop Compiler for
PHP]),所以没太大意义。

不过 OpenJDK
这几年也在努力,最近的 Grall 项目看起来还不错,也有语言在上面取得了显著的效果,但我还没空研究
Grall,所以这里无法判断。

接下来是方案4,它正是 HPHPc(HHVM 的前身)的做法,原理是将 PHP 代码转成
C++,然后编译为本地文件,可以认为是一种 AOT(ahead of
time)的方式,关于其中代码转换的技术细节可以参考 The HipHop Compiler
for
PHP 这篇论文,以下是该论文中的一个截图,可以通过它来大概了解:

4166m金沙 3

这种做法的最大优点是实现简单(相对于一个 VM
来说),而且能做很多编译优化(因为是离线的,慢点也没事),比如上面的例子就将“- 1”优化掉了。但它很难支持
PHP
中的很多动态的方法,如 eval()create_function(),因为这就得再内嵌一个
interpreter,成本不小,所以 HPHPc 干脆就直接不支持这些语法。

除了
HPHPc,还有两个类似的项目,一个是 Roadsend,另一个是 phc ,phc
的做法是将 PHP 转成了 C 再编译,以下是它将 file_get_contents($f) 转成
C 代码的例子:

static php_fcall_info fgc_info;
php_fcall_info_init ("file_get_contents", &fgc_info);
php_hash_find (LOCAL_ST, "f", 5863275, &fgc_info.params);
php_call_function (&fgc_info);

话说 phc
作者曾经在博客上哭诉,说他两年前就去
Facebook 演示过 phc
了,还和那里的工程师交流过,结果人家一发布就火了,而自己忙活了4年却默默无闻,现在前途渺茫。。。

Roadsend 也已经不维护了,对于 PHP
这样的动态语言来说,这种做法有很多的局限性,由于无法动态
include,Facebook 将所有文件都编译到了一起,上线时的文件部署居然达到了
1G,越来越不可接受了。

另外有还有一个叫 PHP
QB 的项目,由于时间关系我没有看,感觉可能是类似的东东。

所以就只剩下一条路了,那就是写一个更快的 PHP
虚拟机,将一条黑路走到底。或许你和我一样,一开始听到 Facebook
要做一个虚拟机是觉得太离谱,但如果仔细分析就会发现其实也只有这样了。

更快的虚拟机

HHVM 为什么更快?在各种新闻报道中都提到了 JIT
这个关键技术,但其实远没有那么简单,JIT
不是什么神奇的魔法棒——用它轻轻一挥就能提升性能,而且 JIT
这个操作本身也是会耗时的,对于简单的程序没准还比 interpreter
慢,最极端的例子是 LuaJIT
2 的
Interpreter 就稍微比 V8 的 JIT
快。所以并不存在绝对的事情,更多还是在细节问题的处理上,HHVM
的发展历史就是不断优化的历史,你可以从下图看到它是如何一点点超过 HPHPc
的:

4166m金沙 4

值得一提的是在 Android 4.4 中新的虚拟机 ART 就采用的是 AOT
方案(还记得么?前面提到的 HPHPc 就是这种),结果比之前使用 JIT 的
Dalvik 快了一倍,所以说 JIT 也不一定比 AOT 快。

因此这个项目是有很大风险的,如果没有强大的内心和毅力,极有可能半途而废。Google
就曾经想用 JIT 提升 Python
的性能,但最终失败了,对于
Google 来说用到 Python 的地方其实并没什么性能问题(好吧,以前 Google
是用 Python 写过 crawl [参考 In The Plex],但那都是1996年的事情了)。

比起 Google,Facebook 显然有更大的动力和决心,PHP 是 Facebook
最重要的语言,我们来看看 Facebook 都投入了哪些大牛到这个项目中(不全):

虽然没有像 Lars Bak、Mike Pall
这样在虚拟机领域的顶级专家,但如果这些大牛能齐心协力,写个虚拟机还是问题不大的,那么他们将面临什么样的挑战呢?接下来我们一一讨论。

规范是什么?

自己写 PHP 虚拟机要面临的第一个问题就是 PHP
没有语言规范,很多版本间的语法还会不兼容(甚至是小版本号,比如 5.2.1 和
5.2.3),PHP
语言规范究竟如何定义呢?来看一篇来自 IEEE 的说法:

The PHP group claim that they have the final say in the specification of
(the language) PHP. This groups specification is an implementation, and
there is no prose specification or agreed validation suite.

所以唯一的途径就是老老实实去看 Zend 的实现,好在 HPHPc
中已经痛苦过一次了,所以 HHVM 能直接利用现成,因此这个问题并不算太大。

语言还是扩展?

实现 PHP 语言不仅仅只是实现一个虚拟机那么简单,PHP
语言本身还包括了各种扩展,这些扩展和语言是一体的,Zend
不辞辛劳地实现了各种你可能会用到的功能。如果分析过 PHP
的代码,就会发现它的 C 代码除去空行注释后居然还有80+万行,而你猜其中
Zend 引擎部分有多少?只有不到10万行。

对于开发者来说这不是什么坏事,但对于引擎实现者来说就很悲剧了。我们可以拿
Java 来进行对比,写个 Java 的虚拟机只需实现字节码解释及一些基础的 JNI
调用,Java 绝大部分内置库都是用 Java
实现的。所以如果不考虑性能优化,单从工作量看,实现 PHP 虚拟机比 JVM
要难得多,比如就有人用8千行的 TypeScript 实现了一个 JVM
Doppio。

而对于这个问题,HHVM 的解决办法很简单,那就是只实现 Facebook
中用到的,而且同样可以先用 HPHPc 中之前写过的,所以问题也不大。

相关文章

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图