2018 年年底,C++ 標準委員會歷史上規(guī)模最大的一次會議在美國 San Diego 召開,討論了哪些特性要加入到 C++20 中。其中,Modules 便是可能進入 C++ 20 的一大重要特性:
“一直以來 C++ 一直通過引用頭文件方式使用庫,而其他90年代以后的語言比如 Java、C#、Go 等語言都是通過 import 包的方式來使用庫?,F(xiàn)在 C++ 決定改變這種情況了,在 C++20 中將引入 Modules,它和 Java、Go 等語言的包的概念是類似的,直接通過 import 包來使用庫,再也看不到頭文件了。”
然而就是這一特性,前段時間在 Twitter 上引發(fā)了不小的討論。再加上諸多其他問題,“C++ 20 還未發(fā)布就已涼涼”的論調(diào)也早有苗頭。C++ 模塊化,究竟是問題多多的無用嘗試,還是如期待般能帶來其承諾的性能升級呢?
作者 | vector-of-bool
譯者 | 蘇本如
責(zé)編 | 仲培藝
出品 | CSDN(ID:CSDNNews)
以下為譯文:
C++ Modules(模塊化)被視作 C++ 自誕生以來最大的變化,其設(shè)計有幾個基本目標:
1. 自頂向下隔離:模塊的“導(dǎo)入程序”不能影響正在導(dǎo)入的模塊的內(nèi)容。導(dǎo)入源中編譯器(預(yù)處理器)的狀態(tài)與導(dǎo)入代碼的處理無關(guān)。
2. 自下而上隔離:模塊的內(nèi)容不會影響導(dǎo)入代碼中預(yù)處理器的狀態(tài)。
3. 橫向隔離:如果兩個模塊由同一個文件導(dǎo)入,則它們之間不會“串?dāng)_”。導(dǎo)入語句的順序無關(guān)緊要。
4. 物理封裝:只有模塊顯式聲明為導(dǎo)出的實體才會對使用者可見。模塊中未導(dǎo)出的實體不會影響其他模塊中的名稱查找(除了 ADL 可能有一些不同之處【依賴實參的名字查找】,但這就說來話長了)。
5. 模塊化接口:強制任何給定模塊的公共接口在稱為“模塊接口單元”(MIU)的單個 TU 中聲明。模塊接口子集的實現(xiàn)可以在稱為“分區(qū)”的不同 TU 中定義。
如果你期望 Modules 可以像 C++ 的許多其它功能一樣經(jīng)久不衰,那么你會注意到上面這個列表中缺少了“編譯速度”。然而,這是 C++ Modules 模塊最大的承諾之一。模塊帶來的速度提升可能就是歸功于上面的設(shè)計。
下面我列出從 Modules 設(shè)計中受益匪淺的 C++ 編譯的幾個方面,按照從最明顯到最不明顯的順序:
1. 標記化緩存(Tokenization Caching):由于 TU 的隔離,當(dāng)模塊后面導(dǎo)入另一個 TU 時,可以緩存已經(jīng)標記化的 TU。
2. 解析樹緩存(Parse-tree Caching):和標記化緩存一樣。標記化和解析是 C++ 編譯中開銷最大的操作之一。我自己的測試顯示,對于具有大量預(yù)處理輸出的文件,解析可能會占用高達 30% 的編譯時間。
3. 延遲重編譯(Lazy Re-generation):如果 foo 導(dǎo)入了bar,然后我們修改了 bar 的實現(xiàn),我們可以不需要對 foo 立即重新編譯。只有對 bar 接口修改后才需要重新編譯 foo。
4. 模板專門化:這一點比較微妙,可能需要更多的工作來實現(xiàn),但潛在的加速是巨大的。簡而言之,模塊接口單元中出現(xiàn)的類或函數(shù)模板在經(jīng)過專門化處理后可以在磁盤上緩存并供后續(xù)需要時加載。
5. 內(nèi)聯(lián)函數(shù)代碼復(fù)制緩存:內(nèi)聯(lián)函數(shù)(包括函數(shù)模板和類模板的成員函數(shù))的代碼復(fù)制結(jié)果可以緩存,然后由編譯器后端重新加載。
6. 內(nèi)聯(lián)函數(shù)省略代碼復(fù)制:extern template 允許編譯器省略對函數(shù)和類模板執(zhí)行代碼復(fù)制,這對編輯器的代碼去重操作非常有益。模塊允許編譯器隱式執(zhí)行更多的 extern template-style 優(yōu)化。
看上去模塊設(shè)計相當(dāng)不錯,不是嗎?
但是我們都忽略了一個非??膳虑覙O為糟糕的缺陷。
還記得…… Fortran 嗎?
FORTRAN 實現(xiàn)了與 C++ 的設(shè)計有點相似的模塊系統(tǒng)。幾個月前,SG15 工具研究小組在圣地亞哥提交了一篇文章(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1300r0.pdf),據(jù)我所知,這篇文章迄今為止沒有得到任何相關(guān)人士的討論和評論。
文章要點摘錄如下:
1. 我們有模塊 foo 和 bar,分別由 foo.cpp 和 bar.cpp 定義。
2. bar.cpp 里有 import foo; 語句。
3. 在編譯 bar.cpp 時,如何確保 import foo 被解析?當(dāng)前的設(shè)計和實現(xiàn)有一個為 foo 定義的所謂“二進制模塊接口”(簡稱BMI)。這個 BMI 是文件系統(tǒng)中描述模塊 foo 導(dǎo)出接口的文件。我就叫它 foo.bmi, 文件擴展名在這里無所謂。
4. foo.bmi 是編譯 foo.cpp 的副產(chǎn)品。編譯 foo.cpp 時,編譯器將生成 foo.o 和 foo.bmi。因此,必須在 bar.cpp 之前編譯 foo.cpp!
趁著警鈴還沒有拉響,我們來討論一下我們目前使用頭文件的工作方式:
1. 我們有一個模塊 foo,由 foo.cpp 和 foo.hpp 定義; 和另一個模塊 bar,由 bar.cpp 和 bar.hpp 定義。
2. bar.cpp 中有 #include <foo.hpp>。
3. 在編譯 bar.cpp 時,如何確保 #include<foo.hpp> 被解析?這很簡單:確保 foo.hpp 存在于 header 搜索路徑列表的目錄中。我們不需要做任何額外的預(yù)處理。
4. 對模塊 foo 和 bar 的編譯沒有次序要求,可以并行處理。
并行化可能是提高 build 性能最重要的方面。優(yōu)化 build 時,你無需再考慮并行化,因為它已經(jīng)存在了。
模塊改變了這一點。模塊的導(dǎo)入導(dǎo)致了一個編譯時間的依賴項,這在 #include 語句中并沒有體現(xiàn)。(關(guān)于模塊編譯的次序問題,可參考:https://vector-of-bool.github.io/2018/12/20/build-like-ninja-1.html)。
Rene Rivera 最近在《Are modules fast?》(https://bfgroup.github.io/cpp_tooling_stats/modules/modules_perf_D1441R1.html)一文中探討了這種設(shè)計的后果。
劇透一下 Rene 文章的結(jié)論:答案是否定的,或者更準確一點來講,這很微妙,但大多數(shù)情況下答案仍然是不。這篇文章中使用的當(dāng)前模塊實現(xiàn)是非常原始的,但仍然在了解哪些模塊看上去對性能有幫助這方面有一定的參考價值??梢云诖S著硬件并行性的提升,header 的引導(dǎo)模塊變得越來越重要,而且與 DAG 深度(即互相導(dǎo)入的模塊鏈的長度)也有關(guān)系。隨著 DAG 深度的增加,模塊會越來越慢,而 header 則保持相當(dāng)穩(wěn)定,即使是對于接近 300 的“極端”深度。
一個徒勞的掃描任務(wù)
假設(shè)我有下面的源文件:
import greetings;
import std.iostream;
int main() {
std::cout << greeting::english() << '\n';
}
這很簡單。因為我們導(dǎo)入了一些模塊,所以我們需要先編譯 greetings 和 std.iostream,然后才能編譯這個文件。
那么,讓我們來……
emmm……
怎么啦?
我們只有一個包含兩個 import 的源文件,僅此而已,別無他物。我們不知道 greetings 是在哪里定義的,我們需要找到這個包含 module greetings; 語句的文件。
在銀河系另一側(cè)的 talk.cpp 文件看起來很可能是:
module;
#ifdef FROMBULATE
#include <hello.h>
#endif
#ifndef ABSYNTH
export module something.pie;
#endif
import std.string;
export namespace greeting {
std::string english();
}
它定義了我們想要的 greeting::english 函數(shù)。但是我們怎么知道這是正確的文件呢?它并沒有 module greetings; 這一行!
但它某些時候確實是我們要的。當(dāng)我們使用 -DFROMBULATE 編譯時,文件 hello.h 會被粘貼到源文件中。讓我們看看 hello.h 里面有什么?
#ifdef __SOME_BUILTIN_MACRO__
# define MODULE_NAME greetings
#else // Legacy module name
# define MODULE_NAME salutations
#endif
export module MODULE_NAME;
Oh no!
好吧好吧……別擔(dān)心。我們需要做的就是……運行預(yù)處理器來檢查文件中是否出現(xiàn) module salutations 或 module greetings。
這是可以的,但是有 4201 個文件可以定義可以被導(dǎo)入的模塊,其中任何一個都可能有 module greetings;。
另外,我們還不能使用自己的預(yù)處理器實現(xiàn),需要精確地運行編譯這段代碼的預(yù)處理器??吹?__SOME_BUILTIN_MACRO__ 了嗎?我們不知道那是什么。如果我們沒有正確地對它進行編譯,編譯就會失敗。更糟的是,我們甚至可能會錯誤地編譯此文件。
那么我們能做什么呢?我們可以在預(yù)處理完所有文件后緩存所有模塊的名稱,對嗎?那么,我們在哪里存儲這個映射表呢?當(dāng)我們想用一個不同的編譯器編譯,生成不同的映射表時會發(fā)生什么?如果我們添加需要掃描的新文件怎么辦?為了檢查任何模塊是否添加、刪除或重命名了,我們是否需要在每次構(gòu)建時搜索這些包含了數(shù)千個源文件的所有目錄?在那些啟動進程和/或訪問文件需要較大開銷的系統(tǒng)上,這些成本也將會疊加上去。
可能的解決方案
這兩個問題雖然不同,但卻是相關(guān)的,我(和許多其他人)認為模塊設(shè)計的一個改變可以解決這兩個問題, 那就是模塊接口單元的位置必須是確定的。
有兩種備選方案可以實施:
1. 強制從模塊名稱派生 MIU 文件名。這模擬了頭文件名的設(shè)計,它與如何從 #include 指令中找到頭文件名直接相關(guān)。
2. 提供一個“manifest”或“mapping”文件,描述基于模塊名的 MIU 文件路徑。此文件需要用戶提供,否則我們將同樣遇到上文描述的掃描問題。
有了確定且易于定義的 MIU lookup(查詢),我們就可以進入下一個必要步驟:必須延遲生成模塊的 BMI。
TU 之間的編譯順序?qū)⒍髿?module adoption 的進程。即使是相對較淺的 DAG 深度也比與頭文件相同的深度慢得多。唯一的答案是 TU 編譯必須是可并行的,即使是導(dǎo)入其他 TU 時。
在這方面,C++ 最好模仿 Python 的導(dǎo)入實現(xiàn):當(dāng)遇到新的導(dǎo)入語句時,Python 將首先找到對應(yīng)于該模塊的源文件,然后以確定性的方式查找預(yù)編譯的版本。如果預(yù)編譯版本已經(jīng)存在并且是最新的,就使用它;如果不存在預(yù)編譯版本,則將編譯源文件,并將生成的字節(jié)碼寫入磁盤。然后加載此字節(jié)碼。如果兩個解釋器實例同時遇到同一個未編譯的源文件,它們將競爭寫字節(jié)碼。不過,競爭并不重要,它們都會得出相同的結(jié)論,并將相同的文件寫入磁盤。
為了方便 DAG 中 TU 的并行編譯,C++ 模塊必須以相同的方式實現(xiàn)。提前編譯 BMI 是不可能的。相反,當(dāng)編譯器第一次遇到有關(guān)模塊的 import 語句時,應(yīng)該延時生成 BMI。Build 系統(tǒng)根本不應(yīng)該與 BMI 有關(guān)。
只有當(dāng)一個 MIU 的位置對于編譯器是確定的時候,以上這些才能實現(xiàn)。
前景渺茫
前段時間,Twitter 上發(fā)生的事讓人心煩意亂。Kona 會議前的郵件列表在 1 月 25 日開放了。在發(fā)布的許多文章中,有一篇《關(guān)注模塊的工具能力(Concerns about module toolability)》(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1427r0.pdf),其作者和貢獻者名單中很多是來自業(yè)界的系統(tǒng)和工具構(gòu)建工程師。我想呼吁權(quán)威人士的關(guān)注,但我覺得這份名單中的人才是最有資格提供 module toolability 反饋的人。
這篇文章的誕生源于許多工具作者和合作者(并不局限于論文中所提及的,包括我自己)的關(guān)注,因為大家都深深感到自己長久以來對于模塊的關(guān)注都被忽視了。
SG15 之外的人一直熱衷于反駁關(guān)于 module toolability 問題的討論,他們聲稱 SG15 缺乏必要的實現(xiàn)經(jīng)驗,無法對模塊這個話題提出有用的建議。
SG15 只搞過面對面的會議,上次在圣地亞哥的會議也沒起到什么作用,因為主席不在,而且大家急急忙忙參會,沒時間進行任何有用的討論。由于在官方的 WG21 會議之外沒有安排 SG15 會議,因此其成員很難保證更新并協(xié)同工作。此外,SG15 曾多次嘗試重提已經(jīng)被拒絕的問題,被拒絕的原因是因為他們提出的問題被認為“超出了 C++ 語言范圍”。
關(guān)于 Kona 會議前郵件列表的推文催生了關(guān)于 C++ 模塊化的討論:關(guān)于 module toolability,該相信誰?(https://twitter.com/horenmar_ctu/status/1089542882783084549)。
這場討論最終以要求 SG15 “他媽的閉嘴”而告終,除非 SG15 能夠提供代碼示例來證明它們所提到的問題。但是這個示例代碼,無法在當(dāng)前的任何編譯器中實現(xiàn),也不能在任何當(dāng)前的構(gòu)建系統(tǒng)中實現(xiàn)。所以即使這些問題確實存在,這個要求也只能得出一個否定的結(jié)論,因為這是一個無法憑經(jīng)驗完成的任務(wù)。也就是說,要求 SG15 提供代碼根本是一個無法永遠完成的任務(wù)。
這些問題沒有繼續(xù)討論下去,也沒有被推翻。甚至沒有人再提到 《關(guān)注模塊的工具能力》中列出的問題。我們只是被簡單地告知要相信一些大人物比我們更了解 C++ 模塊(這里我要再次呼吁權(quán)威人士介入)。
支持目前模塊設(shè)計的人尚未證明模塊能適應(yīng)大規(guī)模生產(chǎn)環(huán)境,但是他們卻要求 SG15 提供模塊不能滿足大規(guī)模生產(chǎn)的證據(jù)。盡管已有的模塊部署并沒有使用當(dāng)前的設(shè)計,也沒有使用真實環(huán)境中構(gòu)建實際系統(tǒng)所需的自動模塊掃描。
如果模塊被合并,結(jié)果發(fā)現(xiàn)它們不能以良好的性能和靈活的方式實現(xiàn),那么人們就不會使用模塊。如果一個 broken module 建議被合并到 C++ 中,后果可能是不可彌補恢復(fù)的,C++ 也將永遠得不到模塊設(shè)計承諾帶來的好處。
至于針對當(dāng)前模塊設(shè)計的改進方案能成功解決這些問題呢?我不能給出確定的答案,但我和許多人都認為 C++ Modules 有重大問題需要解決。
然而,從其他人的做法來看,SG15 怎么想似乎并不重要,他們的提議總是被缺乏 C++ 工具經(jīng)驗的人否決, 他們在整個討論中沒有任何發(fā)言權(quán),提出的任何問題都被認定為“未經(jīng)證實”和“超出范圍”而不予考慮。
我不太敢指責(zé)這種行為的后果,我也并不熱衷“人際沖突”。然而,我更擔(dān)心 C++ 這個無用的模塊設(shè)計最終會害死自己。
原文:https://vector-of-bool.github.io/2019/01/27/modules-doa.html