Trait là cách Rust nói "những kiểu khác nhau cùng làm được việc này" — tương đương interface ở ngôn ngữ khác. Nhưng Rust cho bạn hai cách gọi một phương thức trait, và chúng khác nhau tận gốc ở mã máy: điều phối tĩnh quyết định gọi hàm nào lúc biên dịch, điều phối động quyết định lúc chạy qua một bảng con trỏ. Cái đầu nhanh hơn nhưng làm phình mã; cái sau gọn mã nhưng trả giá mỗi lời gọi. Bài này đo cả hai trong container rust:1 (rustc 1.99.1) — bằng kích thước con trỏ, thời gian chạy, và assembly.

Hai cách viết cùng một việc

Một trait Hinh với hai kiểu cài đặt, rồi hai hàm cộng diện tích — một dùng generic, một dùng &dyn:

trait Hinh { fn dien_tich(&self) -> f64; }
struct Tron  { r: f64 }
struct Vuong { c: f64 }

// TĨNH: generic — trình biên dịch sinh mã riêng cho từng kiểu T
fn tong_tinh<T: Hinh>(xs: &[T]) -> f64 {
    let mut s = 0.0;
    for x in xs { s += x.dien_tich(); }
    s
}
// ĐỘNG: &dyn Hinh — gọi qua bảng ảo lúc chạy
fn tong_dong(xs: &[&dyn Hinh]) -> f64 {
    let mut s = 0.0;
    for x in xs { s += x.dien_tich(); }
    s
}

Thân hai hàm trông giống hệt. Khác biệt nằm ở kiểu tham số: tong_tinh<T: Hinh> nhận một lát cắt các giá trị cùng một kiểu cụ thể, còn tong_dong nhận một lát cắt các con trỏ trait &dyn Hinh — có thể trộn Tron lẫn Vuong. Chính sự linh hoạt đó của dyn là thứ phải trả giá.

Con trỏ béo: &dyn chiếm 16 byte

Khác biệt đầu tiên lộ ra ở kích thước con trỏ:

&Tron        (con trỏ mảnh) : 8 byte
&dyn Hinh    (con trỏ béo)  : 16 byte   (con trỏ dữ liệu + con trỏ vtable)
Box<dyn Hinh>               : 16 byte

&Tron là con trỏ thường, 8 byte, trỏ tới dữ liệu. &dyn Hinh là con trỏ béo (fat pointer) 16 byte: một nửa trỏ tới dữ liệu, nửa kia trỏ tới bảng ảo (vtable) — một bảng tĩnh chứa địa chỉ các phương thức của kiểu thật. Khi gọi x.dien_tich() trên một &dyn Hinh, chương trình phải: đọc con trỏ vtable, tra địa chỉ dien_tich trong bảng, rồi gọi gián tiếp tới địa chỉ đó. Ba bước này xảy ra mỗi lời gọi, lúc chạy — đó là cái giá của sự linh hoạt.

Assembly: nội tuyến thẳng vs gọi gián tiếp qua vtable

Xuất assembly hai hàm ở bản release và đếm, ta thấy đúng cơ chế:

                              tong_tinh (tĩnh)   tong_dong (động)
lệnh gọi gián tiếp (blr)             0                  1
lệnh nhân fmul (tính thẳng)          6                  6
lõi vòng lặp động:  ldr x8, [x8, #24]   ← nạp ô vtable
                    blr x8              ← gọi gián tiếp

Bản tĩnh có 0 lệnh gọi gián tiếp: sau khi đơn hình hoá cho kiểu Tron, trình biên dịch biết chính xác dien_tich là gì, nội tuyến thẳng công thức 3.14 * r * r vào vòng lặp (6 lệnh fmul, thậm chí mở cuộn — như bài 4 về trừu tượng chi phí bằng không). Bản động thì có một lệnh blr (gọi gián tiếp): nó ldr x8, [x8, #24] — nạp ô thứ tư của vtable (offset 24 byte) chứa địa chỉ dien_tich — rồi blr x8 gọi tới đó. Vì địa chỉ chỉ biết lúc chạy, trình biên dịch không thể nội tuyến, và mỗi phần tử là một lời gọi hàm thật qua con trỏ.

Ảnh chụp sơ đồ nền tối so sánh điều phối tĩnh và động của trait trong Rust, bên trái điều phối TĨNH generic T ràng buộc Hinh được đơn hình hoá monomorphization trình biên dịch sinh một bản mã riêng cho kiểu Tron biết chính xác phương thức nên nội tuyến thẳng công thức 3.14 nhân r nhân r vào vòng lặp assembly 0 lệnh gọi gián tiếp 6 lệnh fmul con trỏ mảnh và Tron 8 byte nhanh 12,4 ms nhưng phình mã mỗi kiểu một bản, bên phải điều phối ĐỘNG và dyn Hinh con trỏ béo 16 byte gồm con trỏ dữ liệu cộng con trỏ vtable bảng ảo chứa địa chỉ các phương thức mỗi lời gọi phải nạp ô vtable ldr offset 24 rồi gọi gián tiếp blr x8 không thể nội tuyến assembly 1 lệnh blr chậm 15,5 ms gấp 1,2 lần nhưng gọn mã một bản dùng cho mọi kiểu trộn Tron lẫn Vuong được, ở giữa bảng ảo vtable sơ đồ con trỏ béo nửa trỏ dữ liệu nửa trỏ bảng chứa drop size align và dien_tich

Hình 1: Điều phối tĩnh đơn hình hoá và nội tuyến (0 lời gọi gián tiếp); điều phối động gọi qua vtable của con trỏ béo 16 byte (1 lệnh blr mỗi phần tử).

Thời gian: tĩnh 12,4 ms, động 15,5 ms

Cộng diện tích 20 triệu hình bằng cả hai cách, cùng dữ liệu:

20.000.000 hình, cộng diện tích:
điều phối TĨNH (generic, monomorphization) : 12,40 ms
điều phối ĐỘNG (&dyn Hinh, vtable)         : 15,46 ms
động chậm hơn tĩnh 1,2 lần

Chênh 1,2 lần — không khổng lồ, nhưng thật và ổn định, với một phương thức cực nhỏ. Khoảng cách này giãn ra khi phương thức đủ nhỏ để đáng lẽ được nội tuyến (càng nhỏ, chi phí lời gọi gián tiếp càng chiếm tỉ trọng lớn) và khi điều phối động chặn luôn các tối ưu theo sau như mở cuộn hay vector hoá. Với phương thức lớn, chi phí một lời gọi gián tiếp lọt thỏm, và khoảng cách gần như biến mất. Nói cách khác: cái giá của dyn không cố định — nó lớn nhất đúng ở những vòng lặp nóng gọi hàm nhỏ hàng triệu lần.

Ảnh chụp sơ đồ nền tối tổng hợp output thật so điều phối tĩnh và động trong rust 1.99.1, khối một kích thước con trỏ và Tron con trỏ mảnh 8 byte và dyn Hinh con trỏ béo 16 byte con trỏ dữ liệu cộng con trỏ vtable Box dyn Hinh 16 byte, khối hai benchmark 20 triệu hình cộng diện tích điều phối tĩnh generic monomorphization 12,40 ms điều phối động dyn Hinh vtable 15,46 ms động chậm hơn tĩnh 1,2 lần cùng kết quả, khối ba assembly aarch64 tong_tinh tĩnh 0 lệnh gọi gián tiếp blr 6 lệnh nhân fmul nội tuyến tính thẳng so với tong_dong động 1 lệnh blr gọi gián tiếp lõi vòng lặp ldr x8 phẩy x8 cộng 24 nạp ô vtable rồi blr x8 gọi gián tiếp không thể nội tuyến

Hình 2: Số đo thật — kích thước con trỏ mảnh vs béo, benchmark tĩnh vs động, và assembly cho thấy nội tuyến (0 blr) vs gọi gián tiếp qua vtable (blr).

Chọn cái nào

Không phải "tĩnh luôn tốt hơn". Mỗi bên có cái giá riêng. Điều phối tĩnh nhanh và nội tuyến được, nhưng đơn hình hoá sinh một bản mã cho mỗi kiểu — dùng tong_tinh với 10 kiểu hình là 10 bản sao trong nhị phân, làm phình kích thước tệp và áp lực lên cache lệnh. Điều phối động chỉ có một bản mã dùng chung cho mọi kiểu, nên gọn nhị phân, và là cách duy nhất để chứa các kiểu khác nhau trong cùng một tập hợp (Vec<Box<dyn Hinh>> trộn Tron lẫn Vuong — generic không làm được). Quy tắc thực dụng: mặc định dùng generic (tĩnh) cho đường nóng và thư viện; chuyển sang dyn khi cần chứa nhiều kiểu lẫn lộn, khi muốn cắt thời gian biên dịch và cỡ nhị phân, hoặc khi lời gọi không nằm trên đường nóng.

Gói lại

Hai cách gọi một trait, hai đánh đổi đo được. Tĩnh (generic, đơn hình hoá): con trỏ mảnh 8 byte, 0 lời gọi gián tiếp, nội tuyến thẳng, 12,4 ms — nhưng mỗi kiểu một bản mã. Động (&dyn, vtable): con trỏ béo 16 byte, 1 lời gọi gián tiếp qua vtable mỗi phần tử, 15,5 ms — nhưng một bản mã cho mọi kiểu và chứa được kiểu trộn lẫn. Biết cơ chế — blr x8 qua ô vtable offset 24 — là biết chính xác bạn trả giá gì và khi nào. Bài sau quay lại thứ borrow checker ở bài 2 đã hé: lifetime — cách Rust theo dõi một tham chiếu sống được bao lâu, lại đo bằng những lỗi biên dịch nó sinh ra.

Phần sau