原創內容,轉載請註明出處

作者:汪巖

1. JIT編譯

JIT(just-in-time)即時編譯技術是在運行時(runtime)將調用的函數或程序段編譯成機器碼載入內存,以加快程序的執行。所以,JIT是一種提高程序時間和空間有效性的方法。 程序運行時編譯和執行的概念最早出自John McCarthy在1960年發表的論文《Recursive functions of symbolic expressions and their computation by machine》,James Gosling在1993年在關於Java的論文中使用了」JIT」這個術語。JIT可以分為兩個階段:在運行時生成機器碼和在運行時執行機器碼。其中,第一個階段的生成機器碼方式與靜態編譯並無本質不同,只不過生成的機器碼被保存在內存中,而靜態編譯是在程序運行前將整個程序完全編譯為機器碼保存在二進位文件中。運行時 JIT 緩存編譯後的機器碼,當再次遇到該函數時,則直接從緩存中執行已編譯好的機器。因此,從理論上來說,JIT編譯技術的性能會越來越接近靜態編譯技術。

為了模擬JIT的運行原理,如下代碼演示瞭如何在內存中動態生成add函數並執行,該函數的C語言原型如下:

long add(long num) {

return num + 1; }

void* alloc_writable_memory(size_t size) {

void* ptr = mmap(0, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ptr == (void*)-1) {

perror("mmap");

return NULL; } return ptr; } void emit_code_into_memory(unsigned char* m) { unsigned char code[] = { 0x48, 0x89, 0xf8, // mov %rdi, %rax 0x48, 0x83, 0xc0, 0x01, // add $1, %rax

0xc3 // ret

}; memcpy(m, code, sizeof(code));} int make_memory_executable(void* m, size_t size) { if (mprotect(m, size, PROT_READ | PROT_EXEC) == -1) { perror("mprotect"); return -1; }

return 0;

} const size_t SIZE = 1024;typedef long (*JittedFunc)(long); // Allocates RWX memory directly. void emit_to_rw_run_from_rx() { void* m = alloc_writable_memory(SIZE); emit_code_into_memory(m);

make_memory_executable(m, SIZE);

JittedFunc func = m; int result = func(2); printf("result = %d
", result); }

上述代碼主要可分為三步:

a. alloc_writable_memory調用mmap在堆上分配可讀/可寫/可執行內存塊;

b. emit_code_into_memory將實現add函數的字元串形式機器碼拷貝到內存塊中。這一步驟可類比為JIT中調用運行時生成機器碼;

c. 將內存塊轉換為指針類型並調用執行。這一步驟可類比為JIT中通過獲得函數地址調用函數。

2. LLVM執行引擎(LLVM Execution Engine)

LLVM JIT使用執行引擎(execution engine)來支持LLVM模塊的執行。ExecutionEngine類的申明在<llvm_source>/include/llvm/ExecutionEngine/ExecutionEngine.h中,執行引擎既可以用JIT也可以用解釋器的方式支持執行。執行引擎負責管理整個客體(guest)程序的執行,分析需要執行的下一個程序片段。客體程序是指不能被硬體平臺原生支持的代碼,比如,對於x86平臺來說,LLVM IR模塊就是客體程序,因為x86平臺不能直接執行LLVM IR代碼。

在LLVM中有三個持續演進的JIT執行引擎實現:llvm::JIT類、llvm::MCJIT類和llvm::ORCJIT類,llvm::JIT類在新的LLVM已經不再支持。JIT客戶端會首先產生一個ExecutionEngine對象。ExecutionEngine對象以IR模塊為輸入,通過調用ExecutionEngine:: EngineBuilder()初始化。接下來,ExecutionEngine::create()方法生成一個JIT或MCJIT引擎實例。

圖1

3. 內存管理

JIT引擎的ExecutionManager類調用LLVM代碼生成器,產生目標平臺機器指令的二進位代碼保存在內存中,並返回指向編譯後函數的指針。然後通過函數指針指向指令所在內存區域即可執行該函數。在此過程中,內存管理負責執行內存分配、釋放、許可權處理、庫載入空間分配等操作。

JIT和MCJIT各自實現派生自RTDyldMemoryManager基類的定製內存管理類。執行引擎客戶端也可以定製RTDyldMemoryManager子類,由該子類指定JIT部件在內存中的存放位置。RTDyldMemoryManager定義在<llvm_source>/ include/llvm/ExecutionEngine/ RTDyldMemoryManager.h中。

RTDyldMemoryManager類聲明瞭如下方法:

· allocateCodeSection()和allocateDataSection():這兩個方法分配內存保存可執行代碼和數據,內存管理客戶端可以用一個內部節(section)標識符參數追蹤分配的節。

· getSymbolAddress():該方法返回鏈接的庫中可用符號表地址。注意,這個方法不能用於獲取JIT編譯產生的符號表。調用該方法時必須提供一個std::string實例保存符號名稱。

· finalizeMemory():MCJIT客戶端完成對象載入後調用此方法設定內存許可權,必須在調用此方法後才能運行生成的代碼。

JIT和MCJIT的預設內存管理子類分別是JITMemoryManager和SectionMemoryManager。

4. llvm::JIT框架

支持JIT的LLVM後端要實現二進位代碼發射,JIT類通過MachineCodeEmitter子類JITCodeEmitter發射二進位指令,將二進位塊位元組寫入內存。MachineCodeEmitter類用於發射機器碼,但與新的MC框架無關,只支持少數幾個後端。

MachineCodeEmitter類成員函數完成以下工作:

a. allocateSpace():為當前要發射的函數分配空間。

b. emitByte()、emitWordLE()、emitWordBE()、emitAlignment()等:將二進位塊寫入緩存。

c. 追蹤當前緩存地址,即指向下一條要發射指令地址的指針。

d. 增加相對緩存中指令地址的重定位。

JIT執行引擎也會用到JITMemoryManager和JITResolver。JITMemoryManager負責管理內存的使用,實現低層內存處理方法。例如,allocateGlobal()方法為全局變數分配內存,startFunctionBody()方法為發射的指令分配內存,並標記為讀/寫可執行。JITMemoryManager類聲明在<llvm_source>/include/llvm/ ExecutionEngine/JITMemoryManager.h。

JITResolver負責記錄在哪些位置調用了未編譯函數。

支持JIT的LLVM後端要實現兩個類:<target>CodeEmitter和<target>JITInfo。<target>CodeEmitter包含了一個機器函數(machine function)pass,將目標機器指令轉換為可重定位的機器碼。例如,MipsCodeEmitter會遍歷所有函數基本塊,為每一條機器指令調用emitInstruction():

for (MachineBasicBlock::instr_iterator I = MBB->instr_begin(), E = MBB->instr_end(); I != E;)

emitInstruction(*I++, *MBB);

}

為了支持JIT編譯,編譯器必須提供TargetJITInfo子類(見include/llvm/Target/TargetJITInfo.h),例如MipsJITInfo或X86JITInfo。TargetJITInfo類為所有目標平臺編譯器都需要實現的公共JIT功能提供了介面,包括為代碼生成階段中的各種活動,如代碼發射,實現JIT介面。這些公共JIT功能包括:

-支持執行引擎重新編譯經過修改的方法,實現TargetJITInfo::replaceMachineCodeForFunction()方法,並在原函數的對應位置通過補丁方式加入jump指令或調用新函數。這對自修改(self-modifing)代碼是必須的。

-TargetJITInfo::relocate()方法在當前發射的函數中的每一個符號引用打補丁以指向正確的存儲地址,類似動態鏈接的做法。

-TargetJITInfo::emitFunctionStub()方法發射一個樁函數,樁函數在給定地址調用另一個函數。每個目標平臺編譯器都應提供TargetJITInfo::StubLayout信息,包括發射的樁函數大小和對齊方式。在發射新的樁函數前,JITEmitter使用樁函數信息為其分配空間。

TargetJITInfo類方法的目標不是發射普通指令,但會發射生成樁函數的特殊指令(例如MipsJITInfo::emitFunctionStub()),以及調用新內存位置的特殊指令。

5. 如何使用JIT類

JIT是ExecutionEngine子類,聲明在<llvm_source>/lib/ExecutionEngine/JIT/JIT.h。JIT類是JIT編譯函數的入口。

ExecutionEngine::create()以預設JITMemoryManager為參數調用JIT::createJIT(),然後JIT構造函數執行以下任務:

-生成JITEmitter實例;

-初始化目標信息對象;

-添加代碼生成pass;

-添加<Target>CodeEmitter pass;

當JIT編譯某個函數時,引擎中的PassManager對象調用代碼生成和JIT指令發射pass。

步驟如下:

a. include各種頭文件;

b. InitializeNativeTarget()方法確保鏈接了JIT用到的庫,LLVMContext對象和MemoryBuffer對象負責從硬碟讀取bitcode文件;

InitializeNativeTarget();

LLVMContext Context;

OwningPtr<MemoryBuffer> Buffer;

c. getFile()從硬碟讀入文件到MemoryBuffer;

MemoryBuffer::getFile("./sum.bc", Buffer);

d. ParseBitcodeFile()從MemoryBuffer讀入數據並生成相應的LLVM模塊;

Module *M = ParseBitcodeFile(Buffer.get(), Context, &ErrorMessage);

e. 由EngineBuilder工廠生成ExecutionEngine實例,然後調用其create()方法:

OwningPtr<ExecutionEngine> EE(EngineBuilder(M).create());

create()方法默認生成JIT執行引擎,間接調用JIT構造方法,生成JITEmitter、PassManager,並初始化代碼生成和發射pass。這時引擎雖然已經生成了LLVM模塊,但還沒有編譯其中任何函數。若要編譯函數,需調用getPointerToFunction()獲取指向經JIT編譯的本地(native)函數的指針。如果還沒有編譯,此時可以啟動JIT編譯,並返回函數指針。

f. getFunction()獲取函數IR對象:

Function *SumFn = M->getFunction("sum");

觸發JIT編譯前要做函數指針類型轉換,Sum函數在IR中的原型是define i32 @sum(i32 %a, i32 %b),所以這裡C的函數指針類型是int (*)(int, int):

int (*Sum)(int, int) = (int (*)(int, int)) EE->getPointerToFunction(SumFn);

另外一種選項是不用getPointerToFunction(),而用getPointerToFunctionOrStub()啟動lazy編譯。這個方法會生成一個樁函數,並返回其指針。

g. 接下來,通過Sum指向的JIT編譯後的函數,調用原始Sum函數:

int res = Sum(4,5);

當使用lazy編譯時,Sum會調用樁函數,樁函數再用一個編譯回調方法編譯真正的函數。然後樁函數會重定向到執行真正的函數。除非LLVM模塊中的Sum函數被修改,否則不再會被編譯。

h. 再次調用Sum函數:

res = Sum(res, 6);

當使用lazy編譯時,因為原始函數在第一次調用Sum時已經被編譯過,以後的調用直接執行本地函數。

i. 計算完成後,釋放執行引擎分配的內存(其中保存了函數代碼):

EE->freeMachineCodeForFunction(SumFn);

llvm_shutdown();

return 0;

在上述步驟f中,觸發JIT編譯前要做函數指針類型轉換。執行引擎提供的另一個runFunction()方法,不需要在此之前調用getPointerToFunction()。runFunction()方法編譯和運行函數時的參數是GenericValue向量。GenericValue結構定義在<llvm_source>/include/llvm/ ExecutionEngine/ GenericValue.h,可以保存任何數據類型。runFunction()用法如下:

Function *SumFn = M->getFunction("sum");

std::vector<GenericValue> FnArgs(2);

FnArgs[0].IntVal = APInt(32,4);

FnArgs[1].IntVal = APInt(32,5);

GenericValue Res = EE->runFunction(SumFn, FnArgs);

初始化SumFn函數指針後,生成了一個GenericValue向量FnArgs,並用APInt介面為其元素賦值。然後,可以調用runFunction(),其返回值也是GenericValue類型。

GenericValue Res = EE->runFunction(SumFn, FnArgs);

6. LLVM機器碼JIT(Machine Code JIT, MCJIT)執行引擎

MCJIT是LLVM中JIT編譯的一種新的實現方式,MCJIT類聲明在<llvm_source>/lib/ExecutionEngine/ MCJIT/MCJIT.h。MCJIT與舊版JIT實現的不同之處在於MC(Machine Code)框架。MC提供了統一的指令表示,該框架可被彙編器、反彙編器、彙編列印和MCJIT共享。使用MC庫的首要優點是編譯器後端增加新的目標平臺ISA支持時,只需要指定一次指令編碼,而不需要對MCJIT做改動,因為這個修改會被所有子系統共享。因此,當開發後端時,如果實現了對象代碼發射,也就可以具備了JIT功能。雖然MCJIT完全取代了舊版的JIT,但二者在諸多概念上是相似的,對MCJIT也適用。

給定一個LLVM IR模塊,可以通過執行引擎addModule()介面添加該模塊。該模塊經過代碼生成和MC層處理,會在內存中生成機器碼。如果將此機器碼寫入硬碟,就可以得到目標平臺對應的對象文件。但MCJIT不會將機器碼寫入硬碟,而是將其保留在內存中,並運行RuntimeDyld,將機器碼轉化為可執行代碼塊。客戶端可通過執行引擎查詢介面獲得可執行代碼塊的函數地址,並執行函數。MCJIT工作過程如下圖所示:

圖2

LLVM MCJIT執行引擎利用MC框架集成彙編器、反彙編器和對象連接器,MCJIT也可以共享MC框架的信息,即MCJIT重用了整個靜態編譯器流水線。MCJIT的這種設計是對代碼和工具的高效重用。舊版JIT執行引擎的編譯對象是LLVM IR函數,而MCJIT執行引擎編譯整個模塊。也就是說,在函數執行前,整個模塊都已經被MCJIT編譯過。

通常客戶端不能直接調用MCJIT方法,因為MCJIT的實現隱藏在執行引擎中。MCJIT執行引擎由客戶端EngineBuilder對象產生,並以llvm::Module對象作為構造函數參數。客戶端可通過EngineBuilder的設置選項指定生成的引擎類型為MCJIT,然後調用ExecutionEngine::create()方法產生引擎實例。create()方法調用MCJIT::createJIT()執行MCJIT構造函數。MCJIT構造函數會產生一個預設的內存管理器對象SectionMemoryManager,也可以調用EngineBuilder:: setMCJITMemoryManager()方法根據需要產生內存管理器。如果客戶端調用createJIT()時沒有傳入TargetMachine對象作為參數,createJIT()會根據模塊中的target triple產生一個新的TargetMachine對象。指向模塊的指針、內存管理器和TargetMachine對象這些數據結構都會作為MCJIT對象的成員。MCJIT對象隨後將LLVM模塊添加到其內部模塊容器OwningModuleContainer中,並初始化目標架構信息。但是此時MCJIT不會立即開始為模塊生成機器碼,而是會推遲到客戶端調用MCJIT::finalizeObject()方法或MCJIT::getPointerToFunction()方法時,調用這些方法時會用到機器碼。

圖3

MCJIT類為LLVM模塊標記狀態,這些狀態代表了模塊的編譯階段,包括:

-Added:模塊還未編譯,但已添加到執行引擎中。這個狀態允許模塊暴露函數定義給其它模塊,並延緩對其編譯,直到被調用。

-Loaded:模塊已被JIT編譯,但還未準備好執行。還未重定位(relocation),還未給內存頁合適許可權。客戶端通過使用loaded狀態的模塊重映射(remap)JIT編譯過的函數可以避免重編譯。

-Finalized:包含函數的模塊做好準備,可以執行。該狀態中的函數不能被重映射,因為已經做過重定位。

MCJIT和JIT的一個主要區別就是模塊狀態。在MCJIT中,獲取模塊的符號地址(包括函數和全局變數)之前,模塊必須處在finalized狀態。

MCJIT::finalizeObject()方法負責將added模塊轉換到loaded,再finalize。首先,finalizeObject()方法調用generateCodeForModule()方法生成loaded模塊,然後,調用finalizeLoadedModules()方法將所有模塊轉到finalized。調用MCJIT::getPointerToFunction()之前要求模塊必須處在finalized狀態。因此,在此之前必須調用finalizeObject()方法。

但在LLVM 3.4之後的新增方法getFunctionAddress()取消了上述限制。getPointerToFunction()被棄用。在請求符號地址之前,getFunctionAddress()會載入並定型(finalize)模塊,不必調用finalizeObject()方法。

圖4

注意,在原有的JIT中,各個函數被JIT分別編譯後,由執行引擎執行。在MCJIT中,整個模塊,包括其中所有函數,在執行前都必須被JIT編譯。所以,MCJIT實際上增大了編譯粒度(從函數到模塊),MCJIT不再是基於函數的,而是基於模塊的編譯引擎。

當MCJIT載入某個模塊時,會觸發代碼生成。即,代碼生成發生在模塊對象的載入階段,由MCJIT::generateCodeForModule()方法觸發。generateCodeForModule()方法由客戶端的finalizeObject()方法調用。generateCodeForModule()方法執行以下任務:

a.如果模塊對象已經被載入和編譯過,MCJIT首先嘗試從ObjCache中獲取一個對象image,並在隨後將其標記為loaded狀態,避免重複編譯。

if (ObjCache)

ObjectToLoad = ObjCache->getObject(M);

OwnedModules.markModuleAsLoaded(M);

b. 如果模塊之前沒有被緩存和編譯過,MCJIT調用MCJIT::emitObject()方法執行MC代碼發射,emitObject()方法生成一個新的ObjectBufferStream實例。ObjectBufferStream是ObjectBuffer 的子類,ObjectBufferStream支持流處理。emitObject()方法使用本地PassManger實例、MCContext實例、ObjectBufferStream實例作為參數調用LLVMTargetMachine:: addPassesToEmitMC()方法,生成目標對象的MCCodeEmitter、AsmStreamer以及AsmPrinter,並將對應pass添加到Pass Manager。

c. 隨後PassManager::run()觸發MC代碼生成機制,將中間表示形式的機器碼通過ObjectBufferStream產生完整的、可重定位的二進位對象image。這時ObjectBufferStream包含的是原始對象image,在執行前還需要將其中的數據和代碼去載入內存,並做重定位和內存許可權設置。

d. MCJIT以生成的對象image為界,分為兩個部分。代碼生成模塊和MC層的主要功能是編譯,RuntimeDyld主要功能是鏈接。不論是從代碼生成機制中獲得對象image,還是從ObjectCache中獲得對象image,最終都由RuntimeDyld載入內存。RuntimeDyld動態鏈接器載入存有輸出機器碼的ObjectBuffer對象,並通過調用RuntimeDyld:: loadObject()首先生成目標特定的RuntimeDyldImpl對象。RuntimeDyldImpl對象通過檢查對象image決定其格式,並生成相應的RuntimeDyldELF或RuntimeDyldMachO子類對象。然後調用RuntimeDyldELFRuntimeDyldImpl::loadObject()構造符號表,完成實際載入過程,並生成一個ObjectImage 對象保存載入的模塊。ObjectImage封裝了ObjectFile 類,其中的ObjectFile對象類型可以是ELF、COFF等,ObjectImage 對象可直接訪問ObjectBuffer和ObjectFile對象,解析二進位對象image,從MemoryBuffer對象獲得符號、重定位信息和節信息。處理過程如下圖所示:

圖5

d. RuntimeDyldImpl::loadObject()隨後會遍歷image中的符號。公共符號信息收集後以備後用。與函數符號和數據符號關聯的節被載入內存,符號被保存在一個符號表映射數據結構中。遍歷結束後,公共符號作為單獨一節發射。接下來遍歷節以及節中的重定位信息,對每一重定位信息調用與格式相應的processRelocationRef()方法解析模塊的重定位信息,並將重定位信息保存在節重定位表或外部符號(在本目標文件中引用,但不在本目標文件中定義的符號)重定位表中。

圖6

e. RuntimeDyldImpl::loadObject()結束後,對象的所有數據和代碼節都已載入內存管理器分配的內存中,重定位信息已準備好但還未應用,生成的代碼還不能執行。在應用重定位信息前,還應重映射(remap)節地址。因為代碼可能是用於外部進程,需要被映射到那個進程的地址空間。在將節拷貝到其新內存地址前要重映射節地址,方法是調用MCJIT::mapSectionAddress()。這時,MCJIT會通過RuntimeDyldImpl將新地址保存到其內部數據結構,但還不會更新代碼,因為此時其它節還有可能變化。當客戶端完成節地址重映射後,才會調用MCJIT::finalizeObject()方法,完成整個重映射過程。

f. MCJIT::finalizeObject()方法調用RuntimeDyld::resolveRelocations()方法定位外部符號,並將重定位信息應用於對象。定位外部符號的方法是調用內存管理器的getPointerToNamedFunction()方法,該方法會返回所請求對象在目標地址空間中地址。然後RuntimeDyld會遍歷之前保存的外部符號重定位信息列表,從中找到和符號關聯的重定位信息,將其應用於載入的節內存。接下來RuntimeDyld會遍歷節列表,並遍歷之前保存的每一個節的節重定位表,對表中的每一項調用resolveRelocation()方法。與節重定位表中重定位信息關聯的符號位於該重定位表對應的節中。重定位信息應用於目標位置定位,目標位置可能在另一個不同的節中。

圖7

重定位完成後,MCJIT調用RuntimeDyld::getEHFrameSection()方法。如果返回非0值,便將節數據傳給內存管理器的registerEHFrames()方法,內存管理器可以將EH幀信息註冊給調試器。

g. 最後,MCJIT調用內存管理器的finalizeMemory()方法將標記為loaded狀態的模塊移入模塊組,設置內存讀寫許可權。這之後,模塊準備好運行。

ObjectBuffer類是MemoryBuffer類的包裝。MemoryBuffer類被MCObjectStreamer子類用於向內存發射代碼和數據。另外,ObjectCache類直接引用MemoryBuffer實例,並從MemoryBuffer實例獲得ObjectBuffer。

在模塊定型(finalization)時,使用運行時RuntimeDyld動態鏈接器為模塊解析重定位以及註冊異常處理幀。前面已經提到,執行引擎方法getFunctionAddress()和getPointerToFunction()要求引擎要知道符號地址。所以MCJIT會通過RuntimeDyld::getSymbolLoadAddress()方法查詢任何符號地址。

LinkingMemoryManager類是另一個RTDyldMemoryManager子類,MCJIT引擎使用的真正的內存管理器,其中集成了一個SectionMemoryManager實例,並將代理請求發給這個實例。

RuntimeDyld動態鏈接器通過LinkingMemoryManager::getSymbolAddress()獲得符號地址有兩種方法:如果符號已經在編譯過的模塊中,可以通過MCJIT獲得符號地址;模塊中沒有符號,可以從SectionMemoryManager實例載入和映射的外部庫中獲得符號地址。

7. MC代碼發射

NCJIT通過調用MCJIT::emitObject()發射MC代碼。emitObject()完成以下任務:

-生成一個PassManager對象;

-添加一個目標layout pass並調用addPassesToEmitMC()添加所有代碼生成pass和MC代碼發射;

-調用PassManager::run()方法運行所有pass。輸出結果保存在ObjectBufferStream對象中;

-將編譯後對象添加到ObjectCache實例並返回。

代碼發射完成後,調用MCJIT::finalizeLoadedModules()將模塊定型,解析重定位信息,將載入的模塊移到定型模塊組中,並調用LinkingMemoryManager::finalizeMemory()修改內存頁許可權。此後,MCJIT編譯過的函數準備好執行。

參考文獻

[1] github.com/jtcriswell/S

[2] llvm.org/docs/MCJITDesi

[3] mshockwave.blogspot.com

[4] hackage.haskell.org/pac


推薦閱讀:
相關文章