開發一個會被重複使用的Rust套件(crate)時,想辦法讓這個套件的執行效能達到最佳化,是一件蠻重要的事情,但是要用什麼樣的方式來驗證程式的執行效能呢?



Rust Nightly

Rust程式語言自帶的效能測試(Benchmark)框架還沒有完全穩定,所以目前只能夠使用Nightly版本的Rust來對程式進行效能測試。

《Rust學習之路》系列文章中的測試章節有介紹過Rust程式語言撰寫測試的方法,Rust自帶的效能測試可以很容易地融入Rust的測試架構中。

先看看這個程式:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

以上程式是Cargo的函式庫專案會預設產生的測試程式碼。我們若想要加入Rust自帶的效能測試功能,需先引用Rust Nightly提供的test特色和test這個crate。可以用以下方式來撰寫程式:

#![cfg_attr(test, feature(test))]

#[cfg(test)]
extern crate test;

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

接著在tests模組中就可以撰寫新的函數作為效能測試用的函數,這個函數不使用#[test]屬性,而是使用#[bench]屬性。如下:

#![cfg_attr(test, feature(test))]

#[cfg(test)]
extern crate test;

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }

    #[bench]
    fn benchmark() {
        
    }
}

不過以上程式是不能編譯成功的,因為作為效能測試用的函數,必須要有個參數,可以傳入test這個crate提供的Bencher結構體。所以程式要改成以下這樣:

#![cfg_attr(test, feature(test))]

#[cfg(test)]
extern crate test;

#[cfg(test)]
mod tests {
    use test::Bencher;

    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }

    #[bench]
    fn benchmark(bencher: &mut Bencher) {
        
    }
}

再來就可以開始撰寫我們的測試程式啦!我們可以在效能測試用的函數中,任意初始化效能測試時要用的環境(所需的變數、結構實體等等),接著利用Bencher結構實體所提供的iter方法來傳入一個閉包,來讓Rust能夠測量這個閉包內的程式的執行速度。

例如:

#![cfg_attr(test, feature(test))]

#[cfg(test)]
extern crate test;

pub fn add_three(a: i32) -> i32 {
    a + 3
}

#[cfg(test)]
mod tests {
    use test::Bencher;

    use super::add_three;

    #[test]
    fn it_works() {
        assert_eq!(2 + 3, add_three(2));
    }

    #[bench]
    fn benchmark(bencher: &mut Bencher) {
        bencher.iter(|| add_three(2));
    }
}

以上程式,it_works這個測試函數可以用來驗證add_three函數的正確性,而benchmark這個效能測試函數可以用來測試add_three函數的執行效能。

所以要怎麼執行效能測試呢?我們雖然可以使用cargo test來執行it_worksbenchmark這兩個函數,但benchmark這個效能測試函數只會被當作是一般的測試函數,傳入Bencher結構實體的iter方法的閉包只會被執行一次。

如果要進行效能測試,就得改用cargo bench來執行Cargo程式專案。cargo bench只會執行帶有#[bench]屬性的測試函數,且傳入Bencher結構實體的iter方法的閉包會被執行很多次,直到每次執行時間趨於穩定才會停止。Cargo會將效能測試的結果直接輸出到螢幕上。如下圖:

benchmarking

可以看到螢幕上顯示著:

test tests::benchmark ... bench:           0 ns/iter (+/- 0)

這個就是效能測試的結果啦!cargo bench會將每次執行傳入Bencher結構實體的iter方法的閉包的所需時間顯示出來,還會帶有誤差值。

不過跑個效能測試還需要用到Rust的Nightly版本實在不怎麼方便,因此在crates.io上有些能夠運行在非Rust Nightly的效能測試套件可以使用,比較多人用的有benchercriterion。不過因為criterion有點肥大(甚至會產生圖表報告),執行速度慢,筆者不是很喜歡,所以就不介紹了。

bencher

「bencher」是用來使Rust Nightly的效能測試功能可以在非Rust Nightly的Rust版本上的套件。

Crates.io

Cargo.toml

bencher = "*"

應將bencher加至[dev-dependencies]區塊中。

使用方法

「bencher」套件的用法與Rust內建的效能測試用法差異頗大,我們需要將其與Rust內建的測試架構分開。在Cargo程式專案根目錄底下建立benches目錄,接著在裡面建立用來專門跑效能測試的.rs原始碼檔案,例如bench.rs

然後可以加入以下的程式碼:

extern crate hello;

use hello::add_three;

use bencher::{benchmark_group, benchmark_main, Bencher};

fn benchmark(bencher: &mut Bencher) {
    bencher.iter(|| add_three(2));
}

benchmark_group!(benches, benchmark);
benchmark_main!(benches);

以上的hello這個crate是我們目前開發的crate名稱。bencher這個crate提供的Bencher結構體的功能和Rust Nighlty的test這個crate提供的Bencher結構體是一樣的。benchmark_group巨集可以用來將多個測試函數(如benchmark函數)綁定成某個名稱(此處用的名稱為benches),當作一個測試群組。而benchmark_main巨集則是可以用來載入benchmark_group巨集產生出來的群組。

所以如果有多個測試函數和多個測試群組時,程式可以這樣寫:

benchmark_group!(benches_1, benchmark_1, benchmark_2);
benchmark_group!(benches_2, benchmark_3);
benchmark_main!(benches_1, benches_2);

原本的src/lib.rs內的測試程式就不要再去使用test特色了,可以改成以下這樣:

pub fn add_three(a: i32) -> i32 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::add_three;

    #[test]
    fn it_works() {
        assert_eq!(2 + 3, add_three(2));
    }
}

Cargo.toml設定檔要對每個benches目錄下的.rs原始碼檔案進行登錄的動作,必須加上以下這段設定:

[[bench]]
name = "原始碼檔案名稱(不包含.rs)"
harness = false

例如要登錄benches/bench.rs,就要在Cargo.toml設定檔加上:

[[bench]]
name = "bench"
harness = false

接著就可以使用cargo bench來執行這些效能測試程式了!

上面介紹的效能測試方式要依靠cargo bench來執行,而且會直接將測試結果輸出到螢幕(標準輸出)上。如果我們打算把測試結果儲存在記憶體中以函數的方式回傳給我們自己的Rust程式來使用的話,就得令尋它法了!

Benchmarking

「benchmarking」是筆者開發的套件,可以用來測量程式的執行時間,直接將結果以函數的方式回傳出來,不會在螢幕上或是檔案系統中輸出任何資料。

Crates.io

Cargo.toml

benchmarking = "*"

使用方法

benchmarking這個crate提供了warm_up等函數,可以在進行效能測試前,先將CPU的頻率帶起來,確保測試結果不會因一開始陷入低頻省電狀態的CPU而被拖累到。warm_up函數預設的執行時間是3秒鐘,若要自訂,可以使用bench_function_with_duration函數。

不過warm_up函數只有支援單執行緒,如果想要進行多執行緒的CPU頻率提升,可以使用multi_thread_bench_function,預設時間一樣是3秒鐘,若要自訂,可以使用multi_thread_bench_function_with_duration函數。

在效能測試上,benchmarking這個crate提供了measure_function函數,可以用來傳入一個閉包(稱為外閉包),這個閉包的參數會被代入一個benchmarking提供的Measurer結構實體,可以利用其提供的measure方法傳入閉包(稱為內閉包),來分隔要被納入效能測量範圍的程式碼,只有內閉包的程式才會被測量。

例如:

fn main() {
    const VEC_LENGTH: usize = 100;

    benchmarking::warm_up();

    let bench_result = benchmarking::measure_function(|measurer| {
        let mut vec: Vec<usize> = Vec::with_capacity(VEC_LENGTH);

        measurer.measure(|| {
            for i in 0..VEC_LENGTH {
                vec.push(i);
            }
        });

        vec
    }).unwrap();

    println!("Filling 0 to 99 into a vec takes {:?}!", bench_result.elapsed());
}

Measurer結構實體的measure方法可以在外閉包中被呼叫多次,通常它一定要至少被呼叫到一次,否則就必須呼叫Measurer結構實體的pass方法來確認不呼叫measure方法。

例如:

fn main() {
    const VEC_LENGTH: usize = 100;

    benchmarking::warm_up();

    let bench_result = benchmarking::measure_function(|measurer| {
        let mut vec: Vec<usize> = Vec::with_capacity(VEC_LENGTH);

        unsafe {
            vec.set_len(VEC_LENGTH);
        }

        for i in 0..VEC_LENGTH {
            measurer.measure(|| {
                vec[i]
            });
        }

        vec
    }).unwrap();

    println!("Reading a number from a vec takes {:?}!", bench_result.elapsed());
}

不論是外閉包還是內閉包,都可以回傳一個值,這個值可以避免掉因為它可能因為只被寫入而不被讀取而導致在編譯階段的時候被編譯器給優化忽略掉。

measure_function函數會固定重複執行外閉包10次,如果想要修改執行次數,可以改用measure_function_with_times函數。如果想要重複執行外閉包,直到經過多少時間,可以改用bench_function函數,預設會執行5秒鐘。如果想要修改執行時間,可以改用bench_function_with_duration函數。

如果想要用指定的執行緒數量來執行外閉包一段時間,可以改用multi_thread_bench_function或是multi_thread_bench_function_with_duration函數。

在某些情況下,我們可能需要在一個外閉包內進行兩種以上獨立的效能測量。此時可以改用measure_function_nmeasure_function_n_with_timesbench_function_nbench_function_n_with_durationmulti_thread_bench_function_n或是multi_thread_bench_function_n_with_duration函數,來透過參數改變外閉包參數中會被代入的Measurer結構實體的數量。

例如:

fn main() {
    const VEC_LENGTH: usize = 100;

    benchmarking::warm_up();

    let bench_result = benchmarking::bench_function_n(2, |measurers| {
        let mut vec: Vec<usize> = Vec::with_capacity(VEC_LENGTH);

        for i in 0..VEC_LENGTH {
            measurers[1].measure(|| {
                vec.push(i);
            });
        }

        for i in 0..VEC_LENGTH {
            measurers[0].measure(|| {
                vec[i]
            });
        }

        vec
    }).unwrap();

    println!("Reading a number from a vec takes {:?}!", bench_result[0].elapsed());
    println!("Pushing a number into a vec takes {:?}!", bench_result[1].elapsed());
}