Rust vs. C++:性能大比拼

netwan 8年前發布 | 11K 次閱讀 Rust C/C++

如果 Rust 要做 C++ 做的工作,我們需要知道 Rust 會把 C++ 最擅長的工作做成什么樣子。什么是快,什么是慢? 什么更難做,什么更容易? 我不知道該如何回答這些問題,但我可以編寫程序來尋求答案。

我有一個 C ++ 程序,其長度用來實驗正好合適——一個打印的頁面長度,并且使用不熟悉的語言重寫也不會有什么棘手的問題。(它生成由 Frank Longo 設計的名字為“拼寫蜜蜂”的拼圖游戲的所有可能的方案,我是在“紐約時報”雜志發現的。)我在 Rust 上使用等效的代碼直接重寫了該程序。Rust 程序的長度與之前 C++ 的接近,但運行效率只有原來的一半。因為我使用了更加規范的 Rust 代碼管理,它運行得更快。同時,我努力加快 C++ 程序執行速度,仍然保持原來的代碼長度一頁限制。每次更改后,我都會檢查下性能。很少有程序得到這么多的優化關注。

C ++ 版本現在運行的速度是我開始時的三倍;我認為在不改變增加其長度、不考慮并行或者不使用第三方庫的情況下,這基本達到在性能最佳的情況。在現代硬件上 90ms 內,它執行大約 1.9 億次基本操作(每次迭代的 1 個時鐘周期),過濾高達 500 萬次更復雜的操作(每位占用 28 個時鐘周期)。同時,Rust 程序大致在相同的時間執行相同的操作:在不同硬件上只有幾個百分比的更快或更慢差距。許多變量顯示似乎他們應該運行速度相同或更快但結果卻是速度更慢,通常是慢得多。相比之下,在C++ 中很難發現表達相同操作的不同方式,并獲得不同的運行時間。

下面,我展示下每個程序的片段。代碼可能比你之前常用的更密集,只是為了保持它長度在一個打印頁內。當我在下文中使用“慢得多”,它可能意味著 1.3x 到 2x 之間,而不是在系統編程外的意義上的數量級。忽略這兩種語言的巨大差距:C++ 的模板、析構函數、futures、lambdas;Rust 的通道、線程、traits、單元格、生命周期、借用、模塊、宏。這些都是使用語言的關鍵,但這個項目是更關注編碼的本身。

來看程序。首先是依賴項:頭文件,模塊。

C++:

#include <iostream>
#include <fstream>
#include <vector>
#include <iterator>
#include <string>
#include <algorithm>
#include <bitset>

和 Rust:

use std::io::prelude::*;
use std::{fs, io, env, process};

這里 Rust 贏了。Rust 提供大量默認的標準庫。上面的代碼中,就第一行,就 use 了一堆模塊。Rust 的模塊系統非常典型;任何未來的語言都不能忽視這種模塊系統。

然后是參數和處理輸入文件。

C++:

int main(int argc, char** argv) {
    std::string name = (argc > 1) ? argv[1] : "/usr/share/dict/words";
    std::ifstream fs;
    std::istream& file = (name == "-") ? std::cin : (fs.open(name), fs);
    if (!file)
        return std::cerr << "file open failed: \"" << name << "\"\n", 1;

和 Rust:

fn main() {
    let fname = &*env::args().nth(1).unwrap_or("/usr/share/dict/words".into());
    let stdin = io::stdin();
    let file: Box<Read> = match fname {
        "-" => Box::new(stdin.lock()),
        _ => Box::new(fs::File::open(fname).unwrap_or_else(|err| {
                 writeln!(io::stderr(), "{}: \"{}\"", err, fname).unwrap();
                 process::exit(1)
             }))
    };

在這一點上 C++ 獲勝。用 Rust 來做這些事情需要更多代碼。多數使用 Rust 的人都會讓這種簡單的程序,因為“恐慌”而報告用法錯誤,雖然這樣產生的輸出非常難看。使用 Rust 很難放過一個 I/O 錯誤,只要這不會讓人們習慣忽視之些錯誤,就不算壞事;在 Rust 中忽略錯誤需要很多工作。

兩個程序都從命令行獲得一個可選的文件名參數,并從 stdin 讀取數據,用于測試轉換。在標準的 Linux 系統中,words 文件保存著真實的英文單詞列表,包含合適的名稱、縮寫和需要過濾掉的短小單詞。注意這里使用 Box 擦除類型,因此可以使用 io::stdin 來代替 fs:File 句柄。奇特的 &* 用于提取隱藏在(Option -已包裝)由 nth() 產生的 String 中的字符序列,因此 match 有一些能直接與文本字符串 "-" 比較的東西。

我不介意鎖定 io::stdin 來獲得更快的輸入,但得在另一個語句中要求調用 lock() 是件奇怪的事情。

數據結構和輸入設置如下,同時包括輸入循環的開始部分:

C++:

std::vector<unsigned> sevens; sevens.reserve(1<<14);
std::vector<unsigned> words; words.reserve(1<<15);
std::bitset<32> word; int len = 0; int ones = 0;
for (std::istreambuf_iterator<char> in(file), eof; in != eof; ++in) {

Rust:

if (*in == '\n') {
            if (len >= 5 && ones <= 7)
                (ones == 7 ? sevens : words).push_back(word.to_ulong());
            word = len = ones = 0;
        } else if (ones != 8 && *in >= 'a' && *in <= 'z') {
            ++len, ones = word.set(25 - (*in - 'a')).count();
        } else { ones = 8; }

這里有一點關于 Rust 的說明。Rust 整數類型支持 count_ones()。C ++ 版本需要使用 std::bitset 的成員函數 count()(如果 bitset 算是一個 C++ 集合的話,這將是 size() 接口),因為這是在 C++中不使用非標準的編譯器指令,諸如 GCC 的 __builtin_popcountl 來獲得在 POPCNT 指令的唯一方法。使用 bitset <32> 而不是 <26> 可以避免一些不必要的掩碼操作。 由于在 gcc/amd64 上的最小的 bitset <> 是 64 位的,因此這些值更有效地存儲方式是 unsigned。Rust 到目前為止還沒有 bitset 的替換對象,但很幸運的是我們可以使用一個整數類型來表示所有位; 不過非常類似于 C++。

Rust sevens 和words 向量是從實際在程序中使用的方式推導出來。filter_map 調用剝離了一個 Result 包裝,并丟棄任何其他文件讀取錯誤。

下面是輸入狀態機的代碼:

C++:

if (*in == '\n') {            if (len >= 5 && ones <= 7)
                (ones == 7 ? sevens : words).push_back(word.to_ulong());
            word = len = ones = 0;
        } else if (ones != 8 && *in >= 'a' && *in <= 'z') {
            ++len, ones = word.set(25 - (*in - 'a')).count();
        } else { ones = 8; }

Rust:

if c == b'\n' {            if len >= 5 && ones <= 7
                { if ones == 7 { sevens.push(word) } else { words.push(word) } }
            word = 0; len = 0; ones = 0
        } else if ones != 8 && c >= b'a' && c <= b'z' {
            word |= 1 << (25 - (c - b'a')); len += 1; ones = word.count_ones()
        } else { ones = 8 }

這些都是對稱的。狀態機是直接的:收集和存儲合適的詞,并跳過不合適的詞。在早期版本的 Rust 編譯器上,我不得不使用迭代器管道,使用 .scan(),match,.filter() 和 .collect(),在行數的兩倍位置,以獲得不錯的性能。現在循環更快了。這里可以添加一個匹配工作,但代碼會更長。就像在 C++ 中一樣,Rust 可能僅需一次 push 調用,但它會是丑陋,而且速度更慢。 使用值 ofonesto 標志不合格的字為每個字符保存一個不可預測的分支。

順便說一下,我不知道為什么我可以寫下面代碼:

let (mut word, mut len, mut ones) = (0u32, 0, 0);

而不是

(word, len, ones) = (0, 0, 0);

很顯然現在的語法不支持這種寫法,但是語法不是物理規律。令人驚訝的語法限制使語言對用戶來說更復雜。

下一步,我們需要對保存著 7 個不同字母的 words 列表排序,并統計哪些存在重復。

C++:

std::sort(sevens.begin(), sevens.end());
std::vector<short> counts(sevens.size());
int count = -1; unsigned prev = 0;
for (auto seven : sevens) {
    if (prev != seven)
        sevens[++count] = prev = seven;
    counts[count] += 3;
}

Rust:

sevens.sort();
let (mut count, mut prev, mut counts) = (0, 0, vec![0u16; sevens.len()]);
if !sevens.is_empty() { prev = sevens[0]; counts[0] = 3 }
for i in 1..sevens.len() {
    if prev != sevens[i]
        { count += 1; prev = sevens[i]; sevens[count] = prev }
    counts[count] += 3
}

它們性能相近。Rust 中如果要同時使用一個 vector 中的兩個元素,需要通過索引來訪問以避免產生迭代器中元素所有權的沖突,這來自于邊界檢查,至少因為 count。(優化器應該知道 i 在范圍內。)Rust 需要無符號的索引,但是我們必須從 0(不是 !0,比如所有都是1)開始對 count 計數,因此優化器可能會注意到 count 不會超出 i,于是省略了對它進行邊界檢查。然后,我們需要額外的 if 檢查開始的條件。

到這一點的程序都是設置的,占運行時間的一小部分。 分別使用 <map> 或者 BreeMap 來存儲 sevens 和 counts,使得這最后一個片段是不必要的,交換至少 3% 的總運行時間。

Rust 操作布爾值很方便,順便說一句容易被忽略的,Result 和 Option。 例如,一些代碼更容易閱讀,如果我這么寫:

return is(c).then_some(||f(c))

代替

return is(c) { Some(f(c)) } else { None }

then_some() 主體僅一行,這是一種有用的標準寫法。

主循環就在下面,分為兩段。程序會把幾乎所有時間都花在前面這段上。

C++:

for (; count >= 0; --count) {
    unsigned const seven = sevens[count];
    short bits[7], scores[7];
    for (unsigned rest = seven, place = 7; place-- != 0; rest &= rest - 1) {
        bits[place] = std::bitset<32>((rest & ~(rest - 1)) - 1).count();
        scores[place] = counts[count];
    }
    for (unsigned word : words)
        if (!(word & ~seven))
            for (int place = 0; place < 7; ++place)
                scores[place] += (word >> bits[place]) & 1;

和 Rust:

let stdout = io::stdout();
let mut sink = io::BufWriter::new(stdout.lock());
for count in (0..(count + 1)).rev() {
    let seven = sevens[count];
    let (mut rest, mut bits) = (seven, [0u16;7]);
    for place in (0..7).rev()
        { bits[place] = rest.trailing_zeros() as u16; rest &= rest - 1 }
    let scores = words.iter()
        .filter(|&word| word & !seven == 0)
        .fold([counts[count];7], |mut scores, &word| {
            for place in 0..7
                 { scores[place] += ((word >> bits[place]) & 1) as u16 }
            scores
        });

它們非常相近,不過 Rust 的前兩行看起來有點多余的代碼可以帶來更快的輸出。

第一內循環將 seven 中的 bit 的位置保存到 bit 數組中,每個元素保存一位,以保證后續循環可以被展開并且隨機執行。 (優化器實際上能夠自己完成這一切,但是這樣做代碼會更短,也許更容易理解。)Rust 的 trailing_zeros() 被映射到機器指令 CTZ 上。 C++ 沒有提供直接等價方式,但 bitset<>::count() 提供了類似的位算法。

“.filter”行被執行 190 M 次;程序花費大約 60% 的時間在四條指令上,其他時間幾乎都用在了循環內部。從某種意義上說,這整個練習只測試了語言執行這兩行的能力。但是這僅僅是因為其余代碼之間的競態條件。只有大約 720K 次迭代到達“.fold()”,但是最內層循環執行 5 M 次,scores[place] 實際上自增 3 M 次。 “fold()”將其 scores 狀態從一個迭代傳遞到下一個迭代,比帶有外部范圍狀態變量的等效循環快得多。words 的迭代器是“懶惰的”,但是“fold()”調用驅動它完成。

我發現,使用(例如)“array.iter()”迭代數組比使用“&array”快得多,盡管它理論上應該是一樣的。(我想這將很快就會確定下來的。)奇怪的是,在 bits 和 scores 中使用 16 位元素在較早版本的 C ++ 程序大量降低了其性能——在一些測試中高達 8%。Rust 程序也受到類似影響,但相對輕微些。當前版本中使用 short 或者 int 運行效率是相同的。

主循環的第二段在上面積累積分的基礎上進行輸出。

C++:

        bool any = false; char out[8];
        for (int place = 0; place != 7; ++place) {
            int points = scores[place];
            char a = (points >= 26 && points <= 32) ? any = true, 'A' : 'a';
            out[place] = a + (25 - bits[place]);
        }
        if (any)
            out[7] = '\n', std::cout.rdbuf()->sputn(out, 8);
    }
}

和 Rust:

        let (mut any, mut out) = (false, *b".......\n");
        for place in 0..7 {
            let a = match scores[place]
               { 26 ... 32 => { any = true; b'A' }, _ => b'a' };
            out[place] = a + (25 - bits[place]) as u8
        }
        if any
            { sink.write(&out).unwrap(); }
    }
}

這也很相近。

這個循環遍歷 out 數組,將每一個字節和它對應的分數,以及 bits 中相同位置的位進行配對。Rust 代碼中這個結果保存在 u8 中,沒按一正常情況使用字符是因為在字符和字符串之間運算會很慢,因為運行時會檢查錯誤并進行轉換。(這里的算法只能用于 ASCII。)而 C++ 代碼不同,out 的元素被初始化兩次(不過可以通過優化省略)。有人在網上報怨可供選擇的幾種初始化數組的方法,都需要數組進行不必要的改變。

奇怪的是,大多數 C++ 版本變化的運行速度只有 Intel Haswell 芯片的一半,當使用 Gcc 或者 Clang 構建時,一個指令序列的選擇,需要內部主循環的兩個周期,而不是一個而已。(在 in __builtin_expect(..., false) 封裝了 in __builtin_expect(..., false) 的幫助)。有可能 Gcc 以后會學著為 Haswell 和新的 SKylake 芯片生成更好的代碼。真幸運,Rust 代碼沒有受到影響。

Rust 的包裝有點粗糙,但是用它編程充滿樂趣。假如是 Rust 獨立編程,它會或多或少會產生很好的效果。Rust 的通用支持不斷改善,但是它仍然需要 Rusty 的 STL。雖然其編譯器運行緩慢,但是它們正不斷努力改善,我相信明年的此時它的速度會有很大提升。(如果它可以對多余括號保持現有處理方式就好了)。另外,Rust 的字符串組合迭代體驗也很棒。

Rust 現在的趨勢是:與 C++ 低端性能和簡潔相媲美,安全方面的話正在超越,在可預見的未來有著匹配表達能力的合理前景。C++ 是逐步發展的目標,僅保留傳統的兼容性要求與委員會政策,所以 Rust 將會需要不斷快速發展。Rust 如果可以隨時“跨越障礙”,十年之后,招募者都會以有著十年 Rust 開發經驗而驕傲的。

 

來自:https://www.oschina.net/translate/rust-vs-cpp

 

 本文由用戶 netwan 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!