C++ 開發者怒了:這個無用的模塊設計最終會害死 C++!

2018 年年底,C++ 標準委員會歷史上規模最大的一次會議在美國 San Diego 召開,討論了哪些特性要加入到

C++20

中。其中,Modules 便是可能進入 C++ 20 的一大重要特性:“一直以來 C++ 一直通過引用頭文件方式使用庫,而其他90年代以後的語言比如 Java、C#、Go 等語言都是通過 import 包的方式來使用庫。現在 C++ 決定改變這種情況了,在 C++20 中將引入 Modules,它和 Java、Go 等語言的包的概念是類似的,直接通過 import 包來使用庫,再也看不到頭文件了。”

然而就是這一特性,前段時間在 Twitter 上引發了不小的討論。再加上諸多其他問題,“

C++ 20 還未發佈就已涼涼

”的論調也早有苗頭。C++ 模塊化,究竟是問題多多的無用嘗試,還是如期待般能帶來其承諾的性能升級呢?

C++ 開發者怒了:這個無用的模塊設計最終會害死 C++!

作者 | vector-of-bool

譯者 | 蘇本如

出品 | CSDN(ID:CSDNNews)

C++ Modules(模塊化)被視作 C++ 自誕生以來最大的變化,其設計有幾個基本目標:

1. 自頂向下隔離:模塊的“導入程序”不能影響正在導入的模塊的內容。導入源中編譯器(預處理器)的狀態與導入代碼的處理無關。

2. 自下而上隔離:模塊的內容不會影響導入代碼中預處理器的狀態。

3. 橫向隔離:如果兩個模塊由同一個文件導入,則它們之間不會“串擾”。導入語句的順序無關緊要。

4. 物理封裝:只有模塊顯式聲明為導出的實體才會對使用者可見。模塊中未導出的實體不會影響其他模塊中的名稱查找(除了 ADL 可能有一些不同之處【依賴實參的名字查找】,但這就說來話長了)。

5. 模塊化接口:強制任何給定模塊的公共接口在稱為“模塊接口單元”(MIU)的單個 TU 中聲明。模塊接口子集的實現可以在稱為“分區”的不同 TU 中定義。

如果你期望 Modules 可以像 C++ 的許多其它功能一樣經久不衰,那麼你會注意到上面這個列表中缺少了“編譯速度”。然而,這是 C++ Modules 模塊最大的承諾之一。模塊帶來的速度提升可能就是歸功於上面的設計。

下面我列出從 Modules 設計中受益匪淺的 C++ 編譯的幾個方面,按照從最明顯到最不明顯的順序:

1. 標記化緩存(Tokenization Caching):由於 TU 的隔離,當模塊後面導入另一個 TU 時,可以緩存已經標記化的 TU。

2. 解析樹緩存(Parse-tree Caching):和標記化緩存一樣。標記化和解析是 C++ 編譯中開銷最大的操作之一。我自己的測試顯示,對於具有大量預處理輸出的文件,解析可能會佔用高達 30% 的編譯時間。

3. 延遲重編譯(Lazy Re-generation):如果 foo 導入了bar,然後我們修改了 bar 的實現,我們可以不需要對 foo 立即重新編譯。只有對 bar 接口修改後才需要重新編譯 foo。

4. 模板專門化:這一點比較微妙,可能需要更多的工作來實現,但潛在的加速是巨大的。簡而言之,模塊接口單元中出現的類或函數模板在經過專門化處理後可以在磁盤上緩存並供後續需要時加載。

5. 內聯函數代碼複製緩存:內聯函數(包括函數模板和類模板的成員函數)的代碼複製結果可以緩存,然後由編譯器後端重新加載。

6. 內聯函數省略代碼複製:extern template 允許編譯器省略對函數和類模板執行代碼複製,這對編輯器的代碼去重操作非常有益。模塊允許編譯器隱式執行更多的 extern template-style 優化。

看上去模塊設計相當不錯,不是嗎?

但是我們都忽略了一個非常可怕且極為糟糕的缺陷。

還記得…… Fortran 嗎?

FORTRAN 實現了與 C++ 的設計有點相似的模塊系統。幾個月前,SG15 工具研究小組在聖地亞哥提交了一篇文章(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1300r0.pdf),據我所知,這篇文章迄今為止沒有得到任何相關人士的討論和評論。

文章要點摘錄如下:

1. 我們有模塊 foo 和 bar,分別由 foo.cpp 和 bar.cpp 定義。

2. bar.cpp 裡有 import foo; 語句。

3. 在編譯 bar.cpp 時,如何確保 import foo 被解析?當前的設計和實現有一個為 foo 定義的所謂“二進制模塊接口”(簡稱BMI)。這個 BMI 是文件系統中描述模塊 foo 導出接口的文件。我就叫它 foo.bmi, 文件擴展名在這裡無所謂。

4. foo.bmi 是編譯 foo.cpp 的副產品。編譯 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>。/<foo.hpp>

3. 在編譯 bar.cpp 時,如何確保 #include<foo.hpp> 被解析?這很簡單:確保 foo.hpp 存在於 header 搜索路徑列表的目錄中。我們不需要做任何額外的預處理。/<foo.hpp>

4. 對模塊 foo 和 bar 的編譯沒有次序要求,可以並行處理。

並行化可能是提高 build 性能最重要的方面。優化 build 時,你無需再考慮並行化,因為它已經存在了。

模塊改變了這一點。模塊的導入導致了一個編譯時間的依賴項,這在 #include 語句中並沒有體現。(關於模塊編譯的次序問題,可參考: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)一文中探討了這種設計的後果。

劇透一下 Rene 文章的結論:答案是否定的,或者更準確一點來講,這很微妙,但大多數情況下答案仍然是不。這篇文章中使用的當前模塊實現是非常原始的,但仍然在瞭解哪些模塊看上去對性能有幫助這方面有一定的參考價值。可以期待,隨著硬件並行性的提升,header 的引導模塊變得越來越重要,而且與 DAG 深度(即互相導入的模塊鏈的長度)也有關係。隨著 DAG 深度的增加,模塊會越來越慢,而 header 則保持相當穩定,即使是對於接近 300 的“極端”深度。

一個徒勞的掃描任務

假設我有下面的源文件:

import greetings;
import std.iostream;
int main() {
std::cout << greeting::english() << '\n';
}

這很簡單。因為我們導入了一些模塊,所以我們需要先編譯 greetings 和 std.iostream,然後才能編譯這個文件。

那麼,讓我們來……

emmm……

怎麼啦?

我們只有一個包含兩個 import 的源文件,僅此而已,別無他物。我們不知道 greetings 是在哪裡定義的,我們需要找到這個包含 module greetings; 語句的文件。

在銀河系另一側的 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();

}
/<hello.h>

它定義了我們想要的 greeting::english 函數。但是我們怎麼知道這是正確的文件呢?它並沒有 module greetings; 這一行!

但它某些時候確實是我們要的。當我們使用 -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!

好吧好吧……別擔心。我們需要做的就是……運行預處理器來檢查文件中是否出現 module salutations 或 module greetings。

這是可以的,但是有 4201 個文件可以定義可以被導入的模塊,其中任何一個都可能有 module greetings;。

另外,我們還不能使用自己的預處理器實現,需要精確地運行編譯這段代碼的預處理器。看到 __SOME_BUILTIN_MACRO__ 了嗎?我們不知道那是什麼。如果我們沒有正確地對它進行編譯,編譯就會失敗。更糟的是,我們甚至可能會錯誤地編譯此文件。

那麼我們能做什麼呢?我們可以在預處理完所有文件後緩存所有模塊的名稱,對嗎?那麼,我們在哪裡存儲這個映射表呢?當我們想用一個不同的編譯器編譯,生成不同的映射表時會發生什麼?如果我們添加需要掃描的新文件怎麼辦?為了檢查任何模塊是否添加、刪除或重命名了,我們是否需要在每次構建時搜索這些包含了數千個源文件的所有目錄?在那些啟動進程和/或訪問文件需要較大開銷的系統上,這些成本也將會疊加上去。

可能的解決方案

這兩個問題雖然不同,但卻是相關的,我(和許多其他人)認為模塊設計的一個改變可以解決這兩個問題, 那就是模塊接口單元的位置必須是確定的。

有兩種備選方案可以實施:

1. 強制從模塊名稱派生 MIU 文件名。這模擬了頭文件名的設計,它與如何從 #include 指令中找到頭文件名直接相關。

2. 提供一個“manifest”或“mapping”文件,描述基於模塊名的 MIU 文件路徑。此文件需要用戶提供,否則我們將同樣遇到上文描述的掃描問題。

有了確定且易於定義的 MIU lookup(查詢),我們就可以進入下一個必要步驟:必須延遲生成模塊的 BMI。

TU 之間的編譯順序將扼殺 module adoption 的進程。即使是相對較淺的 DAG 深度也比與頭文件相同的深度慢得多。唯一的答案是 TU 編譯必須是可並行的,即使是導入其他 TU 時。

在這方面,C++ 最好模仿 Python 的導入實現:當遇到新的導入語句時,Python 將首先找到對應於該模塊的源文件,然後以確定性的方式查找預編譯的版本。如果預編譯版本已經存在並且是最新的,就使用它;如果不存在預編譯版本,則將編譯源文件,並將生成的字節碼寫入磁盤。然後加載此字節碼。如果兩個解釋器實例同時遇到同一個未編譯的源文件,它們將競爭寫字節碼。不過,競爭並不重要,它們都會得出相同的結論,並將相同的文件寫入磁盤。

為了方便 DAG 中 TU 的並行編譯,C++ 模塊必須以相同的方式實現。提前編譯 BMI 是不可能的。相反,當編譯器第一次遇到有關模塊的 import 語句時,應該延時生成 BMI。Build 系統根本不應該與 BMI 有關。

只有當一個 MIU 的位置對於編譯器是確定的時候,以上這些才能實現。

前景渺茫

前段時間,Twitter 上發生的事讓人心煩意亂。Kona 會議前的郵件列表在 1 月 25 日開放了。在發佈的許多文章中,有一篇《關注模塊的工具能力(Concerns about module toolability)》(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1427r0.pdf),其作者和貢獻者名單中很多是來自業界的系統和工具構建工程師。我想呼籲權威人士的關注,但我覺得這份名單中的人才是最有資格提供 module toolability 反饋的人。

這篇文章的誕生源於許多工具作者和合作者(並不侷限於論文中所提及的,包括我自己)的關注,因為大家都深深感到自己長久以來對於模塊的關注都被忽視了。

SG15 之外的人一直熱衷於反駁關於 module toolability 問題的討論,他們聲稱 SG15 缺乏必要的實現經驗,無法對模塊這個話題提出有用的建議。

SG15 只搞過面對面的會議,上次在聖地亞哥的會議也沒起到什麼作用,因為主席不在,而且大家急急忙忙參會,沒時間進行任何有用的討論。由於在官方的 WG21 會議之外沒有安排 SG15 會議,因此其成員很難保證更新並協同工作。此外,SG15 曾多次嘗試重提已經被拒絕的問題,被拒絕的原因是因為他們提出的問題被認為“超出了 C++ 語言範圍”。

關於 Kona 會議前郵件列表的推文催生了關於 C++ 模塊化的討論:關於 module toolability,該相信誰?(https://twitter.com/horenmar_ctu/status/1089542882783084549)。

這場討論最終以要求 SG15 “他媽的閉嘴”而告終,除非 SG15 能夠提供代碼示例來證明它們所提到的問題。但是這個示例代碼,無法在當前的任何編譯器中實現,也不能在任何當前的構建系統中實現。所以即使這些問題確實存在,這個要求也只能得出一個否定的結論,因為這是一個無法憑經驗完成的任務。也就是說,要求 SG15 提供代碼根本是一個無法永遠完成的任務。

這些問題沒有繼續討論下去,也沒有被推翻。甚至沒有人再提到 《關注模塊的工具能力》中列出的問題。我們只是被簡單地告知要相信一些大人物比我們更瞭解 C++ 模塊(這裡我要再次呼籲權威人士介入)。

支持目前模塊設計的人尚未證明模塊能適應大規模生產環境,但是他們卻要求 SG15 提供模塊不能滿足大規模生產的證據。儘管已有的模塊部署並沒有使用當前的設計,也沒有使用真實環境中構建實際系統所需的自動模塊掃描。

如果模塊被合併,結果發現它們不能以良好的性能和靈活的方式實現,那麼人們就不會使用模塊。如果一個 broken module 建議被合併到 C++ 中,後果可能是不可彌補恢復的,C++ 也將永遠得不到模塊設計承諾帶來的好處。

至於針對當前模塊設計的改進方案能成功解決這些問題呢?我不能給出確定的答案,但我和許多人都認為 C++ Modules 有重大問題需要解決。

然而,從其他人的做法來看,SG15 怎麼想似乎並不重要,他們的提議總是被缺乏 C++ 工具經驗的人否決, 他們在整個討論中沒有任何發言權,提出的任何問題都被認定為“未經證實”和“超出範圍”而不予考慮。

我不太敢指責這種行為的後果,我也並不熱衷“人際衝突”。然而,我更擔心 C++ 這個無用的模塊設計最終會害死自己。

原文:https://vector-of-bool.github.io/2019/01/27/modules-doa.html

本文為 CSDN 翻譯,如需轉載,請註明來源出處。作者獨立觀點,不代表 CSDN 立場。


分享到:


相關文章: