- 适用于 JDK 24 的 GraalVM(最新)
- 适用于 JDK 25 的 GraalVM(早期访问)
- 适用于 JDK 21 的 GraalVM
- 适用于 JDK 17 的 GraalVM
- 存档
- 开发构建
- Truffle 语言实现框架
- Truffle 分支插桩
- 动态对象模型
- 静态对象模型
- 解释器代码的主机优化
- Truffle 函数内联方法
- 分析 Truffle 解释器
- Truffle 互操作 2.0
- 语言实现
- 使用 Truffle 实现新语言
- Truffle 语言和工具迁移到 Java 模块
- Truffle 原生函数接口
- 优化 Truffle 解释器
- 选项
- 栈上替换
- Truffle 字符串指南
- 特化直方图
- 测试 DSL 特化
- 基于多语言 API 的 TCK
- Truffle 编译队列方法
- Truffle 库指南
- Truffle AOT 概述
- Truffle AOT 编译
- 辅助引擎缓存
- Truffle 语言安全点教程
- 单态化
- 拆分算法
- 单态化用例
- 向运行时报告多态特化
Truffle 库指南
Truffle 库允许语言实现对接收器类型使用多态分派,支持实现特定的缓存/性能分析以及对未缓存分派的自动支持。Truffle 库为基于 Truffle 的表示类型语言实现提供了模块化和封装。在使用它们之前,请务必先阅读本指南。
入门 #
本教程将通过一个用例来演示如何使用 Truffle 库。完整的 API 文档可在 Javadoc 中找到。本文档假定您已事先了解 Truffle API 以及如何将 @Specialization
与 @Cached
注解配合使用。
实例说明 #
在 Truffle 语言中实现数组时,为了提高效率,通常需要使用多种表示形式。例如,如果数组是由整数的算术序列(例如 range(from: 1, step: 2, length: 3)
)构造的,那么最好使用 start
、stride
和 length
来表示,而不是实例化整个数组。当然,当写入数组元素时,数组需要被实例化。在此示例中,我们将实现一个具有两种表示形式的数组实现:
- Buffer:表示由 Java 数组支持的实例化数组表示形式。
- Sequence:表示由
start
、stride
和length
表示的算术数字序列:[start, start + 1 * stride, ..., start + (length - 1) * stride]
。
为了保持示例简单,我们将只支持 int
值,并忽略索引越界错误处理。我们还将只实现读取操作,而不实现通常更复杂的写入操作。
为了使示例更有趣,我们将实现一个优化,即使数组接收器值不是常量,也能让编译器对序列化数组访问进行常量折叠。
假设我们有以下代码片段 range(start, stride, length)[2]
。在此片段中,变量 start
和 stride
不确定是常量值,因此,start + stride * 2
的等效代码会被编译。但是,如果 start
和 stride
值已知始终相同,则编译器可以对整个操作进行常量折叠。此优化需要使用缓存。我们稍后将展示其工作原理。
在 GraalVM 的 JavaScript 运行时环境的动态数组实现中,我们使用了 20 种不同的表示形式。其中包括常量数组、零基数组、连续数组、空洞数组和稀疏数组的表示形式。某些表示形式针对 byte
、int
、double
、JSObject
和 Object
类型进行了进一步的专门化。源代码可在此处找到。注意:目前,JavaScript 数组尚未使用 Truffle 库。
在接下来的章节中,我们将讨论数组表示的多种实现策略,并最终说明如何使用 Truffle 库来实现这一点。
策略 1:按表示形式进行专门化 #
对于此策略,我们将首先为两种表示形式 BufferArray
和 SequenceArray
声明类。
final class BufferArray {
int length;
int[] buffer;
/*...*/
}
final class SequenceArray {
final int start;
final int stride;
final int length;
/*...*/
}
BufferArray
实现具有可变缓冲区和长度,并用作实例化的数组表示形式。序列数组由最终字段 start
、stride
和 length
表示。
现在,我们像这样指定基本的读取操作
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;
}
}
数组读取节点为缓冲区版本和序列指定了两种专门化。如前所述,为简单起见,我们将忽略错误边界检查。
现在我们尝试让数组读取操作根据序列值的常量性进行专门化,以便在 start
和 stride
为常量时,range(start, stride, length)[2]
示例可以折叠。要确定 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
实际上是常量。例如,对于值 3
和 2
,编译器会将其视为 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
接口,并在表示类中实现 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
指定了两个消息:isArray
和 read
。在编译时,注解处理器会生成一个包保护类 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
消息。同样,注解处理器会生成实现库抽象类的样板代码。
接下来,我们实现序列表示。我们首先在不优化 start
和 stride
值的情况下实现它。
@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 库现在支持带有类型封装的多态分派,同时不丧失在表示类型中使用性能分析/缓存技术的能力。
接下来做什么? #
常见问题 #
是否存在已知限制?
- 库导出目前无法显式调用其
super
实现。这使得反射实现目前不可行。请参阅此处的示例。 - 目前不支持对返回值的装箱消除。一个消息只能有一个泛型返回类型。计划支持此功能。
- 目前不支持不依赖
Library
类的静态反射。计划支持完全动态反射。
何时应该使用 Truffle 库?
何时使用?
- 如果表示形式是模块化的,并且无法为某个操作枚举(例如 Truffle 互操作性)。
- 如果一个类型有多种表示形式,并且其中一种表示形式需要性能分析/缓存(例如,请参阅实例说明)。
- 如果需要一种方法来代理语言的所有值(例如,用于动态污点跟踪)。
何时不使用?
- 对于只有一种表示形式的基本类型。
- 对于需要装箱消除以加速解释器的原始表示形式。Truffle 库目前不支持装箱消除。
我决定使用 Truffle 库来抽象我语言中特定于语言的类型。这些类型是否应该暴露给其他语言和工具?
所有库都可以通过 ReflectionLibrary
供其他语言和工具访问。建议语言实现文档明确哪些库和消息旨在供外部使用,以及哪些可能会受到重大更改的影响。
当一个新方法被添加到库中,但动态加载的实现尚未为其更新时,会发生什么?
如果库方法被指定为 abstract
,则会抛出 AbstractMethodError
。否则,将调用库方法体中指定的默认实现。这允许在使用抽象方法时自定义错误。例如,对于 Truffle 互操作性,我们通常抛出 UnsupportedMessageException
而不是 AbstractMethodError
。