譯自《Kati internals》
這是關于Kati內部結構的非正式文檔。本文不是一個Kati或GNU make的綜合文檔。本文檔解釋了其他程序員可能感興趣的一些隨機主題。
動機
Kati的動機是加快Android平臺構建。特別是其增長的構建時間是主要的焦點。 Android平臺的構建系統是一個非常獨特的系統。它提供了一個DSL (ab),使用Turing完整的GNU make。 DSL允許開發人員以描述性的方式編寫構建規則,但缺點是復雜和緩慢。
當我們說構建系統緩慢時,我們考慮 “空構建(null build)” 和 “完整構建(full build)”。 空構建是一個不做任何事情的構建,因為所有的輸出文件都是最新的。完整構建是構建一切的構建,因為沒有任何東西已經構建好。日常開發中的實際構建位于空構建和完整構建之間。下面的大多數基準測試都是基于空構建。
對于Android,我的相當強大的工作站,使用GNU make,空構建花了約100秒。這意味著你需要等待?100秒來查看更改單個C文件時是否有編譯錯誤。公平地說,事情并沒有那么糟糕。因為有稱為mm/mmm的工具。它們允許開發人員構建一個單獨的模塊。當它們忽略模塊之間的依賴關系時,它們很快。但是,你需要有足夠的經驗才能正確使用它們。你應該知道哪些模塊將受到你的更改的影響。如果你更改一些東西時可以只要輸入“make”,那將會更好。
這就是為什么我們開始這個項目的原因。我們決定從頭創建一個GNU make克隆,但還有一些其他選項。一個選擇是用更好的格式替換所有的Android.mk文件。實際上這是一個長期的項目。Kati計劃成為一個短期項目。另一個選擇是hack GNU make,而不是開發克隆。我們沒有采取這個選擇,因為我們認為由于歷史原因,GNU make的源代碼有點復雜。它是用老式C編寫的,對于某些未知的架構等都有很多ifdef。
目前,Kati的主要模式是--ninja
模式。 Kati自己不用執行build命令,而是生成build.ninja
文件,而ninja實際上運行命令。在Kati成為當前形式之前,有一些往返。一些實驗成功,其他一些失敗。我們甚至改變了Kati的語言。起初,我們在Go中寫了Kati。我們天真地期望我們可以用Go獲得足夠的性能。我猜到以下至少一個語句是真的:
- GNU make對于計算重的Makefile不是非常優化,2. 為我們的目的選擇Go有點快了,或3. 我們可以提出一些Android的構建系統的優化技巧。至于3,一些這樣的優化成功了,但是性能的提升沒有取消Go的緩慢。
Go的性能表現會有點有趣的話題。我沒有詳細研究性能差異,但是似乎我們使用Go的方式,以及Go語言本身使得Kati的Go版本變慢。對于我們的錯,我認為Go版本比C++版本有更多的不必要的字符串分配。至于Go本身,似乎GC是主要的展示器。例如,Android的構建系統定義了大約一百萬個變量,緩沖區將永遠不會被釋放。 IIRC,這種分配格局對于非代際GC(non-generational)是不利的。
Go版本和測試用例是由ukai和我寫的,C++重寫主要由我完成。本文檔的其余部分主要是關于C++版本。
整體架構
Kati由以下組件組成:
- 解析器(Parser)
- 評估器(Evaluator)
- 依賴構建器(Dependency builder)
- 執行器(Executor)
- Ninja生成器(Ninja generator)
Makefile有一些由零個或多個表達式組成的語句。有兩個解析器和兩個評估器, 一個用于語句,另一個用于表達式。
GNU make的大部分用戶可能不太關心評估器。但是,GNU make的評估器非常強大,并且是圖靈完整的。對于Android的空構建(null build),大部分時間都花在這個階段。其他任務,例如構建依賴關系圖和調用構建目標的stat函數,并不是瓶頸。這將是一個非常具體的Android特性。 Android的構建系統使用了大量的GNU make黑魔法。
評估器輸出構建規則(build rules)和變量表(variable table)的列表。依賴構建器從構建規則列表中創建依賴圖(dependency graph)。注意這一步不使用變量表。
然后將使用執行器或Ninja生成器。無論哪種方式,Kati再次為命令行運行其評估器。該變量表再次用于此步驟。
我們將仔細查看每個組件。 GNU make是一種與現代語言不同的語言。讓我們來看看。
語句分析器
我不是100%肯定,但我認為GNU make同時進行解析和評估Makefile,但是Kati有兩個階段用于解析和評估。這種設計的原因是為了性能。對于Android構建,Kati(或GNU make)需要讀取?3k個文件?50k次。讀取最多的文件需要讀取?5k次。浪費時間一次次地解析這些文件。當需要再次評估Makefile時,Kati可以重新使用已解析的結果。如果我們停止緩存已解析的結果,則對于Android的構建,Kati將會慢兩倍。緩存已解析的語句在file_cache.cc中完成。
語句解析器在parser.cc中定義。在Kati中,有四種語句:
- 規則(Rules)
- 賦值(Assignments)
- 命令(Commands)
- Make指令(Make directives)
它們的數據結構在stmt.h中定義。以下是這些語句的示例:
VAR := yay! # An assignment
all: # A rule
echo $(VAR) # A command
include xxx.mk # A make directive (include)
除了include指令之外,還有ifeq
/ifneq
/ifdef
/ifndef
指令和export
/unexport
指令。另外,在Kati內部還使用“解析錯誤語句(parse error statement)”。由于GNU make不顯示不被采用的分支中的解析錯誤,因此我們需要將解析錯誤延遲到評估時間。
上下文相關解析器
解析make語句的棘手之處在于,解析取決于評估時的上下文。請參閱以下Makefile塊:
$(VAR)
X=hoge echo $${X}
在$(VAR)被評估之前,你無法判斷第二行是否為命令或賦值。如果$(VAR)是一個規則語句,則第二行是一個命令,否則它是一個賦值。如果在此之前的上一行是
VAR := target:
第二行將成為一個命令。
由于某些原因,僅用于規則,GNU make會在決定語句類型之前展開表達式。將賦值或指令(assignments or directives)存儲在變量中將使其不能用作賦值或指令。例如
ASSIGN := A=B
$(ASSIGN):
不為A賦值B,但定義了一個構建規則,其目標是A = B。
無論如何,因為以tab字符開頭的一行可以是一個命令語句(command statement)或其他語句,這取決于上一行的評估結果,所以有時Kati的解析器不能分辨一行的語句類型。在這種情況下,Kati的解析器推測創建一個命令語句對象,也保留原始行。如果事實證明該行實際上不是命令語句,則評估器將重新運行解析器。
行連結和注釋
在大多數編程語言中,反斜杠字符的行連接和注釋在語言實現的早期階段處理。然而,GNU make根據解析/評估上下文(parse/eval context)改變了它們的行為。例如,以下Makefile輸出“has space”和“hasnospace”:
VAR := has\
space
all:
echo $(VAR)
echo has\
nospace
GNU make通常在行之間插入空格,但對于命令行,它不會。正如我們在上一小節中看到的,有時候Kati不能分辨一行是一個命令語句。這意味著我們在評估語句后應該處理它們。類似的討論也適用于注釋。 GNU make通常去掉(trims)在'#'之后的字符,但對命令行中的'#'不起作用。
我們在Kati的代碼庫的testcase目錄中有一堆關于注釋/反斜杠相關的測試用例。
表達式的解析器
一個語句可能有一個或多個表達式。語句中的表達式數目取決于語句的類型。例如,
A := $(X)
這是一個賦值語句,它有兩個表達式 :A和$(X)。表達式及其解析器的類型在expr.cc中定義。像其他編程語言一樣,一個表達式(an expression)是一棵表達式的樹(a tree of expressions)。葉子表達式(leaf expression)的類型是文字(literal),變量引用(variable reference),替代引用(substitution references)或make函數(make functions)。
如所寫的,反斜杠和注釋根據上下文來改變它們的行為。在這個階段,Kati處理他們。ParseExprOpt是上下文的枚舉。
作為舊系統的一個本質,GNU make是非常寬容的。由于某種原因,它允許某種沒有成對匹配的括號對(unmatched pairs of parentheses)。例如,GNU make不認為$($ foo)是一個錯誤 - 這是對變量$(foo)的引用。如果你有一些解析器的經驗,你可能會想知道怎么會實現這樣的解析器能允許這樣的表達式。似乎GNU有意地允許這樣:
http://git.savannah.gnu.org/cgit/make.git/tree/expand.c#n285
沒有人會有意使用這個功能。然而,不幸的是,由于GNU make允許這一點,一些Makefile有不匹配的括號,所以Kati不應該為他們引發一個錯誤。
GNU make有一堆功能。大多數用戶只能使用簡單的例如$(wildcard ...)和$(subst ...)。還有更復雜的功能,如$(if ...)和$(call ...),這使得GNU make是圖靈完整的。 make函數定義在func.cc中。雖然func.cc不短,但實現相當簡單。關于函數,我記得只有一個比較奇怪的地方。 GNU make對$(if ...),$(and ...)和$(or ...)的解析稍作了改變。請參閱func.h中的trim_space和trim_right_space_1st,以及如何在expr.cc中使用它們。
語句的評估器
語句的評估器定義在eval.cc中。如書面所述,有四種語句:
- 規則(Rules)
- 賦值(Assignments)
- 命令(Commands)
- Make指令(Make directives)
命令和指令之間沒有什么棘手的。規則語句有一些不同形式,在第三個解析器評估表達式之后應該進行解析。這將在下一節討論。
GNU make中的賦值有點棘手。 GNU make中有兩種變量 - 簡單變量和遞歸變量。請參閱以下代碼段:
A = $(info world!) # recursive
B := $(info Hello,) # simple
$(A)
$(B)
此代碼按此順序輸出“Hello,”和“world!”。遞歸變量的評估被延遲,直到引用變量。所以第一行是遞歸變量的賦值,不輸出任何內容。在第一行之后,變量$(A)的內容將是$(info world!)。第二行中的賦值使用:=,這意味著這是一個簡單變量賦值。對于簡單變量,右側將立即進行評估。所以“Hello,”將被輸出,$(B)的值將是一個空字符串,因為$(info ...)返回一個空字符串。然后,當第三行$(A)被評估時,將顯示“world!”,最后第四行不執行任何操作,因為$(B)是一個空字符串。
還有兩種賦值(即+=和?=)。這些賦值保留原始變量的類型。只有當賦值的左側已經被定義并且是一個簡單變量時,才能對它們進行評估。
規則解析器
評估規則語句后,Kati需要解析評估結果。規則聲明實際上可以是以下四件事:
- 一個規則
- 一個目標特定變量
- 一個空行
- 一個錯誤(沒有冒號的非空格字符)
解析它們主要是在rule.cc中完成的。
規則
一個規則就像all: hello.exe。你應該熟悉它。有幾種規則,如模式規則(pattern rules),雙冒號規則(double colon rules)和僅順序依賴(order only dependencies),但它們不會使規則解析器復雜化。
使解析器復雜化的一個特性是分號。你可以在規則的同一行上編寫第一個構建命令。例如,
target:
echo hi!
和
target: ; echo hi!
具有相同的含義。這是棘手的,因為在實際調用命令之前,Kati不應該評估命令中的表達式。因為表達式評估的結果,可以出現分號,有一些邊角情形。一個棘手的例子:
all: $(info foo) ; $(info bar)
$(info baz)
應該按此順序輸出foo,baz,然后bar,但是
VAR := all: $(info foo) ; $(info bar)
$(VAR)
$(info baz)
輸出foo,bar,然后baz。
再次,對于分號后面的命令行,Kati還應該更改反斜杠和注釋的處理方式。
target: has\
space ; echo no\
space
上面的例子說,target依賴于兩個目標,has和space,而為了構建target,echo nospace應該被執行。
目標特定變量
你可能不熟悉目標特定變量(target specific variables)。此功能允許你定義只能從指定目標的命令中引用的變量。請參閱以下代碼:
VAR := X
target1: VAR := Y
target1:
echo $(VAR)
target2:
echo $(VAR)
在這個例子中,target1顯示Y而target2顯示X。我認為這個功能有點類似于其他編程語言中的命名空間。如果為非葉目標(non-leaf target)指定了目標特定變量,則即使在先決條件目標(prerequisite targets)的構建命令中也將使用該變量。
一般來說,我喜歡GNU make,但這是唯一不喜歡的GNU make的功能。請參閱以下Makefile:
hello: CFLAGS := -g
hello: hello.o
gcc $(CFLAGS) $< -o $@
hello.o: hello.c
gcc $(CFLAGS) -c $< -o $@
如果你為目標hello運行make,則CFLAGS會應用于兩個命令:
$ make hello
gcc -g -c hello.c -o hello.o
gcc -g hello.o -o hello
但是,當你只構建hello.o時不會使用給hello的CFLAGS:
$ make hello.o
gcc -c hello.c -o hello.o
當具有不同目標特定變量的兩個目標取決于相同的目標時,事情可能會更糟。構建結果將不一致。我認為沒有對非葉目標(non-leaf targets)的這個功能的有效使用案例。
我們回到解析上來。像分號一樣,我們需要延遲遞歸變量賦值的右側的評估。它的實現與分號非常相似,但是賦值和分號的組合使得解析有點棘手。一個例子:
target1: ;X=Y echo $(X) # A rule with a command
target2: X=;Y echo $(X) # A target specific variable
表達式的評估器(Evaluator for expressions)
表達式的評估在expr.cc,func.cc和command.cc中完成。這個步驟的代碼量相當大,特別是因為GNU make函數的數量。然而,他們的實現是相當簡單的。
一個棘手的功能是$(wildcard ...)。似乎GNU make正在為此功能進行某種優化,命令中的$(wildcard ...)似乎在命令的評估階段之前進行評估。 C++ Kati和Go Kati兩者都以不同的方式與GNU make的行為不同,但似乎這種不兼容性對于Android構建還是OK的。
對Android進行了一個重要的優化。 Android的構建系統有很多$(shell find ...)調用來創建一個目錄下的所有.java/.mk文件的列表,它們很慢。為此,Kati有一個GNU find的內置仿真器。該find仿真器遍歷目錄樹并創建內存中的目錄樹(in-memory directory tree)。然后,該find仿真器使用緩存的樹返回find命令的結果。對于我的環境來說,find命令模擬器使得Kati比AOSP快了1.6倍。
命令中的一些IO相關功能的實現在Ninja生成模式下是棘手的。這將在后面描述。
依賴構建器(Dependency builder)
現在我們得到一個規則列表和一個變量表。dep.cc使用規則列表構建依賴圖。我認為這個步驟是GNU make對普通用戶應該做的。
這個步驟與其他組件類似,相當復雜,但沒有什么奇怪的。 GNU make有三種規則:
- 明確規則(explicit rule)
- 隱式規則(implicit rule)
- 后綴規則(suffix rule)
以下代碼顯示了三種類型:
all: foo.o
foo.o:
echo explicit
%.o:
echo implicit
.c.o:
echo suffix
在上面的例子中,所有這三個規則都匹配目標foo.o。 GNU make首先確定明確規則。當目標沒有明確的規則時,它將使用具有較長模式字符串的隱式規則。后綴規則僅在沒有明確/隱式規則時使用。
Android有超過一千個隱式規則,有成千上萬的目標。使用一個天真的O(NM)算法來匹配它們太慢了。Kati用一個特技加速這一步。
多個沒有命令的規則應該合并到有命令的規則中。例如:
foo.o: foo.h
%.o: %.c
$(CC) -c $< -o $@
foo.o不僅取決于foo.c,還取決于foo.h。
執行器(Executor)
C++ Kati的執行器很簡單。這在exec.cc中定義。這僅對于測試是有用的,因為它缺少構建系統的一些重要功能(例如并行構建)。
命令中的表達式在這個階段進行了評估。當他們進行評估時,應考慮目標特定變量(target specific variables)和一些特殊變量(例如$<和$@)。由command.cc處理它們。該文件由執行器和Ninja生成器使用。
當涉及+=和目標特定變量時,此階段的評估是棘手的。這是一個示例代碼:
all: test1 test2 test3 test4
A:=X
B=X
X:=foo
test1: A+=$(X)
test1:
@echo $(A) # X bar
test2: B+=$(X)
test2:
@echo $(B) # X bar
test3: A:=
test3: A+=$(X)
test3:
@echo $(A) # foo
test4: B=
test4: B+=$(X)
test4:
@echo $(B) # bar
X:=bar
這里test3的$(A)是一個簡單變量。雖然全局范圍內的$(A)是簡單變量,但test1中的$(A)是一個遞歸變量。這意味著全局變量的類型不會影響目標特定變量的類型。但是,test1的結果(“X bar”)顯示目標特定變量的值連接到(concatenated)全局變量的值。
Ninja生成器(Ninja generator)
ninja.cc使用其他組件的結果生成一個ninja文件。這一步實際上是相當復雜的,因為Kati需要將GNU make的功能映射到Ninja。
GNU make中的構建規則可能有多個命令,而ninja始終是單個命令。為了減輕這種情況,Ninja生成器將多個命令轉換為(cmd1) && (cmd2) && ...。Kati也應該轉義一些ninja和shell的特殊字符。
更麻煩的是在命令中的$(shell ...)。當前Kati的實現將其轉換為shell的$(...)。這適用于許多情況。但是當$(shell ...)的結果傳遞給另一個make函數時,這種方法將無法正常工作。例如
all:
echo $(if $(shell echo),FAIL,PASS)
應該輸出PASS,因為$(shell echo)的結果是一個空字符串。 GNU make和Kati的執行器模式正確輸出PASS。然而,Kati的Ninja生成器生成出一個顯示失敗的ninja文件。
我為這個問題寫了幾個實驗補丁,但是它們的運行不好。目前的Kati實現具有Android特定的解決方法。有關詳細信息,請參閱func.cc中的HasNoIoInShellScript。
Ninja再生(Ninja regeneration)
C++ Kati有--regen標志。如果指定了此標志,則Kati會檢查你的環境中的任何內容是否在上次運行后發生更改。如果Kati認為它不需要重新生成Ninja文件,它會很快完成。對于Android,第一次運行Kati需要?30秒,但第二次運行只需要?1秒。
Kati認為,當更改以下任一項時,需要重新生成Ninja文件:
- 傳遞給Kati的命令行標志
- 用于生成上一個ninja文件的Makefile的時間戳
- 評估Makefile時使用的環境變量
- $(wildcard ...)的結果
- $(shell ...)的結果
快速做最后一個檢查不是微不足道的。由于$(shell find ...)的緩慢,在Android的構建系統中運行$(shell ...)需要?18秒。因此,對于由Kati的find仿真器執行的查找命令,Kati通過跟find命令本身一起存儲遍歷目錄的時間戳。對于每個查找命令,Kati檢查它們的時間戳。如果它們沒有改變,則Kati跳過重新運行find命令。
在此檢查過程中,Kati不運行$(shell date ...)和$(shell echo ...)。前者總是改變,所以沒有意義重新運行它們。 Android使用后者創建一個文件,其結果是空字符串。我們不想更新這些文件以獲取空字符串。
待做事項(TODO)
一個大的TODO是由$(MAKE)調用的sub-makes。我寫了一些實驗性的補丁,但是還沒有什么值得可以用來一寫的。