Rust 與 C 的速度比較

語言: CN / TW / HK

關注「 Rust程式設計指北 」,一起學習 Rust,給未來投資

作者 | Kornel

譯者 | Sambodhi

策劃 | 趙鈺瑩

本文最初發表於原作者個人部落格,經原作者 Kornel 授權,InfoQ 中文站翻譯並分享。

使用 Rust 語言編寫的程式,其執行時速度和記憶體使用情況應該和用 C 語言編寫的程式相差不大,但是,由於這些語言的整體程式設計風格不同,所以它們的速度很難一概而論。本文總結了 Rust 和 C 有何相同之處,以及什麼情況 C 更快,什麼情況 Rust 更快。

宣告:本文並非一個客觀的基準,只是揭示了這些語言無可爭辯的事實。這兩種語言理論上能夠實現什麼,以及在實踐中如何使用,存在顯著的差異。這種特別的比較是基於我個人的主觀經驗,包括有交付截止日期、有 Bug,還有懶惰。Rust 語言作為我的主要程式語言已經超過 4 年了,而之前我使用 C 語言也有 10 年之久。在本文中,我專門將 Rust 與 C 進行比較,因為與 C++ 相比,將會有更多的“如果”和“但是”,而我並不想深入討論。

簡而言之:

  • Rust 的抽象是把雙刃劍。它們能夠隱藏不良程式碼,但是也使得改進演算法和利用高度優化的庫更加容易。

  • 我從來沒有擔心過使用 Rust 會陷入效能死衚衕。總是存在一個 不安全 的逃生艙口,可以進行非常低階的優化(而且通常不需要)。

  • 無畏併發(fearless concurrency)確實存在。借用檢查器(borrow checker)偶爾的“笨拙”使並行程式設計變得實用。

我的總體感覺是,如果可以花費無窮無盡的時間和精力,我的 C 程式將和 Rust 一樣快,甚至比 Rust 還快,因為在理論上,沒有什麼是 C 做不到而 Rust 可以做到的。但實際上,C 的抽象較少,標準庫很原始,依賴情況也很糟糕,我真的沒有時間每次都重新“發明輪子”。

Rust 和 C 的相似與不同

兩者都是“可移植彙編器”

Rust 和 C 都給出了對資料結構佈局、整數大小、堆與堆記憶體分配、指標間接定址控制,一般來說,只要編譯器插入一點“魔法”,就可以翻譯成可理解的機器程式碼。Rust 甚至承認,位元組有 8 位,帶符號的整數可能會溢位!

雖然 Rust 具有更高級別的結構,比如迭代器、特性(traits)和智慧指標,但是這些結構被設計成可以預測的優化直接機器程式碼(也就是“零成本抽象”)。Rust 的型別的記憶體佈局很簡單,例如,可增長的字串和向量正是 {byte,capacity,length}。Rust 沒有任何像 move 或 copy 建構函式這樣的概念,因此保證物件的傳遞並不比傳遞指標或 memcpy 複雜。

借用檢查只是編譯時的一種靜態分析。在生成程式碼之前,它什麼也不做,生命週期資訊就被完全剝離了。不存在自動裝箱(autoboxing)之類的聰明做法。

Rust 不是“愚蠢”的程式碼生成器的一個例子是展開(unwinding)。儘管 Rust 不是用異常來處理正常的錯誤,但是 panic(未處理的致命錯誤)可以有選擇地以 C++ 異常的形式出現。可能會在編譯時禁用(panic = abort),但即便如此,Rust 也不喜歡與 C++ 異常或 longjmp 混在一起。

老樣子的 LLVM 後端

由於 Rust 與 LLVM 整合非常好,因此它支援連結時優化(Link-Time Optimization,LTO),包括 ThinLTO,甚至支援跨 C/C++/Rust 語言邊界的內聯,還有配置檔案引導的優化。雖然 rustc 生成的 LLVM IR 比 clang 冗長得多,但是優化器能夠很好地處理。

在使用 GCC 編譯時,我的一些 C 程式碼會比 LLVM 更快一些,而且 GCC 沒有 Rust 前端,而 Rust 沒有做到這一點。

從理論上講,Rust 允許比 C 更好的優化,因為它具有更嚴格的不可變性和別名規則,但是實際上這還沒有發生。對於 LLVM,除 C 外的優化工作正在進行,所以 Rust 還沒有充分發揮出它的潛力。

除少數例外,這兩者都允許手動調優

Rust 程式碼是低階的,而且很容易預測,我可以手動調優它所優化的彙編。Rust 支援 SIMD,能夠很好地控制對內聯、呼叫約定等。Rust 語言與 C 語言很相似,以至於 C 語言的 profiler 分析器通常可以與 Rust 語言一起使用(例如,我可以在一個 Rust-C-Swift 三明治式程式上使用 Xcode 的工具)。

一般來說,在效能絕對關鍵且需要手工優化到最後一點時,優化 Rust 語言與優化 C 語言之間並無太大差別。

有些低階的功能,Rust 並沒有合適的替代:

  • 計算的 goto。goto 的“無聊”用法可以被 loop{break} 等其他 Rust 構造所替代。很多 goto 的用法在 C 語言中是用來清理的,而且由於 RAII/destructors 的存在,Rust 不需要清理。但是,有一個非標準的 goto *addr 擴充套件可以用於直譯器。Rust 不能直接執行(你可以寫一個匹配,並希望它能優化),但是另一方面,如果我需要一個直譯器,我將嘗試使用 Cranelift JIT 來代替。

  • alloca 和 C99 可變長度陣列。它們甚至在 C 語言中也存在爭議,因此 Rust 語言不會使用它們。

Rust 少量開銷

但是,如果 Rust 沒有進行手動調優,則會出現一些低效問題:

  • Rust 缺乏針對 usize 進行索引的隱式型別轉換,這促使使用者僅使用該型別,即使在較小的型別足夠時也是如此。和 C 語言形成鮮明對比的是,32 位的 int 是最受歡迎的選擇。通過  usize 索引在 64 位平臺上更容易優化,無需依賴於未定義的行為,但是額外的位會給暫存器和記憶體帶來更大的壓力。

  • 慣用的 Rust 總是將指標和大小傳遞給字串和切片。在將 C 語言的幾個程式碼庫移植到 Rust 之前,我還沒有意識到有多少 C 語言的函式僅僅使用一個指向記憶體的指標,而沒有任何大小,並且希望得到最佳結果(這些大小可以從上下文中間接地知道,或者僅僅假定它足夠執行該任務)。

  • 並非所有的邊界檢查都得到了優化。 用於 arr 中的 item 或者  arr.iter().for_each(...) 都是儘可能高效的,但是如果  i 的形式在 0..len {arr[i]} 中是必需的,那麼效能就取決於 LLVM 優化器能否證明長度匹配。有時候無法進行,約束檢查就會抑制自動向量化(autovectorization)。有各種變通方法,當然,有安全的,也有不安全的。

  • “聰明”地使用記憶體在 Rust 中不受歡迎。對於 C,任何東西都可以。舉例來說,在 C 語言中,我可能會嘗試將為某個用途而分配的緩衝區再用於其他用途(這種技術叫做 HEARTBLEED)。對於可變大小的資料(如 PATH_MAX),使用固定大小的緩衝區是很方便的,可以避免(重新)分配不斷增長的緩衝區。慣用的 Rust 仍然對記憶體分配有很大的控制權,可以做一些基本的事情,如記憶體池、將多個分配合併為一個,預分配空間等等,但是總體來說,它會引導使用者使用“無聊”的用法或記憶體。

  • 如果借用檢查規則使事情變得困難,那麼一個簡單的解決辦法就是進行額外的複製或者使用引用計數。久而久之,我學到了很多關於借用檢查器的技巧,並且調整了我的編碼風格,使之更適合於借用檢查器,因此這種情況已經很少發生了。它永遠不會成為一個大問題,因為在必要的情況下,總有一個可以回退到“原始”指標。

Rust 的借用檢查器以討厭雙向連結串列而臭名昭著,但幸運的是,連結串列在目前的硬體上的執行非常緩慢(快取區域性性差,而且沒有向量化)。Rust 的標準庫提供了連結串列,以及更快、更適合於借用檢查器的容器可供選擇。

有兩種借用檢查器無法忍受的情況:記憶體對映檔案(來自程序外的神奇變化與引用的不可變性 ^ 排他性語義相沖突)和自引用結構(通過值傳遞結構將內部指標懸空)。這種情況可以通過原始指標解決,就像 C 語言中的每個指標一樣安全,也可以通過心理體操來抽象出這些指標的安全。

在 Rust 中,單執行緒程式只是不作為一個概念存在而已。為了提高效能,Rust 允許使用單個數據結構而忽視執行緒安全,但是任何允許線上程之間共享的東西(包括全域性變數)必須同步,或者標記為 不安全

Rust 的字串支援一些廉價的就地操作,例如 make_ascii_lowercase() (直接與 C 語言中的操作等同),而 .to_lowercase() 的複製不需要使用 Unicode-aware 的方式。說到字串,UTF-8 編碼並不像看上去那麼麻煩,因為字串具有 .as_bytes() 檢視,所以如果需要的話,可以使用 Unicode-ignorant 的方式來處理。

libc 會盡其所能讓 stdoutputc 變得相當快。Rust 的 libstd 沒有這麼神奇,因此除非用 BufWriter 進行包裝,否則不會緩衝 I/O。有些人抱怨說 Rust 比 Python 慢,這是因為 Rust 花了 99% 的時間逐位元組重新整理結果,這與我們所說的完全相同。

可執行檔案的大小

每一種作業系統都會內建一些標準的 C 庫,這些 C 庫是 C 可執行檔案“免費”得到的約 30MB 的程式碼,比如一個小小的“Hello World” C 可執行檔案實際上無法輸出任何內容,它只是呼叫作業系統附帶的 printf 。Rust 不能指望作業系統會內建 Rust 的標準庫,因此 Rust 可執行檔案捆綁了自己的標準庫(300KB 以上)。幸好,這是可以減少的一次性開銷。在嵌入式開發中,標準庫可以關閉,Rust 將生成“裸”程式碼。

Rust 程式碼的大小與 C 語言中每個函式的大小相差不多,但存在“泛型膨脹”(generics bloat)的問題。對於每一種型別,都會有泛型函式經過優化的版本,因此有可能同一個函式最終有 8 個版本,cargo-bloat 工具可以幫助查詢它們。

在 Rust 中使用依賴關係非常簡單。類似於 JS/npm ,也有一種製作小型單用途庫的文化,但它們確實是合二為一。最終,我所有的可執行檔案都包含了 Unicode 規範化表、7 個不同的隨機數生成器,以及一個支援 Brotli 的 HTTP/2 客戶端。在重複資料刪除(deduping)和刪除資料時,cargo-tree 非常有用。

Rust 取得小勝之處

在討論開銷時,我已經討論了許多,但是 Rust 還存在一些地方,它最終更加高效和快速:

  • 為了隱藏實現細節,C 庫經常返回不透明的資料結構指標,並確保結構的每個例項只有一個副本。它會消耗堆分配和指標間接定址的成本。Rust 內建的隱私、單一所有權規則和編碼慣例允許庫暴露其物件,而不需要間接性,這樣,呼叫者可以決定將其放入堆(heap)上還是棧(stack)中。可以主動或徹底地優化棧上的物件。

  • 預設情況下,Rust 可以將來自標準庫、依賴項和其他編譯單元的函式內聯。對於 C 語言,我有時不願意拆分檔案或使用庫,因為這會影響內聯,而且需要對頭和符號可見性進行微觀管理。

  • 對結構體欄位進行重新排序,減少資料結構的填充(padding)。當用 -Wpadding 編譯 C 語言時,會顯示我有多經常忘記這個細節。

  • 字串的大小在它的“胖”指標中進行編碼。這使得長度檢查速度很快,避免了意外的 O(n²) 字串迴圈,並允許就地生成子串(例如將一個字串分割成標記),無需通過修改記憶體或複製來新增 \0 終止符。

  • 與 C++ 模板類似,Rust 也會為它們使用的每個型別生成泛型程式碼的副本,因此像 sort() 這樣的函式和像雜湊表這樣的容器總是針對它們的型別進行優化。對於 C 語言,我必須在修改巨集或者處理 void* 和執行時變數大小的效率較低的函式之間做出選擇。

  • 可以將 Rust 迭代器組合成鏈,作為一個單元進行優化。因此,與呼叫 ibuy(it); use(it); break(it); change(it); mail(upgrade(it)); 一系列可能會多次重寫同一緩衝區呼叫的操作相比,我更願意這樣呼叫  it.buy().use().break().change().upgrade().mail() ,編譯成  buy_use_break_change_mail_upgrade() ,優化後在一次組合傳遞中完成所有這些操作。 (0..1000).map(|x| x*2).sum() 編譯為  返回 999000

  • 另外,還有 讀取 和  寫入 介面,允許函式流式傳輸未緩衝的資料。它們結合得很好,所以我可以把資料寫到一個流中,這個流可以動態地計算資料的 CRC,如果需要,還可以新增 framing/escaping,對資料進行壓縮,然後將資料寫入網路,所有這些都是一次性呼叫。同時,我還可以將這樣的組合流作為輸出流傳遞給我的 HTML 模板引擎,因此現在每個 HTML 標籤都足夠智慧,可以壓縮後傳送。底層機制就像普通的  next_stream.write(bytes) 呼叫的金字塔一樣,所以從技術上講,沒有什麼可以阻止我在 C 語言中做同樣的事情,只是 C 語言中缺乏特性和泛型,這意味著在實際操作中很難做到這一點,而且除了在執行時設定回撥之外,其他的效率都不高。

  • 對於 C 語言來說,過度使用線性搜尋和連結串列是完全合理的,因為誰會去維護又一個半吊子雜湊表的實現呢?由於沒有內建的容器,依賴性非常麻煩,所以我偷工減料去完成任務。我不會去寫一個複雜的 B 樹實現,除非絕對必要。我會用 qsort + bisect,然後收工。在 Rust 中,OTOH 僅需 1 到 2 行程式碼就能實現各種容器,其質量非常高。那就意味著我的 Rust 程式每次都可以使用適當的、難以置信的、經過優化的良好資料結構。

  • 如今似乎一切都需要 JSON。Rust 的 serde 是世界上最快的 JSON 解析器之一,它可以直接解析到 Rust 結構中,因此使用解析後的資料也是非常快速和高效的。

Rust 取得大勝之處

即使是在第三方庫中,Rust 也會強制實現所有程式碼和資料的執行緒安全,哪怕那些程式碼的作者沒有注意執行緒安全。一切都遵循一個特定的執行緒安全保證,或者不允許跨執行緒使用。當我編寫的程式碼不符合執行緒安全時,編譯器會準確地指出不安全之處。

它和 C 語言中的情況完全不同。一般來說,除非庫函式具有明確的文件說明,否則不能相信它們執行緒安全。程式設計師需要確保所有程式碼都是正確的,而編譯器對此通常無能為力。多執行緒化的 C 程式碼有更多的責任和風險,因此假裝多核 CPU 是一種時尚,並且想象使用者有更好的事情可以用剩下的 7 到 15 個核來做,這非常吸引人。

Rust 保證了不受資料爭用和記憶體不安全的影響(例如,釋放後使用(use-after-free)bug,甚至跨執行緒)。並非只有一些爭用可以通過啟發式方法或者工具構建在執行時被發現,而是所有的資料爭用都可以被發現。它是救命稻草,因為資料爭用是並行錯誤中最糟糕的。它們會發生在我使用者的機器上,而不會發生在我的偵錯程式中。也有其他型別的併發錯誤,比如鎖基元使用不當導致更高級別的邏輯爭用條件或死鎖,Rust 無法消除這些錯誤,但它們通常更容易重現和修復。

我不敢用 C 語言在簡單的 for 迴圈上使用更多的 OpenMP 實用程式。我曾試圖更多地在任務和執行緒上冒險,但是結果總是令人遺憾。

Rust 已經有了很多庫,如資料並行、執行緒池、佇列、任務、無鎖資料結構等。有了這類構件的幫助,再加上型別系統強大的安全網,我就可以很輕鬆地並行化 Rust 程式了。有些情況下,用 par_iter() 代替 iter() 是可以的,只要能夠進行編譯,就可以正常工作!這並不總是線性加速( 阿姆達爾定律(Amdahl's law)很殘酷),但往往是相對較少的工作就能加速 2~3 倍。

延伸:阿姆達爾定律,一個電腦科學界的經驗法則,因 Gene Amdahl 而得名。它代表了處理器平行計算之後效率提升的能力。

在記錄執行緒安全方面,Rust 和 C 有一個有趣的不同。Rust 有一個詞彙表用於描述執行緒安全的特定方面,如 Send 和 Sync、guards 和 cell。對於 C 庫,沒有這樣的說法:“可以在一個執行緒上分配它,在另一個執行緒上釋放它,但不能同時從兩個執行緒中使用它”。根據資料型別,Rust 描述了執行緒安全性,它可以泛化到所有使用它們的函式。對於 C 語言來說,執行緒安全只涉及單個函式和配置標誌。Rust 的保證通常是在編譯時提供的,至少是無條件的。對於 C 語言,常見的是“僅當 turboblub 選項設定為 7 時,這才是執行緒安全的”。

總結

Rust 足夠低階,如果有必要,它可以像 C 一樣進行優化,以實現最高效能。抽象層次越高,記憶體管理越方便,可用庫越豐富,Rust 程式程式碼就越多,做的事情越多,但如果不進行控制,可能導致程式膨脹。然而,Rust 程式的優化也很不錯,有時候比 C 語言更好,C 語言適合在逐個位元組逐個指標的級別上編寫最小的程式碼,而 Rust 具有強大的功能,能夠有效地將多個函式甚至整個庫組合在一起。

但是,最大的潛力是可以無畏地並行化大多數 Rust 程式碼,即使等價的 C 程式碼並行化的風險非常高。在這方面,Rust 語言是比 C 語言更為成熟的語言。

作者介紹:

Kornel,程式設計師,專長影象壓縮領域。喜歡閒聊。部落格寫手。

原文連結:

https://kornel.ski/rust-c-speed

推薦閱讀

覺得不錯,點個贊吧

掃碼關注「 Rust程式設計指北