開始使用 GraalVM 中的工具

在 GraalVM 平台中,工具有時會被稱為儀器 (Instruments)Instrument API 用於實作此類儀器。儀器可以追蹤非常細微的、VM 層級的執行時事件,以分析、檢視和剖析在 GraalVM 上執行的應用程式的執行時行為。

簡易工具 #

為了讓工具開發人員更容易入門,我們建立了一個 簡易工具範例專案。這是一個具有 javadoc 的 Maven 專案,實作了一個簡單的程式碼涵蓋率工具。

我們建議複製此儲存庫並探索其原始程式碼,作為工具開發的起點。以下章節將以簡易工具原始程式碼為範例,逐步引導您建置和執行 GraalVM 工具所需的步驟。這些章節並未涵蓋 Instrument API 的所有功能,因此我們鼓勵您查看 javadoc 以取得更多詳細資訊。

需求 #

如前所述,簡易工具是一個程式碼涵蓋率工具。最終,它應向開發人員提供關於執行了多少百分比的原始程式碼行,以及確切執行了哪些行的資訊。考慮到這一點,我們可以從我們的工具定義一些高階需求

  1. 該工具會追蹤已載入的原始程式碼。
  2. 該工具會追蹤已執行的原始程式碼。
  3. 在應用程式結束時,該工具會計算並列印每行的涵蓋率資訊。

Instrument API #

工具的主要起點是繼承 TruffleInstrument 類別。不出所料,簡易工具程式碼庫也做了同樣的事情,建立了 SimpleCoverageInstrument 類別。

類別上的 Registration 註解確保新建立的儀器已在 Instrument API 中註冊,換句話說,它將由框架自動探索。它也提供關於儀器的一些中繼資料:ID、名稱、版本、儀器提供的服務,以及儀器是否為內部儀器。為了使此註解有效,DSL 處理器需要處理此類別。在簡易工具的案例中,這是透過將 DSL 處理器作為 Maven 設定中的相依性來自動完成的。

現在我們將回顧 SimpleCoverageInstrument 類別的實作,也就是它覆寫 TruffleInstrument 中的哪些方法。這些是 onCreateonDisposegetOptionDescriptorsonCreateonDispose 方法是不言自明的:它們在儀器建立和處置時由框架呼叫。我們稍後將討論它們的實作,但首先讓我們討論剩下的方法:getOptionDescriptors

Truffle 語言實作框架附帶其自己的系統,用於指定命令列選項。這些選項允許工具使用者從命令列或在建立 polyglot 內容時控制工具。它是基於註解的,而且此類選項的範例是 SimpleCoverageInstrumentENABLEDPRINT_COVERAGE 欄位。這兩者都是以 OptionKey 類型且使用 Option 註解的靜態最終欄位,與 Registration 註解類似,它為選項提供了一些中繼資料。同樣地,與 Registration 註解一樣,為了使 Option 註解有效,需要 DSL 處理器,它會產生 OptionDescriptors 的子類別 (在我們的案例中名為 SimpleCoverageInstrumentOptionDescriptors)。此類別的執行個體應該從 getOptionDescriptors 方法傳回,以讓框架知道儀器提供哪些選項。

回到 onCreate 方法,我們收到 Env 類別的執行個體作為引數。此物件提供了許多有用的資訊,但對於 onCreate 方法,我們主要對 getOptions 方法感興趣,該方法可用於讀取傳遞給工具的選項。我們使用它來檢查是否已設定 ENABLED 選項,如果已設定,我們會透過呼叫 enable 方法來啟用我們的工具。同樣地,在 onDispose 方法中,我們檢查選項中 PRINT_COVERAGE 選項的狀態,如果已啟用,我們會呼叫 printResults 方法,它將列印我們的結果。

「啟用工具」是什麼意思?一般而言,這表示我們告訴框架我們感興趣的事件,以及我們希望如何對這些事件做出反應。檢視我們的 enable 方法,它會執行以下操作

  • 首先,它會定義 SourceSectionFilter。此篩選器是我們感興趣的原始程式碼部分的宣告式定義。在我們的範例中,我們關心所有被視為運算式的節點,而且我們不關心內部語言部分。
  • 其次,我們取得 Instrumenter 類別的執行個體,它是一個物件,允許我們指定我們希望檢測系統的哪些部分。
  • 最後,使用 Instrumenter 類別,我們指定一個原始碼區段監聽器和一個執行事件處理器,這兩者都在接下來的兩節中描述。

原始碼區段監聽器 #

語言 API 提供 Source 的概念,它是原始程式碼單元,以及 SourceSection,它是 Source 的一個連續部分,例如,一個方法、一個語句、一個運算式等等。更多詳細資訊可以在各自的 javadoc 中找到。

簡易工具的第一個需求是追蹤已載入的原始程式碼。Instrument API 提供 LoadSourceSectionListener,當子類化並向檢測器註冊時,允許使用者對執行階段載入的原始碼區段做出反應。這正是我們對 GatherSourceSectionsListener 所做的事情,它是在儀器的 enable 方法中註冊的。GatherSourceSectionsListener 的實作非常簡單:我們覆寫 onLoad 方法,以通知儀器每個載入的原始碼區段。儀器會維護從每個 SourceCoverage 物件的對應關係,該物件會為每個來源維護一組載入的原始碼區段。

執行事件節點 #

客體語言是作為抽象語法樹 (AST) 直譯器實作的。語言實作人員使用標籤標註某些節點,這允許我們透過使用上述的 SourceSectionFilter 以獨立於語言的方式選擇我們感興趣的節點。

Instrument API 的主要功能在於它能夠在 AST 中插入專用節點,這些節點會「包裝」感興趣的節點。這些節點是使用語言開發人員使用的相同基礎結構建立的,並且從執行階段的角度來看,與語言節點沒有區別。這表示用於將客體語言最佳化為如此高效能的語言實作的所有技術也適用於工具開發人員。

有關這些技術的更多資訊可在語言實作文件中找到。只要說對於簡易工具來說,為了滿足其第二個需求,我們需要使用我們自己的節點來檢測所有運算式,該節點會在執行該運算式時通知我們。

在這個任務中,我們使用 CoverageNode。它是 ExecutionEventNode 的子類別,顧名思義,它用於檢測執行期間的事件。ExecutionEventNode 提供了許多方法可以覆寫,但我們只對 onReturnValue 感興趣。當「包裝」的節點回傳一個值時,也就是它成功執行時,就會調用此方法。實作相當簡單。我們只是通知檢測器具有特定 SourceSection 的節點已被執行,並且檢測器會更新其覆蓋率映射中的 Coverage 物件。

每個節點只會通知檢測器一次,因為邏輯受 flag 保護。事實上,此標記使用 CompilationFinal 註釋,並且在呼叫檢測器之前呼叫了 transferToInterpreterAndInvalidate(),這是 Truffle 中的標準技術,可確保一旦不再需要此檢測(節點已執行),檢測就會從進一步的編譯中移除,並同時移除任何效能開銷。

為了讓框架知道在需要時如何實例化 CoverageNode,我們需要為其提供一個工廠。該工廠是 CoverageEventFactory,它是 ExecutionEventNodeFactory 的子類別。這個類別只是確保每個 CoverageNode 透過在提供的 EventContext 中查找來知道它正在檢測的 SourceSection

最後,當我們 啟用檢測器時,我們會告訴檢測器使用我們的工廠來「包裝」由我們的篩選器選取的節點。

使用者和檢測器之間的互動 #

Simple Tool 的第三個也是最後一個要求是透過將程式碼涵蓋率列印到標準輸出,來實際與其使用者互動。檢測器覆寫了 onDispose 方法,不出所料,該方法會在檢測器被處置時呼叫。在這個方法中,我們會檢查是否已設定適當的選項,如果是,則計算並列印由我們的 Coverage 物件映射所記錄的涵蓋率。

這是一種向使用者提供有用資訊的簡單方法,但絕對不是唯一的方法。工具可以將其資料直接轉儲到檔案,或執行顯示資訊的 Web 端點等等。Instrument API 為使用者提供的一種機制是將檢測器註冊為服務,以供其他檢測器查詢。如果我們查看檢測器的 Registration 註釋,我們可以發現它提供了一個 services 欄位,我們可以在其中指定檢測器為其他檢測器提供的服務。這些服務需要明確地註冊。這允許在檢測器之間更好地分離關注點,例如,我們可以有一個「即時涵蓋率」檢測器,它將使用我們的 SimpleCoverageInstrument 來透過 REST API 向使用者提供按需涵蓋率資訊,以及一個「涵蓋率低時中止」檢測器,如果涵蓋率低於閾值則停止執行,兩者都將 SimpleCoverageInstrument 作為服務使用。

注意:由於隔離的原因,檢測器服務不適用於應用程式碼,並且檢測器服務只能從其他檢測器或客座語言中使用。

將工具安裝到 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 解決的常見使用案例。

檢測事件接聽器 #

Instrument API 在 com.oracle.truffle.api.instrumentation 套件中定義。檢測代理程式可以透過擴展 TruffleInstrument 類別來開發,並且可以使用 Instrumenter 類別附加到正在執行的 GraalVM 實例。一旦附加到正在執行的語言執行時間,只要語言執行時間未被處置,檢測代理程式就會保持可用。GraalVM 上的檢測代理程式可以監控各種 VM 層級的執行時間事件,包括以下任何事件

  1. 與原始碼相關的事件:每次受監控的語言執行時間載入新的 SourceSourceSection 元素時,代理程式都會收到通知。
  2. 配置事件:每次在受監控語言執行時間的記憶體空間中配置新物件時,代理程式都會收到通知。
  3. 語言執行時間和執行緒建立事件:只要建立受監控語言執行時間的新 執行上下文或新執行緒,代理程式就會立即收到通知。
  4. 應用程式執行事件:每次受監控的應用程式執行一組特定的語言操作時,代理程式都會收到通知。此類操作的範例包括語言語句和表達式,因此允許檢測代理程式以非常高的精確度檢查正在執行的應用程式。

對於每個執行事件,檢測代理程式可以定義篩選條件,GraalVM 檢測執行時間將使用這些條件僅監控相關的執行事件。目前,GraalVM 檢測器接受以下兩種篩選器類型之一

  1. AllocationEventFilter 按配置類型篩選配置事件。
  2. SourceSectionFilter 篩選應用程式中的原始碼位置。

可以使用提供的建置器物件來建立篩選器。例如,以下建置器會建立一個 SourceSectionFilter

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

範例中的篩選器可用於監控給定應用程式中所有 JavaScript 語句的執行。也可以提供其他篩選選項,例如行號或檔案副檔名。

像範例中的原始碼區段篩選器可以使用標籤來指定要監控的一組執行事件。語言無關的標籤(例如語句和表達式)在 com.oracle.truffle.api.instrumentation.Tag 類別中定義,並受所有 GraalVM 語言支援。除了標準標籤之外,GraalVM 語言還可以提供其他語言特定的標籤,以啟用對語言特定事件的精細分析。(例如,GraalVM JavaScript 引擎提供 JavaScript 特定的標籤來追蹤 ECMA 內建物件(例如 ArrayMapMath)的使用情況。)

監控執行事件 #

應用程式執行事件能夠進行非常精確和詳細的監控。GraalVM 支援兩種不同的檢測代理程式來分析此類事件,即

  1. 執行接聽器:一種檢測代理程式,可以在給定執行時間事件發生時收到通知。接聽器實作 ExecutionEventListener 介面,並且無法將任何狀態與原始碼位置相關聯。
  2. 執行事件節點:可以使用 Truffle Framework AST 節點表示的檢測代理程式。此類代理程式會擴展 ExecutionEventNode 類別,並且具有與執行接聽器相同的功能,但可以將狀態與原始碼位置相關聯。

簡單檢測代理程式 #

可以在 CoverageExample 類別中找到用於執行時間程式碼涵蓋率的自訂檢測代理程式的簡單範例。以下是該代理程式、其設計及其功能的概述。

所有檢測器都會擴展 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... */
}

檢測器會覆寫 onCreate(Env env) 方法,以在檢測器載入時執行自訂操作。通常,檢測器會使用此方法將其本身註冊到現有的 GraalVM 執行環境中。例如,可以使用以下方式註冊使用 AST 節點的檢測器

@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));
}

檢測器會使用 attachExecutionEventFactory 方法將其自身連線到正在執行的 GraalVM,並提供以下兩個引數

  1. SourceSectionFilter:一個來源區段篩選器,用於告知 GraalVM 要追蹤的特定程式碼區段。
  2. ExecutionEventNodeFactory:Truffle AST 工廠,提供儀器化的 AST 節點,在每次執行階段事件(如來源篩選器所指定)執行時,由代理程式執行。

一個基本的 ExecutionEventNodeFactory,可以以下列方式實作,以對應用程式的 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:在篩選過的原始碼元素擲回例外狀況時執行。

執行事件節點是基於每個程式碼位置建立的。因此,它們可用於儲存特定於儀器化應用程式中給定程式碼位置的資料。例如,儀器化節點可以簡單地使用節點本地旗標追蹤所有已造訪過的程式碼位置。此類節點本地 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 節點。這表示儀器化程式碼將由 GraalVM 執行階段與儀器化應用程式一起最佳化,從而產生最小的儀器化額外負荷。此外,這允許儀器開發人員直接從儀器化節點使用 Truffle 框架編譯器指令。在範例中,編譯器指令用於告知 Graal 編譯器,visited 可以被視為編譯最終的。

每個儀器化節點都繫結到特定的程式碼位置。代理程式可以使用提供的 EventContext 物件存取這些位置。內容物件讓儀器化節點可以存取有關目前正在執行的 AST 節點的各種資訊。透過 EventContext 提供給儀器化代理程式的查詢 API 範例包括

  1. hasTag:查詢儀器化節點是否存在特定節點 Tag(例如,檢查陳述式節點是否也是條件節點)。
  2. getInstrumentedSourceSection:存取與目前節點關聯的 SourceSection
  3. getInstrumentedNode:存取對應於目前儀器化事件的 Node

細粒度表達式分析 #

儀器化代理程式甚至可以分析語言表達式等細微事件。為此,代理程式需要初始化,並提供兩個來源區段篩選器

// 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 回呼報告給儀器化代理程式。例如,假設代理程式需要分析 JavaScript 中總和運算(+)提供的所有運算元

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

透過篩選 JavaScript 二元表達式,儀器化代理程式將能夠偵測到上述程式碼片段的以下執行階段事件

  1. onEnter():針對第 3 行的二元表達式。
  2. onInputValue():針對第 3 行二元運算的第一个運算元。回呼報告的值將是 3,即 a 區域變數的值。
  3. onInputValue():針對二元運算的第二個運算元。回呼報告的值將是 4,即 b 區域變數的值。
  4. onReturnValue():針對二元表達式。提供給回呼的值將是表達式完成評估後傳回的值,即值 7

透過將來源區段篩選器擴展到所有可能的事件,儀器化代理程式將觀察到與以下執行追蹤等效的內容(以虛擬碼表示)

// 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 方法可以與來源區段篩選器結合使用,以攔截非常細微的執行事件,例如語言表達式使用的中間值。儀器化框架可以存取的中間值在很大程度上取決於每種語言提供的儀器化支援。此外,語言可能會提供與語言特定的 Tag 類別關聯的其他中繼資料。

變更應用程式的執行流程 #

我們目前介紹的儀器化功能讓使用者能夠觀察執行中應用程式的某些方面。除了被動監控應用程式的行為之外,Instrument API 還支援在執行階段主動變更應用程式的行為。此類功能可用於編寫複雜的儀器化代理程式,以影響執行中應用程式的行為,從而實現特定的執行階段語義。例如,可以變更執行中應用程式的語義,以確保永遠不會執行某些方法或函式(例如,在呼叫時擲回例外狀況)。

具有此類功能的儀器化代理程式可以透過利用執行事件接聽器和工廠中的 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)

可以使用 ExecutionEventListener 以以下方式實作一個儀器化代理程式,將 inc 的傳回值修改為始終為 42

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;
  }
}

可以執行事件接聽器來攔截所有函式呼叫,例如使用以下儀器

@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);
  }
}

啟用後,儀器將在每次函式呼叫傳回時執行其 onReturnValue 回呼。回呼會讀取關聯的來源區段(使用 getInstrumentedSourceSection)並尋找特定的原始碼模式(在此案例中為函式呼叫 inc(c))。一旦找到此類程式碼模式,儀器就會擲回一個特殊的執行階段例外狀況,稱為 UnwindException,它會指示儀器化框架有關目前應用程式執行流程的變更。例外狀況會被儀器化代理程式的 onUnwind 回呼攔截,該回呼可用於將任何任意值傳回至原始儀器化的應用程式。

在範例中,對 inc(c) 的所有呼叫都將傳回 42,而不考慮任何應用程式特定的資料。更實際的儀器可能會存取和監控應用程式的數個方面,並且可能不依賴原始碼位置,而是依賴物件執行個體或其他應用程式特定的資料。

與我們聯繫