Getting Started with Instruments in GraalVM

在 GraalVM 平台中,工具有时被称为 InstrumentInstrument API 用于实现此类 Instrument。Instrument 可以跟踪非常细粒度的虚拟机级别运行时事件,以分析、检查和剖析在 GraalVM 上运行的应用程序的运行时行为。

Simple Tool #

为了给工具开发者提供一个更简单的起点,我们创建了一个 Simple Tool 示例项目。这是一个 Javadoc 丰富的 Maven 项目,实现了一个简单的代码覆盖工具。

我们建议克隆此存储库并探索源代码,以此作为工具开发的起点。以下各节将以 Simple Tool 源代码作为运行示例,引导您了解构建和运行 GraalVM 工具所需的步骤。这些章节并未涵盖 Instrument API 的所有功能,因此我们鼓励您查阅 Javadoc 以获取更多详细信息。

要求 #

如前所述,Simple Tool 是一个代码覆盖工具。最终,它应该向开发者提供源代码行执行了多少百分比的信息,以及具体哪些行被执行了。考虑到这一点,我们可以为我们的工具定义一些高级别的要求

  1. 该工具跟踪已加载的源代码。
  2. 该工具跟踪已执行的源代码。
  3. 应用程序退出时,工具会计算并打印每行覆盖率信息。

Instrument API #

工具的主要起点是继承 TruffleInstrument 类。不出所料,simple tool 代码库正是这样做的,它创建了 SimpleCoverageInstrument 类。

类上的 Registration 注解确保新创建的 Instrument 已注册到 Instrument API,换句话说,它将由框架自动发现。它还提供了关于 Instrument 的一些元数据:ID、名称、版本、Instrument 提供的服务以及 Instrument 是否为内部的。为了使此注解生效,DSL 处理器需要处理此类别。对于 Simple Tool,通过将 DSL 处理器作为 Maven 配置中的依赖项来自动完成此操作。

现在我们将回顾 SimpleCoverageInstrument 类的实现,即它覆盖了 TruffleInstrument 的哪些方法。这些是 onCreateonDisposegetOptionDescriptorsonCreateonDispose 方法不言自明:它们在 Instrument 创建和销毁时由框架调用。我们稍后将讨论它们的实现,但首先让我们讨论剩下的一个:getOptionDescriptors

Truffle 语言实现框架自带用于指定命令行选项的系统。这些选项允许工具用户从命令行或创建 多语言上下文 (polyglot contexts) 时控制工具。它是基于注解的,此类选项的示例是 SimpleCoverageInstrumentENABLEDPRINT_COVERAGE 字段。这两个都是带有 OptionKey 注解的 Option 类型的静态 final 字段,与 Registration 注解类似,为选项提供了一些元数据。再次,与 Registration 注解一样,为了使 Option 注解生效,需要 DSL 处理器,它会生成 OptionDescriptors 的子类(在我们的示例中名为 SimpleCoverageInstrumentOptionDescriptors)。此类的一个实例应该从 getOptionDescriptors 方法返回,以便让框架知道 Instrument 提供了哪些选项。

回到 onCreate 方法,我们收到一个 Env 类的实例作为参数。这个对象提供了许多有用的信息,但对于 onCreate 方法,我们主要关注 getOptions 方法,它可用于读取传递给工具的选项。我们用它来检查 ENABLED 选项是否已设置,如果已设置,则通过调用 enable 方法启用我们的工具。同样,在 onDispose 方法中,我们检查 PRINT_COVERAGE 选项的状态,如果它已启用,我们调用 printResults 方法,该方法将打印我们的结果。

“启用工具”意味着什么?一般来说,这意味着我们告诉框架我们感兴趣的事件以及我们希望如何对它们作出反应。看看我们的 enable 方法,它执行以下操作:

  • 首先,它定义了 SourceSectionFilter。这个过滤器是对我们感兴趣的源代码部分的声明性定义。在我们的示例中,我们关注所有被认为是表达式的节点,而不关注内部语言部分。
  • 其次,我们获得一个 Instrumenter 类的实例,这是一个允许我们指定希望 Instrument 系统哪些部分的对象。
  • 最后,使用 Instrumenter 类,我们指定一个 Source Section Listener 和一个 Execution Event Factory,这两者都将在接下来的两节中进行描述。

Source Section Listener #

Language API 提供了 Source(即源代码单元)的概念,以及 SourceSection(即 Source 的一个连续部分,例如一个方法、一个语句、一个表达式等)的概念。更多详细信息请参见相应的 Javadoc。

Simple Tool 的第一个要求是跟踪已加载的源代码。Instrument API 提供了 LoadSourceSectionListener,当它被继承并注册到 Instrumenter 时,允许用户对运行时加载的源代码段做出反应。这正是我们对 GatherSourceSectionsListener 所做的,它在 Instrument 的 enable 方法中注册。GatherSourceSectionsListener 的实现非常简单:我们覆盖 onLoad 方法以通知 Instrument 每个已加载的源代码段。Instrument 维护从每个 SourceCoverage 对象的映射,该对象为每个源保留一组已加载的源代码段。

执行事件节点 #

访客语言 (Guest languages) 被实现为抽象语法树 (AST) 解释器。语言实现者用标签注解某些节点,这使得我们能够以语言无关的方式,使用前面提到的 SourceSectionFilter 来选择我们感兴趣的节点。

Instrument API 的主要功能在于它能够在 AST 中插入专门的节点,这些节点“包装”了感兴趣的节点。这些节点使用语言开发者使用的相同基础设施构建,并且从运行时的角度来看,与语言节点没有区别。这意味着用于将访客语言优化为高性能语言实现的所有技术也同样适用于工具开发者。

有关这些技术的更多信息,请参阅 语言实现文档。简单来说,为了让 Simple Tool 满足其第二个要求,我们需要用我们自己的节点来 Instrument 所有表达式,当这些表达式执行时,该节点会通知我们。

对于此任务,我们使用 CoverageNode。它是 ExecutionEventNode 的一个子类,顾名思义,用于在执行期间 Instrument 事件。ExecutionEventNode 提供了许多可覆盖的方法,但我们只对 onReturnValue 感兴趣。当“包装”的节点返回值时,即成功执行时,会调用此方法。实现相当简单。我们只需通知 Instrument 具有此特定 SourceSection 的节点已执行,然后 Instrument 会更新其覆盖映射中的 Coverage 对象。

每个节点只通知 Instrument 一次,因为逻辑由 flag 守护。此标志用 CompilationFinal 注解,并且在调用 Instrument 之前调用了 transferToInterpreterAndInvalidate(),这是 Truffle 中的标准技术,可确保一旦不再需要此 Instrumentation(节点已执行),Instrumentation 将从后续编译中移除,同时消除任何性能开销。

为了让框架知道何时实例化 CoverageNode,我们需要为其提供一个工厂。该工厂是 CoverageEventFactory,它是 ExecutionEventNodeFactory 的子类。此类只是确保每个 CoverageNode 通过在提供的 EventContext 中查找来知道它正在 Instrument 的 SourceSection

最后,当我们启用 Instrument 时,我们告诉 Instrumenter 使用我们的工厂来“包装”通过我们的过滤器选择的节点。

用户与 Instrument 之间的交互 #

Simple Tool 的第三个也是最后一个要求是通过将行覆盖率打印到标准输出与用户进行实际交互。Instrument 覆盖了 onDispose 方法,该方法在 Instrument 被处置时被调用,这不足为奇。在此方法中,我们检查是否已设置正确的选项,如果已设置,则计算并打印由我们的 Coverage 对象映射记录的覆盖率。

这是向用户提供有用信息的一种简单方法,但绝不是唯一的方法。工具可以直接将数据转储到文件中,或者运行一个显示信息的 Web 端点等等。Instrument API 提供给用户的一种机制是将 Instrument 注册为服务,供其他 Instrument 查找。如果我们查看 Instrument 的 Registration 注解,我们可以看到它提供了一个 services 字段,我们可以在其中指定 Instrument 为其他 Instrument 提供哪些服务。这些服务需要显式注册。这允许 Instrument 之间更好地分离关注点,例如,我们可以有一个“实时覆盖”Instrument,它将使用我们的 SimpleCoverageInstrument 通过 REST API 向用户提供按需覆盖信息,以及一个“低覆盖时中止”Instrument,如果覆盖率低于阈值则停止执行,两者都将 SimpleCoverageInstrument 用作服务。

注意:出于隔离原因,Instrument 服务不适用于应用程序代码,且 Instrument 服务只能从其他 Instrument 或访客语言中使用。

将工具安装到 GraalVM 中 #

到目前为止,Simple Tool 似乎满足了所有要求,但问题仍然存在:我们如何使用它?如前所述,Simple Tool 是一个 Maven 项目。将 JAVA_HOME 设置为 GraalVM 安装路径并运行 mvn package 会生成一个 target/simpletool-<version>.jar 文件。这是 Simple Tool 的分发形式。

Truffle 框架提供了语言/工具代码与应用程序代码之间的清晰分离。因此,将 JAR 文件放在类路径上不会让框架识别出需要新工具。为此,我们使用 --vm.Dtruffle.class.path.append=/path/to/simpletool-<version>.jar,如我们简单工具的启动脚本中所示。此脚本还显示我们可以设置我们为 Simple Tool 指定的 CLI 选项。这意味着如果我们执行 ./simpletool js example.js,我们将启动 GraalVM 的 js 启动器,将工具添加到框架类路径,并运行启用了 Simple Tool 的随附 example.js 文件,从而产生以下输出:

==
Coverage of /path/to/simpletool/example.js is 59.42%
+ var N = 2000;
+ var EXPECTED = 17393;

  function Natural() {
+     x = 2;
+     return {
+         'next' : function() { return x++; }
+     };
  }

  function Filter(number, filter) {
+     var self = this;
+     this.number = number;
+     this.filter = filter;
+     this.accept = function(n) {
+       var filter = self;
+       for (;;) {
+           if (n % filter.number === 0) {
+               return false;
+           }
+           filter = filter.filter;
+           if (filter === null) {
+               break;
+           }
+       }
+       return true;
+     };
+     return this;
  }

  function Primes(natural) {
+     var self = this;
+     this.natural = natural;
+     this.filter = null;
+     this.next = function() {
+         for (;;) {
+             var n = self.natural.next();
+             if (self.filter === null || self.filter.accept(n)) {
+                 self.filter = new Filter(n, self.filter);
+                 return n;
+             }
+         }
+     };
  }

+ var holdsAFunctionThatIsNeverCalled = function(natural) {
-     var self = this;
-     this.natural = natural;
-     this.filter = null;
-     this.next = function() {
-         for (;;) {
-             var n = self.natural.next();
-             if (self.filter === null || self.filter.accept(n)) {
-                 self.filter = new Filter(n, self.filter);
-                 return n;
-             }
-         }
-     };
+ }

- var holdsAFunctionThatIsNeverCalledOneLine = function() {return null;}

  function primesMain() {
+     var primes = new Primes(Natural());
+     var primArray = [];
+     for (var i=0;i<=N;i++) { primArray.push(primes.next()); }
-     if (primArray[N] != EXPECTED) { throw new Error('wrong prime found: ' + primArray[N]); }
  }
+ primesMain();

其他示例 #

以下示例旨在展示使用 Instrument API 可以解决的常见用例。

  • Coverage Instrument:一个代码覆盖工具示例,用于构建 Simple Tool。在后续文本中,它在适当时被用作运行示例。
  • Debugger Instrument:关于如何实现调试器的草图。请注意,Instrument API 已经提供了一个可以直接使用的 Debugger Instrument
  • Statement Profiler:一个能够分析语句执行情况的性能分析器。

Instrumentation 事件监听器 #

Instrument API 在 com.oracle.truffle.api.instrumentation 包中定义。Instrumentation 代理可以通过扩展 TruffleInstrument 类来开发,并可以使用 Instrumenter 类附加到正在运行的 GraalVM 实例。一旦附加到正在运行的语言运行时,Instrumentation 代理就仍然可用,只要语言运行时未被处置。GraalVM 上的 Instrumentation 代理可以监控各种虚拟机级别的运行时事件,包括以下任何一种:

  1. 源代码相关事件:每当受监控的语言运行时加载新的 SourceSourceSection 元素时,代理都可以收到通知。
  2. 分配事件:每当在受监控的语言运行时的内存空间中分配新对象时,代理都可以收到通知。
  3. 语言运行时和线程创建事件:一旦创建了新的 执行上下文 (execution context) 或受监控语言运行时的新线程,代理就可以收到通知。
  4. 应用程序执行事件:每当受监控的应用程序执行一组特定的语言操作时,代理都会收到通知。此类操作的示例包括语言语句和表达式,因此允许 Instrumentation 代理以非常高的精度检查正在运行的应用程序。

对于每个执行事件,Instrumentation 代理可以定义过滤标准,GraalVM Instrumentation 运行时将使用这些标准来仅监控相关的执行事件。当前,GraalVM Instrument 接受以下两种过滤器类型之一:

  1. AllocationEventFilter 用于按分配类型过滤分配事件。
  2. SourceSectionFilter 用于过滤应用程序中的源代码位置。

可以使用提供的构建器对象创建过滤器。例如,以下构建器创建一个 SourceSectionFilter

SourceSectionFilter.newBuilder()
                   .tagIs(StandardTag.StatementTag)
                   .mimeTypeIs("x-application/js")
                   .build()

示例中的过滤器可用于监控给定应用程序中所有 JavaScript 语句的执行。还可以提供其他过滤选项,例如行号或文件扩展名。

像示例中这样的源代码段过滤器可以使用标签 (Tags) 来指定一组要监控的执行事件。语言无关的标签(例如语句和表达式)在 com.oracle.truffle.api.instrumentation.Tag 类中定义,并受所有 GraalVM 语言支持。除了标准标签之外,GraalVM 语言还可以提供其他特定于语言的标签,以实现对特定语言事件的细粒度分析。(例如,GraalVM JavaScript 引擎提供了 JavaScript 特定的标签,用于跟踪 ECMA 内置对象(如 ArrayMapMath)的使用情况。)

监控执行事件 #

应用程序执行事件能够实现非常精确和详细的监控。GraalVM 支持两种不同类型的 Instrumentation 代理来分析此类事件,即:

  1. 执行监听器 (Execution listener):一种 Instrumentation 代理,每当给定运行时事件发生时都可以收到通知。监听器实现 ExecutionEventListener 接口,并且不能将任何状态与源代码位置关联。
  2. 执行事件节点 (Execution event node):一种可以使用 Truffle 框架 AST 节点表示的 Instrumentation 代理。此类代理扩展了 ExecutionEventNode 类,并具有与执行监听器相同的功能,但可以将状态与源代码位置关联。

简单 Instrumentation 代理 #

一个用于执行运行时代码覆盖的自定义 Instrumentation 代理的简单示例可以在 CoverageExample 类中找到。以下是该代理的概述、其设计及其功能。

所有 Instrument 都扩展了 TruffleInstrument 抽象类,并通过 @Registration 注解在 GraalVM 运行时中注册。

@Registration(id = CoverageExample.ID, services = Object.class)
public final class CoverageExample extends TruffleInstrument {

  @Override
  protected void onCreate(final Env env) {
  }

  /* Other methods omitted... */
}

Instrument 覆盖 onCreate(Env env) 方法,以便在 Instrument 加载时执行自定义操作。通常,Instrument 会使用此方法将自身注册到现有的 GraalVM 执行环境中。例如,使用 AST 节点的 Instrument 可以通过以下方式注册:

@Override
protected void onCreate(final Env env) {
  SourceSectionFilter.Builder builder = SourceSectionFilter.newBuilder();
  SourceSectionFilter filter = builder.tagIs(EXPRESSION).build();
  Instrumenter instrumenter = env.getInstrumenter();
  instrumenter.attachExecutionEventFactory(filter, new CoverageEventFactory(env));
}

Instrument 使用 attachExecutionEventFactory 方法将自身连接到正在运行的 GraalVM,并提供以下两个参数:

  1. SourceSectionFilter:一个源代码段过滤器,用于告知 GraalVM 要跟踪的特定代码段。
  2. ExecutionEventNodeFactory:Truffle AST 工厂,它提供 Instrumentation AST 节点,供代理在每次执行运行时事件(由源过滤器指定)时执行。

一个基本的 ExecutionEventNodeFactory,用于 Instrument 应用程序的 AST 节点,可以通过以下方式实现:

public ExecutionEventNode create(final EventContext ec) {
  return new ExecutionEventNode() {
    @Override
    public void onReturnValue(VirtualFrame vFrame, Object result) {
      /*
       * Code to be executed every time a filtered source code
       * element is evaluated by the guest language.
       */
    }
  };
}

执行事件节点可以实现某些回调方法来拦截运行时执行事件。示例包括:

  1. onEnter:在评估与过滤的源代码元素(例如,语言语句或表达式)对应的 AST 节点之前执行。
  2. onReturnValue:在源代码元素返回值后执行。
  3. onReturnExceptional:在过滤的源代码元素抛出异常的情况下执行。

执行事件节点是基于每个代码位置创建的。因此,它们可用于在 Instrumented 应用程序中存储特定于给定源代码位置的数据。例如,一个 Instrumentation 节点可以简单地使用节点局部标志来跟踪所有已访问的代码位置。这样的节点局部 boolean 标志可以按以下方式用于跟踪 AST 节点的执行:

// To keep track of all source code locations executed
private final Set<SourceSection> coverage = new HashSet<>();

public ExecutionEventNode create(final EventContext ec) {
  return new ExecutionEventNode() {
    // Per-node flag to keep track of execution for this node
    @CompilationFinal private boolean visited = false;

    @Override
    public void onReturnValue(VirtualFrame vFrame, Object result) {
      if (!visited) {
        CompilerDirectives.transferToInterpreterAndInvalidate();
        visited = true;
        SourceSection src = ec.getInstrumentedSourceSection();
        coverage.add(src);
      }
    }
  };
}

如以上代码所示,ExecutionEventNode 是一个有效的 AST 节点。这意味着 Instrumentation 代码将由 GraalVM 运行时与 Instrumented 应用程序一起优化,从而将 Instrumentation 开销降至最低。此外,这允许 Instrument 开发者直接从 Instrumentation 节点中使用 Truffle 框架编译器指令。在此示例中,编译器指令用于通知 Graal 编译器 visited 可以被视为编译最终 (compilation-final)。

每个 Instrumentation 节点都绑定到特定的代码位置。代理可以使用提供的 EventContext 对象访问这些位置。上下文对象使 Instrumentation 节点能够访问有关当前正在执行的 AST 节点的各种信息。通过 EventContext 可供 Instrumentation 代理使用的查询 API 示例包括:

  1. hasTag:查询 Instrumented 节点以获取特定节点 Tag(例如,检查语句节点是否也是条件节点)。
  2. getInstrumentedSourceSection:访问与当前节点关联的 SourceSection
  3. getInstrumentedNode:访问与当前 Instrumentation 事件对应的 Node

细粒度表达式分析 #

Instrumentation 代理甚至可以分析语言表达式等细微事件。为此,代理需要通过提供两个源代码段过滤器进行初始化:

// What source sections are we interested in?
SourceSectionFilter sourceSectionFilter = SourceSectionFilter.newBuilder()
  .tagIs(JSTags.BinaryOperation.class)
  .build();

// What generates input data to track?
SourceSectionFilter inputGeneratingLocations = SourceSectionFilter.newBuilder()
  .tagIs(StandardTags.ExpressionTag.class)
  .build();

instrumenter.attachExecutionEventFactory(sourceSectionFilter, inputGeneratingLocations, factory);

第一个源代码段过滤器(在示例中为 sourceSectionFilter)是与前面描述的其他过滤器等效的普通过滤器,用于识别要监控的源代码位置。第二个段过滤器 inputGeneratingLocations 由代理用于指定在特定源代码段中应监控的中间值。中间值对应于参与受监控代码元素执行的所有可观察值,并通过 onInputValue 回调报告给 Instrumentation 代理。例如,假设代理需要分析 JavaScript 中提供给求和操作 (+) 的所有操作数值:

var a = 3;
var b = 4;
// the '+' expression is profiled
var c = a + b;

通过过滤 JavaScript 二进制表达式,Instrumentation 代理将能够检测到上述代码片段的以下运行时事件:

  1. onEnter():针对第 3 行的二进制表达式。
  2. onInputValue():针对第 3 行二进制操作的第一个操作数。回调报告的值将是 3,即局部变量 a 的值。
  3. onInputValue():针对二进制操作的第二个操作数。回调报告的值将是 4,即局部变量 b 的值。
  4. onReturnValue():针对二进制表达式。提供给回调的值将是表达式完成评估后返回的值,即值 7

通过将源代码段过滤器扩展到所有可能的事件,Instrumentation 代理将观察到与以下执行跟踪(伪代码)等效的内容:

// First variable declaration
onEnter - VariableWrite
    onEnter - NumericLiteral
    onReturnValue - NumericLiteral
  onInputValue - (3)
onReturnValue - VariableWrite

// Second variable declaration
onEnter - VariableWrite
    onEnter - NumericLiteral
    onReturnValue - NumericLiteral
  onInputValue - (4)
onReturnValue - VariableWrite

// Third variable declaration
onEnter - VariableWrite
    onEnter - BinaryOperation
        onEnter - VariableRead
        onReturnValue - VariableRead
      onInputValue - (3)
        onEnter - VariableRead
        onReturnValue - VariableRead
      onInputValue - (4)
    onReturnValue - BinaryOperation
  onInputValue - (7)
onReturnValue - VariableWrite

onInputValue 方法可以与源代码段过滤器结合使用,以拦截非常细粒度的执行事件,例如语言表达式使用的中间值。Instrumentation 框架可访问的中间值在很大程度上取决于每种语言提供的 Instrumentation 支持。此外,语言可能提供与特定语言的 Tag 类相关的附加元数据。

改变应用程序的执行流 #

我们迄今为止介绍的 Instrumentation 功能使用户能够观察运行中应用程序的某些方面。除了对应用程序行为的被动监控之外,Instrument API 还支持在运行时主动改变应用程序的行为。这些功能可用于编写复杂的 Instrumentation 代理,这些代理会影响运行中应用程序的行为以实现特定的运行时语义。例如,可以改变运行中应用程序的语义,以确保某些方法或函数永远不会执行(例如,在它们被调用时抛出异常)。

具有此类功能的 Instrumentation 代理可以通过利用执行事件监听器和工厂中的 onUnwind 回调来实现。例如,让我们考虑以下 JavaScript 代码:

function inc(x) {
  return x + 1
}

var a = 10
var b = a;
// Let's call inc() with normal semantics
while (a == b && a < 100000) {
  a = inc(a);
  b = b + 1;
}
c = a;
// Run inc() and alter it's return type using the instrument
return inc(c)

一个将 inc 的返回值始终修改为 42 的 Instrumentation 代理可以使用 ExecutionEventListener 以以下方式实现:

ExecutionEventListener myListener = new ExecutionEventListener() {

  @Override
  public void onReturnValue(EventContext context, VirtualFrame frame, Object result) {
    String callSrc = context.getInstrumentedSourceSection().getCharacters();
    // is this the function call that we want to modify?
    if ("inc(c)".equals(callSrc)) {
      CompilerDirectives.transferToInterpreter();
      // notify the runtime that we will change the current execution flow
      throw context.createUnwind(null);
    }
  }

  @Override
  public Object onUnwind(EventContext context, VirtualFrame frame, Object info) {
    // just return 42 as the return value for this node
    return 42;
  }
}

事件监听器可以通过拦截所有函数调用来执行,例如使用以下 Instrument:

@TruffleInstrument.Registration(id = "UniversalAnswer", services = UniversalAnswerInstrument.class)
public static class UniversalAnswerInstrument extends TruffleInstrument {

  @Override
  protected void onCreate(Env env) {
    env.registerService(this);
    env.getInstrumenter().attachListener(SourceSectionFilter.newBuilder().tagIs(CallTag.class).build(), myListener);
  }
}

启用后,每次函数调用返回时,Instrument 都会执行其 onReturnValue 回调。回调读取关联的源代码段(使用 getInstrumentedSourceSection),并查找特定的源代码模式(在本例中为函数调用 inc(c))。一旦找到此类代码模式,Instrument 就会抛出一个特殊的运行时异常,称为 UnwindException,它会通知 Instrumentation 框架当前应用程序执行流的更改。该异常被 Instrumentation 代理的 onUnwind 回调拦截,该回调可用于向原始 Instrumented 应用程序返回任何任意值。

在此示例中,所有对 inc(c) 的调用都将返回 42,无论任何应用程序特定数据如何。一个更真实的 Instrument 可能会访问和监控应用程序的多个方面,并且可能不依赖于源代码位置,而是依赖于对象实例或其他应用程序特定数据。

联系我们