多态内联缓存PIC

来源:互联网 发布:美国海关数据查询 编辑:程序博客网 时间:2024/05/20 07:18

Google的V8作为最优秀的浏览器引擎,在它的内部实现上采用了很多的奇淫巧计,而如果你要想去深入了解下V8的构造,的确需要去了解很多它所包含的思想。其中之一就是大名鼎鼎的polymorphic inline cache(PIC),即多态内联缓存。多态内联缓存的用途之一就是V8通过隐藏类技术加PIC来加速Javascript对象属性的访问。
这篇文章中主要讨论多态内联缓存的实现和一些相关问题。下面所讲的理论部分的内容来自Urs Hölzle的博士论文Optimizing Dynamically-Typed Object-Oriented Languages With Polymorphic Inline Caches [PDF],感兴趣的可以直接阅读英文原文。本文是本人独自翻译,如果转载,需注明出处。

“了解一件事物,先要了解它的历史”。所以在最开始,我们先来说说javascript的历史。

javascript在诞生初期的设计考虑就是为了面向那些并不专业的人士。例如设计师或者对编程并不熟悉的人。匆匆设计的语言,没想到投入市场之后一鸣惊人,但随之种种问题接踵而来。目前javascript最大的问题就是它的性能瓶颈。

javascript是一门动态类型语言,没有类型设计,它的创作者并不想让使用它的人为了了解这门语言去学习cpu指令以及计算机原理这些东西,所有类型的变量仅通过一个var就能被声明出来。

一般认为,javascript程序的性能瓶颈很大程度是因为它的动态的类型系统,与静态类型的语言相比,javascript程序需要额外的操作来处理类型的动态性,所以执行效率比较低。而且,不光是javascript,许多其他采用动态类型的语言都会存在这个问题。

然而目前为止,已经有许多技术诞生来试图解决这个问题,诸如类型分析,类型预测以及内联缓存 等。当然,无论什么方法都有利弊,比如类型分析,如果是在动态编译中,虽然类型分析能够显著的提升代码的效率,但有时候也会延长编译时间,而反而会降低整个系统的效率。

在本文中在,主要讨论其中一种处理动态类型的方案,多态内联缓存。现在这也是被V8以及squirrelfish引擎中所采用的核心优化方案之一。

一些基础知识

可能很多人之前并不了解多态内联缓存,为了更好的认识接下来要讲的内容,首先对多态内联缓存是什么以及它所做的事情做一个简单的介绍。需要了解多态的基本知识。
首先看看下面这段代码:

function area(shape, getArea) {  ...  shape.getArea();  ...}

当编译器面对单纯面对这个函数的时候(程序员也一样),我们无法直接去调用getArea方法,因为我们根本不知道调用这个方法的对象是谁,换句话说,我们不知道shape的类型。当类似的函数出现在静态类型的语言中,编译过程中可以通过诸如查找虚函数表的方式很快确定getArea的方法的真正调用者。但是,在动态类型语言中,很可能会有许多对象和类都拥有getArea方法,并且它们之间没有关联。这时候想要去调用这个方法,就要先经行类型判定,然后将方法名作为key去哈希表里面查找想要的方法。这种方式和静态类型语言相比,相当费时。

我们把 shape.getArea();这一行称作一个call site,并且把getArea叫做一条名为getArea的消息,而实际调用getArea这个方法的对象,无论是circle或是Rectangle,叫做这个call site的receiver,也就是这条getArea消息的接收者。而优化一个call site去查找想要的receiver的过程,就是本文中PICs所做的工作。

本文将会讲解多态内联缓存以及一些其他类似的技术的一些理论内容,并且最后结合V8的实现技术讲下应用。

早期的解决方案。

为了更好的理解多态内联缓存(PIC,下面都将采用缩写形式),本文会在开始先介绍几种其他形式的,针对提高动态类型语言的效率的技术。关于PIC技术,最早提出并且应用应该是SELF和smalltalk,所以会用到部分它们的概念(主要是消息),不过不影响理解。需要的可以去了解下。

内联缓存

派发一个动态绑定的消息(对于js,就是一个call site的receiver是动态确定的),要比静态绑定的话费更多的时间,因为程序要找到正确的接收者,并且考虑对应语言的继承机制。在早期的SmallTalk中,方法查找占据了很大一部分时间。

最开始诞生了一种叫做缓存查找的方法,这个方法的思想是将最近使用频率最多的几个先缓存起来,当然派发消息的时候(对于某个call site寻找对应的receiver),先到缓存中查找,因为缓存中保存了使用频率高的几项,所以很大可能会在缓存中找到对应的方法。当缓存中找不到,再去使用传统查找。

然而,即使使用了缓存查找,消息派发的速度仍然会很慢,因为程序需要在每次消息派发之前检查一遍缓存。后来,经过观察发现,消息派发仍然有很大改进空间。因为对于某一个call site而言,相应receiver类型的变化一般很小。假如一个消息的receiver拥有属性X,那么很大可能上,下次执行这个call site的时候,它的receiver也会拥有属性X。

这里写图片描述

如上图,对于这种call site,我们可以把之前查找的方法地址缓存在这个call site,从而覆盖当前的调用指令,当下次执行时候就无需查找,直接调用缓存的方法。当然,receiver会改变,所以在调用之前会进行验证。当验证失效的时候,再进行查找,替代之前缓存的方法。
这种方式就叫内联缓存,它在Smalltalk中的一些程序可以提升33%的速度。

多态内联缓存

单态与多态

内联缓存这种方法只有call site的receiver相对稳定的时候才会有效率的提升。但是发生在多态情况下的时候,虽然这些call site会拥有相似的receiver,但实际情况却是会在几个不同的方法间来会调用。甚至,使用这种内联缓存会使查找方法的速度更慢,因为频繁的内联缓存缺失。

在一些系统中进行分析,发现多态的级别一般都小于10。所以,多态的级别可以用一个三型分布来表示:消息派发是单态的(monomorphic);多态的(polymorphic),可能有几个receiver;以及megamorphic,意味着会有非常多的receiver。通过这一分布表明,多态调用的性能可以通过更加灵活的缓存形式来得到提升。

多态内联缓存

PIC是基于内联缓存,用于处理多态调用。与内联缓存不同,PIC不仅只存储上一次查询结果,而是会缓存所有多态call site中的查询结果,并把这个结果存储在一个特别产生的桩函数中,这个桩函数实际上会在call site位置上代替原有的call site。下面用一个具体例子。

现在我们要向一个列表中的所有对象发送一个getArea方法来获取他们的面积。假设所有对象都是circle,实际上,这种情况就是我们提到的一个单态的消息。这个时候,我们可以采用普通的内联缓存来处理。但是,这种假设在大部分情况都不会成立,实际上还会有诸如rectangle或者triangle等对象。

那么,要是接下来遇到一个rectangle对象,这时候在缓存里面保存的仍然是circle对象方法。所以,这时候要调用getArea方法就会遭到缓存缺失,接着调用普通的查找,找到rectangle的getArea方法。
我们已经知道,要是在单态内联缓存下,这时候新的方法会去覆盖旧的。

现在引入PIC,当发现缓存缺失的时候,会构建一个桩函数,接着把这个call site绑定到这个桩函数,而新来的rectangle的对应方法地址就会被放到这个桩函数中。
这里写图片描述

所以,在PIC情况下,会在这个桩函数中检查call site的receiver是否是circle亦或是rectangle,并把控制权交到对应的方法。由于这时候receiver的类型已经确定了,所以在每个函数之前的类型检测就会被跳过。这里仍然带着类型检测是因为每个方法仍然有可能是从单态call site被调用,并且使用的是标准的内联缓存。

当这时候PIC桩函数缓存失效,即receiver既不是circle,也不是rectangle。这时候桩函数会进行扩展,将新的分支加进来。最终,这段桩函数可以包含实际存在的所有情况,也就不存在缓存失效了。所以,PIC是一个可扩展缓存,并且里面的任何缓存都不会被新加进来的替换。

PIC细节和问题优化

PIC能够很好的解决多态消息派发时所造成的资源消耗,但是PIC有时会遇到一些特别的情况。下面将讨论PIC的一些细节以及可能的问题和它们的解决方案。

处理megamorphic派发

在一个系统中,有时候会去派发一些很多对象都会具有的公共消息。例如,某个call site会向系统中所有对象派发一个display()消息,对象的数目可能数十上百。对于这种类型的派发,我们叫做megamorphic,如果去建立一个这样的PIC,无疑是浪费时间。因此,在内联缓存丢失的时候,PIC扩容会有一个上限值。换句话说,PIC会做一个标记,当发现它变成megamorphic的时候,会采取一种回退策略,比如调整为传统的单态内联缓存。

提高线性搜索效率

PIC桩函数中会存在几条可能的分支类型(前面提到过,一般小于10条),PIC可以获取每个类型的使用频率,然后周期性的重新排序桩函数,将使用频率高的放在桩函数的前面,减少在桩函数中的分支判断的平均次数。所以,如果发现线性搜索的效率不高时,可以尝试二分查找或者其他哈希形式的算法来应对这种类型较多的情况。

当然,由于PIC桩函数中,分支的数目一般不会很多,所以这种专门的优化可能并不值得。并且在实践中,线性搜索就是PIC最快的方法。

内联短方法

在一个系统中会存在很多短方法,例如仅仅返回一个对象内部变量的get方法,这类方法在实际中非常普遍。所以,在多态派发的call site中,短方法可以直接把代码被嵌入到PIC的桩函数中,从而省去了派发接受的过程。
这里写图片描述

例如,当查找程序找到一个方法仅仅返回圆的面积,相比每次都要去调用这个方法,把简短方法就嵌入到PIC的桩函数中,可以省去调用过程。

提高空间效率

PIC会比普通的内联缓存占据更多的空间,因为每个多态call site都会有一个桩函数关联。因此,当空间紧张的时候,那些具有相同消息名的call site会共享一个PIC桩函数来减小空间。然而,这种做法有时候是通过牺牲时间来减小空间的,因为共享PIC是汇聚了同名消息下所有call site的receiver分支 ,所以有时候会浪费判断在一些不属于当前call site的内容上,这样导致了花费的时间增多。

所以在使用共享PIC的时候,一定要考虑到可能的时间增加。同时,对于共享PIC,如果其中存在的类型很多,使用哈希表和共享PIC结合也是很好的选择,它们的实现仍然要比全局查找快。

结尾

最近在看浏览器方面的内容,也就了解了这个无论在V8还是squirrelfish中都占据一席之地的加速技术。

在chrome的V8引擎中,采用隐藏类加内联缓存的方法来处理对象属性的访问操作;在squirrelfish官方中有专门这项技术的介绍。原本这项技术诞生在smallTalk和SELF之间,现在随着对JavaScript速度需求的提升,这项技术也是大放光彩。我,未来包括PIC和webAssesmely以及其他技术,可以将JavaScript的性能提升达到原生代码的效率。终有一天,我们可以抛弃大部分客户端,而在网页上完成复杂的工作。

原创粉丝点击