從 Go 角度看 LLVM

開發編譯器是一項非常困難的任務。 但是,幸運的是,隨著像 LLVM 這樣的專案的開發,這個問題的解決方案大大簡化了,甚至允許單一程式設計師創建一種效能接近 C 的新語言。使用 LLVM 很複雜,因為它系統由大量程式碼表示,但配備的文件卻很少。 為了嘗試糾正這個缺點,我們今天發布的材料的翻譯將演示用 Go 編寫的程式碼範例,並展示它們如何首先被翻譯成 去SSA,然後在 LLVM IR 中使用編譯器 小小GO。 Go SSA 和 LLVM IR 程式碼已稍作編輯,刪除了與此處給出的解釋無關的內容,以使解釋更容易理解。

從 Go 角度看 LLVM

第一個例子

我要在這裡查看的第一個函數是一個用於添加數字的簡單機制:

func myAdd(a, b int) int{
    return a + b
}

這個函數非常簡單,也許再簡單不過了。 它轉換為以下 Go SSA 程式碼:

func myAdd(a int, b int) int:
entry:
    t0 = a + b                                                    int
    return t0

使用此視圖,資料類型提示位於右側,在大多數情況下可以忽略。

這個小例子已經可以讓你看到SSA某一方面的精髓了。 也就是說,當將程式碼轉換為 SSA 形式時,每個表達式都會分解為其組成的最基本部分。 在我們的例子中,命令 return a + b實際上,代表兩個操作:將兩個數字相加並傳回結果。

另外,在這裡你可以看到程式的基本區塊;這段程式碼中只有一個區塊-入口區塊。 我們將在下面詳細討論區塊。

Go SSA 程式碼可以輕鬆轉換為 LLVM IR:

define i64 @myAdd(i64 %a, i64 %b) {
entry:
  %0 = add i64 %a, %b
  ret i64 %0
}

你可以注意到,雖然這裡使用了不同的語法結構,但函數的結構基本上沒有改變。 LLVM IR程式碼比Go SSA程式碼強一點,類似C。這裡,在函數宣告中,首先有它傳回的資料類型的描述,參數類型在參數名稱之前指示。 另外,為了簡化IR解析,全域實體的名稱前面帶有符號 @,並且在本地名稱之前有一個符號 % (函數也被視為全域實體)。

關於這段程式碼需要注意的一件事是 Go 的類型表示決定 int,可以表示為 32 位元或 64 位元值,取決於編譯器和編譯目標,在 LLVM 產生 IR 程式碼時會被接受。 這是 LLVM IR 程式碼不像許多人認為的那樣獨立於平台的眾多原因之一。 為一個平台創建的此類程式碼不能簡單地為另一個平台獲取和編譯(除非您適合解決這個問題 極為謹慎).

另一個值得注意的有趣點是類型 i64 不是有符號整數:它在表示數字的符號方面是中性的。 根據指令的不同,它可以表示有符號數和無符號數。 在表示加法運算的情況下,這並不重要,因此使用有符號數或無符號數沒有區別。 這裡我想指出的是,在C語言中,溢位有符號整數變數會導致未定義的行為,因此Clang前端為此操作增加了一個標誌 nsw (無簽章換行),它告訴 LLVM 它可以假設加法永遠不會溢位。

這對於某些優化可能很重要。 例如,將兩個值相加 i16 在 32 位元平台(具有 32 位元暫存器)上,在加法之後需要進行符號擴展操作以保持在範圍內 i16。 因此,根據機器暫存器大小執行整數運算通常更有效。

我們現在對這個 IR 程式碼接下來發生的事情並不是特別感興趣。 程式碼經過最佳化(但對於像我們這樣的簡單範例,沒有任何最佳化),然後轉換為機器碼。

第二個例子

我們將看到的下一個範例會稍微複雜一些。 也就是說,我們正在討論一個對整數切片求和的函數:

func sum(numbers []int) int {
    n := 0
    for i := 0; i < len(numbers); i++ {
        n += numbers[i]
    }
    return n
}

此程式碼轉換為以下 Go SSA 程式碼:

func sum(numbers []int) int:
entry:
    jump for.loop
for.loop:
    t0 = phi [entry: 0:int, for.body: t6] #n                       int
    t1 = phi [entry: 0:int, for.body: t7] #i                       int
    t2 = len(numbers)                                              int
    t3 = t1 < t2                                                  bool
    if t3 goto for.body else for.done
for.body:
    t4 = &numbers[t1]                                             *int
    t5 = *t4                                                       int
    t6 = t0 + t5                                                   int
    t7 = t1 + 1:int                                                int
    jump for.loop
for.done:
    return t0

在這裡,您已經可以看到更多用於以 SSA 形式表示程式碼的典型結構。 也許這段程式碼最明顯的特徵是沒有結構化的流控制指令。 控制計算流程,只有條件跳轉和無條件跳轉,如果我們把這個指令看成是控制流程的指令,那就是回傳指令。

事實上,這裡你可以注意一下,程式並沒有使用大括號來分割成區塊(如C族語言那樣)。 它透過標籤來劃分,讓人想起彙編語言,並以基本區塊的形式呈現。 在 SSA 中,基本區塊被定義為以標籤開頭並以基本區塊完成指令結束的連續程式碼序列,例如 - return и jump.

該程式碼的另一個有趣的細節由指令表示 phi。 這些說明非常不尋常,可能需要一些時間才能理解。 記住,那個 公共福利金 是靜態單一分配的縮寫。 這是編譯器使用的程式碼的中間表示,其中每個變數僅被賦值一次。 這對於表達像我們的函數這樣的簡單函數非常有用 myAdd如上所示,但不適合更複雜的函數,例如本節中討論的函數 sum。 特別是,變數在循環執行期間發生變化 i и n.

SSA使用所謂的指令繞過一次分配變數值的限制 phi (它的名字取自希臘字母)。 事實是,為了為 C 等語言產生程式碼的 SSA 表示,您必須採用一些技巧。 呼叫該指令的結果是變數的目前值(in),並使用基本區塊列表作為其參數。 例如,考慮以下指令:

t0 = phi [entry: 0:int, for.body: t6] #n

其意義如下:如果前一個基本塊是一個塊 entry (輸入),然後 t0 是一個常數 0,如果之前的基本塊是 for.body,那麼你需要取值 t6 從這個街區。 這可能看起來很神秘,但正是這種機制使 SSA 發揮作用。 從人類的角度來看,這一切都使程式碼難以理解,但每個值僅分配一次的事實使許多優化變得更加容易。

請注意,如果您編寫自己的編譯器,通常不必處理此類內容。 即使 Clang 也不會產生所有這些指令 phi,它使用了一種機制 alloca (它類似於使用普通的局部變數)。 然後,當執行名為 LLVM 最佳化過程時 記憶體2暫存器, 指示 alloca 轉換為 SSA 形式。 然而,TinyGo 接收來自 Go SSA 的輸入,而 Go SSA 已經方便轉換為 SSA 形式。

正在考慮的中間程式碼片段的另一個創新是,透過索引對切片元素的存取以計算位址的操作和解引用結果指標的操作的形式表示。 在這裡您可以看到常數直接添加到 IR 程式碼中(例如 - 1:int)。 在帶有函數的範例中 myAdd 這個還沒有被使用過。 現在我們已經了解了這些功能,讓我們看看這段程式碼轉換為 LLVM IR 形式後會變成什麼:

define i64 @sum(i64* %ptr, i64 %len, i64 %cap) {
entry:
  br label %for.loop

for.loop:                                         ; preds = %for.body, %entry
  %0 = phi i64 [ 0, %entry ], [ %5, %deref.next ]
  %1 = phi i64 [ 0, %entry ], [ %6, %deref.next ]
  %2 = icmp slt i64 %1, %len
  br i1 %2, label %for.body, label %for.done

for.body:                                         ; preds = %for.loop
  %3 = getelementptr i64, i64* %ptr, i64 %1
  %4 = load i64, i64* %3
  %5 = add i64 %0, %4
  %6 = add i64 %1, 1
  br label %for.loop

for.done:                                         ; preds = %for.loop
  ret i64 %0
}

在這裡,和以前一樣,我們可以看到相同的結構,其中包括其他句法結構。 例如,在通話中 phi 值和標籤交換了。 不過,這裡有一點值得特別注意。

首先,在這裡您可以看到一個完全不同的函數簽名。 LLVM 不支援切片,因此,作為一種最佳化,產生此中間程式碼的 TinyGo 編譯器將此資料結構的描述拆分為多個部分。 它可以代表三個切片元素(ptr, len и cap)作為一個結構(struct),但將它們表示為三個獨立的實體可以進行一些最佳化。 其他編譯器可能以其他方式表示切片,這取決於目標平台函數的呼叫約定。

該程式碼的另一個有趣的功能是指令的使用 getelementptr (通常縮寫為 GEP)。

此指令與指標一起使用,用於取得指向切片元素的指標。 例如,我們將其與以下用 C 編寫的程式碼進行比較:

int* sliceptr(int *ptr, int index) {
    return &ptr[index];
}

或使用與此等效的以下內容:

int* sliceptr(int *ptr, int index) {
    return ptr + index;
}

這裡最重要的是說明 getelementptr 不執行取消引用操作。 它只是根據現有指標計算一個新指標。 可以作為說明 mul и add 在硬體層面。 您可以閱讀有關 GEP 指令的更多信息 這裡.

這個中間程式碼的另一個有趣的功能是指令的使用 icmp。 這是用於實現整數比較的通用指令。 該指令的結果始終是類型的值 i1 ——邏輯值。 在本例中,使用關鍵字進行比較 slt (符號小於),因為我們正在比較先前由類型表示的兩個數字 int。 如果我們要比較兩個無符號整數,那麼我們會使用 icmp,比較中使用的關鍵字是 ult。 為了比較浮點數,使用了另一條指令, fcmp,其工作方式類似。

結果

我相信在本材料中我已經涵蓋了 LLVM IR 最重要的功能。 當然,這裡還有很多。 特別是,程式碼的中間表示可能包含許多註釋,這些註釋允許最佳化過程考慮編譯器已知的程式碼的某些特徵,否則這些特徵無法用 IR 表示。 例如,這是一個標誌 inbounds GEP 指令或標誌 nsw и nuw,可以加入到說明中 add。 關鍵字也是如此 private,向最佳化器表示它所標記的函數不會從目前編譯單元外部引用。 這允許進行許多有趣的過程間優化,例如消除未使用的參數。

您可以閱讀有關 LLVM 的更多信息 文件,您在開發自己的基於 LLVM 的編譯器時會經常參考它。 這裡 領導,它著眼於為一種非常簡單的語言開發編譯器。 在創建您自己的編譯器時,這兩個資訊來源都會對您有用。

親愛的讀者! 你使用LLVM嗎?

從 Go 角度看 LLVM

來源: www.habr.com

添加評論