Truffle 库指南

Truffle 库允许语言实现对接收器类型使用多态分派,支持实现特定的缓存/性能分析以及对未缓存分派的自动支持。Truffle 库为基于 Truffle 的表示类型语言实现提供了模块化和封装。在使用它们之前,请务必先阅读本指南。

入门 #

本教程将通过一个用例来演示如何使用 Truffle 库。完整的 API 文档可在 Javadoc 中找到。本文档假定您已事先了解 Truffle API 以及如何将 @Specialization@Cached 注解配合使用。

实例说明 #

在 Truffle 语言中实现数组时,为了提高效率,通常需要使用多种表示形式。例如,如果数组是由整数的算术序列(例如 range(from: 1, step: 2, length: 3))构造的,那么最好使用 startstridelength 来表示,而不是实例化整个数组。当然,当写入数组元素时,数组需要被实例化。在此示例中,我们将实现一个具有两种表示形式的数组实现:

  • Buffer:表示由 Java 数组支持的实例化数组表示形式。
  • Sequence:表示由 startstridelength 表示的算术数字序列:[start, start + 1 * stride, ..., start + (length - 1) * stride]

为了保持示例简单,我们将只支持 int 值,并忽略索引越界错误处理。我们还将只实现读取操作,而不实现通常更复杂的写入操作。

为了使示例更有趣,我们将实现一个优化,即使数组接收器值不是常量,也能让编译器对序列化数组访问进行常量折叠。

假设我们有以下代码片段 range(start, stride, length)[2]。在此片段中,变量 startstride 不确定是常量值,因此,start + stride * 2 的等效代码会被编译。但是,如果 startstride 值已知始终相同,则编译器可以对整个操作进行常量折叠。此优化需要使用缓存。我们稍后将展示其工作原理。

在 GraalVM 的 JavaScript 运行时环境的动态数组实现中,我们使用了 20 种不同的表示形式。其中包括常量数组、零基数组、连续数组、空洞数组和稀疏数组的表示形式。某些表示形式针对 byteintdoubleJSObjectObject 类型进行了进一步的专门化。源代码可在此处找到。注意:目前,JavaScript 数组尚未使用 Truffle 库。

在接下来的章节中,我们将讨论数组表示的多种实现策略,并最终说明如何使用 Truffle 库来实现这一点。

策略 1:按表示形式进行专门化 #

对于此策略,我们将首先为两种表示形式 BufferArraySequenceArray 声明类。

final class BufferArray {
    int length;
    int[] buffer;
    /*...*/
}

final class SequenceArray {
    final int start;
    final int stride;
    final int length;
    /*...*/
}

BufferArray 实现具有可变缓冲区和长度,并用作实例化的数组表示形式。序列数组由最终字段 startstridelength 表示。

现在,我们像这样指定基本的读取操作

abstract class ExpressionNode extends Node {
    abstract Object execute(VirtualFrame frame);
}

@NodeChild @NodeChild
abstract class ArrayReadNode extends ExpressionNode {

    @Specialization
    int doBuffer(BufferArray array, int index) {
        return array.buffer[index];
    }

    @Specialization
    int doSequence(SequenceArray seq, int index) {
        return seq.start + seq.stride * index;
    }
}

数组读取节点为缓冲区版本和序列指定了两种专门化。如前所述,为简单起见,我们将忽略错误边界检查。

现在我们尝试让数组读取操作根据序列值的常量性进行专门化,以便在 startstride 为常量时,range(start, stride, length)[2] 示例可以折叠。要确定 startstride 是否为常量,我们需要对其值进行性能分析。为了对这些值进行性能分析,我们需要像这样向数组读取操作添加另一个专门化

@NodeChild @NodeChild
class ArrayReadNode extends ExpressionNode {
    /* doBuffer() */
    @Specialization(guards = {"seq.stride == cachedStride",
                              "seq.start  == cachedStart"}, limit = "1")
    int doSequenceCached(SequenceArray seq, int index,
             @Cached("seq.start")  int cachedStart,
             @Cached("seq.stride") int cachedStride) {
        return cachedStart + cachedStride * index;
    }
    /* doSequence() */
}

如果此专门化的推测守卫成功,则 startstride 实际上是常量。例如,对于值 32,编译器会将其视为 3 + 2 * 2,即 7。限制设置为 1,表示只尝试一次这种推测。增加限制可能会导致效率低下,因为它会给编译后的代码引入额外的控制流。如果推测不成功,即如果操作观察到多个 startstride 值,我们希望回退到正常的序列专门化。为了实现这一点,我们通过添加 replaces = "doSequenceCached" 来更改 doSequence 专门化,如下所示

@NodeChild @NodeChild
class ArrayReadNode extends ExpressionNode {
    /* doSequenceCached() */
    @Specialization(replaces = "doSequenceCached")
    int doSequence(SequenceArray seq, int index) {
        return seq.start + seq.stride * index;
    }
}

现在,我们已经实现了数组表示(包括附加性能分析)的目标。策略 1 的可运行源代码可在此处找到。此策略具有一些优点:

  • 操作易于阅读,所有情况都已完全枚举。
  • 读取节点生成的代码每个专门化只需一位即可记住运行时观察到的表示类型。

如果不是存在一些问题,本教程到此就可以结束了:

  • 新的表示形式无法动态加载;它们需要静态已知,这使得表示类型与操作的分离变得不可能。
  • 更改或添加表示类型通常需要修改许多操作。
  • 表示类需要向操作公开大部分实现细节(无封装)。

这些问题是引入 Truffle 库的主要动机。

策略 2:Java 接口 #

现在,我们将尝试使用 Java 接口来解决这些问题。我们首先定义一个数组接口

interface Array {
    int read(int index);
}

现在,实现可以实现 Array 接口,并在表示类中实现 read 方法。

final class BufferArray implements Array {
    private int length;
    private int[] buffer;
    /*...*/
    @Override public int read(int index) {
        return buffer[index];
    }
}

final class SequenceArray implements Array {
    private final int start;
    private final int stride;
    private final int length;
    /*...*/
    @Override public int read(int index) {
        return start + (stride * index);
    }
}

最后,我们指定操作节点

@NodeChild @NodeChild
abstract class ArrayReadNode extends ExpressionNode {
    @Specialization
   int doDefault(Array array, int index) {
        return array.read(index);
    }
}

这种操作实现的问题在于,部分求值器不知道数组接收器具有哪个具体类型。因此,它需要停止部分求值,并为 read 方法调用发出一个慢速接口调用。这不是我们想要的,但我们可以引入多态类型缓存来解决它,如下所示

class ArrayReadNode extends ExpressionNode {
    @Specialization(guards = "array.getClass() == arrayClass", limit = "2")
    int doCached(Array array, int index,
           @Cached("array.getClass()") Class<? extends Array> arrayClass) {
        return arrayClass.cast(array).read(index);
    }

    @Specialization(replaces = "doCached")
    int doDefault(Array array, int index) {
        return array.read(index);
    }
}

我们解决了部分求值实现的问题,但是在此解决方案中无法表达针对常量步幅和起始索引优化的额外专门化。

这是我们目前发现/解决的问题:

  • 接口是 Java 中众所周知的多态概念。
  • 可以加载新的接口实现,从而实现模块化。
  • 我们找到了一种方便的方法来使用慢速路径中的操作。
  • 表示类型可以封装实现细节。

但我们也引入了新的问题:

  • 无法执行特定于表示形式的性能分析/缓存。
  • 每次接口调用都需要在调用站点上进行多态类缓存。

策略 2 的可运行源代码可在此处找到。

策略 3:Truffle 库 #

Truffle 库的工作方式与 Java 接口类似。我们不是创建一个 Java 接口,而是创建一个扩展 Library 类的抽象类,并用 @GenerateLibrary 进行注解。我们创建抽象方法,就像接口一样,但在开头插入一个接收器参数,在我们的示例中为 Object 类型。我们不执行接口类型检查,而是在库中使用一个显式抽象方法,通常命名为 is${Type}

我们为示例这样做

@GenerateLibrary
public abstract class ArrayLibrary extends Library {

    public boolean isArray(Object receiver) {
        return false;
    }

    public abstract int read(Object receiver, int index);
}

这个 ArrayLibrary 指定了两个消息:isArrayread。在编译时,注解处理器会生成一个包保护类 ArrayLibraryGen。与生成的节点类不同,您无需引用此类。

我们不是实现 Java 接口,而是在表示类型上使用 @ExportLibrary 注解来导出库。消息导出是使用表示上的实例方法指定的,因此可以省略库的接收器参数。

我们以这种方式实现的第一种表示形式是 BufferArray 表示

@ExportLibrary(ArrayLibrary.class)
final class BufferArray {
    private int length;
    private int[] buffer;
    /*...*/
    @ExportMessage boolean isArray() {
      return true;
    }
    @ExportMessage int read(int index) {
      return buffer[index];
    }
}

此实现与接口版本非常相似,但此外,我们还指定了 isArray 消息。同样,注解处理器会生成实现库抽象类的样板代码。

接下来,我们实现序列表示。我们首先在不优化 startstride 值的情况下实现它。

@ExportLibrary(ArrayLibrary.class)
final class SequenceArray {
    private final int start;
    private final int stride;
    private final int length;
    /*...*/
    @ExportMessage int read(int index) {
        return start + stride * index;
    }
}

到目前为止,这与接口实现是等效的,但使用 Truffle 库,我们现在还可以通过使用类而不是方法导出消息来在我们的表示中使用专门化。惯例是类的名称与导出的消息名称完全相同,但首字母大写。

现在我们使用此机制实现步幅和起始专门化

@ExportLibrary(ArrayLibrary.class)
final class SequenceArray {
    final int start;
    final int stride;
    final int length;
    /*...*/

    @ExportMessage static class Read {
        @Specialization(guards = {"seq.stride == cachedStride",
                                  "seq.start  == cachedStart"}, limit = "1")
        static int doSequenceCached(SequenceArray seq, int index,
                 @Cached("seq.start")  int cachedStart,
                 @Cached("seq.stride") int cachedStride) {
            return cachedStart + cachedStride * index;
        }

        @Specialization(replaces = "doSequenceCached")
        static int doSequence(SequenceArray seq, int index) {
            return doSequenceCached(seq, index, seq.start, seq.stride);
        }
    }
}

由于消息是使用内部类声明的,我们需要指定接收器类型。与普通节点相比,此类不能扩展 Node,并且其方法必须是 static,以便注解处理器能够为库子类生成高效代码。

最后,我们需要在读取操作中使用数组库。库 API 提供了一个名为 @CachedLibrary 的注解,负责向库分派。数组读取操作现在看起来像这样

@NodeChild @NodeChild
class ArrayReadNode extends ExpressionNode {
    @Specialization(guards = "arrays.isArray(array)", limit = "2")
    int doDefault(Object array, int index,
                  @CachedLibrary("array") ArrayLibrary arrays) {
        return arrays.read(array, index);
    }
}

类似于我们在策略 2 中看到的类型缓存,我们将库专门化为一个特定值。@CachedLibrary 的第一个属性 "array" 指定了库所专门化的值。专门化的库只能用于其专门化的值。如果它们与其它值一起使用,框架将抛出断言错误。

我们不在参数类型中使用 Array 类型,而是在守卫中使用 isArray 消息。使用专门化的库需要我们指定专门化的限制。该限制指定了可以实例化多少个库的专门化,直到操作应将其自身重写为使用库的未缓存版本。

在数组示例中,我们只实现了两种数组表示形式。因此,不可能超出限制。在实际的数组实现中,我们可能会使用更多的表示形式。应将此限制设置为一个在代表性应用程序中不太可能超出,但同时又不会产生过多代码的值。

通过超出专门化限制可以达到库的未缓存或慢速路径版本,但也可以手动使用,例如,当没有可用节点时需要调用数组操作。对于语言实现中不常调用的部分,通常是这种情况。使用接口策略(策略 2),可以通过直接调用接口方法来使用数组读取操作。

使用 Truffle 库,我们首先需要查找库的未缓存版本。每次使用 @ExportLibrary 都会生成一个缓存的以及一个未缓存/慢速路径库子类。导出的库的未缓存版本使用与 @GenerateUncached 相同的语义。通常,如我们的示例所示,未缓存版本可以自动派生。如果 DSL 需要关于如何生成未缓存版本的更多详细信息,它将显示错误。库的未缓存版本可以像这样调用

ArrayLibrary arrays = LibraryFactory.resolve(ArrayLibrary.class).getUncached();
arrays.read(array, index);

为了减少此示例的冗长性,建议库类提供以下可选的静态实用程序

@GenerateLibrary
public abstract class ArrayLibrary extends Library {
    /*...*/
    public static LibraryFactory<ArrayLibrary> getFactory() {
        return FACTORY;
    }

    public static ArrayLibrary getUncached() {
        return FACTORY.getUncached();
    }

    private static final LibraryFactory<ArrayLibrary> FACTORY =
               LibraryFactory.resolve(ArrayLibrary.class);
}

上面冗长的示例现在可以简化为

ArrayLibrary.getUncached().readArray(array, index);

策略 3 的可运行源代码可在此处找到。

总结 #

在本教程中,我们了解到使用 Truffle 库,我们不再需要通过为每个表示形式创建专门化(策略 1)来牺牲表示类型的模块化,并且性能分析不再被接口调用(策略 2)阻塞。Truffle 库现在支持带有类型封装的多态分派,同时不丧失在表示类型中使用性能分析/缓存技术的能力。

接下来做什么? #

  • 运行并调试所有示例,可在此处找到。

  • 阅读互操作性迁移指南,作为 Truffle 库使用的一个示例,可在此处找到。

  • 阅读 Truffle 库参考文档,可在此处找到。

常见问题 #

是否存在已知限制?

  • 库导出目前无法显式调用其 super 实现。这使得反射实现目前不可行。请参阅此处的示例。
  • 目前不支持对返回值的装箱消除。一个消息只能有一个泛型返回类型。计划支持此功能。
  • 目前不支持不依赖 Library 类的静态反射。计划支持完全动态反射。

何时应该使用 Truffle 库?

何时使用?

  • 如果表示形式是模块化的,并且无法为某个操作枚举(例如 Truffle 互操作性)。
  • 如果一个类型有多种表示形式,并且其中一种表示形式需要性能分析/缓存(例如,请参阅实例说明)。
  • 如果需要一种方法来代理语言的所有值(例如,用于动态污点跟踪)。

何时不使用?

  • 对于只有一种表示形式的基本类型。
  • 对于需要装箱消除以加速解释器的原始表示形式。Truffle 库目前不支持装箱消除。

我决定使用 Truffle 库来抽象我语言中特定于语言的类型。这些类型是否应该暴露给其他语言和工具?

所有库都可以通过 ReflectionLibrary 供其他语言和工具访问。建议语言实现文档明确哪些库和消息旨在供外部使用,以及哪些可能会受到重大更改的影响。

当一个新方法被添加到库中,但动态加载的实现尚未为其更新时,会发生什么?

如果库方法被指定为 abstract,则会抛出 AbstractMethodError。否则,将调用库方法体中指定的默认实现。这允许在使用抽象方法时自定义错误。例如,对于 Truffle 互操作性,我们通常抛出 UnsupportedMessageException 而不是 AbstractMethodError

联系我们