Hình dung một thu ngân cộng một chồng hóa đơn dài, làm tròn tới từng xu sau mỗi bước. Nếu bạn cho phép anh ta cộng theo thứ tự nào cũng được — chia cho bốn người cùng cộng song song rồi gộp lại — thì nhanh hơn hẳn. Nhưng vì mỗi bước đều làm tròn, tổng cuối có thể lệch vài xu tùy thứ tự cộng. Với tiền thật thì vài xu đó là chuyện lớn; với ước lượng thô thì chẳng ai bận tâm. Cờ -ffast-math của trình biên dịch chính là câu "cộng thứ tự nào cũng được cho nhanh" đó, và cái tên fast đã lặng lẽ bỏ quên chữ accurate.

-ffast-math là một cờ hấp dẫn: bật lên, code toán học của bạn nhanh hơn, đôi khi gấp mấy lần, chỉ tốn một dòng lệnh. Nhiều người dán nó vào mọi bản build "cho nhanh" mà không nghĩ nhiều. Bài này đo cả hai mặt của cờ ấy trong container gcc:13 (ARM) — tốc độ nó cho, và cái nó lấy đi — bằng một phép tổng đơn giản cho ra hai kết quả khác nhau.

Số thực máy tính không kết hợp

Trước khi hiểu -ffast-math, phải hiểu một sự thật gây bất ngờ: phép cộng số thực dấu phẩy động (float) KHÔNG có tính kết hợp. Trong toán học, (a + b) + c = a + (b + c) luôn đúng. Với float thì không, vì mỗi phép cộng phải làm tròn kết quả về số bit hữu hạn. Tôi đo thẳng:

float a = 1e20, b = -1e20, c = 1.0;
(a + b) + c  =  1.0      // (1e20 - 1e20) + 1 = 0 + 1 = 1
a + (b + c)  =  0.0      // 1e20 + (-1e20 + 1) = 1e20 + (-1e20) = 0

Cùng ba số, cùng phép cộng, hai kết quả khác nhau tùy thứ tự. Ở vế thứ hai, -1e20 + 1 bị làm tròn về -1e20 (số 1 quá nhỏ so với 10²⁰ để giữ lại), nên +1 biến mất. Vì thứ tự cộng thay đổi kết quả, một trình biên dịch tuân thủ chuẩn IEEE 754 không được phép đổi thứ tự các phép cộng float — nó phải cộng đúng thứ tự bạn viết, để cho ra đúng kết quả bạn mong.

Đo: fast-math vector hóa tổng, nhanh 4 lần — và ra số khác

Ràng buộc "không đổi thứ tự" đó chặn một tối ưu lớn: vector hóa phép rút gọn (reduction). Cộng một mảng float, s += a[i], có thể vector hóa bằng cách cộng bốn phần tử song song vào bốn tổng riêng rồi gộp cuối — nhưng cách đó đổi thứ tự cộng, nên bộ vector hóa không dám làm nếu không được phép. -ffast-math cho phép chính điều đó. Tôi đo tổng một mảng 20 triệu float, biên dịch hai cách:

Tổng mảng 20 triệu float:
  -O3 (đúng IEEE)   : 0,571 ns/phần tử | tổng = 1999531776
  -O3 -ffast-math   : 0,143 ns/phần tử | tổng = 1999546496
Số lệnh vector NEON trong hàm tổng:
  -O3             : 1
  -O3 -ffast-math : 6

-ffast-math nhanh gấp 4 lần (0,143 so với 0,571 ns), và mã máy xác nhận vì sao: từ 1 lệnh vector lên 6 — nó đã vector hóa phép tổng thành NEON, cộng nhiều float song song (chính là chia cho bốn người cộng cùng lúc). Nhưng nhìn cột kết quả: hai tổng khác nhau — 1999531776 so với 1999546496, lệch khoảng 14.720. Không phải một cái đúng một cái sai; cả hai đều là xấp xỉ hợp lệ của tổng thật, chỉ khác nhau ở cách làm tròn do thứ tự cộng đổi. Với 20 triệu số nhỏ cộng dồn xen vài số lớn, thứ tự cộng ảnh hưởng đáng kể tới các bit cuối. Đây là chỗ tôi vấp: -ffast-math không chỉ "nhanh hơn" — nó đổi kết quả số.

Cú vấp: fast-math không hề vô hại

Định kiến của tôi: "-ffast-math chỉ là một cờ tăng tốc, bật vô tư". Con số bác bỏ. Tốc độ 4 lần là thật, nhưng nó đổi lấy độ chính xác và sự tuân thủ IEEE — và cái giá đó không phải lúc nào cũng chấp nhận được. -ffast-math thực ra là một nhóm giả định phóng khoáng, mỗi cái có rủi ro riêng:

  • Cho sắp lại phép cộng/nhân (-fassociative-math): đổi kết quả như tôi vừa đo. Với một tổng tích lũy dài, sai số có thể lớn hơn nhiều.
  • Giả định không có NaN (-ffinite-math-only): nếu code của bạn có if (isnan(x)) để phòng dữ liệu hỏng, trình biên dịch — được phép giả định NaN không tồn tại — có thể xóa nhánh kiểm tra đó, làm code phòng thủ hỏng im lặng (đúng kiểu compiler xóa nhánh dựa trên giả định, như hành vi không xác định đã đo).
  • Giả định không có Inf, không -0.0, cho chia thành nhân nghịch đảo: mỗi cái đổi hành vi ở các trường hợp biên.

Vậy -ffast-math không phải "nút nhanh miễn phí"; nó là một sự đánh đổi có điều kiện. Nó đáng dùng khi bạn chấp nhận sai số làm tròn nhỏ và biết chắc dữ liệu không có NaN/Inf — như trong đồ họa, game, machine learning, xử lý tín hiệu, nơi "gần đúng và nhanh" là tiêu chí. Nó tai họa khi bạn cần kết quả tái lập chính xác hay đúng IEEE — tài chính, tính toán khoa học, kiểm thử so byte, hay bất cứ chỗ nào có NaN/Inf hợp lệ trong dòng dữ liệu.

Về đến code hằng ngày

Điều đầu tiên: đừng bật -ffast-math cho cả dự án theo phản xạ. Nếu bạn cần tốc độ float mà chỉ ở vài nhân tính toán, hãy bật nó chọn lọc cho đúng những tệp/hàm đó (bằng #pragma hay tách file với cờ riêng), giữ phần còn lại tuân thủ IEEE. Bật toàn cục là ép mọi phép float trong chương trình — kể cả code phòng thủ NaN, kể cả thư viện bạn không đọc — vào giả định phóng khoáng, và một lỗi làm tròn hay một nhánh isnan bị xóa có thể ẩn rất sâu.

Điều thứ hai: nếu cần vector hóa tổng float mà không muốn cả gói -ffast-math, dùng cờ hẹp hơn. Chỉ -fassociative-math (kèm -fno-signed-zeros -fno-trapping-math mà GCC đòi) cho phép sắp lại để vector hóa reduction mà không bật giả định "không NaN/Inf" nguy hiểm. Hoặc tự viết tổng với nhiều tổng tích lũy như bài bung vòng lặp đã làm — bạn kiểm soát chính xác việc đổi thứ tự, và biết mình đang đánh đổi cái gì.

Và đây là chỗ đáng nhớ, vì "phép toán số thực không kết hợp" là một sự thật vượt xa C, âm thầm cắn ở mọi tầng. Java cố ý cấm kiểu tối ưu này theo mặc định — ngữ nghĩa float của Java được định nghĩa chặt để một chương trình cho cùng kết quả trên mọi máy (từng có từ khóa strictfp cho chuyện đó), đánh đổi ngược với C. Nhưng cái bẫy sâu nhất ở tầng dữ liệu lớn: trong Spark, một phép sum() hay groupBy().agg() trên cột float chạy song song qua nhiều partition — thứ tự cộng phụ thuộc cách chia dữ liệu, nên cùng một truy vấn có thể ra tổng khác nhau giữa các lần chạy nếu phân vùng đổi, đúng bản chất phi kết hợp mà -ffast-math phơi bày. Đó là lý do dân dữ liệu tài chính dùng DECIMAL (số thập phân chính xác) chứ không FLOAT cho tiền. Mẫu số chung: số thực máy tính là xấp xỉ, và thứ tự các phép cộng đổi kết quả ở những bit cuối — nên bất cứ khi nào tốc độ đến từ việc song song hóa/sắp lại phép cộng float (từ -ffast-math tới Spark), bạn đang đổi tính tái lập lấy tốc độ, và phải biết mình có được phép đổi hay không.

Muốn tự thấy thì chép đoạn (a+b)+c với a=1e20f, b=-1e20f, c=1.0f, khai a,b,c là volatile float (để trình biên dịch không gấp hằng), và in cả (a+b)+c lẫn a+(b+c): bạn sẽ thấy 1.0 và 0.0 — bằng chứng float không kết hợp, ngay trước mắt. Rồi thử tổng một mảng float lớn, biên dịch gcc -O3 so với gcc -O3 -ffast-math, và in tổng đủ nhiều chữ số: hai con số sẽ khác nhau ở phần đuôi. Ba mươi giây đó cho bạn thấy -ffast-math không phải phép màu miễn phí — nó là một cái công tắc đổi tốc độ lấy độ chính xác, và bạn nên biết mình đang gạt cái gì trước khi gạt.