关于Doug的方法查找补丁笔记

加速方法查找


加速方法查找

上周,Doug MacEachern提供了一个有趣且极具创意的补丁来加速方法调用。他的基准测试显示,它完全减少了使用方法的开销,并且似乎表明方法调用实际上可能比子程序调用更快。让我们详细看看这个补丁,看看它是如何做到的。

Sarathy最初的思考,这是所有这一切的起点

    If @ISA isn't modified at run time, Foo::->bar() could be resolved
    at compile time to a subroutine call, as can this:

    my Foo $obj = shift;
    $obj->bar();

正如所有关于Perl面向对象编程的教程正确指出的那样,当你调用一个方法时,你是在调用一个子程序。然而,区别在于方法调用必须执行查找以确定其子程序所在的包。

在没有继承的正常情况下,Foo->bar()将是包Foo中的子程序bar。然而,由于继承,仅通过查看Foo->bar(),你无法知道bar在哪里 - 例如,Foo可能从Frob继承了bar

为了解决这个问题,当你调用一个方法时,Perl会在包Foo中查找子程序bar。如果没有找到,它会检查Foo的父包:所有列在Foo@ISA数组中的包。然后它会检查祖父母,依此类推。这个查找必须在运行时执行,而不是编译时,因为@ISA是一个普通变量 - 某人可能在你脚下改变继承树。

Sarathy的想法是,如果你在执行编译时确信@ISA中会包含什么,你就可以将昂贵的方法调用转换为稍微便宜一点的子程序调用。这正是Doug的补丁所做的事情。

我们可以将补丁分为两部分:函数method_to_entersub执行转换并告诉我们如何做到这一点,以及调用method_to_entersub的代码部分,告诉我们何时做到这一点。我们首先看看何时。

不过,在那样做之前,我们先了解一下子程序调用是如何在内部工作的。

Perl以类似的方式为方法调用和普通子程序调用构建entersub操作符。区别在于对于子程序调用,entersub操作符之前有一个操作符用于获取存储子程序代码的GV,而对于方法调用,entersub操作符之前有一个操作符用于在继承树中查找方法名。你可以在以下B::Terse输出中看到这一点。

        % perl -MO=Terse,exec -e 'bar()'
        ...
        SVOP (0xa043b00) gv  GV (0xa04ecf8) *bar              <- cvop
        UNOP (0xa0b6a60) entersub [1]
        ...

然而,对于方法调用,Perl还会附加一个表示类的操作符(让我们称它为o2,因为这是任何好名字),以及svop,而不是GV,它成为一个特殊的操作符,表示应在该类中调用方法。

    % perl -MO=Terse,exec -e 'Foo->bar()'
    ...
    SVOP (0xa043b00) const  PV (0xa0411f0) "Foo"          <- o2
    SVOP (0xa043b40) method_named  PVIV (0xa04ecf8) "bar" <- cvop
    UNOP (0xa0d0500) entersub [1]
    ...

对于类方法,o是一个常量,而对于对象方法,o2获取对象,使我们能够在运行时确定类。

    % perl -MO=Terse,exec -e '$foo->bar()'
    ...
    SVOP (0xa043b00) gvsv  GV (0xa04ecf8) *foo            <- o2
    SVOP (0xa0d5720) method_named  PVIV (0xa0411f0) "bar" <- cvop
    UNOP (0xa0d04d0) entersub [1]
    ...

method_named是表示命名字符串操作符的操作符。你还可以像这样动态调用方法:

                Foo->$methname()

在这种情况下,method操作符之前有一个操作符用于从$methname获取方法名。在这个例子中,它是一个简单的变量查找。

    % perl -MO=Terse,exec -e 'Foo->$x()'
    ...
    SVOP (0xa043b00) const  PV (0xa0411f0) "Foo"          <- o2
    SVOP (0xa043b60) gvsv  GV (0xa04ed70) *x              <- child of cvop
    UNOP (0xa043b40) method                               <- cvop
    UNOP (0xa0d04d0) entersub [1]
    ...

我们想要捕捉这两种方法风格的 cvopmethod_namedmethod,并查看我们是否能在编译时执行方法查找。一旦我们知道将调用哪个子程序,我们就可以更改操作树,以便它直接调用子程序,这样在运行时实际调用方法时,Perl 就不必再搜索继承树了。

 --- ./op.c.orig Fri Jun  2 15:49:32 2000
 +++ ./op.c  Thu Jun 15 22:45:33 2000
 @@ -6243,6 +6309,12 @@
          } 
      }
      else if (   cvop->op_type == OP_METHOD 
               || cvop->op_type == OP_METHOD_NAMED) {
 +        if (o2->op_type == OP_CONST || o2->op_type == OP_PADSV) {
 +            OP *nop;
 +            if ((nop = method_to_entersub(aTHX_ o2, cvop))) {
 +                return nop;
 +            }
 +        }
          if (o2->op_type == OP_CONST) {
              o2->op_private &= ~OPpCONST_STRICT;
          }

如果你不习惯阅读补丁,前三条线告诉你我们在哪里:补丁应用前 op.c 中的第 6243 行,应用补丁后变为第 6309 行的 13 行。以加号开头的行是要添加的。

第 6243 行位于函数 Perl_ck_subr 的中间,该函数是在 entersub 操作码被提交时调用的检查例程。检查例程接收一个操作码并产生一个“清理”版本,执行任何优化或消除冗余。Perl 将编译阶段构建的操作树中的每个操作码替换为这些优化版本。

如上所述,我们此时将处于三种情况之一:普通子程序,它将在我们的 entersub 操作码上附加一个 GV 操作码;命名方法,它附加有 method_named;或无命名方法,它附加有 method。我们关注当 cvopmethodmethod_named 操作码的情况。

 else if (   cvop->op_type == OP_METHOD 
          || cvop->op_type == OP_METHOD_NAMED) {

我们正在修复类方法,这意味着当 o2 是一个常量时,我们尝试进行转换。我们还想修复 my Dog $sam; $sam->bark;,在这种情况下,o2,即对象,是一个词法变量:(在内部术语中,是一个 pad sv。)

 if (o2->op_type == OP_CONST || o2->op_type == OP_PADSV) {

在这些情况下,我们尝试将方法转换为子程序,如果成功则返回新的方法

     OP *nop;
     if ((nop = method_to_entersub(aTHX_ o2, cvop))) {
         return nop;
     }

最后的修饰是原始源代码的一部分:在 Foo->bar 中,Foo 明显是一个类名,在 strict 检查裸词时不应触发警告。因此,我们从类名常量中删除了严格的测试。

 if (o2->op_type == OP_CONST) {
     o2->op_private &= ~OPpCONST_STRICT;
 }

那就是“何时”。现在让我们看看“如何”。

如何转换方法

在检查例程内部,变量 svop 保存了指定 Perl 如何定位实际调用的子程序的 methodmethod_named 节点;o 是在执行此节点之前执行的节点,它获取类名或包含将被调用方法的对象的值的 GV。例如

    Foo->bar():

    SVOP (0xa043b00) const  PV (0xa0411f0) "Foo"          <- o
    SVOP (0xa043b40) method_named  PVIV (0xa04ecf8) "bar" <- svop
    UNOP (0xa0d0500) entersub [1]
    ...

    $foo->bar():

    SVOP (0xa043b00) gvsv  GV (0xa04ecf8) *foo            <- o
    SVOP (0xa0d5720) method_named  PVIV (0xa0411f0) "bar" <- svop
    UNOP (0xa0d04d0) entersub [1]

还有一个 method 变量,它保存了包含在 o 中的 SV。我们的函数首先从中获取方法名称

 if (svop->op_type == OP_METHOD_NAMED) {
     methname = SvPV(method, methlen);
 }
 else {
     return Nullop;
 }

如果不是命名方法(即,如果我们有类似 $foo->$bar() 的东西),我们无法对其进行任何操作。我们返回假值 Nullop,这是一个空指针被转换为操作码,向调用例程发出信号,表示我们无法修改程序。

 if (cvop->op_type == OP_METHOD_NAMED &&
     o2->op_type == OP_CONST || o2->op_type == OP_PADSV) {

现在,我们正在修复两件事:当 o 是常量时的类方法调用,以及 my Dog $sam 情况,其中 o 是一个 pad SV。

在这个函数内部,我们有三个任务

找到存储库

存储库是一个包符号表,例如 Perl 空间中的 %Foo::。我们需要找到我们的方法所属的类,并从中提取符号表。

提取GV条目

我们正在尝试创建一个 entersub 操作码,例如调用 &Foo::bar。我们已经有了 %Foo::(如上所述):下一个任务是找到 *Foo::bar。在此阶段,我们想要处理继承。

重写entersub

最后,我们调整操作树,将 entersub 的方法形式转换为仅调用我们刚刚找到的 GV 的子程序调用。

在这个过程中,我们还需要注意 Perl 缓存方法查找的事实。

查找stash

我们正在尝试找到GV *Class::Method,以便将其传递给entersub。因此,我们首先需要做的事情是找到该类的包符号表,或者说是“stash”。目前有两种可能的情况,这决定了我们如何找到stash:要么我们有一个常量类,要么对象是词法。

如果我们有一个常量类,找到stash很简单:隐藏在SvOP o中的SV的PV告诉我们包名,而gv_stashpvn根据PV返回stash。

 if (o->op_type == OP_CONST) {
     STRLEN len;
     char *package = SvPV(((SVOP*)o)->op_sv, len);
     stash = gv_stashpvn(package, len, FALSE);
 }

不要害怕第3行中的可怕类型转换:我们只是在从const op获取字符串值。

如果我们有一个词法(pad sv)对象,例如my Dog $sam,生活就稍微有趣一些。

 else if (o->op_type == OP_PADSV) {

我们从当前的pad中获取实际的SV - pad是Perl保持当前块中词法变量的地方,它只是一个普通的数组。

     SV *sv = *av_fetch(PL_comppad_name, o->op_targ, FALSE);

我们希望这个SV存在,并且是对象。如果是对象,SvSTASH将给我们stash,因为Perl在我们使用bless创建对象时存储了stash的指针。

     if (sv && SvOBJECT(sv)) {
         stash = SvSTASH(sv);
     }

否则,我们无法做太多。

     else {
         return Nullop;
     }
 }

这只有两种可能的情况 - 词法和常量 - 但计算机编程有时会把不可能变成可能。Doug坚定地对抗着逻辑的恶魔。

 else {
     return Nullop;
 }

提取GV条目

如果我们有一个stash,我们现在可以尝试找到GV。幸运的是,在gv.c中有一个函数可以做到这一点,并且还会为我们处理继承。

 if (!(stash && (gv = gv_fetchmeth(stash, methname, methlen, 0)) 
             &&  isGV(gv))) {
     return Nullop;
 }

如果gv_fetchmeth失败,或者由于某种原因返回的不是gv,那么我们无法找到实际包含该方法的包,所以我们放弃并返回Nullop。否则,gv指向包含我们想要entersub调用的子例程的GV。或者,它将在首次查找方法时这样做。然而,如果我们的方法是继承的并由另一个stash提供,gv_fetchmeth将在对象的stash中提供一个指向真实GV的别名。

为了获取真实子例程的代码,我们必须从这个别名中获取原始GV;我们使用GvCV宏来做到这一点。如果从GvCV获取的GV与我们的方法属于不同的类,那么我们有一个别名。我们需要entersub指向原始GV,而不是这个别名。

 if (GvSTASH(CvGV(GvCV(gv))) != stash) {
     gv = CvGV(GvCV(gv)); /* point to the real gv */
 }

在注释中,Doug指出,只有当继承在运行时不改变时,这个缓存的查找才有效。目前还没有方法可以在运行时冻结@ISA,(嘿,这是一个工作!),所以Doug建议作为替代方案,如果有一个缓存的查找,就简单地返回Nullop来终止。

重写entersub

最后,我们有了足够的信息来重写entersub op:我们有一个GV,它包含一个CV,我们现在可以直接调用它。让我们安排一下如何做到这一点。

首先,像以前一样从方法名中删除strict测试。

 o->op_private &= ~(OPpCONST_BARE|OPpCONST_STRICT);

现在找到op_method_named op,并将其更改为创建子程序调用的GV。

 for (mop = o; mop->op_sibling->op_sibling; mop = mop->op_sibling) ;
 op_free(mop->op_sibling); /* loose OP_METHOD{,_NAMED} */
 mop->op_sibling = scalar(newUNOP(OP_RV2CV, 0,
                             newGVOP(OP_GV, 0, gv)));

成功地将转换后的op返回。

 nop = convert(OP_ENTERSUB, OPf_STACKED, o);
 return nop;

结论

我们从这个补丁中获得了很多收益;我们看到了Perl如何查找和调用方法,并展示了Doug的补丁如何将方法转换为子例程。这几乎可以完全消除使用面向对象Perl的开销,但有两个问题。

练习

  1. Doug的补丁需要一种在编译时验证继承树的方法;显然的方法是冻结@ISA。写一个pragma来做到这一点。

    作为加分项和本周神祇的地位,找到一种在编译时验证方法缓存的方法,同时保持@ISA变量。

  2. 此功能仅适用于命名方法;找到一种方法使其适用于从变量或其他方式获取的方法。也就是说,使 Class->$thing() 可转换为子程序。

标签

反馈

这篇文章有什么问题吗?请在 GitHub 上打开一个问题或拉取请求以帮助我们。