int hay long, float hay double — phần lớn chúng ta chọn kiểu số theo thói quen, ngầm tin "một phép cộng là một phép cộng", kiểu nào cũng thế. Tôi cũng từng vậy, cho tới khi đo. 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.
Hình dung một cái xe tải giao hàng. Mỗi chuyến đi tốn như nhau — cùng quãng đường, cùng xăng — bất kể bạn chở gì. Nếu hàng là những thùng nhỏ, một chuyến chở được 16 thùng; nếu là kiện lớn cồng kềnh, chỉ nhét được 2. Cùng số "chuyến", nhưng lượng hàng giao xong chênh nhau 8 lần. Thanh ghi SIMD của CPU chính là cái xe tải đó: mỗi lệnh là một chuyến, và kiểu số quyết định mỗi chuyến chở được bao nhiêu phần tử. 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 tôi nghĩ — kèm một sắc thái ngược mà nếu bỏ qua thì tối ưu thành phản tác dụ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 càng cao. 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, tức độ 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 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. Hai con đường khác nhau — một do độ rộng SIMD, một do lượng byte — nhưng cùng dẫn tới cùng kết luận: kiểu hẹp thắng đậm.
Chỗ dễ vấp: scalar thì mọi kiểu int như nhau
Đây là chỗ tôi suýt kết luận sai. Nếu chỉ nhìn hai bảng trên rồi phán "kiểu nhỏ luôn nhanh nhất" thì hớ. 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. Cái xe tải giờ chở đúng một món mỗi chuyến, nên món to hay nhỏ cũng thế. 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).
Hai thái cực đều sai
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". Con số đập tan: khi vector hóa, int8 nhanh gấp 10 lần int64 (0,010 vs 0,102 ns) vì NEON gói 16 so với 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à "một phép là một phép" giấu hoàn toàn.
Nhưng nếu vội rút ra "vậy cứ dùng kiểu nhỏ nhất (int8) là nhanh nhất" thì lại sa vào cực ngược, và nó sai theo hai hướng. Một: ở scalar (khi vòng 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 — chứ không phải "nhỏ nhất bằng mọi giá".
Về đến code hằng ngày
Việc đầ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 gần như miễn phí nhất: đổi khai báo kiểu, không đụng thuật toán.
Việc 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 thì 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.
Cái quyết định "chọn kiểu số" này hóa ra là một chủ đề nóng ở tầng cao chứ không riêng gì C. Cả làn sóng machine learning mấy năm nay xoay quanh đúng nó: người ta hạ mô hình từ float32 xuống float16, bfloat16, rồi int8 (quantization) chính vì mỗi bước hẹp đi là mỗi bước gói thêm phần tử vào một lệnh vector và cắt lượng byte phải nạp — cùng cơ chế bài này đo, chỉ ở quy mô tỉ tham số. Numpy để bạn khai dtype=np.int8 hay np.float32 cũng vì lý do này. Còn phía database, kiểu cột không chỉ là chuyện đúng/sai: khai một cột là SMALLINT thay vì BIGINT, hay REAL thay vì DOUBLE PRECISION, là mỗi hàng gọn hơn, mỗi trang chứa nhiều hàng hơn, và một lần quét bảng đọc ít byte hơn hẳn — đúng cái lợi "ít byte" ở cột RAM phía trên, nhân lên hàng triệu hàng. Nguyên tắc xuyên suốt vẫn thế: "một phép tính" không có một cái giá cố định, nó tùy kiểu và tùy vòng có vector hóa hay bị byte trói hay không.
Muốn tự thấy thì viết một vòng elementwise đơn giản a[i] = a[i]*k + c và chạy trên mảng 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, 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 hết ảnh hưởng. Ba mươi giây đó cho 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.