Hình dung hai người dán hai nhãn khác nhau — "Hộp INT" và "Hộp FLOAT" — lên cùng một cái hộp mà không ai biết. Một anh quản kho cực kỳ máy móc tin chắc: nhãn khác nhau nghĩa là hộp khác nhau. Nên khi bạn bảo "sửa Hộp FLOAT", anh ta làm, rồi lúc bạn hỏi "Hộp INT giờ ra sao?", anh ta trả lời theo cái đã ghi nhớ mà không thèm mở hộp ra ngó lại — vì theo sổ sách, hai hộp đâu liên quan. Câu trả lời sai, nhưng anh ta làm đúng luật anh được giao. Trình biên dịch C đối xử với hai con trỏ khác kiểu đúng như hai cái nhãn đó, và đây là nguồn của một loại bug khó chịu bậc nhất: chạy đúng ở -O0, sai ở -O2.

Thường thì đó không phải lỗi của trình biên dịch — bạn đã vi phạm một quy tắc của chuẩn C mà nó được phép giả định. Bài này đo một quy tắc như vậy, strict aliasing (bí danh nghiêm), trong container gcc:13 (ARM), bằng một thí nghiệm 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. Đúng anh quản kho tin hai nhãn khác nhau là hai hộp khác nhau.

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. Cái giá của tốc độ này nằm ở mặt kia — nơi tôi vấp.

Cú vấp: é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án hai nhãn khác kiểu lên cùng một hộp. 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, không thèm mở hộp ngó lại. 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ề đến code hằng ngày

Điều đầ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.

Điều 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ó.

Và đây là chỗ đáng nhớ nhất, vì strict aliasing là một trong những chỗ triết lý thiết kế ngôn ngữ chia đôi rõ nhất. Java và Go không cho type-pun bằng ép kiểu con trỏ chút nào — hệ kiểu chặn thẳng, và muốn đọc lại bit bạn phải qua API tường minh (Float.floatToIntBits của Java, math.Float32bits của Go) vốn định nghĩa rõ việc diễn giải lại, không có vùng xám UB. Đổi lại, chúng mất sự tự do (và một phần tốc độ) mà con trỏ trần của C cho. Rust đi đường giữa: ép kiểu như vậy chỉ được làm trong khối unsafe, và cách hợp lệ là f32::from_bits — an toàn được đưa vào ngôn ngữ. Thú vị là cách đúng ở mọi nơi đều giống nhau về bản chất: một phép "diễn giải lại bit" được định nghĩa rõ ràng, chứ không phải lén lút qua con trỏ. Mẫu số chung: "cùng vùng nhớ, hai cách diễn giải" là một thao tác hợp lệ và hữu ích, nhưng nó phải đi qua cửa chính thức (memcpy, union, API bit) để trình biên dịch biết mà không giả định sai — lách qua cửa sau bằng ép kiểu con trỏ là mời gọi UB, và cửa chính thường chẳng chậm hơn.

Muốn tự thấy thì 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 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.