Truffle 库指南

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

入门 #

本教程提供了如何使用 Truffle 库的用例跟踪。完整的 API 文档可以在 Javadoc 中找到。本文档假设您已经了解 Truffle API,并了解如何使用 @Specialization@Cached 注解。

示例说明 #

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

  • 缓冲区:表示由 Java 数组支持的具体化数组表示。
  • 序列:表示由 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;
    }
}

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

现在,我们尝试使数组读取专门针对序列中值的常量性,以便允许 range(start, stride, length)[2] 示例在 start 和 stride 为常量时折叠。为了找出 start 和 stride 是否为常量,我们需要对其值进行性能分析。为了对这些值进行性能分析,我们需要像这样向数组读取操作添加另一个专业化

@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() */
}

如果此专业化的推测保护成功,则 start 和 stride 实际上是常量。例如,使用值 32,编译器将看到 3 + 2 * 2,即 7。限制设置为 1 仅尝试一次此推测。增加限制可能会降低效率,因为这会向编译后的代码引入额外的控制流。如果推测不成功,即如果操作观察到多个 start 和 stride 值,则希望回退到正常的序列专业化。为了实现这一点,我们通过添加 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 接口,并在表示类中实现读取方法。

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 消息。同样,注解处理器会生成实现库抽象类的样板代码。

接下来,我们实现序列表示。我们首先在没有步长和起始值优化的前提下实现它。

@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

与我们联系