和面向過程的 C 語言相比,其繼承者 C++ 不僅可以進行 C 語言的過程化程序設計,還可以進行以繼承和多態為特點的面向對象的程序設計。要論兩者上手的難易度,對此,有網友評價道,學好 C 只要 1 年,而學好 C++ 需要的可能不止 10 年。然而這麼多年過去了,C++ 卻一直未能取代 C,且在本文中,作者發現自己常用的庫,可以使用到的 C++ 特性越來越少,進而準備向 C 語言過渡。
作者 | Arseny Kapoulkine
譯者 | 姜松浩
責編 | 屠敏
以下為譯文:
我最近經常使用 meshoptimizer(https://github.com/zeux/meshoptimizer) 這個庫,隨著時間的推移,能用到的 C++ 庫的特性越來越少。截至目前,儘管其中仍包含一些 C ++ 特性,但整體上來看,其代碼已與 C 極為相似。
這些變化背後有很多原因,例如刪除 C++ 11 的要求可以確保任何人都能在任何平臺上編譯庫;刪除 std::vector 大大改進了未優化構建的性能;刪除 algorithm 可以提升編譯速度等等。但是,我目前更改的這個代碼庫並沒有完全變成 C 語言的代碼。今天我們來探索這個特定算法,網格簡化器,後面簡稱為 simplifier.cpp,看看這個算法的全部 C ++ 實現的範圍,是否值得一直改進到 C 語言的版本。
方法
這個網格簡化器,是通過多次調整以改善代碼的性能和質量的一種基於邊緣壓縮的二次曲面簡化算法的實現結果。該算法仍處於開發階段,但已投入相當大的努力。細節真的不那麼重要,但它有助於理解結構和大小:
整個算法在一個獨立的 .cpp 文件中實現,該文件幾乎有一千行代碼(撰寫本文時為 1004 行),包括注釋,空行,帶括號的行等。該算法基本上只使用堆分配的數組作為數據結構,並使用原始指針。該算法需要一個自定義實現的哈希表和一個排序例程。我們將看一下實現的幾個變化過程,首先是從使用 C ++ 容器和算法的變化開始,這將有助於該算法,然後一次刪除一個 C ++ 特性並測試編譯速度和運行時的性能。我們使用了三種編譯器,分別是 gcc7.3、clang 6 和 msvc 2017,並將它們運行在 Core i7-8700K 上的 Windows 10 / Ubuntu 16.10 系統中。我們將通過編譯一個 .cpp 文件(debug 使用默認選項,release 使用 -O2)來測量編譯性能,並通過將 buddha.obj (1M 左右的三角形網格)簡化為其大小的 25%,用來測試運行時性能。在我們達到這一狀態之後,我們再來探究將代碼更改為純 C99。
請注意,我完成這些實現的方式是通過獲取現在可以在存儲中看到的代碼,並將其更改為更慣用的 Modern C ++ [1]。但是,這些通常與之前版本的simplifier.cpp非常接近,不同之處在於現在可以直接比較變化。
Baseline:很多 C ++ 代碼
我們開始的版本是來自 current meshoptimizer master 的原始 simplifier.cpp,具有以下修改:
所有原始指針都更改為 std::vector我們使用 std::unordered_set 取代原自定義的哈希表我們使用 std::sort 取代原自定義的排序例程下表是我們得到的結果:
compiler/stl
debug compile
release compile
debug run
release run
gcc
520 ms
646 ms
2273 ms
572 ms
clang
400 ms
684 ms
2356 ms
566 ms
clang libc++
400 ms
725 ms
1535 ms
584 ms
msvc
422 ms
566 ms
36317 ms
579 ms
從表中我們可以看出來這是一個很好的開始。我們可以看到性能在發布運行時非常的穩定,簡化 1M 三角形網格用了 0.6 秒是一個很好的性能水平。通常在調試時或多或少基本都是合理的,除了一個明顯的例外 MSVC(MSVC STL在調試模式下的不良行為是一個強制函數會將從 meshoptimizer 中刪除所有 STL 的使用)。而在編譯時長方面基本都有所不同,但沒有特別奇怪的情況。
為了正確看待編譯時長,Jonathan Blow 最近發布了一個帶有編譯器性能改進的視頻流(video stream with compiler performance improvements),他的遊戲引擎和用他的新語言編寫的遊戲在大約一秒內完成編譯和連結(編譯本身大約需要 0.9 秒)。這是在具有 100K 行的代碼庫上, 而我們的算法只有 1K 行代碼(當然我們的代碼中不包括 STL 雖然排除 STL 並不完全公平,但是加入 STL 計算代碼行也不完全公平,因為我們知道我們的算法完全可以在沒有任何 STL 依賴的情況下用 1K 行代碼來實現)。在編譯代碼時你會注意到 400 毫秒,即使它只有一個文件。而當我處理代碼的時候,這樣的事情會讓我不那麼開心,因為有很多這樣的文件,累積的編譯性能可能會很差。這樣的情況是因為我們的實現對於 STL 依賴非常簡單,我們只使用其中三個算法/容器。讓我們看看當我們停止使用其中一個時會發生什麼。
首先不使用 unordered_set
我們基準測試的先前版本的秘密就在於 unordered_set 從來沒有在那個版本中存在過。雖然 meshoptimizer 最初使用的是 STL 容器和算法,但它從未使用過 std::unordered_set。因為根據以前的經驗,我預計性能不足以滿足我想要編寫的算法類型,但是有一個自定義替代方式就是使用二次探測在一個大的二維數組中實現,這類似於谷歌的 dense_hash_set 設計。它是我通常在不同的代碼庫中為不同的應用程式經常實現和使用的一種哈希表,所以我對它非常熟悉。在 simplifier.cpp 中的實現只有35行代碼 [2],可見這個方式很容易插入並適應手頭的用例。讓我們看看當我們使用它時會發生什麼。
compiler/stl
debug compile
release compile
debug run
release run
gcc
334 ms
461 ms
2054 ms
460 ms
clang
270 ms
517 ms
2152 ms
452 ms
clang libc++
314 ms
609 ms
1179 ms
415 ms
msvc
361 ms
461 ms
28337 ms
380 ms
從結果上看,額外的 35 行用於手動實現的更好的哈希表是值得的。我們在整個版本,調試/發布以及編譯時長和運行時長方面都看到了顯著的性能提升。運行時長性能的最大提升是在 MSVC 編譯器上,我們快了 1.5 倍,事實上哈希表沒有被用作算法的核心部分,它僅僅是用於在算法開始之前建立各個頂點之間的唯一性關係。
測試結果突顯了 std::unordered_set 不適用於起決定性能的重要工作,特別是那些插入量很大的工作負載。不幸的是,這不是一個實現缺陷,因此無法糾正,這個問題是無序容器的標準要求妨礙了更有效的實現。希望最後我們能夠在標準中得到一個更好的哈希表。
高估了精確的排序算法
在開發 simplifier 的過程中,對各種網格的重複分析表明,大量時間都花在了 std::sort 上。現在,std::sort 不是最快的排序算法,但它通常與自定義實現相比極具競爭力,並且在不改變問題的情況下很難被擊敗。在我的例子中,排序用於邊壓縮的數組,排序鍵是一個浮點錯誤值,所以很自然的是使用3遍基數排序,依次使用鍵的11位,11位和10位(浮點值為32位每次使用一部分用於排序)。但是,我們這裡有一個有趣的替代方案,我們可以使用11位的排序鍵僅需進行1次基數排序 [3]。
我們有一個 32 位的非負浮點值會發生什麼;如果我們取前 12 位並忽略最前面的1位(因為第一位是一個符號位且始終為0),我們得到11位代表8位指數和3位尾數,這本質上給了我們一個近似的數值但卻存在一個嚴重的捨入錯誤。如果我們使用此值作為鍵進行排序,那麼排序順序就不會完全按照完整的32位鍵進行排序。然而,在我們的例子中,我們需要排序以便能夠首先更好的基於啟發式算法處理邊壓縮,並且啟發式算法是一個粗略的近似過程,因此我們的排序帶來的額外錯誤並不明顯。這種技術在其他您不一定需要確切順序的領域中非常有用。一次基數排序的好處是它更快(你只需要對數據進行1次排序而不是 3 次!)並且比完整的基數排序更容易實現,只需 36 行代碼 [4]。
compiler/stl
debug compile
release compile
debug run
release run
gcc
287 ms
403 ms
949 ms
334 ms
clang
230 ms
461 ms
962 ms
327 ms
clang libc++
312 ms
546 ms
940 ms
328 ms
msvc
330 ms
430 ms
26824 ms
285 ms
這次編譯時間的增加稍微適度。我們已經刪除了<algorithm> 標題,但它似乎並沒有對編譯時長產生非常顯著的好處,因為我們仍然存在 <vector>,並且可能兩者都提取了一些大 STL 頭文件。但是,對運行性能的影響非常顯著,特別是在 libstdc ++ 中的調試運行性能上(很可能 std::sort在調試中非常慢)此外在發布版本的收效上也同樣令人興奮。從這張圖表的結果中觀察不出來排序算法變得多麼的快,與其他工作相比,它幾乎完全從配置文件中消失,然而整個算法「僅」運行速度快了 1.35 倍。但單純在排序代碼上測試得到的收效更好,在發布版本中從 117 毫秒減少到了 10 毫秒。
再見,std :: vector
有一個數字是我們尚未大幅度調整的,那就是使用 MSVC 在調試代碼所需的時間。雖然很自然的會想到未經優化的構建過程會比優化後的慢,但是優化後的代碼它們必須足夠快才行。有時你希望在有意義的輸入數據集上調試問題。有時你希望運行調試能夠進行全面檢查以通過你的測試,確保在發布版本中它們不會觸發任何可能在隱匿的 bug。有時你試圖調試程序的不同部分,但仍需要運行其餘部分。程式設計師創造性地提出了許多變通方法,使問題不那麼嚴重,例如你可以製作特殊的構建來實現一些優化而不是全部,你也可以對不同的項目使用混合優化設置,你可以使用 #pragma optimize 這樣的指令來暫時禁用有問題的部分的代碼的優化,但所有的這些看起來像是臨時使用的補丁。讓我們嘗試用一個非常簡單的動態數組替換我們仍然使用的唯一 STL 組件 std::vector。我們的代碼中不需要 resize 或 push_back,所有數組都使用正確的大小進行初始化。我們的要求足夠低,我們這種 std::vector 的替代方式僅僅需要 40行代碼 [5],並且主要由 operator[] 定義組成。
compiler/stl
debug compile
release compile
debug run
release run
gcc
158 ms
303 ms
980 ms
318 ms
clang
138 ms
320 ms
1021 ms
297 ms
clang libc++
142 ms
324 ms
1028 ms
299 ms
msvc
156 ms
219 ms
3482 ms
265 ms
上表中的結果相當的有趣。通過用我們自己的類型替換 std::vector ,我們不僅顯著提高了 MSVC 的調試性能,而且還減半了我們測試使用的幾個編譯器的編譯時間。gcc / clang 中的調試運行性能有點退步,我相信這是因為我的替換代碼中使用 assert 來對每個 operator[] 訪問執行邊界檢查,而在 libc ++ 和 libstdc ++ 中,它們分別使用 _GLIBCXX_ASSERTIONS 和_LIBCPP_DEBUG 單獨定義來完成邊界檢查控制的。為 std::vector 的變量啟用這些定義會將兩個庫的調試運行時長提高到大約 1350 ms [6],因此在啟用類似功能時,我們的替換代碼運行速度會更快。
發布的性能整體來看也略有提高,這是因為對於我們代碼中的許多數組而言,std::vector 的構造函數執行的默認初始化是多餘的,因為我們無論如何都要填充數組。當然,使用 std::vector,你也可以 resize 那些大數組的大小,然後計算條目(這需要對每個條目進行冗餘的默認初始化),或者 reserve 和 push_back(這需要更多的代碼來每個條目進行添加,而這個花銷是累加起來的)。與之相反的是,使用自定義容器,可以輕鬆地選擇跳過初始化。實際上,在我們的替換代碼中這是唯一的選項,因為如果需要,可以很輕鬆的手動添加 memset 設置數組大小。
又回到了之前
帶有邊界檢查的 operator[] 的自定義容器大部分都是成功的,但它並不能讓我滿意。在某些算法中,容器的額外成本仍然非常龐大。在一些算法中,內部函數將使用原始指針來最佳化發布的運行性能,這意味著無論如何都不會執行邊界檢查。此外,算法輸入使用原始指針,需要仔細處理。由於在許多關鍵位置使用了原始指針,我會使用 Address Sanitizer 作為 CI 管道的一部分運行構建,偶爾也會在本地運行嘗試,因此我對缺少越界訪問感到安全。在沒有自定義可視化工具的情況下,調試器將無法顯示數組,更關鍵的是,在評估成員訪問權限時會遇到問題(這在 std::vector 中也是如此,因為這取決於調試器),這使得查看表達式更加複雜,調試也不那麼愉快。現狀是既沒有提供完美的安全性,也沒有提供完美的性能,因此我決定嘗試使用原始指針。
當然,容器的另一個好處是對內存洩漏的額外保護,由於我不是特別熱衷於記住釋放每個分配的指針,所以我創建了一個 meshopt_Allocator 類[7]。這個類可以分配大塊的類型數據並且會記住分配的每一個指針,在運行末尾階段,它將會刪除所有已分配的塊。這導致融合的分配器+數組的類被拆分為兩部分,一個是特殊的分配器類用於完成了內存管理任務,而對於數組而言一個原始指針就足夠了。Address Sanitizer,以及嚴格的測試和手動填寫的斷言聲明,這些將保持代碼正確。
compiler/stl
debug compile
release compile
debug run
release run
gcc
147 ms
260 ms
720 ms
320 ms
clang
132 ms
294 ms
699 ms
301 ms
clang libc++
131 ms
297 ms
697 ms
300 ms
msvc
141 ms
194 ms
1080 ms
261 ms
雖然我對這種權衡並不是百分之百滿意,但到目前為止它仍然運行良好。刪除與確定每個函數中是否應該使用原始指針、迭代器或容器相關的檢測的開銷是很有必要的。值得注意的是,使用 Address Sanitizer 構建的開銷是非常合理的,並且使用它會讓我感覺更安全,因為它會捕獲容器中的問題邊界檢查的超集。
compiler/sanitizer
compile
run
gcc
147 ms
721 ms
gcc asan
200 ms
1229 ms
gcc asan ubsan
260 ms
1532 ms
clang
135 ms
695 ms
clang asan
154 ms
1266 ms
clang asan ubsan
180 ms
1992 ms
讓我們來試試改成 C
一旦我們切換到原始指針,我們的代碼中 C ++ 就剩不下多少了。偶爾還有一兩個模板(template),但實例化的數量足夠小,這樣我們可以僅為我們需要的每種類型複製代碼。meshoptimizer 使用了 C ++ 中的指針類型強制轉換和函數調用方式的強制轉換(例如int(v)),但 C 語言沒有這兩種強制轉化的方式,所以必須對代碼進行相應的調整。同樣,我們還遇到了一些其他的語法問題,但實際上在這一方面將代碼更改為 C 語言的版本並不難。這樣做的確需要更多的犧牲,還有就是 MSVC 的問題,要麼我們必須使用 C89,要麼將我們的 C99 代碼編譯為 C ++,除非我們願意只支持最新的 MSVC 版本,但這樣做確實是可行的。在我們停止使用每個 C ++ 標準頭之後,這些真的重要嗎?
compiler/stl
debug compile
release compile
debug run
release run
gcc
105 ms
209 ms
710 ms
321 ms
clang
95 ms
254 ms
711 ms
310 ms
msvc c++
139 ms
192 ms
1087 ms
262 ms
msvc c99
125 ms
180 ms
1085 ms
261 ms
對 gcc / clang 編譯時間有顯著影響,我們通過將代碼切換到 C 語言之後可以節省大約 40 ms。此時的真正區別在於標準頭文件上。例如simplifier.cpp 使用的 math.h 這個頭文件在 C ++模式下與 C 模式下相比實際上大了不少,一旦默認編譯模式設置為 C ++ 17 時,這種差異將會增加得更多:
compiler
c99
c++98
c++11
c++14
c++17
gcc
105 ms
143 ms
147 ms
147 ms
214 ms
clang
95 ms
129 ms
133 ms
134 ms
215 ms
clang libc++
95 ms
130 ms
132 ms
136 ms
140 ms
問題是 math.h 在 gcc / clang 編譯時會包含 cmath,cmath 會帶來很多 C ++ 機制進而增加運行成本,而在 libstdc ++ 中的 C ++ 17 中則會添加一連串新的特殊函數,這些函數卻很少有用,但無論如何都會使編譯速度變慢。在這種情況下,刪除對 math.h 的依賴很容易[8]:
#ifdef __GNUC__#define fabsf(x) __builtin_fabsf(x)#define sqrtf(x) __builtin_sqrtf(x)#else#include<math.h>#endif
就是上述這樣的方式一直到改成 C 語言版本的編譯時間。這絕對是 libstdc++ 和 libc++ 未來可以改進的領域。我認為對使用 C 的頭文件的而言,承擔 C++ 的包帶來的成本是不合理的。除了 math.h 問題之外,假設在編譯時間中 C 語言的代碼有意識的用到了 C++ 的子集,這樣的結果看起來 C 語言版本的代碼的編譯時間並不比 C++ 版本的快,所以這種情況的時候切換到 C 語言也不能保證 meshoptimizer 會更快。
結論
希望通過 simplifier.cpp 中的過去、現在和未來可能的變化進行探索是有用的。在製作 C / C ++ 庫時,重要的是要注意不僅僅只有代碼的正確性,而可移植性、編譯的簡易性、編譯時間、在調試和發布時的運行時間、可調試性等等,所有的這些都很重要,這些有助於減少庫和代碼貢獻者之間的衝突。C ++ 是一種無情的語言,但是,如果有足夠的時間和精力,就可以獲得良好的表現。前提是你願意質疑一切,甚至包括有時被認為是極其常見的方法,例如 STL 或 CRT 的有效性或效率。
我們在調試模式下在 gcc 中用了半秒的編譯時間,在 MSVC 中用了 36s 的運行時間,並以 gcc 的 100ms 編譯時間和 MSVC 上大約一秒的運行時間結束,這種方式使用起來更加愉快。當然,在 1K 行編譯 100ms 的前提下,並假設是線性關係,每 10K 行我們大約需要一整秒,這樣的結果仍然比其他一些語言慢得多,但這對於在單核上運行的完整構建來說並非完全不合理。為開發多年的大型代碼庫提供服務是一個更難的問題,這些將留給讀者作為練習了;)
點擊此連結(https://gist.github.com/zeux/bf847986e0474cf48f61bb5749da38e4)可以獲得對 simplifier.cpp 的所有源修改;按照文章中描述的順序,它們依次是simplifiervsm.cpp、simplifiervs.cpp、simplifierv.cpp、simplifierb.cpp、simplifier.cpp、simplifier.c。
註:
[1]:去年,在工作中討論了 C ++,有人說「這是一個很好的 C++ 子集,一個擁有類的 C 語言」,我回答說「有一個更好的子集,擁有結構的 C 語言」。這就是大多數 meshoptimizer 原始碼的樣子,除了幾個模板。
[2]:哈希表接口只有兩個函數,hashBuckets和hashLookup:simplifier.cpp:124 。
[3]:使用 11 位是一個合理的選擇,因為它需要一個 2048 條目的直方圖,它需要 8 KB 並且可以輕鬆地適應 16 KB 的 L1 緩存。給定 32 KB L1 高速緩存,你可以將直方圖擴展到 12 位,但超出此範圍通常效率較低。你可以在 Pierre Terdiman 的 Radix Sort Revisited 文章中閱讀有關基數排序的更多信息(http://www.codercorner.com/RadixSortRevisited.htm)。
[4]:sortEdgeCollapses 函數的完整實現可在此處獲得: simplifier.cpp:712(https://github.com/zeux/meshoptimizer/blob/c93ba0987baa84bd73b61edf1c0ba7ba2e48df4b/src/simplifier.cpp#L712)。
[5]:這個類不再是 meshoptimizer 的一部分了,但你可以在這裡查看較舊的稍長版本:meshoptimizer.h:605(https://github.com/zeux/meshoptimizer/blob/5b0d10bb3c0c174965b716dda3270bce4f3278b6/src/meshoptimizer.h#L605)。
[6]:我在調查了調試中的奇怪性能差異後發現了這一點;我不願重複所有先前測試用例的調試基準,所以我假設開銷是 std::vector 導致的額外的大約 30%。希望這不會改變一般情況。我不確定為什麼默認情況下這些斷言沒有首先啟用,這似乎不是用戶友好的,但這應該反映了使用這些庫的默認體驗。
[7]:所有 meshoptimizer 中的算法都使用了此類,可在此處獲得: meshoptimizer.h:662(https://github.com/zeux/meshoptimizer/blob/c93ba0987baa84bd73b61edf1c0ba7ba2e48df4b/src/meshoptimizer.h#L662)。
[8]:這感覺就像補丁,而且我必須獨立地應用於多個源文件,所以現在我選擇不這樣做。但是,如果在修復此問題之前 C ++ 17 模式成為默認模式,我將不得不重新考慮,因為 2x 編譯時間損失有點太大了。
原文:https://zeuxcg.org/2019/01/17/is-c-fast本文為 CSDN 翻譯,如需轉載,請註明來源出處。