int hay long, float hay double — nhiều người chọn kiểu số theo thói quen, coi "một phép cộng là một phép cộng", kiểu nào cũng thế. Nhưng kiểu số bạn chọn có thể làm cùng một vòng lặp chạy nhanh gấp chục lần hoặc chậm đi — nhất là khi trình biên dịch vector hóa hoặc khi vòng bị băng thông bộ nhớ trói. Tôi đo cùng một phép trên mảng của sáu kiểu số trong container gcc:13 trên host ARM, và khoảng chênh lớn hơn nhiều người tưởng — cùng với một sắc thái ngược quan trọng.
Kiểu hẹp gói nhiều phần tử hơn mỗi lệnh SIMD
Thanh ghi vector NEON trên ARM rộng 128 bit. Một lệnh SIMD xử lý cả thanh ghi cùng lúc — nghĩa là số phần tử nó làm mỗi lệnh phụ thuộc kích thước kiểu: 16 số int8, 8 số int16, 4 số int32/float, hay 2 số int64/double. Kiểu càng hẹp, một lệnh làm càng nhiều phần tử → thông lượng cao hơn. Và với vòng memory-bound, kiểu hẹp còn lợi lần nữa: ít byte hơn nghĩa là ít băng thông phải tiêu.
Tôi đo một phép elementwise a[i] = a[i]*k + c (vector hóa được) trên mảng từng kiểu, đo ns mỗi phần tử — cả khi mảng vừa L1 (đo tính toán/độ rộng SIMD) và khi mảng lớn hơn cache (đo băng thông).
Đo: int8 nhanh gấp 10 lần int64
a[i]=a[i]*k+c, ns/phần tử, host ARM (NEON 128-bit):
L1 (vector hóa, -O3 -mcpu=native)
int8 (16/lệnh) : 0,010 float (4/lệnh) : 0,051
int16 ( 8/lệnh) : 0,020 double (2/lệnh) : 0,102
int32 ( 4/lệnh) : 0,051
int64 ( 2/lệnh) : 0,102
RAM (memory-bound)
int8 : 0,021 int32 : 0,089 int64 : 0,182 double : 0,192
Nhìn cột L1: throughput tỉ lệ nghịch với kích thước kiểu một cách gần như hoàn hảo. int8 tốn 0,010 ns/phần tử, int64 tốn 0,102 — int8 nhanh gấp ~10 lần int64. Mỗi lần kiểu hẹp đi một nửa, thông lượng gấp đôi (0,010 → 0,020 → 0,051 → 0,102), đúng như số phần tử mỗi lệnh (16 → 8 → 4 → 2). float (4/lệnh) nhanh gấp 2 double (2/lệnh) — 0,051 vs 0,102. Không phải "một phép là một phép"; kiểu quyết định bạn làm bao nhiêu phần tử mỗi nhịp.
Cột RAM (mảng lớn, memory-bound): kiểu hẹp cũng thắng, vì ít byte phải chuyển — int8 0,021 ns vs int64 0,182 = ~8,8 lần. Ở đây lợi thế đến từ băng thông: int8 chỉ chuyển 1/8 số byte của int64.
Đo hớ ngược: scalar thì mọi kiểu int như nhau
Nhưng đây là chỗ dễ vấp nếu vội kết luận "kiểu nhỏ luôn nhanh nhất". Khi không vector hóa (biên dịch -fno-tree-vectorize), tôi đo lại:
L1 scalar (không vector): int8 = int16 = int32 = int64 = 0,224 ns/phần tử (Y HỆT)
Mọi kiểu int cho đúng cùng 0,224 ns — vì không có đóng gói SIMD, mỗi phần tử là một phép vô hướng, và một phép cộng/nhân vô hướng tốn ~1 chu kỳ bất kể kích thước kiểu. Lợi thế của kiểu hẹp chỉ xuất hiện khi SIMD vào cuộc (hoặc khi memory-bound). Ở scalar thuần, int8 và int64 nhanh như nhau — và int8 còn dễ tràn (chỉ chứa -128..127; a*k+c tràn ngay).
Một lần tôi đo hớ: "kiểu nào cũng như nhau" và "cứ nhỏ nhất là nhanh nhất"
Tôi vào đo với mô hình lười: "cộng là cộng, int hay long gì cũng một phép, tốc độ như nhau". Đo phá tan: khi vector hóa, int8 nhanh gấp 10 lần int64 (0,010 vs 0,102 ns) vì NEON gói 16 vs 2 phần tử mỗi lệnh; và memory-bound thì int8 nhanh ~8,8 lần vì ít byte. Chọn kiểu hẹp hơn cho cùng bài toán là một tăng tốc thật, cả về SIMD lẫn băng thông — điều "một phép là một phép" giấu hoàn toàn.
Nhưng đo cũng phá niềm tin ngược — cái người vừa thấy kiểu hẹp thắng dễ mắc: "vậy cứ dùng kiểu nhỏ nhất (int8) là nhanh nhất". Sai theo hai hướng. Một: ở scalar (không vector hóa được — có phụ thuộc, có nhánh, không liền mạch), mọi kiểu int cho cùng 0,224 ns; kiểu nhỏ không giúp gì. Hai: kiểu nhỏ dễ tràn (int8 chỉ -128..127) và đôi khi phải chuyển đổi kiểu (widen/narrow) tốn thêm. Nguyên tắc đúng là hẹp nhất đủ dùng — vừa khít miền giá trị thật của dữ liệu — không phải "nhỏ nhất bằng mọi giá".
Bài học đo lường: KIỂU SỐ quyết định throughput khi VECTOR HÓA và khi MEMORY-BOUND — thanh ghi SIMD (NEON 128-bit) gói 16 int8 / 8 int16 / 4 int32-float / 2 int64-double mỗi lệnh. Đo (L1 vector): int8 0,010 vs int64 0,102 = ~10x; float 2x double. Memory-bound int8 ~8,8x int64 (ít byte). NHƯNG scalar (không vector) mọi kiểu int = 0,224 ns Y HỆT; và kiểu nhỏ dễ tràn. 'Kiểu nào cũng như nhau' và 'cứ nhỏ nhất là nhanh nhất' đều SAI. Nếu tin "kiểu nào cũng như nhau" tôi bỏ lỡ tăng tốc chục lần; nếu tin "nhỏ nhất luôn nhanh" tôi rước tràn số và tưởng scalar cũng lợi.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chọn kiểu hẹp nhất đủ chứa miền giá trị, cho các vòng nóng xử lý mảng. Ảnh (uint8), tín hiệu (int16), tọa độ (int32 thay int64), tính toán khoa học chịu được float thay double — đổi sang kiểu hẹp cho tăng tốc SIMD và băng thông. Đây là một trong những tối ưu "miễn phí" nhất: đổi khai báo kiểu, không đổi thuật toán.
Hệ quả thứ hai: cân nhắc tràn số và độ chính xác — hẹp đủ dùng, không hẹp bừa. Trước khi hạ int64→int32 hay double→float, kiểm tra miền giá trị thật và sai số chấp nhận được. Kiểu nhỏ tràn âm thầm (wrap) và float mất chính xác — nhanh mà sai thì vô nghĩa. Và nhớ: nếu vòng không vector hóa được, kiểu hẹp chẳng giúp tốc độ, nên tối ưu này chỉ đáng cho các vòng SIMD được hoặc memory-bound.
Hệ quả thứ ba là tinh thần đo lường: "một phép tính" không có một giá — nó tùy kiểu, và tùy vòng có vector hóa/memory-bound hay không. Con số mang theo: vector hóa: throughput ~ tỉ lệ nghịch kích thước kiểu (int8 ~10x int64, float 2x double); memory-bound: kiểu hẹp ~8,8x (ít byte); scalar: mọi int như nhau. Chọn kiểu hẹp nhất ĐỦ DÙNG cho vòng nóng xử lý mảng. Cùng một phép nhân-cộng, trên int8 hay int64, nhanh chậm gấp mười lần tùy độ rộng kiểu và độ rộng thanh ghi SIMD — đo mới thấy kiểu số cũng là một quyết định hiệu năng.
Thử ba mươi giây
Viết một vòng elementwise đơn giản a[i] = a[i]*k + c và chạy nó trên mảng của các kiểu khác nhau: int8_t, int16_t, int32_t, int64_t, float, double. Biên dịch với -O3 -march=native (hoặc -mcpu=native) để bật vector hóa, giữ số phần tử như nhau, và bấm giờ ns mỗi phần tử. Bạn sẽ thấy int8 nhanh gấp tám tới mười lần int64, và mỗi lần kiểu hẹp đi một nửa thì thông lượng gần gấp đôi — đúng bằng số phần tử một thanh ghi SIMD chứa được. Rồi biên dịch lại với -fno-tree-vectorize (tắt SIMD) và đo lại: lần này mọi kiểu int cho cùng con số — vì không đóng gói thì kích thước kiểu không còn ảnh hưởng. Ba mươi giây đó cho bạn thấy điều mà "kiểu nào cũng một phép" giấu đi: khi phần cứng làm nhiều phần tử mỗi lệnh, kiểu hẹp gói được nhiều hơn nên nhanh hơn hẳn — nhưng lợi thế ấy biến mất ở scalar, và kiểu quá nhỏ thì rước lấy tràn số. Hẹp nhất đủ dùng, không phải nhỏ nhất.