35 Truffle深度解析:一门新语言如何快速达到原生性能?

图片[1]-35 Truffle深度解析:一门新语言如何快速达到原生性能?-速优课

本文导读

你有没有想过,实现一门编程语言到底有多难?传统做法是从零开始写编译器、垃圾回收器、运行时…… 每一项都是巨大的工程。但如果有一个框架,让你只需实现解释器就能享受 JIT 编译、GC 等成熟优化,那会怎样?

Truffle 就是这样一个语言实现框架。它基于 Graal 编译器,让语言开发者只需实现词法分析、语法分析和 AST 解释器,就能获得接近原生的性能。本文将带你深入了解 Truffle 的工作原理。

通过阅读本文,你将了解:

  • Truffle 是什么,它能解决什么问题
  • Truffle 与 Graal 编译器的关系
  • Partial Evaluation(部分求值)的原理
  • 节点重写(Node Rewriting)如何处理动态类型
  • Truffle 的 Polyglot 多语言互操作机制
  • Truffle 语言的实际性能表现

一、Truffle 项目简介

1.1 语言实现的两种传统路径

实现一门新语言,传统上有两条路径:

路径一:从零实现完整的编译器

编译器通常分为前端和后端:

  • 前端:词法分析、语法分析、类型检查、中间代码生成
  • 后端:代码优化、目标代码生成

但很多编译器教程只讲到词法和语法分析就结束了,真正能生成可运行代码、还能做优化的,少之又少。

路径二:编译到现有中间表示

另一种取巧的做法是把新语言编译成某种已知语言或中间表示,比如把 Scala、Kotlin 编译成 Java 字节码。

这样做的好处是可以直接复用 JVM 的各种优化——即时编译、自动内存管理等等。对生成的字节码优化程度要求不高,因为 JVM 会帮你做二次优化。

但不管哪种路径,都需要一个”运行前编译”的步骤。

1.2 解释型语言的困境

和编译型语言对应的是解释型语言,比如 JavaScript、Ruby、Python。它们不需要编译步骤,直接由解释器解析执行。

但要让解释器高效运行大型程序,语言开发者通常需要把它包装在一个虚拟机里,还要实现即时编译、垃圾回收等组件。这些组件和语言设计本身关系不大,纯粹是为了性能而做的工程实现。

理想状态下,这些基础设施应该能在不同语言之间复用。 每开发一门新语言,只需实现它的解释器,就能直接复用 JIT、GC 等组件,从而达到高性能。这正是 Truffle 项目的目标。

1.3 Truffle 是什么

Truffle 是一个用 Java 编写的语言实现框架。基于 Truffle 的语言实现,只需用 Java 实现:

  1. 词法分析器
  2. 语法分析器
  3. 抽象语法树(AST)的解释执行器

就能享用 Truffle 提供的各种运行时优化。

由于 Truffle 框架和语言实现本身都是用 Java 写的,所以它们可以运行在任何 JVM 上。

当然,如果运行在搭载了 Graal 编译器的 JVM 上,Truffle 会调用 Graal 提供的 API,主动触发即时编译——把对 AST 的解释执行,转换成直接执行编译后的机器码。

在这种模式下,Graal 编译器相当于一个提供 JIT 功能的库。宿主虚拟机本身仍然可以用 C2 作为 JIT 编译器,或者在分层编译模式下用 C2 做第 4 层编译。

图片[2]-35 Truffle深度解析:一门新语言如何快速达到原生性能?-速优课

1.4 Truffle 生态与性能表现

Oracle Labs 已经基于 Truffle 实现并开源了多门语言:

  • Graal.js:JavaScript 实现
  • TruffleRuby:Ruby 实现
  • FastR:R 语言实现
  • GraalPython:Python 实现
  • Sulong:LLVM bitcode 解释器(可以运行 C/C++ 等能编译成 LLVM bitcode 的语言)

下面我们来看看这些语言的性能表现(2017 年数据,基于 GraalVM EE):

图片[3]-35 Truffle深度解析:一门新语言如何快速达到原生性能?-速优课

图中的基准是每种语言各自有竞争力的实现:

  • Java/Scala:对比 C2 和 Graal
  • Ruby:对比 JRuby 和 TruffleRuby
  • R:对比 GNU R 和 FastR
  • C/C++:对比 LLVM 编译的二进制和 Sulong
  • JavaScript:对比 V8 和 Graal.js

纵轴是加速比的几何平均值:

  • 等于 1:和基准性能相当
  • 大于 1:比基准性能更好
  • 小于 1:比基准性能差

从结果可以看出:

  • Java:Graal 和 C2 差不多
  • Scala:Graal 是 C2 的 1.2 倍
  • Ruby:TruffleRuby 是 JRuby 的 4.1 倍
  • R:FastR 是 GNU R 的 4.5 倍
  • JavaScript:Graal.js 还在追赶 V8
  • C/C++:Sulong 略低于原生编译(毕竟多了一层托管)

对于 Ruby、R 这类解释型语言,Truffle + Graal 的组合优势非常明显。这正是专业 JIT 编译器带来的威力。

顺带一提,Sulong 虽然性能比原生 C/C++ 略低,但它把 C/C++ 代码放在托管环境中运行,所有内存访问都在监控之下。无论是会触发段错误的异常访问,还是读取敏感数据的恶意访问,都能被拦截处理。在安全敏感的场景下,这是非常有价值的特性。


二、Partial Evaluation:部分求值

2.1 什么是部分求值

要理解 Truffle 的原理,首先得了解 Partial Evaluation(部分求值) 这个概念。

假设有一段程序 P,它接收输入 I,产生输出 O(即 P: I → O)。输入 I 又可以分成两部分:

  • IS:编译时已知的常量(静态部分)
  • ID:编译时未知的部分(动态部分)

那么我们可以把程序 P: I → O 转换成等价的另一段程序 P': ID → O。这个新程序 P' 就是 P特化(Specialization),而从 PP' 的转换过程就是部分求值

2.2 套用到 Truffle 上

把这个概念套到 Truffle 上:

  • P:Truffle 语言的解释器(用 Java 写的)
  • IS:某段用 Truffle 语言写的具体程序(编译时已知)
  • P':特化后的解释器(针对该具体程序特化)

由于解释器是用 Java 写的,特化后的解释器 P' 也是 Java 代码,我们可以用 Graal 编译器把它编译成机器码。

听起来有点抽象?我们用一个具体例子来说明。

2.3 一个具体的例子

假设我们有一门极简的语言 X,只支持两种操作:

  • 读取整数参数
  • 整数加法

对应的 AST 节点定义如下:

abstract class Node {
  abstract int execute(int[] args);
}
​
class Arg extends Node {
  final int index;
​
  Arg(int i) { this.index = i; }
​
  int execute(int[] args) {
    return args[index];
  }
}
​
class Add extends Node {
  final Node left, right;
​
  Add(Node left, Node right) {
    this.left = left;
    this.right = right;
  }
​
  int execute(int[] args) {
    return left.execute(args) +
           right.execute(args);
  }
}
​
static int interpret(Node node, int[] args) {
  return node.execute(args);
}

所谓 AST 节点的解释执行,就是调用这些节点的 execute 方法;一段程序的解释执行,就是调用它的 AST 根节点的 execute 方法。

现在用语言 X 写一段程序,计算三个输入参数之和 arg0 + arg1 + arg2。解析生成的 AST 是:

// 示例程序:arg0 + arg1 + arg2
sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));

解释执行就是 interpret(sample, args),其中 args 是传入的参数数组。

由于 sample 是编译时常量,我们可以通过部分求值把它特化成 interpret0 方法:

static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
​
static int interpret0(int[] args) {
  return sample.execute(args);
}

Truffle 的 Partial Evaluator 会不断进行方法内联(直到遇到被 @TruffleBoundary 注解的方法为止)。

内联第一层 Add.execute 后:

static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
​
static int interpret0(int[] args) {
  return sample.left.execute(args) + sample.right.execute(args);
}

继续内联,最终会变成:

static int interpret0(int[] args) {
  return args[0] + args[1] + args[2];
}

至此,我们成功地把一段 Truffle 语言代码的解释执行,转换成了直接的 Java 代码。接下来再用 Graal 编译器把这段 Java 代码编译成机器码,就实现了 Truffle 语言的即时编译。

这就是 Truffle 的核心魔法:把解释器和具体的程序一起做部分求值,得到特化后的高效代码。


三、节点重写:应对动态类型

3.1 动态类型的挑战

Truffle 的另一项关键优化是节点重写(Node Rewriting)

在动态语言中,很多变量的类型要到运行时才能确定。比如加法运算符 +,既可能是整数加法,也可能是浮点数加法,甚至可能是字符串拼接。

对于静态语言,编译器可以通过类型推断确定操作类型。但对于动态语言,只能在运行时动态判断操作数类型,然后选择对应的操作。这种运行时选择语义的节点,对 JIT 编译非常不友好,会严重影响性能。

3.2 基于类型 profile 的特化

Truffle 的解决办法是:解释器在运行过程中收集每个 AST 节点的类型信息,然后在即时编译时,根据收集到的类型 profile 进行特化。

还是以加法操作为例:

  • 如果收集的类型 profile 显示这是整数加法,编译时就把这个节点当成整数加法
  • 如果显示是字符串加法,就当成字符串加法
  • 如果既有整数又有字符串,那只好在运行时判断类型再选择
图片[4]-35 Truffle深度解析:一门新语言如何快速达到原生性能?-速优课

这种基于类型 profile 的优化,和我们之前讲过的 JVM 解释执行器、C1 编译代码的思路很像——核心都是基于假设的投机性优化,以及假设失败时的去优化。

编译之后,如果运行时发现 AST 节点的实际类型和假设的不一样,Truffle 会主动调用 Graal 提供的去优化 API,回到解释执行状态,重新收集类型信息,然后再进行新一轮的即时编译。

3.3 类型稳定需要多少次调用

如果第一次编译就能达到稳定状态,不再触发去优化和重新编译,那程序达到峰值性能的时间就会短很多。

那么,Truffle 语言的方法通常需要调用多少次,AST 节点的类型才能稳定下来呢?

统计结果是这样的:

  • JavaScript 和 Ruby:80% 的方法在 5 次调用后稳定,90% 在 7 次后稳定,99% 在 19 次后稳定
  • R 语言:比较特殊,即使不调用,也有 50% 的方法已经稳定了

原因也很容易理解:R 语言主要用于数值统计,几乎所有操作都是浮点数类型,所以类型自然很稳定。


四、Polyglot:多语言无缝互操作

4.1 为什么需要多语言

在实际开发中,我们通常会为项目选定一门语言,但经常会遇到两个问题:

  1. 这门语言没有我们需要的库
  2. 这门语言不适用于某类问题

Truffle 框架支持 Polyglot(多语言互操作),允许在同一段代码中混合使用不同的编程语言。开发者可以自由选择最合适的语言来实现各个子组件。

4.2 Truffle Polyglot 的独特之处

和其他多语言框架不同的是,Truffle 语言之间可以共享对象。也就是说,不需要复制对象,也不需要序列化/反序列化,就能把一个语言中的对象直接传递给另一个语言。

因此,Truffle 的 Polyglot 在语言切换时性能开销非常小,甚至经常能达到零开销。

4.3 Polyglot API

Truffle 的多语言特性是通过 Polyglot API 实现的。只要实现了 Polyglot API,一门 Truffle 语言的对象就能被其他 Truffle 语言通过 API 来解析。

实际上,通过 Polyglot API 访问外来对象时,你甚至不需要了解对方语言是什么,就能识别它的数据结构、访问其中的数据、进行进一步的计算。


总结与思考

本文要点回顾

  1. Truffle 是一个语言实现框架
    • 只需实现词法分析、语法分析和 AST 解释器
    • 就能复用 JIT 编译、GC 等成熟基础设施
    • 运行在 JVM 上,配合 Graal 编译器可达高性能
  2. 核心原理一:Partial Evaluation(部分求值)
    • 把解释器看作程序 P,把目标程序看作常量 IS
    • 通过部分求值得到特化后的解释器 P’
    • 本质是大量的方法内联,把 AST 解释执行展开成直接计算
    • 特化后的 Java 代码再由 Graal 编译成机器码
  3. 核心原理二:节点重写(Node Rewriting)
    • 解决动态类型语言的类型不确定性问题
    • 运行时收集类型 profile,编译时基于 profile 特化
    • 类型不匹配时去优化,回到解释执行重新收集
    • 大多数方法只需几次调用就能达到类型稳定
  4. Polyglot 多语言互操作
    • 不同 Truffle 语言之间可以无缝混用
    • 对象可以直接共享,无需复制或序列化
    • 语言切换开销极低,甚至零开销
  5. 性能表现
    • 对 Ruby、R 等解释型语言,性能提升非常显著(数倍)
    • 对 Java 等已有高性能实现的语言,也有一定优势

思考与实践

  1. 除了 Truffle,你还知道哪些语言实现框架或虚拟机?它们和 Truffle 的思路有什么异同?
  2. 部分求值这个概念很有意思,你觉得它还能应用在哪些场景中?
  3. 如果让你基于 Truffle 实现一门语言,你会选择实现什么语言?为什么?

Truffle 展示了一种全新的语言实现思路:不再重复造轮子,而是把基础设施下沉到框架层面,让语言开发者专注于语言本身的设计。这可能也是未来编程语言发展的一个重要方向。

© 版权声明
THE END
喜欢就支持一下吧
点赞8
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!