DEV Community

Daniel Lin
Daniel Lin

Posted on

Zero-Cost Abstractions in Rust

abstraction cost nothing in runtime, only in compile time.

要做到 Zero-Cost Abstractions 其中一個手段就是 generic 的單態化 Monomorphization,Rust 對於 generic 會在編譯時做單態化,什麼意思呢?讓我們直接來看 範例

fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
    a + b
}

fn main() {
    let a = add(1, 2);
    let b = add(1.1, 2.2);

    println!("a: {}, b: {}", a, b);
}
Enter fullscreen mode Exit fullscreen mode

第一個 add 跟第二個 add 其實是不同的函數,讓我們看一下在下面一段天書 -- LLVM IR
可以從 playground 中 compile 的選項調整LLVM IR

裡面可以找到一些蛛絲馬跡,在 LLVM 編譯前變成了兩個函數,看不懂沒關係,我也不懂:P,我們只需要知道 define internal double @_ZN10playground3add17h6a70b05fd089ab8cE(double %a, double %b) unnamed_addr 是給 add::<f64> 的函數實際上的名字
define internal i32 @_ZN10playground3add17hf37e311d0825bf03E(i32 %a, i32 %b) unnamed_addr 是給 add::<i32> 的函數的名字

; playground::add
; Function Attrs: nonlazybind uwtable
define internal double @_ZN10playground3add17h6a70b05fd089ab8cE(double %a, double %b) unnamed_addr #1 !dbg !371 {
start:
    ...
}

; playground::add
; Function Attrs: nonlazybind uwtable
define internal i32 @_ZN10playground3add17hf37e311d0825bf03E(i32 %a, i32 %b) unnamed_addr #1 !dbg !381 {
...
}
Enter fullscreen mode Exit fullscreen mode

在看到 main 函數中 call i32 @XXXcall double @XXX 實際上呼叫了不同的 function

; playground::main
; Function Attrs: nonlazybind uwtable
define internal void @_ZN10playground4main17h51a284336407de02E() unnamed_addr #1 !dbg !389 {
start:
  ...
; call playground::add
  %0 = call i32 @_ZN10playground3add17hf37e311d0825bf03E(i32 1, i32 2), !dbg !405
  ...
; call playground::add
  %1 = call double @_ZN10playground3add17h6a70b05fd089ab8cE(double 1.100000e+00, double 2.200000e+00), !dbg !406
  ...
Enter fullscreen mode Exit fullscreen mode

也就是說這個 Add 的 generic 在 compile 時期就將 Add 變成了兩個函數去呼叫,而不是在程式執行的時候才去決定要用什麼函數,不佔用到 runtime 的時間也就達成了 zero-cost abstraction,當然這樣的 trade-off 就是 rust 在 compile 時期會花的時間較多,換取執行時的效能。

不過, rust 編譯時間比較長並非只有這個原因,可以從 playground 的 compile option 這個地方看到 rust compile 要經過非常多的步驟,整個流程 Rust Code -> HIR -> MIR -> LLVM IR -> ASM

rust compile option

而單態化只是 MIR 到 LLVM IR 處理的其中一塊,還有非常多像是:展開 Macro, type check, life-time check 等,都會佔用一些 compile 的時間

另一個手段則是 ZST(Zero Sized Types),Golang 也是做的到 The Go Playground

func main() {
    type zero struct{}
    fmt.Println(unsafe.Sizeof(zero{}))
}
Enter fullscreen mode Exit fullscreen mode

Zero struct佔用的空間就是 0 ,你可以針對這個自定義的 type 做出不一樣的行為。

而 Rust 則有更多的使用方式,除了 struct 還可以在 generic struct 放入 PhantomData 來將型別一樣的東西,抽象出不同的行為, 下面這段程式碼中的 _f size 也是 0 ,執行後可以看到 Expression 只佔了 12

struct Zero();

#[derive(Default)]
struct Expression<T, F> {
    a: T,
    b: T,
    _result: T,
    _f: std::marker::PhantomData<F>,
}

macro_rules! f {
    ( $op:ident($a:tt,$b:tt) ) => {
        Expression::<_, $op> {
            a: $a,
            b: $b,
            ..Default::default()
        }
    };
}

impl<T, F> Expression<T, F>
where
    T: Copy,
{
    fn get_result(&self) -> T {
        self._result
    }
}

impl<T> Expression<i32, T>
where
    T: Call<i32>,
{
    fn execute(&mut self) {
        self._result = T::call(self.a, self.b);
    }
}

trait Call<T> {
    fn call(a: T, b: T) -> T;
}

#[derive(Default)]
struct Add();
impl Call<i32> for Add {
    fn call(a: i32, b: i32) -> i32 {
        a + b
    }
}

#[derive(Default)]
struct Multiply();
impl Call<i32> for Multiply {
    fn call(a: i32, b: i32) -> i32 {
        a * b
    }
}

fn main() {
    println!("{}", std::mem::size_of::<Zero>());
    println!("{}", std::mem::size_of::<Expression<i32, Add>>()); // 3 * size of i32 -> 3 * 4 = 12

    let mut e1 = f!(Add(1, 2));
    e1.execute();
    let mut e2 = f!(Multiply(1, 2));
    e2.execute();
    println!("{} {}", e1.get_result(), e2.get_result());
}
Enter fullscreen mode Exit fullscreen mode

當然不可能所有抽象的手段在 Rust 中都是沒有成本的,先看一下 Golang 最常被使用的抽象手段 interface ,不需要明確寫出實作了哪些 interface 只需要符合接口即可

type Abser interface {
    Abs() float64
}

type MyFloat float64

// 只要函數長的一模一樣就好
func (f MyFloat) Abs() float64 {
    if f < 0 {
        return float64(-f)
    }
    return float64(f)
}
Enter fullscreen mode Exit fullscreen mode

Rust 則需要明確表示實作該接口

struct add();

impl Call<i32> for add {  // 需要明確表示實作 Call<i32> 這個 trait
    fn call(a: i32, b: i32) -> i32 {
        a + b
    }    
}
Enter fullscreen mode Exit fullscreen mode

相較之下 Golang 選擇了的方式,雖然效能較差,但是用法較為靈活,而 Rust 雖然必須明確指出實作接口,但是一樣有提供 Trait Object 做動態時期的分發

引用自rust doc

trait Printable {
    fn stringify(&self) -> String;
}

impl Printable for i32 {
    fn stringify(&self) -> String { self.to_string() }
}

fn print(a: Box<dyn Printable>) {
    println!("{}", a.stringify());
}

fn main() {
    print(Box::new(10) as Box<dyn Printable>);
}
Enter fullscreen mode Exit fullscreen mode

上面可以這樣解讀 10 被當成實作了 Printable 擁有 stringify 的能力的物件,所以在 print function 中可以執行,dyn Printable 意思就是 實作 Printable 的物件。

跟 Golang interface 的概念非常接近,其實底層也非常類似 Golang 的 interface 的實作方式,Rust 會用一個 vtable 來儲存物件的資料與使用到的行為,10 在 main 執行時才被傳入,compiler 並不知道是那個 object 執行 stringify 這個 method,只知道 stringify 的函數位置。

但是既然用了這樣的方式實作就代表執行時要先找這個 vtable 才能找到 stringify 的地址,然後才能執行,經過這樣一層層的尋址,自然就無法達成 Zero-Cost Abstractions,但是我們也因此換取了一些程式的彈性,工程上沒有哪一種最好,只有最適合的方式,在 Rust 中你可以比較自由的選擇抽象的方式,但是設計上的選擇多了,自然就會多一些開發的成本,Golang 則是提供一個簡單的方式,有些時候等到你的 application 流量大了,再優化效能也不遲。

Discussion (0)