1. 程式人生 > >LLVM程式碼生成器進一步深入,第一部分

LLVM程式碼生成器進一步深入,第一部分

作者:Eli Bendersky

在前一篇文章中,我追蹤了當從源語言編譯到機器程式碼時,在LLVM中一條指令的各種化身。那篇文章簡明地提到了LLVM中的許多層面,它們中的每一個都有趣且不簡單。

這裡我希望關注其中一個最重要及複雜的層面——程式碼生成器,特別地指令選擇機制。一個簡短的提醒:程式碼生成器的任務是把高階的、幾乎機器無關的LLVM IR轉換為低階的、機器相關的機器語言。指令選擇是這樣的過程,其中IR中的抽象操作被對映到目標機器架構的具體指令。

本文將追尋一個簡單的例子來展示起作用的指令選擇機制(LLVM的說法,ISel)。

開始:簡單乘法的DAG

下面是一些簡單的IR:

define i64 @imul(i64 %a, i64 %b) nounwind readnone {
entry:
  %mul = mul nsw i64 %b, %a
  ret i64 %mul
}

它是在x64機器上從下面的C程式碼,用Clang(選項-emit-llvm)編譯得到的:

long imul(long a, long b) {
    return a * b;
}

程式碼生成器完成的第一件事是把IR轉換為一個selection DAG表示。這是剛開始的DAG,就在構建出來之後:


這裡沒有什麼特別有趣的東西,對於目標機器架構所有的型別都是合法的;因此,這也是到達指令選擇階段的DAG。

指令選擇的模式

指令選擇是程式碼生成階段最重要的部分(對此有爭議)。它的任務是將一個合法的selection DAG轉換為一個目標機器碼形式的新DAG。換而言之,抽象的、機器無關的輸入必須被匹配到具體的、機器相關的輸出。對此,LLVM使用了一個優雅的模式匹配演算法,它包括了兩大步驟。

第一步發生在“線下(offline)”,在LLVM在進行自身構建時,涉及到了TableGen工具,它從指令定義產生模式匹配表。TableGen是LLVM生態系統的一個重要部分,在指令選擇中它扮演了一個特別關鍵的角色,因此值得花幾分鐘來介紹它(也有從開始的官方文件)。

TableGen的問題是它的某些應用實在太複雜(而指令選擇,正如我們很快會看到的,是其中一個最惡劣的冒犯者),很容易忘記它的核心是很簡單的想法。很久之前,LLVM的開發者意識到開發每個新的目標機器需要編寫大量的重複程式碼。以機器指令為例。一條指令被用在程式碼生成、彙編器、反彙編器、優化器及其他許多地方。每個這樣的應用產生了一個將指令對映到某塊資訊的一張“表”。如果我們可以只在一個集中位置定義所有的指令,收集我們所需的關於它們的資訊,然後自動地產生所有的表,不是很好嗎?這就是TableGen與生俱來要做的事。

讓我們看一個與本文有關的指令定義(摘自lib/Target/X86/X86InstrArithmetic.td並稍作修改):

def IMUL64rr : RI<0xAF, MRMSrcReg, (outs GR64:$dst),
                                   (ins GR64:$src1, GR64:$src2),
                  "imul{q}\t{$src2, $dst|$dst, $src2}",
                  [(set GR64:$dst, EFLAGS,
                        (X86smul_flag GR64:$src1, GR64:$src2))],
                  IIC_IMUL64_RR>,
                 TB;

如果這看起來凌亂,不用擔心,這正是應有的第一印象。為了提取出公用的程式碼且瘋狂地保持硬實(preserve DRY),TableGen發展了一些先進的特性,像多重繼承,模板化的形式,等等;所有這些使得定義一開始有些難以理解。如果你希望看到IMUL64rr“裸露的”定義,你可以在LLVM原始碼樹的根節點執行這個命令:

$ llvm-tblgen lib/Target/X86/X86.td -I=include -I=lib/Target/X86

13.5MB僅包含簡單def的輸出——TableGen後端可以從中獲取所需的表項。IMUL64rr的def擁有大約75個域。不過我們將只關注文字所需要的那些,上面所貼的扼要描述足夠了。

對於我們的討論,最重要的域是上面def中的第六個模板引數:

[(set GR64:$dst, EFLAGS,
      (X86smul_flag GR64:$src1, GR64:$src2))],

這是IMUL64rr賴以被選中的模式。它實際上是一個描述要匹配的DAG路徑的。在這裡它大意為:一個帶有兩個64位GPR(通用暫存器)的X86ISD::SMUL節點(這被隱藏在X86smul_flag定義之後)被呼叫並返回兩個值——一個被賦給一個目標GPR,另一個賦給狀態標記暫存器(雖然狀態標記暫存器在x86裡是隱含的(沒有你可以操作的明確的暫存器),LLVM把它處理作明確的以輔助程式碼生成演算法)。當自動化指令選擇在DAG中看到這樣一個序列,將把它匹配到上述的IMUL64rr指令。

在這裡,仔細的讀者會注意到我撒了點小謊。如果這個模式匹配的節點是X86ISD:: SMUL,那麼它如何匹配上面所示的包含一個ISD::MUL節點的DAG呢?確實,它不能。很快我會展示實際匹配這個DAG的模式,不過我覺得展示帶有模式的指令定義很重要,使我後面能夠討論所有的模式如何交織在一起。

那麼ISD::MUL與X86ISD::SMUL之間有什麼差別呢(X86ISD::SMUL是ISD::SMULO通用節點特定於X86的降級)?在前者,我們不關心乘法實際影響的標記,而後者我們關心。在C,就乘法而言,我們通常不關心受影響的標記,因此選擇了ISD::MUL。但LLVM提供了某些特殊的固有函式,比如llvm.smul.with.overflow,操作可以返回一個溢位標記。對於這些(連同其他可能的使用),X86ISD::SMUL節點應運而生(對此你可能有“噢!天,為什麼這如此複雜?”的反應。簡要的回答是“編譯器很難,讓我們釣魚去吧”。一個更長的理由則是:x86指令集非常大且複雜。另外,LLVM是一個帶有許多(相當不同)目標機器的編譯器,其許多手段被設計作目標機器無關的。這個結果與生俱來就是複雜的。換一個角度——x86 TableGen定義大約有20KLOC大小。加上另外20 KLOC左右的C++降級程式碼,對比包含3000頁左右的Intel架構手冊。就Kolmogorov複雜度來說,這不能算太壞)

這裡是什麼匹配ISD::MUL節點呢?這個模式來自lib/Target/X86/X86InstrCompiler.td:

def : Pat<(mul GR64:$src1, GR64:$src2),
          (IMUL64rr GR64:$src1, GR64:$src2)>;

這是一個匿名的TableGen記錄,它定義了一個脫離了任何特定指令的“模式”。這個模式只是一個從DAG輸入到DAG輸出的對映,後者包含一條選中的指令。我們不關心這個對映叫什麼,因此TableGen讓我們定義匿名的例項。在這個情形裡,該模式應該是相當簡單的。下面是來自include/llvm/Target/TargetSelectionDAG.td的一段有趣的片段,其中定義了類Pattern(連同它的特化Pat):

// Selection DAG Pattern Support.
//
// Patterns are what are actually matched against by the target-flavored
// instruction selection DAG.  Instructions defined by the target implicitly
// define patterns in most cases, but patterns can also be explicitly added when
// an operation is defined by a sequence of instructions (e.g. loading a large
// immediate value on RISC targets that do not support immediates as large as
// their GPRs).
//
class Pattern<dag patternToMatch, list<dag> resultInstrs> {
  dag             PatternToMatch  = patternToMatch;
  list<dag>       ResultInstrs    = resultInstrs;
  list<Predicate> Predicates      = [];  // See class Instruction in Target.td.
  int             AddedComplexity = 0;   // See class Instruction in Target.td.
}
// Pat - A simple (but common) form of a pattern, which produces a simple result
// not needing a full list.
class Pat<dag pattern, dag result> : Pattern<pattern, [result]>;

片段開頭的大段註釋是有幫助的,但它描述了與我們在IMUL64rr觀察到的實際相反的情形。在我們的情形裡,定義在指令裡的模式實際上更為複雜,而基本的模式以Pattern定義在外面。

模式匹配機制

TableGen目標機器指令描述支援多種模式型別。我們已經研究了指令定義中隱含定義的模式,以及單獨顯式定義的模式。另外,還有指定要呼叫的C++函式的“複雜”模式,以及包含任意C++程式碼片段執行定製匹配的“模式片段(pattern fragments)”。如果你有興趣,這些模式型別在include/llvm/Target/TargetSelectionDAG.td的註釋中做了稍許描述。

在TableGen中混合C++程式碼可行,是因為TableGen(與特定DAG ISel後端)執行的最終結果是一個嵌入到一個目標機器SelectionDAGISel介面實現的C++方法。

更具體些,這個序列是:

  • 通用的SelectionDAGISel::DoInstructionSelection方法對每個DAG節點呼叫Select。
  • Select是一個抽象方法,由目標機器實現。例如,X86DAGToDAGISel::Select。
  • 後者攔截某些節點進行手工匹配,但向X86DAGToDAGISel::SelectCode委託了大量的工作。
  • X86DAGToDAGISel::SelectCode由TableGen自動生成(它生成在一個由lib/Target/X86/X86ISelDAGToDAG.cpp包含的檔案<BUILD_DIR>/lib/Target/X86/ X86GenDAGISel.inc中),包含匹配表(matcher table),隨後將該表作為引數呼叫通用的SelectionDAGISel::SelectCodeCommon。

那麼匹配表是什麼呢?本質上,它是一個針對指令選擇,以某種“位元組碼”寫成的程式。為了實現靈活的模式匹配同時保持效率,TableGen把所有的模式合併起來產生一個程式,給定一個DAG樣式(mode),找出它匹配哪個模式(pattern)。SelectionDAGISel:: SelectCodeCommon作為這個位元組碼的解析器。

不幸的是,用於模式匹配的位元組碼語言沒有文件記錄。為了理解它如何工作,除了檢視解析器的程式碼及為某個後端產生的位元組碼外,別無他法(如果你希望理解這個位元組碼如何從TableGen的模式定義產生,你還需要看TableGenDAG ISel後端)。

例子:匹配我們的案例DAG節點

讓我們研究一下我們案例DAG中的ISD::MUL是如何匹配的。出於這個目的,向llc傳遞-debug選項是有用的,它使llc在程式碼生成過程中轉儲詳細的除錯資訊。特別的,可以追蹤每個DAG節點的選擇過程。下面是我們ISD::MUL節點的相關部分:

Selecting: 0x38c4ee0: i64 = mul 0x38c4de0, 0x38c4be0 [ORD=1] [ID=7]
ISEL: Starting pattern match on root node: 0x38c4ee0: i64 = mul 0x38c4de0, 0x38c4be0 [ORD=1] [ID=7]
  Initial Opcode index to 57917
  Match failed at index 57922
  Continuing at 58133
  Match failed at index 58137
  Continuing at 58246
  Match failed at index 58249
  Continuing at 58335
  TypeSwitch[i64] from 58337 to 58380
MatchAddress: X86ISelAddressMode 0x7fff447ca040
Base_Reg nul Base.FrameIndex 0
 Scale1
IndexReg nul Disp 0
GV nul CP nul
ES nul JT-1 Align0
  Match failed at index 58380
  Continuing at 58396
  Match failed at index 58407
  Continuing at 58516
  Match failed at index 58517
  Continuing at 58531
  Match failed at index 58532
  Continuing at 58544
  Match failed at index 58545
  Continuing at 58557
  Morphed node: 0x38c4ee0: i64,i32 = IMUL64rr 0x38c4de0, 0x38c4be0 [ORD=1]
ISEL: Match complete!
=> 0x38c4ee0: i64,i32 = IMUL64rr 0x38c4de0, 0x38c4be0 [ORD=1]

這裡提到的索引援引匹配表。在生成檔案X86GenDAGISel.inc每行開頭的註釋裡,你可以看到它們。下面是這個表的開頭(注意表中的值與我為這個例子所構建的LLVM的版本(r174056)是相關的。X86模式定義的變化可能導致不同的編號,但原理是相同的):

// The main instruction selector code.
SDNode *SelectCode(SDNode *N) {
  // Some target values are emitted as 2 bytes, TARGET_VAL handles
  // this.
  #define TARGET_VAL(X) X & 255, unsigned(X) >> 8
  static const unsigned char MatcherTable[] = {
/*0*/     OPC_SwitchOpcode /*221 cases */, 73|128,103/*13257*/,  TARGET_VAL(ISD::STORE),// ->13262
/*5*/       OPC_RecordMemRef,
/*6*/       OPC_RecordNode,   // #0 = 'st' chained node
/*7*/       OPC_Scope, 5|128,2/*261*/, /*->271*/ // 7 children in Scope

在位置0我們有一個OPC_SwitchOpcode操作,它是相當於節點操作碼的一個大的switch表。它跟有一組case。每個case以其大小開始(這樣匹配器知道如果這個case匹配失敗要去哪裡),然後是操作碼。例如,正如在上面你會看到的,表中第一個case用於操作碼ISD::STORE,其大小是13257(這個尺寸以一個特殊的變長編碼方式編碼,因為表是按位元組組織的)。

看一下除錯輸出,我們MUL節點的匹配始於偏移57917。下面是表中相關的部分:

          /*SwitchOpcode*/ 53|128,8/*1077*/,  TARGET_VAL(ISD::MUL),// ->58994
/*57917*/   OPC_Scope, 85|128,1/*213*/, /*->58133*/ // 7 children in Scope

正如期待的,這是以ISD::MUL作為操作碼的switch case。這個case的匹配始於OPC_Scope,它是讓解析器壓入當前狀態的一條指令。如果在這個域裡某個case失敗了,可以恢復當前狀態,進而匹配下面的case。在上面的片段裡,如果該域中的匹配失敗,將前進到偏移58133。

在除錯輸出裡你可以看到發生了這些:

Initial Opcode index to 57917
Match failed at index 57922
Continuing at 58133

在57922,解析器嘗試匹配該節點的孩子到一個ISD::LOAD(意思是——與記憶體內參數相乘),失敗,按域指示跳到58133。類似的,餘下的匹配過程可以被追蹤——跟隨除錯輸出並以匹配表為參考。在偏移58337發生了一些有趣的事情。下面是表相關的部分:

/*58337*/     OPC_SwitchType /*2 cases */, 38,  MVT::i32,// ->58378
/*58340*/       OPC_Scope, 17, /*->58359*/ // 2 children in Scope
/*58342*/         OPC_CheckPatternPredicate, 4, // (!Subtarget->is64Bit())
/*58344*/         OPC_CheckComplexPat, /*CP*/3, /*#*/0, // SelectLEAAddr:$src #1 #2 #3 #4 #5
/*58347*/         OPC_MorphNodeTo, TARGET_VAL(X86::LEA32r), 0,
                      1/*#VTs*/, MVT::i32, 5/*#Ops*/, 1, 2, 3, 4, 5,
                  // Src: lea32addr:i32:$src - Complexity = 18
                  // Dst: (LEA32r:i32 lea32addr:i32:$src)

這是上面描述的一個複雜模式的結果。SelectLEAAddr是一個C++方法(由X86後端的ISel實現),它被嘗試呼叫來匹配節點運算元到一個LEA(某些乘法可以被優化為使用更快的LEA指令)。跟在其後的除錯輸出來自該方法,並正如我們看到的,最終失敗。

最後,解析器到達偏移58557,匹配成功。下面是表相關的部分:

/*58557*/       /*Scope*/ 12, /*->58570*/
/*58558*/         OPC_CheckType, MVT::i64,
/*58560*/         OPC_MorphNodeTo, TARGET_VAL(X86::IMUL64rr), 0,
                      2/*#VTs*/, MVT::i64, MVT::i32, 2/*#Ops*/, 0, 1,
                  // Src: (mul:i64 GR64:i64:$src1, GR64:i64:$src2) - Complexity = 3
                  // Dst: (IMUL64rr:i64:i32 GR64:i64:$src1, GR64:i64:$src2)

簡而言之,在一大串優化及特殊情形的匹配失敗後,匹配器最終使用匹配到IMUL64rr 機器指令的64位暫存器間的通用整數乘法。

如果追蹤記錄顯示指令選擇器賣力查詢一條合適的指令,那是真的。要產生好的程式碼,在妥協到通用指令序列之前,必須進行一些工作來嘗試匹配各種優化的指令序列。在文字的下一部分,我將說明帶有優化的指令選擇的某些先進的案例。

最終程式碼

下面是指令選擇後DAG的外觀:


因為入口DAG是相當基本的,這個非常類似;主要的區別是對實際指令選中乘法與返回節點。

如果你記得這篇文章,在被指令選擇器匹配後,這條指令經歷了幾個另外的化身。最終流出的程式碼是:

imul:                                   # @imul
      imulq   %rsi, %rdi
      movq    %rdi, %rax
      ret

imulq是X86::IMUL64rr的彙編表示(GAS形式)。它把函式的引數相乘(根據AMD64 ABI,頭兩個整數進入%rsi與%rdi);然後結果被移到返回暫存器——%rax。

結論

本文提供了指令選擇過程——LLVM程式碼生成器的一個關鍵部分的一個深入的窺探。儘管它使用了一個相對簡單的例子,它應該包含足夠的資訊獲得所涉及機制的一些初步認識。本文的下一部分,我將調查另外幾個例子,通過它們,程式碼生成過程的其他方面將更為清晰。