Có một loại lỗi khó chịu bậc nhất: code chạy đúng ở -O0, sai ở -O2, và bạn đổ lỗi cho trình biên dịch. Thường thì không phải lỗi của nó — bạn đã vi phạm một quy tắc của chuẩn C mà trình biên dịch được phép giả định. Bài này đo một quy tắc như vậy, strict aliasing (bí danh nghiêm), bằng một thí nghiệm nhỏ cho ra hai kết quả khác nhau từ cùng một dòng code — dấu hiệu không thể nhầm của hành vi không xác định.
Strict aliasing: con trỏ khác kiểu không trỏ chung
Quy tắc strict aliasing của chuẩn C nói: hai con trỏ có kiểu không tương thích (ví dụ int* và float*) được giả định không trỏ vào cùng một vùng nhớ — không "alias" nhau. Đây không phải suy đoán mà là một quyền mà chuẩn trao cho trình biên dịch, và nó dùng quyền đó để tối ưu.
Vì sao giả định này quý? Xét một vòng lặp đọc giá trị *ip (một int) và ghi fp[i] (các float) mỗi vòng. Nếu int* và float* có thể trỏ chung, thì mỗi lần ghi fp[i] có thể đã đổi *ip, nên trình biên dịch buộc phải nạp lại *ip từ bộ nhớ mỗi vòng — chậm. Nhưng với strict aliasing, nó biết fp[i] (float) không thể đụng tới *ip (int), nên giữ *ip trong thanh ghi suốt vòng, bỏ nạp lại, sắp lại đọc/ghi tự do. Tôi đo vòng ấy:
Vòng đọc *ip (int) + ghi fp[i] (float), ns mỗi phần tử:
-O2 (strict-aliasing, mặc định) : 0,233 ns
-O2 -fno-strict-aliasing : 0,255 ns (chậm ~9%)
Strict aliasing nhanh hơn khoảng 9% ở đây — không kịch tính, nhưng thật, và đó là lý do -O2 bật nó mặc định. (Đôi khi các phân tích khác của trình biên dịch cũng suy ra được không alias, nhưng strict aliasing là bảo đảm để nó tối ưu mà không cần chứng minh.) Cái giá của tốc độ này nằm ở mặt kia — nơi tôi đo hớ.
Một lần tôi đo hớ: ép kiểu con trỏ cho ra hai kết quả
Định kiến rất phổ biến (và tôi từng tin): "muốn đọc lại các byte của một int như một float, cứ ép con trỏ: *(float*)&x — nó chỉ là đọc lại cùng bộ nhớ, an toàn thôi". Đây gọi là type-punning (ép kiểu để diễn giải lại bit), và nó vi phạm giả định strict aliasing: bạn đang dùng một float* để chạm vào bộ nhớ vốn là int. Tôi viết một hàm phơi bày điều đó:
int pun_ptr(int* i, float* f){
*i = 1; // ghi qua con trỏ int
*f = 2.0f; // ghi qua con trỏ float (khác kiểu, cùng địa chỉ)
return *i; // đọc lại qua con trỏ int
}
// gọi với cùng một địa chỉ ép hai kiểu:
int x; pun_ptr(&x, (float*)&x);
Rồi biên dịch hai cách và chạy:
pun_ptr(&x, (float*)&x) trả về:
-O2 (strict-aliasing) : 1
-O2 -fno-strict-aliasing : 1073741824
Cùng một dòng code, cùng một đầu vào, hai kết quả khác nhau. Với -O2, trình biên dịch giả định *f (float) không thể đụng tới *i (int) — nên nó kết luận lệnh *f = 2.0f không đổi *i, và return *i dùng thẳng giá trị 1 vừa ghi, bỏ qua việc *f = 2.0f thực ra đã ghi đè lên chính vùng nhớ đó. Với -fno-strict-aliasing, trình biên dịch không được giả định vậy, nên nó nạp lại *i từ bộ nhớ và thấy các byte mà 2.0f để lại — 1073741824 chính là bit pattern 0x40000000 của số 2.0f đọc như một int.
Con số nào "đúng"? Không con nào cả — đây là hành vi không xác định (undefined behavior, UB). Khi hai cờ biên dịch cho hai kết quả cho cùng một code, đó là dấu hiệu chắc chắn rằng bạn đã rơi vào vùng UB, nơi chuẩn không định nghĩa điều gì phải xảy ra và trình biên dịch được tự do làm bất cứ gì. Đây không phải bug của trình biên dịch — nó đang tối ưu đúng theo quyền mà chuẩn cho; bug nằm ở code type-pun sai cách.
Cách đúng, và vì sao nó cũng nhanh
Vậy làm sao đọc lại bit của một int như float một cách hợp lệ? Dùng memcpy (hoặc một union):
float bits_as_float(uint32_t x){
float f; memcpy(&f, &x, 4); return f;
}
// bits_as_float(0x40000000) = 2.0 (đúng, ở CẢ HAI cờ)
memcpy sao chép byte theo nghĩa "byte thô", không vi phạm strict aliasing, nên nó xác định — cho 2.0 ở cả -O2 lẫn -fno-strict-aliasing. Và điều tuyệt nhất: trình biên dịch hiểu memcpy của kích thước nhỏ cố định và thường biến nó thành đúng một lệnh mov — nên bạn được cả tính đúng lẫn tốc độ, không phải hy sinh cái nào. "Cách an toàn" ở đây không hề chậm hơn "cách ẩu"; nó chỉ đúng hơn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên, rất cụ thể: đừng type-pun bằng cách ép kiểu con trỏ; dùng memcpy hay union. Bất cứ khi nào bạn thấy *(T2*)&x với T2 khác kiểu của x (đọc float từ int, đọc struct từ mảng byte, ...), đó là một quả bom UB có thể nổ khi ai đó bật -O2 hay đổi trình biên dịch. Thay bằng memcpy(&dst, &src, sizeof) — cùng ý nghĩa, hợp lệ, và thường sinh cùng mã máy. Đây là một trong những lỗi UB phổ biến nhất trong code C thật, đặc biệt trong parser nhị phân, serialization, và code đồ họa/mạng.
Hệ quả thứ hai: "chạy đúng ở -O0, sai ở -O2" gần như luôn là UB của bạn, không phải bug compiler. -O0 không tối ưu nên nhiều UB "tình cờ" chạy như bạn mong; -O2 tối ưu dựa trên giả định chuẩn, và UB lộ ra. Khi gặp triệu chứng này, đừng đổ lỗi cho trình biên dịch — chạy với -fsanitize=undefined (UBSan) để nó chỉ ra chỗ UB, hoặc thử -fno-strict-aliasing để xem có phải aliasing không. Nếu tắt một tối ưu làm code "đúng" trở lại, bạn vừa tìm thấy UB, không phải sửa nó.
Hệ quả thứ ba là bài học đo lường sắc bén nhất: một cờ biên dịch làm đổi kết quả (không phải tốc độ) là bằng chứng của hành vi không xác định. Con số mang theo: cùng hàm pun_ptr với cùng đầu vào trả về 1 với -O2 (strict-aliasing) nhưng 1073741824 với -fno-strict-aliasing — một cờ đổi kết quả nghĩa là UB; strict aliasing cho tốc độ (~9% ở đây) bằng cách giả định con trỏ khác kiểu không alias, và type-punning bằng ép kiểu con trỏ vi phạm giả định đó, còn memcpy thì hợp lệ và thường nhanh bằng. Tối ưu của trình biên dịch dựa trên việc bạn tuân thủ chuẩn; bước ra khỏi chuẩn là bước vào vùng nơi hai lần biên dịch có thể cho hai vũ trụ khác nhau — và đồng hồ lẫn output đều sẽ cho bạn thấy, nếu bạn đo cả hai cờ.
Thử ba mươi giây
Chép hàm pun_ptr ở trên vào một file, gọi int x; printf("%d\n", pun_ptr(&x, (float*)&x));, rồi biên dịch hai lần: gcc -O2 file.c và gcc -O2 -fno-strict-aliasing file.c, chạy cả hai. Bạn sẽ thấy hai số khác nhau — cùng một chương trình. Đó là UB hiện hình trước mắt. Rồi thử gcc -O2 -fsanitize=undefined file.c và chạy: rất có thể UBSan in ra một cảnh báo về "type punning" hay "load of misaligned/aliased" ngay tại dòng đó. Ba mươi giây ấy dạy bạn nhận ra dấu vân tay của UB — kết quả đổi theo cờ tối ưu — một kỹ năng cứu bạn khỏi hàng giờ đổ lỗi nhầm cho trình biên dịch.