LLVM,一堆積木的故事

如果我說,C可以像Java一樣被虛擬機解釋執行,也可以對熱點代碼使用Just-In-Time(JIT)技術編譯,也可以實現“一次編譯,到處運行”,你信嗎?

少俠且慢動手,聽我解釋!無論是相對高級的語言,如Python、Java(這裏說的高級是說接近自然語言,而不是字面意思),還是相對底層的C/C++,甚至是彙編語言,都只不過人們對一個問題描述的文本體現。Java所具備的內存託管、垃圾回收機制,歸根到底,是Java虛擬機(JVM)賦予它的,因此只要能讓JVM接受,不管是什麼語言都能享受Java的待遇,例如Groovy、Kotlin、Scala等語言。

多種不同語言都能運行在JVM上的原因,是因爲它們都被編譯成了一種JVM能識別的中間表示形式(IR,Intermediate Representation)——字節碼(bytecode),JVM識別字節碼並翻譯到機器碼,最終實現了代碼的執行。

對於JVM,Java摸得,我C語言就摸不得?只要把C編譯到字節碼,C++就能實現內存託管、“一次編譯,到處運行”。我們先不管爲什麼要大費周章,也不管這樣做得利弊得失,我們只是爲一個想法找到了一條切實可行得路線。有趣得是,很多事情當你想到得時候,別人已經在做,或者已經完成了。將C++編譯成運行在JVM上得字節碼,並非僅僅存在於腦洞中,有好事者已經將它實現了:它就是LLJVM項目,代碼開源地址爲 https://github.com/davidar/lljvm 有興趣得小夥伴可以前去觀摩。從這個角度來說,那些論壇上喋喋不休的爭論哪個語言好哪個語言優秀的可以歇一歇了,這些爭論其實是沒有意義的,你們所驕傲的是編譯器賦予你們的,有什麼樣的編譯器,就會有什麼樣的語言。語言只是個工具,工具是沒有好壞之分的,只有順不順手。

LLJVM怎麼把C編譯到JVM上呢?主要有以下三步:

  1. 使用llvm-gcc或者clang將C源代碼編譯到LLVM IR;
  2. 將LLVM IR轉換到Jasmin彙編代碼;
  3. Jasmin彙編代碼轉換到字節碼。

兜兜轉轉一圈之後,C語言編寫的代碼邏輯終於在JVM上執行了。除了將C代碼編譯到字節碼,目前已經實現的方案中還有另外一種使得C代碼可以解釋執行的方案,它就是LLVM提供了一個名叫lli的解釋器,專門用來解釋執行LLVM IR,與JVM類似,lli也有Just-In-Time(JIT)等功能。由此,我們引出了今天的主角:LLVM。

從編譯說起

編譯器,就是一個將源代碼編譯到目標機器代碼的一個程序(圖1)。
Figure 1  Compiler
Figure 1 Compiler

經典的編譯過程分爲詞法分析、語法分析、優化、代碼生成等階段。而一個經典編譯器就可以根據這些階段分成三部分(圖2):

  1. 前端(Frontend):負責此法分析、語法分析,產生一箇中間結果;
  2. 優化器(Optimizer):負責代碼邏輯優化,比如做一些等效代換、去掉一些無用代碼等,使得執行時間更短;
  3. 後端(Backend):負責將優化後的中間結果翻譯成目標機器可以執行的機器指令。

其中根據需要,優化部分可以沒有。
Figure 2  Simple Compiler
Figure 2 Simple Compiler

這麼分的好處顯而易見(圖3):

  1. 如果出現了新的語言,只需要編寫新的前端,複用優化器和後端,就能然新的語言在已有的目標機器上執行;
  2. 如果出現了新的CPU架構或者要讓已有的語言支持一種新的機器,只需要編寫對應機器的後端複用前端和優化器就可以實現。

Figure 3  Retargetable Compiler
Figure 3 Retargetable Compiler

通過對着三個部分使用不同的方案去實現,就得到了不同的編譯器:

  1. 如果輸入源代碼直接輸出的是機器碼,那麼就是典型的編譯器。典型代表是gcc(上圖3);
  2. 如果輸入的是源代碼輸出的是結果,那麼就是解釋器,典型代表是bash(圖4);
  3. 如果輸入的是源代碼,輸出的是中間結果,那麼就是把編譯器拆分成了兩部分:前端成了新的編譯器,優化器和後端組成了虛擬機。典型代表是javac和JVM(圖5)。

有趣的是Python,它介於2和3之間,如果代碼一行一行輸入,它就是相當於解釋器;如果輸入的是一個.py文件,其實它也是先編譯成它自己的字節碼然後在虛擬機上執行,所以一般會有一個.pyc的文件生成,這就是Python的字節碼文件。

FIgure 4  Interpreter
FIgure 4 Interpreter
Figure 5  javac&JVM
Figure 5 javac&JVM

LLVM

LLVM,最開始是(Low Level Virtual Machine)的縮寫,但是現在表示的就是LLVM項目本身。LLVM項目下面有多個子項目,包括clang、libc++以及LLVM Core等。正如其名字所體現的那樣,LLVM Core處於LLVM項目的核心。

LLVM Core框架主要有以下幾部分組成:

  1. 一個獨立於源代碼語言和目標機器代碼的優化器,這個優化器的輸入和輸出都是一個叫LLVM IR(LLVM Intermediate Representation)的東西;
  2. 主流CPU的機器碼生成器,也就是所謂的後端。

LLVM Core是LLVM項目的核心,而LLVM IR又是LLVM Core的核心。它們的關係如下(圖6):
Figure 6 LLVM Compiler
Figure 6 LLVM Compiler

通過圖6可以看到,clang是一個C類型語言(C/C++/Ojective-C)前端,也稱之爲驅動。clang以及其他的前端通過此法分析、語法分析、構造抽象語法樹(AST)等過程,最終生成了LLVM IR。這個LLVM IR輸入到優化器中進行優化,優化器經過一系列通用的優化後,輸出一個優化了的IR給後端進行代碼生成,後端可以根據目標機器繼續進行一些特殊的優化。

LLVM IR用一種類RISC的指令集表示源代碼,它的特點如下:

  1. 有着類似於RISC指令集的加、減、比較、分支等指令;
  2. 操作數是強類型的,例如它可以使用i32表示一個32位的整數;
  3. 可使用的寄存器是無限的,畢竟它只是一種類RISC指令,最終寄存器的分配是後端來做,因此它只需要用類似%tmp等方式表示除操作數的存取是寄存器就可以,數量是不限的。

例如下面一段源代碼

unsigned add1(unsigned a, unsigned b) {
  return a+b;
}

// Perhaps not the most efficient way to add two numbers.
unsigned add2(unsigned a, unsigned b) {
  if (a == 0) return b;
  return add2(a-1, b+1);
}

使用類RISC的IR 表示則如下所示:

define i32 @add1(i32 %a, i32 %b) {
entry:
  %tmp1 = add i32 %a, %b
  ret i32 %tmp1
}

define i32 @add2(i32 %a, i32 %b) {
entry:
  %tmp1 = icmp eq i32 %a, 0
  br i1 %tmp1, label %done, label %recurse

recurse:
  %tmp2 = sub i32 %a, 1
  %tmp3 = add i32 %b, 1
  %tmp4 = call i32 @add2(i32 %tmp2, i32 %tmp3)
  ret i32 %tmp4

done:
  ret i32 %b
}

LLVM IR有三種表現形式:

  1. 文本文件表示形式,如上面的例子中所示;
  2. 二進制文件表示形式,稱爲比特碼(bitcode);
  3. 內存中的表現形式。

LLVM編譯器和gcc的區別

有一種說法,gcc編譯器的代碼,很難被複用到其他項目中。但是我們從圖3和6中看到,gcc和基於LLVM實現的編譯器其實都是分爲前端、優化器、後端等模塊,爲什麼gcc就不能被複用呢?

這就是LLVM設計的精髓所在:完全模塊化。就拿優化器來說,典型的優化類型(LLVM優化器中稱爲Pass)有代碼重排(expression reassociation)、函數內聯(inliner)、循環不變量外移( loop invariant code motion)等。在gcc的優化器中,這些優化類型是全部實現在一起形成一個整體,你要麼不用,要麼都用;或者你可以通過配置只使用其中一些優化類型。而LLVM的實現方式是,每個優化類型自己獨立稱爲一個模塊,而且每個模塊之間儘可能的獨立,這樣就可以根據需要只選擇你需要的優化類型編譯進入你的程序中而不是把整個優化器都編譯進去。

LLVM實現的方法是用一個類來表示一個優化類型,所有優化類型都直接或着間接繼承自一個叫做Pass的基類,並且大多數都是自己佔用一個.cpp文件,並且位與一個匿名命名空間中,這樣別的.cpp文件中的類便不能直接訪問它,只提通過一個函數獲取到它的實例,這樣pass之間就不會存在耦合,如下面代碼所示:

namespace {
  class Hello : public FunctionPass {
  public:
    // Print out the names of functions in the LLVM IR being optimized.
    virtual bool runOnFunction(Function &F) {
      cerr << "Hello: " << F.getName() << "\n";
      return false;
    }
  };
}

FunctionPass *createHelloPass() { return new Hello(); }

每個.cpp會被編譯成一個目標文件.o文件,然後被打包進入一個靜態鏈接庫.a文件中。當第三方又需要使用到其中一些優化類型,它只需要選擇自己需要的。由於這些類型都是自己獨立於.a的一個.o中,因此的只有真正被用到的.o會被鏈接進入目標程序,這就實現了“用多少取多少”的目標,不搞“搭售”。而第三方如果還有自己獨特的優化要求,只要按照同樣的方法實現一個優化即可(圖7)。
Figure 7 Pass Linkage
Figure 7 Pass Linkage

打個比方,如果將優化器比作賣電腦的,那麼gcc的優化器相當於賣筆記本,稱爲A;而LLVM的優化器相當於賣組裝的臺示機的,稱爲B。或許你自己有了其他合適的部件,就差一顆強勁的CPU。你去A店裏要麼不買,要麼就買一個功能齊全的筆記本,你不可能說你只買某檯筆記本上的一顆芯片;而你去B店裏可以做到只買一顆芯片。

到了這裏,我們終於可以回答LLVM和gcc的區別了:
LLVM本身只是一堆庫,它提供的是一種機制(mechanism),一種可以將源代碼編譯的機制,但是它自己本身並不能編譯任何代碼。也就是說編譯什麼代碼、怎麼編譯、怎麼優化、怎麼生成這些策略(strategy)是由用戶自己定的。例如clang就使用LLVM提供的這些機制制定了編譯C代碼的策略,因此前文中說clang可以稱之爲驅動(driver)。還拿電腦做例子:一堆電腦零件本身並不能做任何事情,這麼將它們組裝起來讓它們工作是使用者的事兒。例如散熱風扇,它提供的是一種可以散熱的機制,它自己是不能給任何東西散熱的,需要使用者把它拿去吹散熱片或者吹自己的腦門。

而gcc,它並沒有將機制和策略分的很清楚,它只做了一件事:我這有個工具可以編譯C代碼。

總結

強行總結的話,LLVM中由兩個重要的東西:

  1. LLVM IR;
  2. 完全模塊化。

這兩個特性組合在一起,使得它具備了提供編譯能力的機制。

公衆號二維碼

首發於個人微信公衆號TensorBoy。微信掃描上方二維碼或者微信搜索TensorBoy並關注,及時獲取更多最新文章!
C++ | Python | 推理引擎 | AI框架源碼,有一起玩耍的麼?

References

[1] http://www.aosabook.org/en/llvm.html
[2] https://llvm.org/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章