其實 WebAssembly 跟 JavaScript 比,看名字就知道 Assebmly 會比Script小 (不專業分析XD)。我們還是實際跑一下看rust可以做到什麼樣的程度,它官網都標榜又快又省記憶體了。
回顧一下昨天的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看訊息
}
}
結果如下:
測試結果告訴我們一個Game物件耗用了48 byte,所以可以把數字改為48讓測試通過,順便把fn名字也改一下,蛤已經偷改好了(?)。
附帶一提,如果覺得跑起來的出現的測試太多,不想出現不相干的話,可以在cargo test 指令後加匹配字段,這樣cargo test就會篩選符合字段的測試案例跑,如:
~demo-app$ cargo test -p core size
執行結果如下,只會顯示一筆,就不會顯示其他好多筆案例,可以讓終端畫面清爽一點。
回過頭來看我們一個遊戲使用的記憶體空間是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,相對有優化過。
另外我們把usize
改u8
,usize
在筆者的環境是u64
,而u8
的範圍是0-255,很夠我們用了,我們才用不到10的數字大小。值得注意的是,陣列的指標,就是上面map()
裡取出的x
指標會是usize
,所以我們要額外使用try_from
試著轉成u8
,轉換結果是Result
,因為我們知道一定不會有錯(不會超過0-255的範圍),所以這裡直接unwrap
取出Result
的結果資料。
修改完後,測試的終端畫面如下:
可以看到瘦身完剩下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的範圍是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#可以用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),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
包起來,其實我們一直在用的Result
的Ok
及Err
,就是要傳遞給使用者(不單指操作UI的用戶,可能是呼叫這個fn的另一個開發人員,可能是呼叫這個fn的前端程式等等)。所以呼應剛剛提到的,我們比較常見到panic的情境,通常是範例、程式碼原型與測試。
好了,既然panic是提醒我們開發人員的訊息,我們就來著手進行修正。不過修正之前我演示一下發布的情境,我們使用下列指令執行:
~demo-app$ cargo run -p example-overflow --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發佈模式:
debug開發模式的溢位警告:
可以看到不管是在debug模式或是在release模式,都一樣會得到錯誤的結果,這個錯誤是Result
的Err
,所以是可以透過?
、is_err()
或其他我們之前應用的方式處理(捕捉)。
有些鐵齒的朋友可能又會說,我們平常都用i32應該就不會爆了吧,i32對應C#的int,可以用到20億欸:
2,147,483,647 平常來說應該很夠用了(?),但不出意外的話總是會出意外,之前在保哥群裡有聽到一個真實案例,可以跟大家分享一下,有人的PK是設西元年2碼加月日4碼再加4位流水號,如2018年1月5號就是 1801050001,看出來了嗎,沒有的話我幫大家加上分隔號:1,801,050,001
,所以到2021年之前都很happy (path),到了2022年就爆了,之前遺留下來的代碼是怎麼來的也不可考了,所以真心奉勸大家要小心。
雖然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
可以看到平均 203 ns,看看有沒有什麼地方可以調整的,我們看一下check_winner方法
可以看到 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我們要配合轉型才能使用,改好跑跑看:
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>>()
蝦米,uszie還比較快,而且快了一倍以上,時間減少了60%,這是怎麼回事呢?我們再試試u32看看(我的機器會把usize編譯成u64),照著上面的改法把uszie再改u32:
咦怎麼又更快了,時間又少了約27%,這不科學啊,我們把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
usize
u32
u16
本系列專案源始碼放置於 https://github.com/kenstt/demo-app