栈上替换 (OSR)

在执行过程中,Truffle 会将“热门”调用目标排队以进行编译。一旦目标被编译,该目标的后续调用将可以执行编译后的版本。但是,正在执行的调用目标将无法从这种编译中获益,因为它无法将执行转移到编译后的代码。这意味着长时间运行的目标可能会“卡”在解释器中,从而影响预热性能。

栈上替换 (OSR) 是 Truffle 中使用的一种技术,用于从解释器中“跳出”,将执行从解释后的代码转移到编译后的代码。Truffle 支持对 AST 解释器(即具有 LoopNode 的 AST)和字节码解释器(即具有分派循环的节点)进行 OSR。在任何情况下,Truffle 都使用启发式方法来检测何时正在解释长时间运行的循环,并且可以执行 OSR 来加速执行。

用于 AST 解释器的 OSR #

使用标准 Truffle API 的语言可以在 Graal 上免费获得 OSR。运行时跟踪 LoopNode(使用 TruffleRuntime.createLoopNode(RepeatingNode) 创建)在解释器中执行的次数。一旦循环迭代次数超过阈值,运行时就会认为该循环是“热门”循环,它将透明地编译该循环,轮询其完成情况,然后调用编译后的 OSR 目标。OSR 目标使用与解释器相同的 Frame。当循环在 OSR 执行中退出时,它会返回到解释后的执行,后者会转发结果。

有关更多详细信息,请参阅 LoopNodejavadoc

用于字节码解释器的 OSR #

用于字节码解释器的 OSR 需要语言进行稍微更多的配合。字节码分派节点通常看起来像这样

class BytecodeDispatchNode extends Node {
  @CompilationFinal byte[] bytecode;

  ...

  @ExplodeLoop(kind = ExplodeLoop.LoopExplosionKind.MERGE_EXPLODE)
  Object execute(VirtualFrame frame) {
    int bci = 0;
    while (true) {
      int nextBCI;
      switch (bytecode[bci]) {
        case OP1:
          ...
          nextBCI = ...
          ...
        case OP2:
          ...
          nextBCI = ...
          ...
        ...
      }
      bci = nextBCI;
    }
  }
}

与 AST 解释器不同,字节码解释器中的循环通常是非结构化的(并且是隐式的)。尽管字节码语言没有结构化循环,但代码中的反向跳转(“反向边”)往往是循环迭代次数的一个很好的替代指标。因此,Truffle 的字节码 OSR 是围绕反向边及其边的目标(通常对应于循环头)设计的。

为了使用 Truffle 的字节码 OSR,语言的分派节点应实现 BytecodeOSRNode 接口。该接口至少需要三种方法实现

  • executeOSR(osrFrame, target, interpreterState):此方法使用 osrFrame 作为当前程序状态,将执行分派到给定的 target(即字节码索引)。interpreterState 对象可以传递恢复执行所需的任何其他解释器状态。
  • getOSRMetadata()setOSRMetadata(osrMetadata):这些方法代理对类上声明的字段的访问。运行时将使用这些访问器来维护与 OSR 编译相关的状态(例如,反向边计数)。该字段应使用 @CompilationFinal 进行注释。

在主分派循环中,当语言遇到反向边时,它应该调用提供的 BytecodeOSRNode.pollOSRBackEdge(osrNode) 方法以向运行时通知反向边。如果运行时认为该节点适合进行 OSR 编译,则此方法将返回 true

如果(且仅当)pollOSRBackEdge 返回 true 时,语言可以调用 BytecodeOSRNode.tryOSR(osrNode, target, interpreterState, beforeTransfer, parentFrame) 来尝试 OSR。此方法将请求从 target 开始的编译,并且一旦编译后的代码可用,后续调用可以透明地调用编译后的代码并返回计算结果。我们将在稍后讨论 interpreterStatebeforeTransfer 参数。

上面的示例可以重构以支持 OSR,如下所示

class BytecodeDispatchNode extends Node implements BytecodeOSRNode {
  @CompilationFinal byte[] bytecode;
  @CompilationFinal private Object osrMetadata;

  ...

  Object execute(VirtualFrame frame) {
    return executeFromBCI(frame, 0);
  }

  Object executeOSR(VirtualFrame osrFrame, int target, Object interpreterState) {
    return executeFromBCI(osrFrame, target);
  }

  Object getOSRMetadata() {
    return osrMetadata;
  }

  void setOSRMetadata(Object osrMetadata) {
    this.osrMetadata = osrMetadata;
  }

  @ExplodeLoop(kind = ExplodeLoop.LoopExplosionKind.MERGE_EXPLODE)
  Object executeFromBCI(VirtualFrame frame, int bci) {
    while (true) {
      int nextBCI;
      switch (bytecode[bci]) {
        case OP1:
          ...
          nextBCI = ...
          ...
        case OP2:
          ...
          nextBCI = ...
          ...
        ...
      }

      if (nextBCI < bci) { // back-edge
        if (BytecodeOSRNode.pollOSRBackEdge(this)) { // OSR can be tried
          Object result = BytecodeOSRNode.tryOSR(this, nextBCI, null, null, frame);
          if (result != null) { // OSR was performed
            return result;
          }
        }
      }
      bci = nextBCI;
    }
  }
}

字节码 OSR 的一个细微差别是,OSR 执行会继续执行,直到调用目标结束,而不是循环结束。因此,一旦执行从 OSR 返回,执行就不需要继续在解释器中执行;结果可以直接转发给调用者。

tryOSRinterpreterState 参数可以包含执行所需的任何其他解释器状态。此状态将传递给 executeOSR,并可用于恢复执行。例如,如果解释器使用数据指针来管理读写操作,并且每个 target 的数据指针都是唯一的,则可以将此指针传递到 interpreterState 中。它对编译器可见,并在部分评估中使用。

tryOSRbeforeTransfer 参数是一个可选的回调,将在执行 OSR 之前调用。由于 tryOSR 可能执行也可能不执行 OSR,因此此参数是一种在转移到 OSR 代码之前执行任何操作的方法。例如,语言可以传递一个回调来发送一个仪器事件,然后再跳转到 OSR 代码。

BytecodeOSRNode 接口还包含一些挂钩方法,这些方法的默认实现可以被覆盖

  • copyIntoOSRFrame(osrFrame, parentFrame, target)restoreParentFrame(osrFrame, parentFrame):在 OSR 代码中重用解释后的 Frame 并不是最优的,因为这样会导致 Frame 逃逸出 OSR 调用目标,并阻止标量替换(有关标量替换的背景信息,请参阅 这篇论文)。如果可能,Truffle 会使用 copyIntoOSRFrame 将解释后的状态 (parentFrame) 复制到 OSR Frame (osrFrame) 中,并使用 restoreParentFrame 将状态复制回父 Frame。默认情况下,这两个挂钩都会在源帧和目标帧之间复制每个插槽,但可以将其覆盖以进行更精细的控制(例如,仅复制活动变量)。如果覆盖,则应仔细编写这些方法以支持标量替换。
  • prepareOSR(target):此挂钩在编译 OSR 目标之前被调用。它可用于强制在编译之前执行任何初始化操作。例如,如果一个字段只能在解释器中初始化,prepareOSR 可以确保它被初始化,这样 OSR 代码在尝试访问它时不会进行反优化。

基于字节码的 OSR 可能难以实现。一些调试技巧

  • 确保元数据字段使用 @CompilationFinal 进行标记。
  • 如果具有给定 FrameDescriptorFrame 之前已经实例化,Truffle 将会重用解释后的 Frame,而不是复制(如果使用复制,任何现有的实例化 Frame 都可能与 OSR Frame 脱节)。
  • 跟踪编译和反优化日志有助于识别可能在 prepareOSR 中完成的任何初始化工作。
  • 在 IGV 中检查编译后的 OSR 目标可能有助于确保复制挂钩与部分评估良好地交互。

有关更多详细信息,请参阅 BytecodeOSRNodejavadoc

命令行选项 #

有两个(实验性的)选项可用于配置 OSR

  • engine.OSR:是否执行 OSR(默认值:true
  • engine.OSRCompilationThreshold:触发 OSR 编译所需的循环迭代次数/反向边数(默认值:100,352)。

调试 #

OSR 编译目标使用 <OSR>(或 <OSR@n>,其中 n 是字节码 OSR 的分派目标)标记。可以使用标准调试工具(如编译日志和 IGV)查看和调试这些目标。例如,在编译日志中,字节码 OSR 条目可能看起来像这样

[engine] opt done     BytecodeNode@2d3ca632<OSR@42>                               |AST    2|Tier 1|Time   21(  14+8   )ms|Inlined   0Y   0N|IR   161/  344|CodeSize   1234|Addr 0x7f3851f45c10|Src n/a

有关调试 Graal 编译的更多详细信息,请参阅 调试

与我们联系