分享

执行代码比编译慢,原因竟然是这些数字...

 半佛肉夹馍 2023-10-20 发布于河南

解释执行为何比编译执行慢?

入眼就看到一群人振振有词的在这缠杂不清……

其实这类问题有一个简单粗暴的解决思路,建议不想当桃谷六仙的跟着学学。

这个思路就是——极端化,极限化。

什么叫极端化、极限化?

极端化、极限化的意思,就是把问题尽量简化、把解决方案也尽量简化,直到简无可简——那么这时候,我们就可以给出一个结论“解释执行的效率上限是xxxx”,清清楚楚明明白白,就不用桃谷六仙一样缠杂不清了。


具体到“解释执行为何比编译执行慢”这件事上,我们不妨也给它极端化、极限化一下。

怎么极端化、极限化呢?

让我们想象一种特殊的、解释执行的语言;这种语言当然不是x86机器码(不然CPU直接就能运行,就不好腆着脸叫它“解释执行”了),但它和x86机器码是一一对应关系。

也就是说,x86有的每个指令,这种语言都有;x86没有的指令,这种语言也没有。

我们还可以要求,这种语言的指令都是数字,这样可以省去字符串解析需要的时间;另外,我们可以要求这种语言的寄存器、指令执行逻辑都和x86指令集兼容——这样就不需要复杂的翻译工作了。

好了,现在,当我们拿到这样一种语言写的程序、想要让x86CPU解释执行它,我们需要做什么?

没错,查表

我们可以按顺序读入这种语言写的程序的每一条指令,然后查表转换到x86机器码——前面说过,两者一一对应。

那么,怎么查表呢?

三个方案。方案一是哈希表,方案二是二分法查找——受评论区提示,增加方案三:以空间换时间,直接用指令码当跳转地址,跳对应指令处执行。

对方案一,执行一条指令时,必需的操作是:

1、读入一条指令
2、计算哈希值
2.1、读入计算相关数字
2.2、计算——这里耍个赖,假设只需一个时钟周期就能算出hash值
3、访问哈希表
3.1、寻址
3.2、读取该地址的哈希表元素(值对pair)
4、比对哈希表主键
4.1、compare指令
4.2、跳转或者不跳转
5、假设直接命中,那么读入对应的x86机器指令
6、改写指令,把操作数、相关指示符(如[word ptr]之类)覆写过去
7、执行这条指令
8、保存执行现场(或,避免使用目标代码用过的寄存器)
9、返回解释器,跳转到1继续

可见,哪怕耍赖到极限,方案一也需要起码10个时钟周期(考虑跳转本身的比较可能更多)。

对方案二,执行一条指令时,必需的操作和hash表方案差不多;但需要 \log_{2}{N} 次搜索(N是x86指令条数),才能找到对应的指令。因此总的执行时间是只多不少的。

对方案三,大概是这样:

1、读入指令
2、提取指令码(等长指令最简单,只需opcode & bitmask即可;变长指令需要较为复杂的处理)
3、指令参数读入寄存器(合适优化前提下,可以合并到1)
4、goto [基址+指令码]
5、执行指令,操作寄存器完成计算
6、返回1

不考虑缓存未命中等问题,5~6个时钟周期。

综上,可见,解释执行哪怕优(耍)化(赖)到极致,它也至少需要5~10倍的性能消耗。

换句话说,解释执行至少要比直接执行编译后的程序慢5~10倍!

这个“解释执行至少比直接执行多消耗10倍时间”的结论其实来自国外一位学者的研究论文,但我没具体看过——所以,我随便搞出来的这个论证思路有效,但肯定不严谨。比如没有考虑“高级语言指令可以翻译成一组机器指令”、于是一次打包处理效率可以更高之类情况(这的确可以提高解释执行效率;但这条线是无法无限发展的。因为这会引入语法分析等动作,结果是得不偿失的:所以Java才会搞一个几乎和机器指令对应的字节码,解释执行字节码而不是高级语言代码)。

总之,“解释执行至少比直接执行多消耗10倍时间”这个结论是至今未曾受到挑战的——熟悉街机模拟器、ps2/3模拟器的小伙伴肯定知道我在说什么。


当然,如你所见,上面这个讨论是在设置了一大堆奇葩先决条件的基础上来的;现实中是不存在这么理想的解释性语言的——除非你故意玩梗。

实践中,常见的情况是:

1、在任意CPU上(通过虚拟机)解释执行精心优化的、极端理想的“字节码”(如传说中极端牛逼的Java字节码)

2、在x86 CPU上解释执行arm平台的可执行程序(如VMware/virtual box等虚拟机)

3、在x86 CPU上解释执行其它异构平台的可执行程序(典型如街机模拟器)

4、在任意CPU上解释执行通常水平的字节码(python字节码等)

5、在任意CPU上解释执行高级语言程序(如VBA、perl、php等)

自上至下,执行效率越来越差。

其中,1要求精心设计字节码,最好能使得一条字节码平均相当于10条以上物理机器指令,这才能把执行需要的额外时间压缩到2倍(不考虑缓存未命中以及分支预测失败消耗)——但,无论如何这个额外消耗是逃不掉的;尤其是,高级语言指令到物理机器指令,平均来说根本不可能对应十条机器指令。

如上,我们可以比对Java到字节码和c++到x86指令,可见Java根本做不到“一条字节码对应多条x86机器码”。

因此,虽然我很理想化的把Java字节码的效率排在“虚拟机执行其它CPU上面的程序”之前;但很遗憾,其实它并不能证明它就配坐在这个位置。

换句话说,除非直接调用预编译的东西作弊、或者动用jit;否则,一行行解释执行的话,这段Java程序的执行时间消耗起码是c++的10倍;如果考虑来回跳转引起的缓存未命中、分支预测失效等等问题,这个差距只会更大——并不会比你在PC上用Android模拟器跑Android app高明。

尤其到了高级语言程序,每个标识符、每条指令都是个字符串,读入字符串这个操作本身就需要多个时钟周期(x86的movs指令并不是每个平台都有的);更不要说什么语法分析、生成语法树、翻译然后才能执行了——这些复杂的动作,使得哪怕高级语言一条指令可以翻译成10条以上的机器指令,最终效率也远低于虚拟机执行同构平台上面的可执行程序。

当然,类似xen、kvm等半虚拟化、硬件虚拟化在x86cpu上执行x86指令集是另一种情况,可以达到物理机95%以上效能:但这已经不是解释执行了。

当然,如你所见,上面的c++示例代码到机器指令,指令条数比绝对到不了10——加上保存现场、参数传递、恢复现场、返回等等,3行c++代码最终转换成了8条机器指令。这是远远远远抵消不了文本载入、语法分析等等消耗的:这就是为什么解释性语言后来纷纷搞字节码、而不是直接分析“信息损失最少”的源代码的原因。

事实上,熟悉C/C++的程序员都知道,较长的C/C++程序编译到汇编之后,长度并不会增加很多;因为编译器会自动执行很多优化、从而使得必须执行的指令数远少于预期——这甚至会给懂汇编的程序员分析代码问题带来不便。所以编译器专门给了个-O0选项来禁止优化,从而使得高级语言代码可以和机器指令相互对应。

正因为这道“10倍性能消耗”墙卡的死死的,Java才不得不上了JIT,也就是即地编译(Just In Time Compilation)——宁可付出巨大代价、在执行期执行程序编译过程,都不能再走解释执行的路子。

原因很简单,解释执行——此路不通。


附录:

各种编程语言的执行速度比较(删除旧的,以评论区  同学更科学的测试为准):

原测试没有给Java等jit语言“预热”时间,优化也不够到位,使得Java等语言耗用的时间达到c/c++的两倍左右;新的测试去除了这些步骤,仅测试执行速度——此时只要优化过关,那么jit语言其实也是编译型语言,速度自然可以提到极限。

最后的Python是解释执行,拿它和编译型语言比,结果就近乎公开处刑了……

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多