Android系統優化Ninja加快編譯

背景

Android系統模塊代碼的編譯實在是太耗時瞭,即使寥寥幾行代碼的修改,也能讓一臺具有足夠性能的編譯服務器工作十幾分鐘以上(模塊單編),隻為編出一些幾兆大小的jar和dex。

這裡探究的是系統完成過一次整編後進行的模塊單編,即m、mm、mmm等命令。

除此之外,一些不會更新源碼、編譯配置等文件的內容的操作,如touch、git操作等,會被Android系統編譯工具識別為有差異,從而在編譯時重新生成編譯配置,重新編譯並沒有更新的源碼、重新生成沒有差異的中間文件等一系列嚴重耗時操作。

本文介紹關於編譯過程中的幾個階段,以及這些階段的耗時點/耗時原因,並最後給出一個覆蓋一定應用場景的基於ninja的加快編譯的方法(實際上是裁剪掉冗餘的編譯工作)。

環境

編譯服務器硬件及Android信息:

  • Ubuntu 18.04.4 LTS
  • Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz (28核56超線程)
  • MemTotal: 65856428 kB (62.8GiB)
  • AOSP Android 10.0
  • 僅修改某個Java文件內部的boolean初始化值(true改false)
  • 不修改其他任何內容,包括源碼、mk、bp的情況下,使用m單編模塊(在清理後,使用對比的ninja進行單編)
  • 使用time計時
  • 此前整個系統已經整編過一次
  • 編譯時不修改任何編譯配置文件如Android.mk

之所以做一個代碼修改量微乎其微的case,是因為要分析編譯性能瓶頸,代碼變更量越小的情況下,瓶頸就越明顯,越有利於分析。

關鍵編譯階段和耗時分析

由於Makefile結構復雜、不易調試、難以擴展,因此Android決定將它替換掉。Android在7.0時引入瞭Soong,它將Android從Makefile的編譯架構帶入到瞭ninja的時代。

Soong包含兩大模塊,其中Kati負責解析Makefile並轉換為.ninja,第二個模塊Ninja則基於生成的.ninja完成編譯。

Kati是對GNU Make的clone,並將編譯後端實現切換到ninja。Kati本身不進行編譯,僅生成.ninja文件提供給Ninja進行編譯。

Makefile/Android.mk -> Kati -> Ninja
Android.bp -> Blueprint -> Soong -> Ninja

因此在執行編譯之前(即Ninja真正開動時),還有一些生成.ninja的步驟。關鍵編譯階段如下:

Soong的自舉(Bootstrap),將Soong本身編譯出來

系統代碼首次編譯會比較耗時,其中一個原因是Soong要全新編譯它自己

遍歷源碼樹,收集所有編譯配置文件(Makefile/Android.mk/Android.bp)

  • 遍歷、驗證非常耗時,多麼強勁配置的機器都將受限於單線程效率和磁盤IO效率
  • 由於Android系統各模塊之間的依賴、引入,因此即使是單編模塊,Soong(Kati)也不得不確認目標模塊以外的路徑是否需要重新跟隨編譯。

驗證編譯配置文件的合法性、有效性、時效性、是否應該加入編譯,生成.ninja

  • 如果沒有任何更改,.ninja不需要重新生成
  • 最終生成的.ninja文件很大(In my case,1GB以上),有很明顯的IO性能效率問題,顯然在查詢效率方面也很低下

最後一步,真正執行編譯,調用ninja進入多線程編譯

  • 由於Android加入瞭大量的代碼編譯期工作,如API權限控制檢查、API列表生成等工作(比如,生成系統API保護名單、插樁工作等等),因此編譯過程實際上不是完全投入到編譯中
  • 編譯過程穿插“泛打包工作”,如生成odex、art、res資源打包。雖然不同的“泛打包”可以多線程並行進行,但是每個打包本身隻能單線程進行

下面將基於模塊單編(因開發環境系統全新編譯場景頻率較低,不予考慮),對這四個關鍵階段進行性能分析。

階段一:Soong bootstrap

在系統已經整編過一次的情況下,Soong已經完成瞭編譯,因此其預熱過程占整個編譯時間的比例會比較小。

在“環境”下,修改一行Framework代碼觸發差異進行編譯。並且使用下面的命令進行編譯。

time m services framework -j57

編譯實際耗時22m37s:

build completed successfully (22:37 (mm:ss)) ####
real    22m37.504s
user    110m25.656s
sys     12m28.056s

對應的分階段耗時如下圖。

  • 可以看到,包括Soong bootstrap流程在內的預熱耗時占比非常低,耗時約為11.6s,總耗時約為1357s,預熱耗時占比為0.8%

  • Kati和ninja,也就是上述編譯關鍵流程的第2步和第3步,分別占瞭接近60%(820秒,13分鐘半)和約35%(521秒,8分鐘半)的耗時,合計占比接近95%的耗時。

註:這個耗時是僅小幅度修改Java代碼後測試的耗時。如果修改編譯配置文件如Android.mk,會有更大的耗時。

小結:看來在完成一次整編後的模塊單編,包括Soong bootstrap、執行編譯準備腳本、vendorsetup腳本的耗時占比很低,可以完全排除存在性能瓶頸的可能。

階段二:Kati遍歷、mk搜集與ninja生成

從上圖可以看到,Kati耗時占比很大,它的任務是遍歷源碼樹,收集所有的編譯配置文件,經過驗證和篩選後,將它們解析並轉化為.ninja

從性能角度來看,它的主要特點如下:

  • 它要遍歷源碼樹,收集所有mk文件(In my case,有983個mk文件)
  • 解析mk文件(In my case,framework/base/Android.mk耗費瞭~6800ms)
  • 生成並寫入對應的.ninja
  • 單線程

直觀展示如下,它是一個單線程的、IO速度敏感、CPU不敏感的過程:

Kati串行地處理文件,此時對CPU利用率很低,對IO的壓力也不高。

小結:可以確定它的性能瓶頸來源於IO速度,單純為編譯實例分配更多的CPU資源也無益於提升Kati的速度。

階段三:Ninja編譯

SoongClone瞭一份GNU Make,並將其改造為Kati。即使我們沒有修改任何mk文件,前面Kati仍然會花費數分鐘到數十分鐘的工作耗時,隻為瞭生成一份能夠被Ninja.ninja的生成工具能夠識別的文件。接下來是調用Ninja真正開始編譯工作。

從性能角度來看,它的主要特點如下:

  • 根據目標target及依賴,讀取前面生成的.ninja配置,進行編譯
  • 比較獨立,不與前面的組件,如blueprint、kati等耦合,隻要.ninja文件中能找到target和build rule就能完成編譯
  • 多線程

直觀展示如下,Ninja將會根據傳入的並行任務數參數啟動對應數量的線程進行編譯。Ninja編譯階段會真正的啟動多線程。但做不到一直多線程編譯,因為部分階段如部分編譯目標(比如生成一個API文檔)、泛打包階段等本身無法多線程並行執行。

可以看到此時CPU利用率應該是可以明顯上升的。但是耗時較大的階段僅啟用瞭幾個線程,後面的階段和最後的圖形很細(時間占比很小)的階段才用起來更多的線程。

其中,一些階段(圖中時間占比較長的幾條記錄)沒能跑滿資源的原因是這些編譯目標本身不支持並行,且本次編譯命令指定的目標已經全部“安排”瞭,不需要調動更多資源啟動其他編譯目標的工作。當編譯整個系統時就能夠跑滿瞭。

最後一個階段(圖中最後的幾列很細的記錄)雖然跑滿瞭所有線程資源,但是運行時間很短。這是因為本case進行編譯分析的過程中,僅修改瞭一行代碼來觸發編譯。因編譯工作量很小,所以這幾列很細。

小結:我們看到,Ninja編譯啟動比較快,這表明Ninja.ninja文件的讀取解析並不敏感。整個過程也沒有看到顯著的耗時點。且最後面編譯量很小,表明Ninja能夠確保增量編譯、未更新不編譯。

編譯優化

本節完成點題——Android系統編譯優化:使用Ninja加快編譯。

根據前面分析的小結,可以總結性能瓶頸:

  • Kati遍歷、生成太慢,受限於IO速率
  • Kati吞吐量太低,單線程
  • 不論有無更新均重新解析Makefile

利用Ninja進行編譯優化的思路是,大多數場景,可以舍棄Kati的工作,僅執行Ninja的工作,以節省掉60%以上的時間。其核心思路,也是制約條件,即在不影響編譯正確性的前提下,舍棄不必要的Kati編譯工作。

  • 使用Ninja直接基於.ninja文件進行編譯來改善耗時:

結合前面的分析,容易想到,如果目標被構建前,能夠確保mk文件沒有更新也不需要重新生成一長串的最終編譯目標(即.ninja),那麼make命令帶來的Soong bootstrap、Kati等工作完全是重復的冗餘的——這個性質Soong和Kati自己識別不出來,它們會重復工作一次。

既重新生成.ninja是冗餘的,那麼直接命令編譯系統根據指定的.ninja進行編譯顯然會節省大量的工作耗時。ninja命令is the key:

使用源碼中自帶的ninja:

./prebuilts/build-tools/linux-x86/bin/ninja --version
1.8.2.git

對比最上面列出的make命令的編譯,這裡用ninja編譯同樣的目標:

 time ./prebuilts/build-tools/linux-x86/bin/ninja 
 -j 57 -v -f out/combined-full_xxxxxx.ninja services framework

ninja自己識別出來CPU平臺後,默認使用-j58。這裡為瞭對比上面的m命令,使用-j57編譯

-f參數指定.ninja文件。它是編譯配置文件,在Android中由Kati生成。這裡文件名用'x'替換修改

編譯結果,對比上面的m,有三倍的提升:

real    7m57.835s
user    97m12.564s
sys     8m31.756s

編譯耗時為8分半,僅make的三分之一。As we can see,當能夠確保編譯配置沒有更新,變更僅存在於源碼范圍時,使用Ninja直接編譯,跳過Kati可以取得很顯著的提升

直接使用ninja:

./prebuilts/build-tools/linux-x86/bin/ninja 
-j $MAKE_JOBS -v -f out/combined-*.ninja <targets...>

對比匯總

這裡找瞭一個其他項目的編譯Demo,該Demo的特點是本身代碼較簡單,編譯配置也較簡單,整體編譯工作較少,通過make編譯的大部分耗時來自soong、make等工具自身的消耗,而真正執行編譯的ninja耗時占比極其低。由於ninja本身跳過瞭soong,因此可以跳過這一無用的繁瑣的耗時。可以看到下面,ninja編譯iperf僅花費10秒。這個時間如果給soong來編譯,預熱都不夠。

$ -> f_ninja_msf iperf
Run ninja with out/combined-full_xxxxxx.ninja to build iperf.
====== ====== ======
Ninja: ./prebuilts/build-tools/linux-x86/bin/[email protected]
Ninja: build with out/combined-full_xxxxxx.ninja
Ninja: build targets iperf
Ninja: j72
====== ====== ======
time /usr/bin/time ./prebuilts/build-tools/linux-x86/bin/ninja -j 72 -f out/combined-full_xxxxxx.ninja iperf
[24/24] Install: out/target/product/xxxxxx/system/bin/iperf
53.62user 11.09system 0:10.17elapsed 636%CPU (0avgtext+0avgdata 5696772maxresident)
4793472inputs+5992outputs (4713major+897026minor)pagefaults 0swaps
real    0m10.174s
user    0m53.624s
sys     0m11.096s

下面給出soong編譯的恐怖耗時:

$ -> rm out/target/product/xxxxxx/system/bin/iperf
$ -> time m iperf -j72
...
[100% 993/993] Install: out/target/product/xxxxxx/system/bin/iperf
#### build completed successfully (14:45 (mm:ss)) ####
real    14m45.164s
user    23m40.616s
sys     11m46.248s

As we can see,m和ninja一個是10+ minutes,一個是10+ seconds,比例是88.5倍。

以上就是Android系統優化Ninja加快編譯的詳細內容,更多關於Android Ninja加快編譯的資料請關註WalkonNet其它相關文章!

推薦閱讀: