Khi bạn viết 60 * 60 * 24 cho "số giây trong một ngày", trình biên dịch không để chương trình nhân ba số đó mỗi lần chạy — nó tính sẵn 86400 lúc dịch và nướng luôn con số vào nhị phân. Tối ưu này gọi là gấp hằng số (constant folding), và nó khiến việc viết biểu thức dễ đọc không tốn gì lúc chạy. Nhưng "trình dịch tính hộ" không có nghĩa là nó tính trong một hệ số học hoàn hảo — và đó là chỗ tôi vấp, khi tin rằng gấp hằng số sẽ cứu tôi khỏi cả tràn số lẫn sai số thực.

Gấp hằng số

Trình dịch tính sẵn những gì nó biết là hằng

Gấp hằng số nghĩa là: bất kỳ biểu thức nào trình biên dịch biết chắc giá trị lúc dịch đều được tính ngay, và kết quả thay thế cho cả biểu thức. Tôi đo bằng cách đọc assembly ở -O2:

  • return 60*60*24; — assembly chỉ nạp hằng số 86400 vào thanh ghi (mov + movk cho một số lớn hơn 16 bit), không có một lệnh nhân nào. Ba phép nhân đã xảy ra lúc dịch, một lần duy nhất, thay vì lặp lại ở mỗi lần hàm chạy. Với một biểu thức nằm trong vòng lặp nóng, khác biệt giữa 'tính một lần lúc dịch' và 'tính mỗi vòng lúc chạy' có thể là hàng trăm triệu phép toán được bỏ đi.
  • Lan truyền hằng (constant propagation) đi cùng: int a=10; int b=a+5; return b*2; — giá trị hằng 10 chảy qua a, thành b=15, rồi b*2=30. Assembly chỉ còn mov w0, 30. Cả chuỗi biến trung gian bốc hơi.
  • Ngay cả một hàm "thuần" trên hằng cũng được gấp: strlen("khong gian")-O2 thành mov x0, 10 — trình dịch biết độ dài chuỗi literal nên tính sẵn 10, không gọi strlen lúc chạy.

Lợi ích rõ ràng: bạn được viết 1024 * 1024 thay vì gõ 1048576, viết SECONDS_PER_DAY bằng biểu thức có ý nghĩa, mà không trả giá hiệu năng. Tôi tin tưởng cơ chế này đến mức cho rằng nó cũng "làm toán hộ" mình một cách sạch sẽ. Đo ra thì không.

Một lần tôi đo hớ: gấp hằng số không phải toán học, mà là số học của chương trình

Tôi có hai giả định. Thứ nhất: cứ viết biểu thức hằng lớn thoải mái, trình dịch sẽ tính đúng — nên 1000000 * 1000000 (một nghìn tỷ) chẳng vấn đề gì. Thứ hai: một phép so sánh số thực được tính sẵn lúc dịch thì chắc chắn "đúng toán học", nên (0.1 + 0.2) == 0.3 sẽ là đúng. Đo cả hai:

1000000 * 1000000  (int)  ->  gấp thành -727379968
                              gcc cảnh báo: integer overflow ... results in '-727379968'
0.1 + 0.2                 ->  gấp thành 0.30000000000000004
(0.1 + 0.2) == 0.3        ->  0  (SAI)
1e20 + 1.0 - 1e20         ->  gấp thành 0  (toán học phải là 1)

Cả hai giả định sai. 1000000 * 1000000 là 10^12, vượt xa giới hạn của int 32 bit, nên phép nhân tràn và cho kết quả quấn vòng -727379968 — một con số âm vô nghĩa về mặt toán học. Quan trọng: trình dịch không tính trong độ chính xác vô hạn rồi mới nhét vào int; nó thực hiện phép nhân với đúng luật tràn của int, y hệt như lúc chạy sẽ tràn. Và gcc còn cảnh báo -Woverflow — công cụ nói thẳng cho tôi biết, nếu tôi chịu đọc.

Số thực cũng vậy. 0.1 + 0.2 được gấp thành 0.30000000000000004 — đúng kết quả IEEE 754 của phép cộng hai số double không biểu diễn chính xác được 0.10.2 — chứ không phải 0.3 "toán học". Nên phép so bằng 0.3 cho sai, dù được tính sẵn lúc dịch. Và 1e20 + 1.0 - 1e20 gấp thành 0, không phải 1: vì 1e20 + 1.0 trong double không phân biệt được với 1e20 (mất chữ số), rồi trừ đi 1e20 còn 0. Trình dịch giữ nguyên thứ tự và độ chính xác IEEE, không tự ý kết hợp lại để ra con số "đúng toán".

Bài học đo lường: gấp hằng số không phải là trình dịch làm toán hộ bạn trong một hệ số đẹp hơn — nó chạy chính biểu thức của bạn, lúc dịch, với đúng luật số học của chương trình. int thì tràn quấn, double thì theo IEEE 754 không tái kết hợp. Giá trị được nướng vào nhị phân y hệt giá trị bạn sẽ nhận lúc chạy — kể cả bug tràn số và sai số dấu phẩy động. Tôi đã nhầm "được tính sẵn" với "được tính đúng theo trực giác toán học"; đo ra chúng là hai chuyện khác nhau.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là để ý kiểu của biểu thức hằng. 1000000 * 1000000 tràn vì cả hai toán hạng là int. Muốn kết quả đúng, phải ép ít nhất một toán hạng sang kiểu đủ rộng trước khi nhân: 1000000L * 1000000 (long) hoặc 1000000LL * 1000000LL. Điều này đúng cho cả hằng gấp lúc dịch lẫn phép tính lúc chạy — vì chúng dùng chung một luật. Đừng cho rằng "hằng số thì trình dịch lo được"; kiểu quyết định miền giá trị, và gấp hằng số tôn trọng kiểu đó tuyệt đối.

Hệ quả thứ hai là đừng bao giờ so sánh số thực bằng ==, kể cả khi giá trị "trông có vẻ" tính được chính xác. 0.1 + 0.2 == 0.3 sai ở cả compile-time lẫn runtime vì cùng lý do IEEE 754. Hãy so sánh với một dung sai (fabs(a-b) < epsilon). Cùng lý do đó, một hằng số như 0.1 viết trong mã nguồn không hề là 0.1 chính xác khi vào double — nó là số IEEE gần nhất, và mọi phép tính về sau kế thừa sai lệch tí xíu đó, dù được gấp sẵn lúc dịch hay tính lúc chạy. Việc gấp hằng số cho kết quả IEEE chính xác là một lời nhắc tốt: sai số dấu phẩy động không phải "lỗi lúc chạy" mà là bản chất của kiểu — nó có mặt ngay cả trong hằng số.

Hệ quả thứ ba là một góc nhìn xuyên suốt: tối ưu bảo toàn ngữ nghĩa, nên nó cũng bảo toàn cả những cái "sai" mà ngữ nghĩa quy định. Gấp hằng số không sửa tràn số hay sai số thực cho bạn, vì làm vậy sẽ đổi kết quả chương trình — mà tối ưu thì không được đổi kết quả quan sát được. Đây là lý do trình dịch không dám tái kết hợp 1e20+1-1e20 thành 1 (trừ khi bạn bật -ffast-math, tự nhận rủi ro). Con số mang theo: gấp hằng số tính sẵn biểu thức hằng lúc dịch (606024 thành 86400, strlen literal thành độ dài, không tốn gì lúc chạy), nhưng nó dùng đúng số học của chương trình — int tràn quấn (1000000*1000000 = -727379968), double theo IEEE (0.1+0.2 ≠ 0.3) — nên giá trị nướng vào nhị phân y hệt giá trị runtime, bug và tất cả. Trình dịch tính hộ bạn, nhưng bằng chính cỗ máy số học mà chương trình bạn sẽ dùng — không phải một cái máy tính lý tưởng.

Thử ba mươi giây

Viết int x = 1000000 * 1000000; rồi dịch gcc -O2 file.c — bạn sẽ thấy cảnh báo -Woverflow ngay, kèm con số quấn vòng mà trình dịch tính ra. Sửa thành long x = 1000000L * 1000000; và cảnh báo biến mất. Rồi thử printf("%d\n", 0.1 + 0.2 == 0.3); — in ra 0 — sai, dù được tính sẵn lúc dịch. Đổi sang printf("%.17g\n", 0.1 + 0.2); để thấy 0.30000000000000004. Chỉ vài dòng là bạn thấy tận mắt: gấp hằng số tính sẵn cho bạn, nhưng bằng đúng số học mà chương trình sẽ chạy — và cả cái warning kia là công cụ đang nói thật.