iT邦幫忙

2023 iThome 鐵人賽

DAY 18
0
Software Development

前端? 後端? 摻在一起做成全端就好了系列 第 18

18 今天來調教一下,哦不是,是調校一下 rust 效能

  • 分享至 

  • xImage
  •  

其實 WebAssembly 跟 JavaScript 比,看名字就知道 Assebmly 會比Script小 (不專業分析XD)。我們還是實際跑一下看rust可以做到什麼樣的程度,它官網都標榜又快又省記憶體了。

Size of 記憶體大小

回顧一下昨天的wasm,我們把service放在記憶體裡面跑,有人會問這樣子的話如果跑很多遊戲,這樣會不會吃很多記憶體空間?(謎之音:把瀏覽器關掉就好了)。其實我們可以透過rust的sizeof來查看該物件佔用多少記憶體空間:

我們實測一下,到寫game的檔案中,加一項test,記得把之前批次core test跑起來放一旁:

// core/src/tic_tac_toe.rs
mod test
{
    use std::mem::size_of;                   // 引用 size_of fn

    #[test]
    fn the_size_of_game_is_48_bytes() {
        assert_eq!(size_of::<Game>(), 0);    // 不知道多少,直接打0看訊息
    }
}

結果如下:
測試報錯,預期的size為48

測試結果告訴我們一個Game物件耗用了48 byte,所以可以把數字改為48讓測試通過,順便把fn名字也改一下,蛤已經偷改好了(?)。

附帶一提,如果覺得跑起來的出現的測試太多,不想出現不相干的話,可以在cargo test 指令後加匹配字段,這樣cargo test就會篩選符合字段的測試案例跑,如:

~demo-app$ cargo test -p core size

執行結果如下,只會顯示一筆,就不會顯示其他好多筆案例,可以讓終端畫面清爽一點。
cargo test執行指定測試案例

回過頭來看我們一個遊戲使用的記憶體空間是48 Bytes。不過這什麼概念呢,這裡有個43秒的Youtube Short短片,(以1920p_24fps來說)就使用了8.16MB,換算下來可以讓我們放17.8萬個遊戲,如果玩家每5秒玩1局,就可以玩不眠不休的玩10天。所以我們的情境,直接存記憶體裡的空間耗用應該是小到可以忽略不計(?。

註:連結的YouTube短片是在調侃工程師和設計師,可以點進去看一看笑一笑。

可是有些朋友是效能追求者,覺的要盡可能調校好,能改嗎?接下來我們就來演示一下如何利用rust追求效能(?,重新檢視一下我們的程式有沒有調整的空間:

@@ core/src/tic_tac_toe.rs @@
+const SYMBOLS: [Symbol; 2] = [Symbol::O, Symbol::X];

 pub struct Game {
     ...
-    // /// 使用符號清單,預設為`[O, X]`,下棋時會依序輪流使用
-    // pub symbols: [Symbol; 2],
+    pub won_line: Option<[u8; 3]>,
-    pub won_line: Option<[usize; 3]>,
...
 impl Game {
-    /// 取得使用符號清單,預設為`[O, X]`,下棋時會依序輪流使用
-    pub fn symbols(&self) -> [Symbol; 2] { self.symbols }
...
+    let symbol = SYMBOLS[self.current_step() % 2];
-    let symbol = self.symbols[self.current_step() % 2];
...
 if winner.is_some() {
     self.won_line = Some(idx.iter()
+        .map(|x| u8::try_from(*x + 1).unwrap())
+        .collect::<Vec<u8>>()
-        .map(|x| *x + 1)
-        .collect::<Vec<usize>>()
...
     fn default() -> Self {
         ...
-        symbols: [Symbol::O, Symbol::X],

畫記O, X符號是固定的,應該沒必要每局存一份,就算之後想要調換先手後手的符號,其實只要加個bool欄位,然後在算index的時候加1就好。所以上面我們把Symbol拉出來當常數,這裡使用到const常數,常數在rust裡的慣例是全大寫用底線分隔單字,與static一樣是整個程式的生命週期。rust說當你可以二選一時,就選const,相對有優化過

另外我們把usizeu8usize在筆者的環境是u64,而u8的範圍是0-255,很夠我們用了,我們才用不到10的數字大小。值得注意的是,陣列的指標,就是上面map()裡取出的x指標會是usize,所以我們要額外使用try_from試著轉成u8,轉換結果是Result,因為我們知道一定不會有錯(不會超過0-255的範圍),所以這裡直接unwrap取出Result的結果資料。

修改完後,測試的終端畫面如下:
顯示game大小為15

可以看到瘦身完剩下15Bytes,原來的三分之一不到,所以我們遊戲可以不眠不休玩30天了(?)。

要注意一下,我們剛剛直接用unwrap是我們很清楚我們在做什麼。一般直接用unwarp會在測試或是poc或是example裡,production code最好還是使用Error的處理,不要讓程式發生panic,unwrap操作可以去參考高大的文章。如果不做錯誤處理可能會發生溢位的情況,以下說明一下溢位的情境:

溢位

在rust裡,時不時大家寫著寫著就突然想要來優化一下記憶體的使用,或是執行效能上面的調效,怕有些朋友寫著寫著太開心,就不小心會遇到溢位的問題,所以專章說明一下。

什麼是溢位呢,就是位數不夠用溢出了。比如在我們日常生活十進制的世界裡,假設只有2個位數可以來表達數字的話,我們就只能表達 00 ~ 99。感覺好像不太夠用,有些朋友可能覺得很簡單啊不就增加位數就好了。但真實世界要處理的問題往往遠大於我們的想像,而且還不少,可以看Y2K千禧年問題和下面的小故事:

丹麥,一位高齡 107 歲的老太太, 收到一封由電腦印製的規格信件,通知她到當地的小學去註冊入學。 難道這是丹麥最新的「終身學習」社會福利嗎? 不是的,只是因為 (就像千禧蟲一樣),當初寫程式的人沒考慮百歲人瑞, 他寫的程式只用兩位數來儲存年齡,因此一百歲的人就被「歸零」了, 而 107 歲被視為 7 歲,正是入學的年齡。

好我們開一個範例專案講解一下:

examples/overflow/
├── Cargo.toml
└── src
    └── main.rs
# examples/overflow/Cargo.toml
[package]
name = "example-overflow"
version = "0.1.0"
edition = "2021"
// examples/overflow/src/main.rs
fn main() {
    let num: u8 = 200;               // u8 上限是 255
    let num2: u8 = 100;              // u8 上限是 255
    println!("num: {}", num + num2); // u8 + u8 還是 u8,上限255
}

寫好跑起來

~demo-app$ cargo run -p example-overflow

編譯u8相加因為溢位而失敗

u8的範圍是0-255,這裡我們看到編譯器直接報錯不讓我們編譯了。難怪人家都說rust比較安全不是沒道理的,這都幫我們檢查了。什麼,有人說這不是基本的嗎,我們看一下C#的範例對比一下,C#直接給過耶:

C# 程式碼:

using System;
					
public class Program
{
	public static void Main()
	{
		byte a = 100;
		byte b = 200;
		byte c = (byte) (a + b);
		Console.WriteLine($"{a} + {b} = {c} ");
		
		int d = 2147483647;
		int e = d+1;
		Console.WriteLine($"{d} + 1 = {e}");
	}
}

執行結果:

C#的溢位範例

到這裡可能有些人知道C#可以用check捕捉溢位,只要加上check就好了啊,這麼說來rust也沒什麼了不起。等等,這位仁兄是不是忽略了什麼,rust是在compile的時候就告訴我們問題,不讓編譯了耶。跟C#程式跑到那邊(runtime)才突然報錯,然後還要害我們查Log檔查半天,更不用說有人亂寫Exception該包不包,該外拋不外拋都不知道錯哪兒了,所以rust好棒棒(?)。

不過rust這樣反而讓我有點難舉例,還要特意想個案例讓rust跑出溢位的結果。值得注意的是rust在開發模式中會報錯,但在發佈模式裡不會報錯,所以要奉勸大家在使用u8, u16, i8, i32等等的時候要特別小心自己的案例,我們看一下下面的例子:

fn main() {
    let num: u8 = 5;
    let input = "255";
    let num2: u8 = input.parse().unwrap();
    println!("num: {}", num + num2);
}

我們用字串騙過rust的編譯器,讓rust在編譯時(compile time)無法判斷,只能在執行時(runtime)才能發現溢位,看一下rust的溢位訊息:
rust因為溢位而產生的panic

這裡rust發生了恐慌(panic),panic基本上就是不可恢複的錯誤,所以程式遇到panic會直接中斷,後面的code就不會跑了,也沒有像其他語言的try...catch語法來捕捉,恐慌的文件說明如下:

The panic! macro is used to construct errors that represent a bug that has been detected in your program. With panic! you provide a message that describes the bug and the language then constructs an error with that message, reports it, and propagates it for you.

panic訊息主要是給我們開發人員看的,可能有人會問,rust的panic只能中斷的話,那要怎麼進行錯誤處理呢?呃,我們前面十來個章節就一直在處理錯誤Error啊,還用Result包起來,其實我們一直在用的ResultOkErr,就是要傳遞給使用者(不單指操作UI的用戶,可能是呼叫這個fn的另一個開發人員,可能是呼叫這個fn的前端程式等等)。所以呼應剛剛提到的,我們比較常見到panic的情境,通常是範例、程式碼原型與測試

好了,既然panic是提醒我們開發人員的訊息,我們就來著手進行修正。不過修正之前我演示一下發布的情境,我們使用下列指令執行:

~demo-app$ cargo run -p example-overflow --release

在release模式跑出溢位的結果

這時候卻通過了,--release不是什麼魔法,有經驗的應該知道這是用來發佈用的,編譯器會最佳化我們的程式碼(白話版:就是要compile很久)。這就是剛剛提到,溢位在生產環境中不會報錯,所以千萬要小心。

我們修改如下:

fn main() {
    let num: u16 = 300;
    let result = u8::try_from(num);
    println!("num: {:?}", result);
}

我們用try_from來試著轉換,轉換後會得到一個Result的enum,分別是Ok(u8)Err,我們試著跑看看:

release發佈模式:
release發佈模式的溢位警告
debug開發模式的溢位警告:
debug開發模式的溢位警告

可以看到不管是在debug模式或是在release模式,都一樣會得到錯誤的結果,這個錯誤是ResultErr,所以是可以透過?is_err()或其他我們之前應用的方式處理(捕捉)。

有些鐵齒的朋友可能又會說,我們平常都用i32應該就不會爆了吧,i32對應C#的int,可以用到20億欸:
C#的數字型別範圍

2,147,483,647 平常來說應該很夠用了(?),但不出意外的話總是會出意外,之前在保哥群裡有聽到一個真實案例,可以跟大家分享一下,有人的PK是設西元年2碼加月日4碼再加4位流水號,如2018年1月5號就是 1801050001,看出來了嗎,沒有的話我幫大家加上分隔號:1,801,050,001,所以到2021年之前都很happy (path),到了2022年就爆了,之前遺留下來的代碼是怎麼來的也不可考了,所以真心奉勸大家要小心。

benchmark 效能評測

雖然rust也有內建bench,不過使用criterion相對比較容易,照著官方的說明,我們在core裡建立一個評測來試試剛剛改的結果:

@@ core/Cargo.toml @@
+[dev-dependencies]
+criterion = "0.5"
+
+[[bench]]
+name = "my_benchmark"
+harness = false
// core/benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

use core::tic_tac_toe::{Game, Symbol};

fn test_check_winner() {
    let mut game = Game::default();
    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
        Some(Symbol::X), Some(Symbol::X), None,
        None, None, None,
    ];
    assert_eq!(game.check_winner(), Some(Symbol::O));
    assert_eq!(game.won_line, Some([1, 2, 3]));

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), None,
        None, None, None,
    ];
    assert_eq!(game.check_winner(), None);
    assert_eq!(game.won_line, None);

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
    ];
    assert_eq!(game.check_winner(), None);
    assert_eq!(game.won_line, None);

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::O),
    ];
    assert_eq!(game.check_winner(), Some(Symbol::O));
    assert_eq!(game.won_line, Some([7, 8, 9]));

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::O), Some(Symbol::X), Some(Symbol::O),
    ];
    assert_eq!(game.check_winner(), None);
    assert_eq!(game.won_line, None);

    game.cells = [
        Some(Symbol::O), Some(Symbol::O), Some(Symbol::X),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
        Some(Symbol::X), Some(Symbol::X), Some(Symbol::O),
    ];
    assert_eq!(game.check_winner(), Some(Symbol::X));
    assert_eq!(game.won_line, Some([3, 5, 7]));
}

fn criterion_benchmark(c: &mut Criterion) {
    c.bench_function("check winner", |b| b.iter(|| test_check_winner()));
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

我們把test裡的check winner拿出來跑試試:

~/demo-app$ cargo bench -p core

第一次跑bench的結果(usize,u8)

可以看到平均 203 ns,看看有沒有什麼地方可以調整的,我們看一下check_winner方法
查看check_winner fn的型別

可以看到 win_patterns是 usize,我們把它改成一樣 u8 試試

+let win_patterns: [[u8; 3]; 8] = [
-let win_patterns = [
...
+self.cells[idx[0] as usize], self.cells[idx[1] as usize], self.cells[idx[2] as usize],
-self.cells[idx[0]], self.cells[idx[1]], self.cells[idx[2]],

因為陣列指標是usize我們要配合轉型才能使用,改好跑跑看:

使用u8跑完的結果

criterion會幫我們和上一輪作比較,這裡說效能優化了,時間減少14.076%,我們試試都改回usize,把開頭瘦身節省空間的won_line也改回usize看看:

+pub won_line: Option<[usize; 3]>,
-pub won_line: Option<[u8; 3]>,
...
+let win_patterns: [[usize; 3]; 8] = [
-let win_patterns: [[u8; 3]; 8] = [
...
+.map(|x| usize::try_from(*x + 1).unwrap())
+.collect::<Vec<usize>>()
-.map(|x| u8::try_from(*x + 1).unwrap())
-.collect::<Vec<u8>>()

使用usize跑完的結果

蝦米,uszie還比較快,而且快了一倍以上,時間減少了60%,這是怎麼回事呢?我們再試試u32看看(我的機器會把usize編譯成u64),照著上面的改法把uszie再改u32:

使用u32跑完的結果

咦怎麼又更快了,時間又少了約27%,這不科學啊,我們把u16也湊齊試試看:

使用u16跑完的結果

結果換成u16反而又變慢了,統整一下:

速度快至慢: 32bit >> 64bit >>>> 8bit > 16bit
空間小至大: 8bit > 16bit > 32bit > 64bit

這是怎麼回事,噢原來這會涉及到硬體架構,我使用的cpu是x86-64的架構,據這個回答指出通常32-bit的資料是最有效率的,大約是跟翻譯成機器碼有關係,之前CPU主要是32位元為主流,所以針對32位元的優化處理比較多,因x86-64是相容跑32位元模式,所以32會相對快,而低於32位元的資料,因為放到CPU暫存器裡會放不滿,所以要額外的操作步驟,就損耗掉了一些效能。

註:int32的的效能優勢推測應該是不限程式語言的,畢竟是取決於機器架構,所以大家可以keep in mind,有效能需求的話,不見得縮小資料是有效的。

最後的結果我選擇使用u32的類別,改為u32後的game大小為28 Bytes,比原本usize的48 Bytes少了四成。記得開發就是時常要面臨取捨,在這提供大家一些比較的方式,可以在實際的情境中按需調校。

以上是i7跑的,下面用 Apple M1 Pro跑看看,也是類似的結果。

u8
M1 pro跑u8測試結果

usize
M1 pro跑usize測試結果

u32
M1 pro跑u32測試結果

u16
M1 pro跑u16測試結果

參考資料

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
17 親愛的,我把rust後端搬進前端裡了 (tauri/wasm)
下一篇
19 再探 WebAssembly 及 rust closure
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言