bài về vector hóa SIMD, ta thấy trình biên dịch vector hóa được tổng số nguyên nhưng không vector hóa được tổng float — trừ khi bật -ffast-math. Lý do: cộng dấu phẩy động không kết hợp, nên đổi thứ tự cộng là đổi kết quả, và trình biên dịch mặc định không dám. -ffast-math cho phép nó đổi. Bài này đo cả cái được (tốc độ) lẫn cái mất (bảo đảm) — và tôi vào bài với một định kiến bị chính phép đo lật ngược.

Dấu phẩy động và fast-math

Vì sao mặc định không kết hợp

Số dấu phẩy động chỉ có hữu hạn bit, nên mỗi phép cộng phải làm tròn kết quả về giá trị biểu diễn được gần nhất. Điều đó khiến phép cộng không kết hợp: (a+b)+c có thể khác a+(b+c), vì hai cách nhóm làm tròn ở những chỗ khác nhau. Do đó, khi bạn viết một vòng cộng dồn s += a[i], chuẩn ngôn ngữ buộc trình biên dịch giữ đúng thứ tự trái sang phải, một luỹ tích duy nhất — nó không được gộp bốn phần tử vào một phép cộng vector song song, vì thứ tự khác cho kết quả khác.

-ffast-math cởi bỏ ràng buộc đó. Nó nới lỏng chuẩn IEEE 754: cho trình biên dịch coi phép cộng là kết hợp (đổi thứ tự tùy ý), giả định không có NaN hay Inf, xóa các số cực nhỏ (denormal) về 0, và cho phép xấp xỉ những phép như nghịch đảo hay căn. Đổi lại, nó mở khóa vector hóa và nhiều tối ưu khác. Nghe như một món hời — nhanh hơn, chỉ "sai số một chút". Đó chính là chỗ tôi hiểu lầm.

Đo: bản strict mới là bản sai nặng

Tôi tính tổng một mảng 20 triệu float, cố tình xếp một số lớn ở đầu (1e7) rồi toàn số 1.0 phía sau. Tổng đúng theo toán học là khoảng 29999999. Đo hai bản, ghim một lõi, lấy nhỏ nhất trong 7 lần:

Cấu hình Kết quả (đúng ≈ 29999999) Thời gian
-O2 (IEEE strict) 16777216 12,78 ms
-O2 -ffast-math 30000000 3,20 ms

Hãy nhìn kỹ cột kết quả. Bản -O2 strict cho 16777216 — sai kinh khủng, chỉ bằng hơn một nửa đáp án đúng. Con số đó chính là 2^24, ngưỡng mà float 32-bit không còn phân biệt được các số nguyên liên tiếp: khi luỹ tích chạm 16777216, cộng thêm 1.0 cho ra... đúng 16777216 (số 1.0 bị làm tròn mất hoàn toàn). Vòng cộng vô hướng trái-sang-phải kẹt cứng ở đó và không nhích thêm được nữa.

Bản -ffast-math cho 30000000 — gần đúng, và nhanh gấp 4 lần. Đọc assembly rõ vì sao: bản strict dùng một luỹ tích vô hướng (fadd s0, một thanh ghi), còn bản fast-math dùng fadd v0.4sbốn luỹ tích vector song song. Vì tổng được chia cho bốn luỹ tích, mỗi cái chỉ chạy tới khoảng 7,5 triệu — chưa chạm ngưỡng 2^24 — nên không luỹ tích nào bị mất chữ số, và kết quả gần đúng hơn hẳn.

Một lần tôi đo hớ: fast-math không "kém chính xác"

Tôi vào bài này với một định kiến phổ biến: "-ffast-math phá vỡ IEEE nên làm kết quả kém chính xác — mình sẽ dựng một ví dụ để bắt quả tang nó". Tôi dựng đúng ví dụ đó, chạy, và phép đo tát vào mặt tôi: bản strict (giữ đúng IEEE) mới là bản sai nặng (16777216), còn bản fast-math (phá IEEE) lại gần đúng hơn nhiều (30000000) nhanh gấp 4 lần. Ngược hoàn toàn với điều tôi định chứng minh.

Hai bất ngờ cùng lúc — nhanh hơn mà lại đúng hơn — là dấu hiệu tôi đã đo bằng một mô hình sai. Sự thật: -ffast-math không làm kết quả "kém chính xác"; nó làm kết quả khác. Khác tốt hay khác xấu là tùy dữ liệu. Ở đây, việc chia thành bốn luỹ tích song song tình cờ tránh được ngưỡng 2^24 nên tốt hơn; ở dữ liệu khác, việc đổi thứ tự có thể tệ hơn. Điều fast-math thật sự lấy đi không phải "độ chính xác" mà là tính tái lập theo IEEE: kết quả không còn được chuẩn bảo đảm, có thể đổi giữa các trình biên dịch, các mức -O, các kiến trúc.

Và hiểm họa thật nằm ở một chỗ tôi suýt bỏ qua khi mải nhìn con số tổng. -ffast-math giả định không có NaN. Tôi viết hàm phát hiện NaN theo cách chuẩn — int is_nan(double x){ return x != x; } (chỉ NaN mới khác chính nó) — rồi đọc assembly:

-O2:            fcmp d0, d0 ; cset w0, ne   -> so sánh thật, đúng
-O2 -ffast-math: mov  w0, #0                 -> LUÔN trả về 0

Với -ffast-math, vì trình biên dịch "biết chắc" không có NaN, nó gấp x != x thành hằng falseis_nan luôn trả về 0. Lá chắn phát hiện NaN của tôi bị xóa sạch — một NaN thật sẽ lọt qua mà không ai biết. Đây mới là cái giá đáng sợ: không phải sai số nhỏ, mà là các bảo đảm biến mất âm thầm. Bài học đo lường: "nhanh" là vô nghĩa nếu chưa hỏi "đúng và bền chưa" — tôi đã suýt kết luận "fast-math free 4x, dùng đi" mà quên hỏi cái vế thứ hai, và vế thứ hai chính là chỗ nó cắn.

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ả chương trình theo phản xạ "nhanh hơn". Nó là một cờ toàn cục cực mạnh, và nó tắt những thứ mã của bạn có thể đang dựa vào mà bạn không nhớ: kiểm tra NaN (x != x, isnan), so sánh với Inf, xử lý denormal, tính tái lập giữa các nền. Một thư viện tài chính hay khoa học dựa vào ngữ nghĩa IEEE chặt chẽ có thể hỏng theo cách rất khó lần, vì lỗi không nổ — nó chỉ cho số hơi khác, hoặc để một NaN trôi qua. Nếu cần tốc độ, hãy bật fast-math cục bộ cho đúng vòng lặp số học chịu được sai khác (nhiều trình biên dịch cho phép #pragma theo hàm), chứ đừng rải cả dự án.

Hệ quả thứ hai: hiểu rằng thứ tự cộng float là một quyết định số học, không phải chi tiết vặt. Bài này cho thấy chính thứ tự cộng quyết định kết quả đúng hay sai — vòng vô hướng trái-sang-phải kẹt ở 2^24, còn bốn luỹ tích song song thì không. Nếu bạn cộng nhiều số dấu phẩy động có độ lớn chênh nhau, thứ tự quan trọng; các kỹ thuật như cộng Kahan (giữ lại phần sai số bị làm tròn) hay cộng theo cây tồn tại chính vì lý do này. -ffast-math "vô tình" cải thiện độ chính xác ở đây chỉ vì nó cho phép chia luỹ tích — nhưng đừng dựa vào sự tình cờ đó; nếu cần chính xác, hãy chọn thuật toán cộng một cách có chủ đích.

Hệ quả thứ ba là bài học đo lường mang theo: một cờ tối ưu "nhanh hơn" phải luôn được đo cả về tính đúng đắn, không chỉ tốc độ. Con số mang theo: -ffast-math cho trình biên dịch đổi thứ tự phép cộng float, mở khóa vector hóa (nhanh 4 lần ở đây) và đổi kết quả — không phải "kém chính xác" mà là "khác và không còn tái lập theo IEEE" — đồng thời xóa các kiểm tra NaN/Inf (is_nan luôn trả 0); nên chỉ bật cục bộ cho phần chịu được, và luôn hỏi "đúng/bền chưa" trước khi khen "nhanh". Cái đồng hồ nói fast-math nhanh 4 lần là thật; nhưng nếu chỉ nghe cái đồng hồ, bạn sẽ không thấy lá chắn NaN đã rơi mất lúc nào.

Thử ba mươi giây

Viết hàm int is_nan(double x){ return x != x; } vào một file, biên dịch hai lần và đọc assembly: gcc -O2 -S cho ra một lệnh so sánh fcmp thật (đúng chuẩn: chỉ NaN mới khác chính nó); gcc -O2 -ffast-math -S cho ra một hàm chỉ return 0 — lá chắn NaN của bạn biến mất. Rồi thử tổng: tính tổng một mảng float gồm một số lớn (1e7f) và vài triệu số 1.0f, in ra với %.1f, biên dịch có và không có -ffast-math. Bạn sẽ thấy hai con số khác nhau — và nếu tò mò, con số 16777216 xuất hiện ở bản strict chính là 2^24, ranh giới nơi float thôi đếm nổi từng đơn vị. Đó là toàn bộ câu chuyện: fast-math đổi bảo đảm lấy tốc độ, và bạn phải biết mình đang đổi cái gì.