2010/08/22

翻譯:C++的編譯速度(C++ Compilation Speed) by Walter Bright

文章:C++ Compilation Speed(C++的編譯速度)
日期:2010.08.17
作者:Walter Bright
作者的部落格:Walter Bright Home Page
作者簡介:
Walter Bright是位電腦程式設計師,是D語言的設計者,第一套C++原生編譯器的主要開發員,也就是Zortech C++(後來變成Symantec C++,現在是Digital Mars C++),在C++編譯器之前,他開發了Datalight C編譯器,先以Zorland C後以Zortech C之名販售。



C++的編譯速度


我常聽到有人抱怨說C++程式碼編譯速度很慢,有時候甚至要花上整夜的時間,編譯慢是exported templates這玩意的源由之一,甚至列在發展Go語言的理由清單上,這點確實是個問題;既然我身在C++編譯器的產業中,三不五時就會被問到這點。

為什麼C++編譯速度慢?一旦我們合理假設開發C++編譯器的人都擅長於寫出效能高的程式碼,那麼,一定有某個深植於C++語言本身的原因;的確,不同牌的C++編譯器速度快慢相差極大,但還沒完喔,其他語言通常能夠快上一整個等級,而且厲害的編譯器專家應該不會只為其他語言寫編譯器吧(!)。

我從1987就在寫C++編譯器了,對比今日想當年,電腦可是慢的不得了,所以我投注極大的精神讓編譯器能夠快一點,花上大把的時間做效能分析以及微調各個小地方來讓編譯器更快,我發現,語言本身的某些特性讓編譯速度快不了。

理由是:


1. 轉譯過程中有七個階段[1]。雖然有些可被合併處理,但在前端處理原始碼最少要有三個階段,至少我還沒找到降到三以下的法子。要快的話語言設計時就只能有一個階段,C++0x惡化了這點,居然要求trigraph轉換與行尾為\與下行接合這兩個功能要能夠支援string literals[2]

2. 每個階段都相依於前一個階段,意思是,沒有可靠的方式可以做往前先看的動作,例如,往前先去找#include然後先去讀進檔案;編譯器沒辦法往前先看出是個string literal所以不要做trigraph轉換,必須先做trigraph轉換,但要做好回到上一步的準備;我從沒找出能夠平行編譯C++程式碼的方法,除了在make時加上-j參數這種很粗略的作法。

3. 因為#include是種文本逐字置入(textual insertion)的機制,而不是符號式(symbolic)的,當一個檔案被#include很多次,編譯器只能悲情地做白工地一再地處理,即使是被#ifndef包起來也一樣。(Kenneth Boyd跟我說,如果將標準文件讀的仔細一點,是有可能允許編譯器省略被#ifndef包起來的#include,但我不知道有哪一支編譯器利用了這點。)

4. 程式檔案中總是傾向於,通通#include進來就對了,當責任全部落在編譯器身上時,每一個.cpp檔通常都會連帶引出一拖拉庫的檔案要處理,在Ubuntu上,就算只把標準函式庫#include進來,居然需要處理74支檔案總共37,687行耶(不包括同支檔案被#include多次的情形);templates以及generic programming的興起更惡化了這點,而且,把更多的程式碼放進標頭檔(header files)中的壓力也逐漸升高,更是雪上加霜。

5. 語意上的與語法上的(不只是詞彙上的)處理單位依賴處在它之前的整個原始文本,意思是,沒有東西是上下文無關的;不把#include的東西先看一看,就不能正確地解析(parse)檔案,甚至是先做lex的動作也不行,標頭檔在第二次#include時可能含有不一樣的內容(事實上,確有標頭檔利用這點)。

譯註:語意上的 semantic,語法上的 syntactic,詞彙上的 lexical。

6. 因為第5點,編譯器在某個TU[3]所編譯的#include的結果,不能下一個TU共用,每個TU都必須從頭開始。

7. 因為不同的TU之間彼此不知道對方的存在,常用的templates在每個TU都會被產生出來,鏈結器(linker)會將重複的刪除,但當初所花的時間都白費了。

預先編譯標頭檔(precompiled headers)解決了一些問題,但那是對非標準的C++做出某些簡化後的假設,才可辦到,例如,標頭檔被#include還是會含有同樣的內容;所以你必須小心,不能違反這些假設。

想解決這些問題又要跟舊有的程式碼保持相容性,真是高難度的挑戰啊,我預期在C++0x之後,會有相當份量的心力花在這些問題上,但那至少是10年後了。

在那之前,並沒有哪個方法可稱得上是解答,exported templates被廢棄了,precompiled headers是不符合標準的,imports被踢出C++0x標準之外,以及往往你沒有選擇編譯器的權力,諸如此類的;現在來說,有效地使用make -j參數可算得上是最好的方法了。

我會再談談關於語言的設計,哪些特性能夠導致快速的編譯速度。


註解

[1] C++98標準文件的2.1章節, 七個階段是:

1. Trigraph與萬國碼轉換。
2. 行尾為\時接到下一行
3. 轉換成預先處理的標記(preprocessing tokens),標準文件註明說這是上下文相依的。
4. 預先處理的指令執行,展開巨集,#include的讀取以及再跑一次1到4。
5. 將原始碼中處在char與string literals的字元轉換成執行字元集(execution character set)。
6. string literal的接合。
7. 將預先處理的標記轉成C++的標記(C++ tokens)。

[2] 在C++0x標準文件中的例子在2.14.5-4:

const char *p = R"(a\
b
c)";
assert(std::strcmp(p, "a\\\nb\nc") == 0);

[3] 一個TU,也就是一個轉譯單位(Translation Unit),通常就是一支C++原始碼檔案,通常是以.cpp為副檔名,編譯一支TU會生出一個目的檔(object file),每個TU的編譯過程都與其他TU不相關,最後由鏈結器(linker)將目的檔整合成單一的執行檔。


感謝

感謝Andrei Alexandrescu、Jason House、Brad Roberts以及Eric Niebler給予這份文章草稿時的有用建議。

3 comments:

  1. c++編譯速度要快,語言層面需要用編譯器防火牆,預宣告,不需要的include要移除,非必要不要在表頭檔引入其他表頭檔,儘量讓相依性限縮在.cpp中,這需要程式猿有相當高的素質,寫程式很花時間,編譯工具需要有類似make這種函數式平行建置工具來輔助,這是我寫c++十年的心得,現在因為社會變遷,客戶傾向快速,不大願意付錢,用慣了android免費軟體,使得快速原型建造,快速開發變得比過去重要許多,公司不賺錢也不會請太多程式猿,一個程式猿的生產力有時候需要過去的五倍,所以這幾年我也放下c++,專案也以.net和ruby為主,除非一些嵌入式系統的案子,否則一般桌面應用程式,網站建置,和伺候器維護使用c++理由趨進無,我最後使用c++的時候是幫一個嵌入式裝置用boost asio寫了一個有特用功能的http server,因為這個裝置需要同時連接上萬個iot裝置,需要能快速回應http request和一些私有的socket協定,遇到這種案子機會太少,大部份的情況都是用加機器水平擴展的方式,畢竟工程師比機器貴太多,我已經老了,需要ruby這種元編程強的程式語言讓機器幫我寫程式,c++有元編程,難用難維護且只限於編譯時期,當我沾沾自喜完全通曉model c++ design那套時,地球上早就有ruby這種東西了,甚至於c#也改進很大,當c++礙於潮流失去和c語音相容性的那天也是它死亡那天,但為了和c語言相容它只能放棄很多東西,這是它的宿命,也是它現在還能生存的原因,現在2016年,我和c++一樣垂垂老已

    ReplyDelete