-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. Nhưng cái tên đã cảnh báo: fast không đi kèm accurate. Bài này đo cả hai mặt của cờ ấy — 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. 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ỗ đo hớ: -ffast-math không chỉ "nhanh hơn" — nó đổi kết quả số.
Một lần tôi đo hớ: 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. - 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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ì. Đọc kỹ -ffast-math bung ra thành những cờ con nào, và chỉ bật cái bạn thật sự cần.
Hệ quả thứ ba là bài học đo lường của bài: một cờ đổi kết quả số (không chỉ tốc độ) là một sự đánh đổi phải cân nhắc, không phải một tối ưu miễn phí. Con số mang theo: cùng tổng 20 triệu float, -O3 cho 1999531776 trong 0,571 ns còn -O3 -ffast-math cho 1999546496 trong 0,143 ns (nhanh 4 lần nhờ vector hóa reduction) — hai tổng khác nhau vì số thực máy không kết hợp ((a+b)+c ≠ a+(b+c)), và -ffast-math còn giả định không NaN/Inf có thể làm hỏng code phòng thủ. Trước khi dán -ffast-math vào, hỏi: mình có chấp nhận sai số làm tròn không, và dữ liệu có thể có NaN/Inf không? Nếu câu trả lời là "không chấp nhận" hay "có thể có NaN", thì cái nhanh gấp bốn kia không đáng.
Thử ba mươi giây
Chép đoạn (a+b)+c với a=1e20f, b=-1e20f, c=1.0f ở trên vào một chương trình, 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.