Truffle 程式庫指南

Truffle 程式庫允許語言實作針對接收器類型使用多型分派,並支援特定實作的快取/剖析以及自動支援未快取的分派。Truffle 程式庫為 Truffle 之上的語言實作的表示類型啟用模組化和封裝。在使用它們之前,請先閱讀本指南。

開始使用 #

本教學課程提供如何使用 Truffle 程式庫的使用案例追蹤。完整的 API 文件可在 Javadoc 中找到。本文檔假設您事先瞭解 Truffle API 以及 @Specialization@Cached 註釋的使用。

動機範例 #

在 Truffle 語言中實作陣列時,通常需要使用多種表示形式以提高效率。例如,如果陣列是從整數的算術序列建構而成的 (例如,range(from: 1, step: 2, length: 3)),則最好使用 startstridelength 來表示,而不是實體化整個陣列。當然,當寫入陣列元素時,就需要實體化陣列。在本範例中,我們將使用兩種表示形式實作陣列實作

  • Buffer:表示由 Java 陣列支援的已實體化的陣列表示形式。
  • Sequence:表示由 startstridelength 表示的數字算術序列:[start, start + 1 * stride, ..., start + (length - 1) * stride]

為了讓範例簡單,我們將僅支援 int 值,並且將忽略索引界限錯誤處理。我們也將僅實作讀取操作,而不是通常更複雜的寫入操作。

為了讓範例更有趣,我們將實作一個最佳化,即使陣列接收器值不是常數,編譯器也能允許常數摺疊循序陣列存取。

假設我們有以下程式碼片段 range(start, stride, length)[2]。在此程式碼片段中,變數 startstride 並非已知為常數值,因此會編譯與 start + stride * 2 等效的程式碼。但是,如果已知 startstride 值始終相同,則編譯器可以常數摺疊整個操作。此最佳化需要使用快取。我們稍後將說明其運作方式。

在 GraalVM 的 JavaScript 執行階段的動態陣列實作中,我們使用 20 種不同的表示形式。有些表示形式適用於常數、以零為基礎、連續、孔和稀疏陣列。某些表示形式還針對類型 byteintdoubleJSObjectObject 進行特殊化。原始碼可以在此處找到。注意:目前,JavaScript 陣列尚未使用 Truffle 程式庫。

在以下章節中,我們將討論陣列表示形式的多種實作策略,最終說明如何使用 Truffle 程式庫來達成此目的。

策略 1:每個表示形式的特殊化 #

針對此策略,我們將首先宣告兩個表示形式 BufferArraySequenceArray 的類別。

final class BufferArray {
    int length;
    int[] buffer;
    /*...*/
}

final class SequenceArray {
    final int start;
    final int stride;
    final int length;
    /*...*/
}

BufferArray 實作具有可變的緩衝區和長度,並用作已實體化的陣列表示形式。序列陣列由最終欄位 startstridelength 表示。

現在,我們指定如下基本讀取操作

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

陣列讀取節點指定緩衝區版本和序列的兩種特殊化。如前所述,為了簡單起見,我們將忽略錯誤界限檢查。

現在,我們嘗試讓陣列讀取針對序列值的常數性進行特殊化,以便在開始和跨距是常數時,允許摺疊 range(start, stride, length)[2] 範例。為了找出開始和跨距是否為常數,我們需要剖析它們的值。為了剖析這些值,我們需要在陣列讀取操作中新增另一個特殊化,如下所示

@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() */
}

如果此特殊化的推測保護成功,則開始和跨距實際上是常數。例如,使用值 32,編譯器會看到 3 + 2 * 2,即 7。限制設定為 1,僅嘗試推測一次。增加限制可能會沒有效率,因為這會向已編譯的程式碼引入其他控制流程。如果推測不成功,也就是說,如果操作觀察到多個開始和跨距值,我們希望退回到正常的序列特殊化。為了達成此目的,我們變更 doSequence 特殊化,新增 replaces = "doSequenceCached",如下所示

@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 介面,並在表示形式類別中實作讀取方法。

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 指定兩個訊息:isArrayread。在編譯時,註釋處理器會產生受套件保護的類別 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 訊息。同樣地,註釋處理器會產生實作程式庫抽象類別的樣板程式碼。

接下來,我們實作序列表示形式。首先,我們實作它,而不對開始和跨距值進行最佳化。

@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 函式庫,我們現在支援具有類型封裝的多型派送,但不會失去在表示法類型中使用分析/快取技術的能力。

下一步該做什麼? #

  • 在此處執行和偵錯所有範例 這裡

  • 閱讀互通性遷移指南,作為 Truffle 函式庫使用範例這裡

  • 閱讀 Truffle 函式庫參考文件這裡

常見問題解答 #

是否有任何已知的限制?

  • 函式庫匯出目前無法明確調用其 super 實作。這使得反射實作目前不可行。請參閱此處的範例這裡
  • 目前不支援傳回值的 Boxing 消除。一個訊息只能有一個泛型傳回類型。計畫支援此功能。
  • 目前不支援在沒有對 Library 類別的靜態相依性的情況下進行反射。計畫支援完整的動態反射。

我應該在什麼時候使用 Truffle 函式庫?

何時使用?

  • 如果表示法是模組化的,且無法針對某個操作進行枚舉(例如,Truffle 互通性)。
  • 如果一個類型有多個表示法,且其中一個表示法需要分析/快取(例如,請參閱動機範例)。
  • 如果需要一種代理語言所有值的方式(例如,用於動態污點追蹤)。

何時不使用?

  • 對於只有一個表示法的基本類型。
  • 對於需要 Boxing 消除以加速直譯器的基本表示法。Truffle 函式庫目前不支援 Boxing 消除。

我決定使用 Truffle 函式庫來抽象化我的語言的特定類型。這些類型應該對其他語言和工具公開嗎?

所有函式庫都可以透過 ReflectionLibrary 供其他語言和工具存取。建議語言實作文件指定哪些函式庫和訊息打算供外部使用,以及哪些可能會發生重大變更。

當一個新的方法被添加到函式庫中,但動態載入的實作尚未更新時會發生什麼事?

如果函式庫方法被指定為 abstract,則會拋出 AbstractMethodError。否則,將呼叫函式庫方法主體指定的預設實作。這允許在使用了抽象方法時自訂錯誤。例如,對於 Truffle 互通性,我們通常會拋出 UnsupportedMessageException 而不是 AbstractMethodError

與我們聯繫