[讨论]CPython能否用V8的方式优化性能

lurker0 2010-02-19
我知道现在有Unladen Swallow项目正在做CPython的优化。
流程是弃用现有的Python VM和Bytecode,改用LLVM那一套做优化。

我在考虑一个问题:
就是Python其实很大一部分应用是使用源代码做Wire format。
就这一点而言,和Javascript是一致的。
既然现在的V8是直接编译为机器码获得性能提升,那么CPython
为什么不用同样的方法来提升性能。

编译为VM bytecode,多出了一个中间环节。目前为止,我能看到的好处就是可以跨平台。但是主流的Python受限于Python库的实现,还是在同平台运行(比如Jython就没法直接用CPython的库,甚至ARM平台的CPython实现也没法用X86的CPython库)。感觉这样做意义不是很大。

我想对于Unladen Swallow项目的设计者来说好处就是自己不用管优化的部分,让llvm的人做持续优化。而对于用户来说,既然这个项目以性能做目标,为什么不做彻底一点,干脆编译到机器码。

我想知道采用V8的方式重构CPython的代价有多大?值不值得?
欢迎大家讨论。

RednaxelaFX 2010-02-19
那个……如果我了解的没错的话,Unladen Swallow并没有“放弃现有的Python bytecode”。事实上它内部仍然包含有字节码解释器,解释的对象也是跟原来一样的字节码,只是解释器的实现方式跟3.1之前的CPython不同;另外也有基于LLVM的JIT来编译热代码。

========================================================

实现虚拟机不只有性能重要,有很多时候性能都不是主要的关注点,所以可以看到有各种不同的tradeoff带来各种不同的设计。从解释到编译可以有连续的过渡过程。我另外有篇草稿,“虚拟机”系列的第二篇是讲这个的,不过那篇太乱就先不扔出来了。先直接在这边简单说说。

我们要让某种编程语言写的程序的运行,就需要相应的语言处理器,像是解释器或者编译器。

最简单的是源码级解释器。这里是指直接在匹配了源码中的文本后就解释执行其语义,连语法树都不构造的解释器。其中,甚至有不需要完整的语法分析器就能完成解释的,只要源语言足够简单。许多“栈语言”(stack language或者叫stack-oriented language)的语法实际上就是后缀记法的,这类语言如果想简单的直接在源码上解释,实现起来很容易。
源码级解释器的优点在于实现非常简单,间接层少,适合处理简单的、小型的语言。如果源语言有分支、循环、函数调用之类的控制流结构,那么源码级解释器的缺点就暴露出来了:源码被处理一趟之后保存下来的中间过程信息太少,要重复做的事情太多,因而执行效率非常低。例如说执行一个循环,每轮循环都得从文本开始分析一直到得到运算结果;跳转目标的定位也是个问题。
相似的还有“行解释器”,以源码中的“行”为单位进行解释。有些BASIC的解释器就是这种。

那么稍微改进一下,可以先分析源码得到对应的语法树(或者抽象语法树),在树上实现解释器。这样至少不用重复执行文本分析了。
树解释器的执行效率仍然不太高。典型的树遍历算法是通过递归实现的,典型的树解释器也一样。而且树解释器还有另外一个问题:语法树在这里可能有两重身份,一是与源码比较相关的,例如说树的节点可能会记录对应源码的行号、列范围之类;二是与执行相关的,也就是树的节点所代表的语义的部分。这样,树解释器里的语法树势必会比较臃肿,不利于高效的解释执行。从代码组织的整洁性看,一个数据结构同时担负两种不同的责任也不太好。

再进一步,可以将语法树转换为某种线性的中间表示,便于执行;如果解析与生成中间代码被塞到同一趟里,那么可以不显式构造出语法树。如果这种中间表示是用来立即执行的,就很有可能被实现为“字节码”(意味着每个指令的操作码只占一字节),像是CPython,这样比较省空间;也有考虑到对齐的数据访问较快而用机器字长来实现操作码的,例如YARV,虽说概念跟字节码相似不过占的空间会多些。在这样的中间表示上实现的解释器可以笼统的称为“字节码解释器”。
线性的中间表示比树这种链式结构要更紧凑些,遍历起来也更方便些;它也很可能不继续记录与执行关系不大的信息,也使得它比语法树紧凑。从代码组织上看,内部采用字节码解释器的解释器中,源语言到语法树,语法树到中间表示,中间表示到最终执行结果,各层间分工明确,也就便于组织得整洁。
对直接用于执行的中间表示来说,如果要追求节省空间,那么中间表示采用基于栈的字节码是很常见的选择,假如中间表示要被持久化也是如此;如果追求执行效率,基于寄存器的中间表示则更合适一些。

字节码解释器按照指令的分派方式也可以分为许多种不同的threaded-code。
switch-threading就是C之类的语言中很直观的解释器实现方式,在解释器主循环里放一个大switch语句来分派之类。这种方式对平台依赖性小,易于移植,但由于其中的分派跳转难以预测,在现代处理器上执行效率会比较低。打上某补丁前的CPython的解释器用的就是switch-threading。
token-threading的话就是拿token(例如说字节码指令中的操作码)查跳转表来分派指令,并且把取指令与分派指令的代码放在每个指令处理程序的末尾,减少跳转次数也提高跳转的可预测性。打了上面提到的那个补丁之后CPython 3.1就变成在支持first-class label的地方用computed goto来实现token-threading,在不支持的地方跟以前一样用switch-threading。
direct-threading的话干脆把每条指令对应的处理程序的地址当成指令,进一步减少分派指令的开销,代价是中间表示占的空间会比token-threading、indirect-threading之类的方式要多些,而且direct-threading会对平台产生一定依赖。
subroutine-threading与context-threading用函数调用来实现指令处理程序,于是指令分派变成了函数调用与返回,更多的依赖硬件来实现分派。
我自己是觉得当指令分派完全不是在解释器里做而是在生成的代码直接完成的话,这样的实现方式就可以叫编译而不叫解释了。当然这里有很大的讨论余地,大家可能会从各自的出发点得出不同的观点。

更进一步,字节码可能有些序列频繁重复出现,可以把它们合并起来处理。像是说两条加载局部变量的指令与一条加法指令可能被发现经常挨在一起出现,那么可以造一条“超级指令”取代这种指令序列。使用超级指令可以减少指令的条数,也就减少了指令分派的开销,代价是指令处理程序需要占用更多空间。超级指令可以看作“代码复制”(code replication)的一种特殊形式。

既然可以有“频繁重复出现的指令序列”层次上的“超级指令”,代码复制也可以应用在别的层次上,例如说源程序的函数/方法层次。也就是把一个函数/方法里的所有字节码的处理程序复制到一块儿,那么在函数/方法内部就没有字节码指令的分派开销了。这也就是迈向编译的一步。比起前面的各种threaded-code,使用代码复制会带来可观的代码膨胀,需要占用更多空间。如果影响到了cache表现,反而有可能降低性能。所以这些实现方式都需要调教(tune)就是这样,得根据实际情况来做调整。
前面提到context-threading,没记错的话它是在基本块层次上应用了代码复制技巧,于是消除了基本块内的字节码指令分派开销。

再接下来逐渐转入编译的领域,中间表示不是用来立即执行,而是便于分析和优化用。这样的话基于栈的中间表示就没有什么优势,比较流行的是采用基于寄存器的图或线性表示。接下来可以做的优化就多了,这里不展开说。

========================================================

上面乱七八糟说了那么多,在解释器里用字节码有什么好处呢?总结一下大概有下面几点:
1、相对源码级解释器和树解释器,使用字节码有利于将代码组织得整洁;
2、相对源码级解释器和树解释器,使用字节码有利于减少重复的操作,提高执行效率;
3、……,字节码比源码或语法树都更为紧凑,可以减少对空间的占用;
4、字节码本身一般是平台无关的,可以持久化之后当作程序的可移植表示使用,像Java就是很典型的例子。

而在编译器中,使用字节码就不见得有什么好处了。反正编译过程中用于分析代码用的中间表示在编译结束后都要扔掉,如何能方便的将数据表现出来更为重要。正是如此,因为基于寄存器的中间表示将数据的依赖关系直接表现在指令中(特别是SSA形式的中间表示),比基于栈的中间表示更易于分析,所以编译器中一般是采用基于寄存器的中间表示。

========================================================

V8在“执行程序”方面快是快在:
1、纯编译。直接从抽象语法树生成机器码,不使用其它中间表示;尽可能多的把对代码的操作压缩在一趟内;使用简单的寄存器分配算法。这使得编译速度很快,但生成出来的代码指令不会很好;编译是lazy的JIT方式触发,只有真的被调用过的函数(代码块)的内容才会被编译;
2、使用隐藏类来弥补JavaScript没有类带来的效率损失;
3、使用tagged pointer来实现小整数,显著提高小整数的运算效率,并减轻GC压力(小整数不需要在堆上创建对象);
4、使用inline caching来应对JavaScript程序的动态性,显著减少函数调用的开销。

在别的方面,像是GC、多线程等,V8也用了些简单高效的方式。

相比之下,CPython是:
1、纯解释。不采用编译,而采用解释方式实现;解释器中,中间表示是基于栈的字节码;
2、Python语言有类,不需要用隐藏类的技巧;但由于类可变,所以也可以采用类似带版本号的隐藏类的方式来加快成员访问的速度,但CPython没有实现这样的优化;
3、没有用tagged pointer,连整数运算都涉及对堆上对象的操作;
4、没有使用inline caching;不过要用的话,当前的字节码设计可能也不太合适。

Unladen Swallow现在的编译过程其实很长:Python源码先变成抽象语法树,做少量优化,然后生成Python字节码用于解释执行;热的函数会从字节码被转换为LLVM IR,然后再被编译为本地代码。同样使用LLVM的MacRuby则是把Ruby源码变为抽象语法树之后直接生成LLVM IR(抛弃了YARV指令集),这至少就比Unladen Swallow的编译过程短一些;不过要考虑到MacRuby在使用LLVM后变成纯编译方式执行了,对它来说YARV字节码确实没啥意义,而Unladen Swallow还要用原本的字节码来解释执行。
Unladen Swallow采用现在的方式也很好理解:资源不足,为了减少工作量也为了保证兼容性只好尽量多用CPython原有的实现。有少量Python库会用ast、dis之类的模块,直接用CPython原有的源码->字节码编译器实现就不用担心不兼容。

如果要像V8一样纯编译,那么Python字节码是应该扔掉的,于是Python源码->字节码编译器就得改一堆地方;如果要用tagged pointer,整个对象系统的实现都需要修改。“用V8的方式来重构CPython”这个恐怕是相当麻烦的事,搞不好比从头写个新的Python实现还麻烦。不过从头写的话要达到完全与CPython兼容就又是一个漫长的过程。

采取什么方式值不值得这是要看手上有多少资源,要达到什么目标的。如果说某公司有人力有物力,有80%的代码都是用Python写的,实在是碰到性能问题了,那它组织开发个新的Python实现怎么做都“值得”。如果像我们就一两个人自己玩玩的话,那怎么做都是“不值得”,不是么? >_<|||
lurker0 2010-02-20
我的砖抛得实在是太值了!!!!!

RednaxelaFX的回复比看V8的文档管用。 哈哈。
还有一些疑问,请教一下:
引用
1、纯编译。直接从抽象语法树生成机器码,不使用其它中间表示;尽可能多的把对代码的操作压缩在一趟内;使用简单的寄存器分配算法。这使得编译速度很快,但生成出来的代码指令不会很好;编译是lazy的JIT方式触发,只有真的被调用过的函数的内容才会被编译;

这样的设计是不是还是处于对启动速度和编译代价的考虑?反正V8现在已经在JS解析方法相对竞争对手属于领先,就不需要做更多优化方法。

引用
2、使用隐藏类来弥补JavaScript没有类带来的效率损失;

我第一次看到隐藏类的时候就想到了CPython中的PyTypeObject,
通过解析器把类型信息传送到运行时中实现动态类型的目的。不知道两者是不是基于同一思想?

引用
3、使用tagged pointer来实现小整数,显著提高小整数的运算效率,并减轻GC压力(小整数不需要在堆上创建对象);

能否讲一下V8如何使用tagged pointer提高效率?
我所知道的tagged pointer,是指针的一部分用于类型编码,比如
我规定bit1表示只读,那么指针0x3421表示指向0x3420的只读指针。
这和小整数运算如何结合起来。

引用
相比之下,CPython是:
1、纯解释。不采用编译,而采用解释方式实现;解释器中,中间表示是基于栈的字节码;
2、Python语言有类,不需要用隐藏类的技巧;
3、没有用tagged pointer,连整数运算都涉及对堆上对象的操作;
4、没有使用inline caching;不过要用的话,当前的字节码设计可能也不太合适。

第4条能否讲讲为啥现在字节码不太适合inline caching?
RednaxelaFX 2010-02-20
lurker0 写道
还有一些疑问,请教一下:
引用
1、纯编译。直接从抽象语法树生成机器码,不使用其它中间表示;尽可能多的把对代码的操作压缩在一趟内;使用简单的寄存器分配算法。这使得编译速度很快,但生成出来的代码指令不会很好;编译是lazy的JIT方式触发,只有真的被调用过的函数的内容才会被编译;

这样的设计是不是还是处于对启动速度和编译代价的考虑?反正V8现在已经在JS解析方法相对竞争对手属于领先,就不需要做更多优化方法。

“JS解析”?你是说对JavaScript源码的语法分析么? << 在这个语境下我只能把“解析”理解为“语法分析”或者是“词法+语法分析”。
V8的解析器是手写的递归下降式与运算符优先级相结合的混合解析方式,在手写解析器中是种不错的方式,比纯的递归下降式快些。Sun的javac也是用同样的方式实现的
竞争对手的解析器的话……KDE KJS是用bison生成的,主要是LALR(1)的;Apple JavaScriptCore(现在叫Nitro)源自KJS,也是用bison生成;Mozilla SpiderMonkey的是手写的递归下降式TraceMonkey也是;Microsoft JScript不知道是用什么方式,昙花一现的Managed JScript是用运算符优先级方式。
V8在这之中确实也还算先进的,至少比SpiderMonkey的先进 =_=||

V8的设计理念是“简单、可靠”。虽然V8也追求高执行速度,但“简单、可靠”才是首要的。
在Ajax流行起来之前,页面里嵌的JavaScript主要是些简单的处理,很有可能只执行一次,循环也不多。执行这样的JavaScript程序用解释方式很合适——除非有人硬要在页面上计算fib啊factorial之类的。但在Ajax流行起来之后,JavaScript被用于编写web app,其复杂度和执行时间都显著增加。在这种背景下,使用JIT编译的方式来执行才变得有意义。
既然如此,V8可以选择使用解释+编译的混合执行模式,也可以选择使用纯编译的执行模式。前者在程序的各阶段都能有较好的表现,但实现起来很复杂:要维护解释器与编译器这两套相对独立的系统;中间表示必须同时满足解释与编译的需求,许多别的数据结构也是如此;函数调用也得考虑到调用目标可能在解释模式也可能已经被编译,于是每个函数需要多个入口;等等。V8项目的领导者,Lars Bak参与过多个VM的实现,包括Strongtalk、HotSpot等混合执行模式系统,认为还是简单对V8来说更重要,所以选择了使用纯编译的执行方式。这样,函数调用的目标要么是一个引发编译的stub,要么就是实际要调用的函数,每个函数只需要一个入口就够了。要维护的代码也少一些。最重要是概念简单,可靠性也就容易上去。

纯编译的方式自然会带来较高的启动成本,有几种解决方案:
1、使用单层编译器,用很傻的编译器(dumb compiler)或者说baseline compiler,基本上不做优化,尽量快速生成代码。许多早期JIT都是这类。
2、也是使用单层编译器,但还是根据静态的heuristics(启发参数?)来做一些优化。.NET的CLR就是这种。
3、使用多层编译,在一开始还是用baseline compiler,在收集到足够profile信息后根据代码的“热”的程度使用更高优化程度的方式重新编译部分代码。JRockit、Jikes RVM等属于这种;IBM J9中的JIT编译也是用多层方式组织的;Sun HotSpot在Java 6中通过-server -XX:+TieredCompilation也可以打开JIT编译器的多层编译模式,在Java 7中该模式是-server的默认模式。

V8追求“简单、可靠”,因而最初选择了第一种,只是快速把代码生成出来就算了,没做什么优化。不过随着V8的发展,越来越多开销较少、效果较明显的优化应用到了V8的JIT编译器中,对重复执行的JavaScript程序来说整体性能有所提升,但启动速度毫无疑问是在下降的。

V8里其实有浓厚的Strongtalk、HotSpot血缘,像是其中的MacroAssembler就非常相似。但指导思想不同使得最终出来的整体设计也有不同的取向。

lurker0 写道
引用
2、使用隐藏类来弥补JavaScript没有类带来的效率损失;

我第一次看到隐藏类的时候就想到了CPython中的PyTypeObject,
通过解析器把类型信息传送到运行时中实现动态类型的目的。不知道两者是不是基于同一思想?

这个应该用“是”还是用“不是”来回答好呢……?
JavaScript中的对象(指JavaScript中typeof为object的那些)没有显式表现其“结构”的东西;而Python则有“类”的概念,类型相同的对象实例的结构也是相同(或者至少在类型中定义了的部分的结构相同,实例自己动态扩展的部分用别的办法实现)。
在不知道对象结构时,实现JavaScript对象最直观的方式就是hash或者红黑树之类实现的map,但这样访问对象属性比较慢。如果知道了对象的结构,就可以给每个属性安排一个固定的offset,那么就可以直接用数组来存放对象实例的内容,把key-offset对作为元数据存放在别的地方。V8采用的隐藏类就是挖掘JavaScript对象的结构用的,存着key-offset映射关系的元数据,还存着hidden-class transition。隐藏类还被用于类型反馈(type feedback),与后面的inline caching相关。
Python已经有“类”来记录结构信息,可以直接应用类型相关的优化技巧,这些技巧跟V8挖掘出隐藏类之后应用类型信息做的优化是相通的。

lurker0 写道
引用
3、使用tagged pointer来实现小整数,显著提高小整数的运算效率,并减轻GC压力(小整数不需要在堆上创建对象);

能否讲一下V8如何使用tagged pointer提高效率?
我所知道的tagged pointer,是指针的一部分用于类型编码,比如
我规定bit1表示只读,那么指针0x3421表示指向0x3420的只读指针。
这和小整数运算如何结合起来。

使用tagged pointer来表示小整数或其它少量但常用的不可变对象其实是很常见的做法。以官方版Ruby 1.8为例,Ruby程序中的值在C中都用VALUE来表示,而VALUE是这样定义的:
#if SIZEOF_LONG != SIZEOF_VOIDP
# error  ruby requires sizeof(void*) == sizeof(long) to be compiled.
#else
typedef unsigned long VALUE;
typedef unsigned long ID;
#endif

很明显VALUE有可能被用作void*,否则不会要求sizeof(long) == sizeof(void*)。
VALUE代表的是什么呢?它是一个tagged pointer,可以表示多种数据。
源码中的注释如是说:
/*
 *                32-bit VALUE space
 *          MSB ------------------------ LSB
 *  false   00000000000000000000000000000000
 *  true    00000000000000000000000000000010
 *  nil     00000000000000000000000000000100
 *  undef   00000000000000000000000000000110
 *  symbol  ssssssssssssssssssssssss00001110
 *  object  oooooooooooooooooooooooooooooo00        = 0 (mod sizeof(RVALUE))
 *  fixnum  fffffffffffffffffffffffffffffff1
 *
 *                    object_id space
 *                                       LSB
 *  false   00000000000000000000000000000000
 *  true    00000000000000000000000000000010
 *  nil     00000000000000000000000000000100
 *  undef   00000000000000000000000000000110
 *  symbol   000SSSSSSSSSSSSSSSSSSSSSSSSSSS0        S...S % A = 4 (S...S = s...s * A + 4)
 *  object   oooooooooooooooooooooooooooooo0        o...o % A = 0
 *  fixnum  fffffffffffffffffffffffffffffff1        bignum if required
 *
 *  where A = sizeof(RVALUE)/4
 *
 *  sizeof(RVALUE) is
 *  20 if 32-bit, double is 4-byte aligned
 *  24 if 32-bit, double is 8-byte aligned
 *  40 if 64-bit
 */

可以看到Ruby 1.8使用了VALUE中的多个位当作tag,从而实现tagged pointer。当最低位是1时,一个VALUE是一个Fixnum;当最低位为0时,VALUE可能是指向对象的指针;一些特殊对象的VALUE值非常接近0,这个区域一般是不可写的,一般对象不会分配到这里,正好可以利用上。
然后看看将C的int转换为VALUE的宏:
#define FIXNUM_FLAG 0x01
#define INT2FIX(i) ((VALUE)(((long)(i))<<1 | FIXNUM_FLAG))

FIXNUM_FLAG就是Fixnum对应的tag。通过tagged pointer技巧,Fixnum(Ruby里的小整数)就可以直接用VALUE表示。Fixnum之间的运算只要不溢出都可以在剥除tag后直接用C的相应运算来实现,比较快,也不需要为Fixnum对象在堆上创建实例。

很明显Ruby 1.8的这种实现是更偏向与指针访问的效率,而稍微牺牲了小整数运算的效率。
V8也采用了tagged pointer,但tag是加在指针上。在V8中,最低位为1的是加了tag的指针,解引用时要先将tag去除;最低位为0的是左位移了1位的小整数,做加减运算时不需要位移,可以直接算,反正末尾的0不会变。这样牺牲了指针的访问效率而提高了小整数的运算效率。

看看V8为function addOne(x) { return x + 1 }生成的代码是怎样的:

实际实现+ 1的是位于0x02E32FD4的add eax,0x2指令。可以看到加的是2,这是常量1经过tagged pointer编码后的值。

前面也提到,Lars Bak参与过Strongtalk的实现。无独有偶,Strongtalk也是在指针一侧加tag来实现tagged pointer;用了值的低2位做tag,tag为1的值是加了tag的指针,tag为0的是左位移了1位的小整数,表示Smalltalk的SmallInteger。这里也可以看出V8与Strongtalk的渊源。
Strongtalk会经常使用addr()方法将oop去掉tag转换为oopDesc*,其中一处实现是:
// conversion from memOop to memOopDesc*
memOopDesc* addr() const { return (memOopDesc*) (int(this) - Mem_Tag); }

几种tag分别是:
const int Int_Tag      = 0;
const int Mem_Tag      = 1;
const int Mark_Tag     = 3;
const int Mark_Tag_Bit = 2;	// (oop & Mark_Tag_Bit) != 0  --> oop is a markOop

const int Tag_Size     = 2;
const int Tag_Mask     = nthMask(Tag_Size);
const int Num_Tags     = nthBit(Tag_Size);


lurker0 写道
引用
相比之下,CPython是:
1、纯解释。不采用编译,而采用解释方式实现;解释器中,中间表示是基于栈的字节码;
2、Python语言有类,不需要用隐藏类的技巧;
3、没有用tagged pointer,连整数运算都涉及对堆上对象的操作;
4、没有使用inline caching;不过要用的话,当前的字节码设计可能也不太合适。

第4条能否讲讲为啥现在字节码不太适合inline caching?

嗯……大概是因为Python字节码没有包含类型吧?我是根据Unladen Swallow的Project Plan页面上的描述这么写的。
Unladen Swallow Project Plan 写道
The current CPython VM opcode fetch/dispatch overhead makes implementing additional optimizations prohibitive. For example, we would like to implement type feedback and dynamic recompilation ala SELF-93 (Hölzle, Chambers and Ungar, 1992), but we feel that implementing the polymorphic inline caches in terms of CPython bytecode would be unacceptably slow.

如果要改造CPython的字节码解释器,为它添加类型反馈相关的优化(包括inline caching)会很麻烦,因为指令本身是多态的,无法作为类型反馈的依据。那么得把针对反馈的类型信息的特化处理放在解释器外面,动态生成。
类型反馈一般是记录在调用点(call site)上。这是根据一种观察:在同一个调用点上出现的对象类型可能只有1个或很少量的多个。
a.foo(b)  # 1
a.foo(b)  # 2

这里的1与2虽然都是对foo()方法的调用,但却是两个调用点。也就是说,一个callee可以有多个caller,每个caller里都有独立的调用点。

对一段Python代码:
a = 1
b = 2
c = a + b     # 1. int之间的二元加法
d = "d"
e = "ef"
f = d + e     # 2. str之间的二元加法

class Foo():
  def __init__(self,i):
    self.value = i
  
  def __add__(self,o):
    return self.value + o.value

f1 = Foo(3)
f2 = Foo(2)
f3 = f1 + f2  # 3. Foo之间的二元加法

虽然上面三处对二元“+”的调用点实际在运行时遇到的类型不同,但它们都被编译为BINARY_ADD这个Python字节码指令。从效果上看,在解释器中二元“+”都被压缩为同一个调用点来处理了。这样,在BINARY_ADD指令的处理程序中,能收集到的类型信息就会十分混杂,无法满足“一个调用点上出现的对象类型很少”的假设,也就很难应用类型反馈来优化。干脆把二元“+”全都编译为LOAD_NAME n (__add__); CALL_FUNCTION 2这样的字节码指令序列,然后为每条CALL_FUNCTION指令都关联一个CallSite对象来记录类型反馈信息,并安装相应的inline cache,实现起来还更简单高效些。IronPython就应用了这种思想。
lurker0 2010-02-21
引用
干脆把二元“+”全都编译为LOAD_NAME n (__add__); CALL_FUNCTION 2这样的字节码指令序列,然后为每条CALL_FUNCTION指令都关联一个CallSite对象来记录类型反馈信息,并安装相应的inline cache,实现起来还更简单高效些。IronPython就应用了这种思想。

我能理解ByteCode对类型信息的丢失,按照上面例子,就是不同类型对象的加法实现对应到字节码只有一种。关键是这句“为每条CALL_FUNCTION指令都关联一个CallSite对象来记录类型反馈信息”,这个关联是如何实现的?
如果是一种映射关系的话,是不是每次解析CALL_FUNCTION都要查找一次?
RednaxelaFX 2010-02-21
lurker0 写道
引用
干脆把二元“+”全都编译为LOAD_NAME n (__add__); CALL_FUNCTION 2这样的字节码指令序列,然后为每条CALL_FUNCTION指令都关联一个CallSite对象来记录类型反馈信息,并安装相应的inline cache,实现起来还更简单高效些。IronPython就应用了这种思想。

我能理解ByteCode对类型信息的丢失,按照上面例子,就是不同类型对象的加法实现对应到字节码只有一种。关键是这句“为每条CALL_FUNCTION指令都关联一个CallSite对象来记录类型反馈信息”,这个关联是如何实现的?
如果是一种映射关系的话,是不是每次解析CALL_FUNCTION都要查找一次?

分开两层来说。

首先是BINARY_ADD指令与通过CALL_FUNCTION指令调用__add__方法的最大的区别:BINARY_ADD自身包含有处理int、float与str的加法运算的逻辑,这是条“fast-path”;在fast-path里无法处理的则退到调用PyNumber_Add,其中会委托给实际的__add__方法处理,这是slow-path。这样,fast-path与slow-path都是写死的,无法应对每个调用点实际遇到的类型来做调整。而CALL_FUNCTION不关心调用目标是什么,只是去完成调用而已;它没有把任何具体的运算逻辑写死在指令的处理中。
假如要为BINARY_ADD(以及其它特化的运算指令)添加类型反馈相关优化,那么fast-path的内容应该是根据调用点的实际状况动态生成和调整的;既然如此它实际上就跟CALL_FUNCTION去调用一个会动态调整的目标一样,那么也就没必要专门弄一个特化的BINARY_ADD指令出来了。

然后是CALL_FUNCTION指令能如何利用类型反馈信息。其实办法有很多啦,最不济可以建一个全局共享的映射表,以function+bytecode_index为key,以关联的CallSite对象为value,这样在执行CALL_FUNCTION指令时可以先查询这个表获取对应的CallSite对象,并根据其中的信息跳转到实际目标(或查找到实际目标后更新CallSite对象的状态然后再跳转到实际目标)。不过全局的表势必会很大,查询起来效率会比较低。
现在CPython里与函数相关的对象有两层:一层是PyCodeObject,保存函数的代码和结构信息,都是“静态”信息,每一块代码只有一份;另一层是PyFunctionObject,在每次def语句执行的时候创建一个,里面保存着一些运行时Python函数共享的信息,像是全局变量的引用等。还有PyFrameObject是函数被调用时关联的活动记录,以它为栈帧串起来就是Python的栈。“调用点”是静态确定的,那么可以在PyCodeObject中保存一组CallSite对象,每个对应于该PyCodeObject中出现的一条CALL_FUNCTION指令所表示的调用点;在执行CALL_FUNCTION时,Python虚拟机可以知道当前是在哪个函数中,字节码偏移量是多少,那么可以根据这个信息在PyCodeObject中找到CallSite对象,这样就不用维护一个全局的查询表了。当然更好是能稍微修改一下Python的源码->字节码编译器,在编译时直接把加载CallSite对象的逻辑写到字节码中,这样“查找”就在编译时完成了,后面执行的代价会小些。
在HotSpot的解释器中有类似的东西。HotSpot用methodOop对象来代表Java方法,其中记录着方法的字节码、常量池、方法入口、调用计数器、回边计数器等等,可以与PyCodeObject对应。每个methodOop可以引用一个methodDataOop对象,在methodDataOop中保存有一组DataLayout对象,按方法中字节码出现的顺序排列。每个DataLayout对象记录了一些profile信息,它们的header里记录有BCI(bytecode index),可以知道对应与方法中哪条字节码指令。使用ProfileInterpreter选项启动HotSpot的话(client默认不使用,server默认使用),解释器执行字节码时就会不断更新methodDataOop里的profile信息,为后面JIT编译器提供优化的依据。

IronPython 2.x使用了DLR。DLR无法执行Python字节码,只认语言中立的Expression Tree。于是Python代码的逻辑在IronPython中是由Expression Tree编织起来的,而每个Python函数调用都变成了对一个CallSite对象的调用,包括二元“+”“-”等。可以参考这篇老帖的描述,虽然具体过程跟现在的DLR有点不同了不过思想没怎么变;其中提到的level 0、1、2 cache可以参考这篇。DLR的CallSite对象也可以叫“stateful call site”,记录着调用点遇到的类型信息,并持有一个内容会被动态更新的委托,里面就是inline cache。DLR采用的inline cache是从monomorphic到polymorphic到megamorphic转换的,许多别的采用inline cache的实现则倾向于跳过polymorphic状态,只从monomorphic到megamorphic转换。
这种实现可以看作CPython的所有运算和调用都用CALL_FUNCTION,配上记录类型反馈信息的CallSite。
Global site tag (gtag.js) - Google Analytics