Ở bài về gộp biểu thức chung, ta thấy một lời gọi hàm mờ là bức tường chặn trình biên dịch gộp phép tính lặp: nó phải giả định hàm có thể làm bất cứ gì. Thuộc tính __attribute__((pure)) và __attribute__((const)) là cách bạn phá bức tường đó — hứa với trình biên dịch rằng hàm này thuần, không có tác dụng phụ, nên các lời gọi trùng nhau có thể gộp lại. Bài này đo cả hai mặt của lời hứa: nó tăng tốc bao nhiêu khi đúng, và hỏng thế nào khi sai.
pure và const hứa điều gì
Hai thuộc tính khai cho một hàm, khác nhau ở mức độ:
const(mạnh hơn): kết quả chỉ phụ thuộc vào các đối số, và hàm không đọc bộ nhớ nào ngoài đối số — không đọc biến toàn cục, không dereference con trỏ. Đây là hàm toán thuần:bình_phương(x),sin(x).pure(yếu hơn): hàm không có tác dụng phụ (không ghi gì quan sát được), nhưng có thể đọc bộ nhớ toàn cục hay heap. Ví dụstrlen(s)— nó đọc chuỗi nhưng không đổi gì.
Lời hứa này cho phép trình biên dịch làm ba việc mà một hàm bình thường thì không: gộp nhiều lời gọi cùng đối số thành một (mở rộng gộp biểu thức chung qua ranh giới lời gọi), kéo một lời gọi có đối số bất biến ra khỏi vòng lặp (tính một lần rồi dùng lại), và xóa hẳn một lời gọi mà kết quả không ai dùng. Khác biệt then chốt: const gộp mạnh nhất (nó coi lời gọi như một phép toán thuần trên đối số); pure phải tôn trọng các phép ghi bộ nhớ xen giữa hai lời gọi, vì kết quả của nó có thể phụ thuộc bộ nhớ đó.
Đo: const biến O(n) thành O(1)
Tôi viết một vòng lặp gọi fn(K) 50 triệu lần với K bất biến theo vòng. Để cô lập tác dụng của thuộc tính, tôi đặt hàm ở một file riêng (nên caller không thấy thân nó) và lấy K từ tham số dòng lệnh (nên nó không phải hằng biên dịch). Hai bản giống hệt, chỉ khác một dòng khai báo:
long fn_a(long); // không thuộc tính
long fn_b(long) __attribute__((const)); // hứa: chỉ phụ thuộc đối số
long loop_a(long K,int n){ long s=0; for(int i=0;i<n;i++) s+=fn_a(K); return s; }
long loop_b(long K,int n){ long s=0; for(int i=0;i<n;i++) s+=fn_b(K); return s; }
Kết quả, ghim một lõi, lấy nhỏ nhất trong 7 lần:
| Vòng | Thời gian |
|---|---|
loop_a (không thuộc tính) |
371 ms |
loop_b (const) |
0,00 ms |
Đọc assembly cho biết vì sao. loop_a có một lệnh bl <fn_a> nằm trong vòng — trình biên dịch không thấy thân fn_a, không biết nó có tác dụng phụ hay không, nên phải gọi thật 50 triệu lần. loop_b chỉ có một bl <fn_b> nằm ngoài vòng, theo sau bởi một lệnh mul: trình biên dịch gọi fn_b(K) đúng một lần, rồi nhân kết quả với n. Cả vòng lặp biến mất. Một lời hứa biến một vòng O(n) thành một phép tính O(1) — từ 371 mili giây xuống gần bằng không.
Một lần tôi đo hớ: hứa thừa khi trình dịch đã biết
Trước khi tách file, tôi thử phiên bản đơn giản: để cả hai hàm ngay trong cùng file với main, và dùng K là hằng số 12345. Rồi đo. Kết quả: const cho 0,00 ms, mà bản không thuộc tính (plain) cũng 0,00 ms — bằng nhau. Tôi suýt kết luận: "const chẳng giúp gì cả".
May là con số 0,00 ở cả hai bên quá đáng ngờ để bỏ qua. Lý do: khi thân hàm nằm ngay trong file, trình biên dịch tự phân tích ra rằng nó là hàm thuần — nó đọc được toàn bộ thân, thấy hàm chỉ tính toán trên đối số và không đụng gì khác, nên tự áp dụng gộp/kéo mà không cần thuộc tính. Cộng thêm K là hằng, nó gấp luôn kết quả tại thời điểm biên dịch. Cả hai bản đều thành hằng số, nên cả hai đều 0,00 ms. Thuộc tính const của tôi hoàn toàn thừa — không phải vì nó vô dụng, mà vì trình biên dịch đã biết điều tôi định nói.
Chỉ khi tôi đưa hàm sang một đơn vị dịch khác — để thân nó mờ với caller, đúng như bài đơn vị dịch mô tả — thuộc tính mới thực sự có việc để làm, và lúc đó 371 ms mới tụt xuống 0,00 ms. Bài học đo lường lặp lại y hệt sê-ri này: một chỉ dẫn tối ưu là lời hứa thừa khi trình biên dịch đã tự suy ra được — giống hệt gợi ý nhánh (thừa khi heuristic đã đoán đúng) và PGO (cái nhanh đến từ cờ ngầm chứ không từ hồ sơ). Để đo tác dụng thật của một thuộc tính, tôi phải đặt nó vào đúng tình huống mà trình biên dịch không tự biết — tức là qua một ranh giới nó không nhìn thấu.
Mặt kia của lời hứa: hứa sai thì sai kết quả
Tốc độ chỉ là một nửa. Nửa còn lại nguy hiểm hơn: thuộc tính là một lời hứa mà trình biên dịch tin tuyệt đối, và hứa sai không báo lỗi — nó cho ra đáp án sai. Tôi viết một hàm thật sự đọc bộ nhớ:
int g_counter = 0;
int read_counter(void){ return g_counter; }
rồi cố tình khai nhầm nó là const (hứa "không đọc bộ nhớ", trong khi nó đọc g_counter). Chương trình gọi nó, đổi biến, gọi lại:
int a = read_counter(); // đúng ra = 0
g_counter = 99;
int b = read_counter(); // đúng ra = 99
Kết quả đo được: a=0 b=0 — sai. Assembly cho thấy trình biên dịch chỉ gọi read_counter một lần rồi dùng chung kết quả cho cả a và b, vì const hứa hàm không phụ thuộc bộ nhớ nên hai lời gọi không đối số "phải" trả cùng giá trị. Phép ghi g_counter = 99 xen giữa bị bỏ qua hoàn toàn. Khai đúng (không thuộc tính, hoặc pure) cho a=0 b=99 với hai lời gọi thật.
Điều tinh tế đáng nhớ: hàm này đọc bộ nhớ, nên thuộc tính đúng cho nó là pure, không phải const. Và khi tôi khai pure, kết quả đúng trở lại (a=0 b=99, hai lời gọi) — vì pure cho phép hàm đọc bộ nhớ, nên trình biên dịch biết phép ghi g_counter = 99 xen giữa có thể đổi kết quả và không dám gộp. Đây là bản sao chính xác của restrict và volatile: một lời hứa đánh đổi an toàn lấy tốc độ, và vi phạm nó thì hỏng lặng lẽ.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dùng const chỉ cho toán thuần, pure cho hàm đọc-mà-không-ghi. Một hàm băm phụ thuộc duy nhất đối số → const. Một hàm đọc cấu hình toàn cục hay đo độ dài một chuỗi → pure, không bao giờ const, vì nó đọc bộ nhớ. Đánh nhầm const cho hàm đọc bộ nhớ là mời trình biên dịch gộp các lời gọi lẽ ra phải đọc lại dữ liệu đã đổi — một lỗi chỉ lộ ra ở -O2 (nơi gộp mới bật), không lộ ở -O0, đúng cái bẫy "kết quả đổi theo mức -O" của các bài trước.
Hệ quả thứ hai: đừng rắc thuộc tính bằng trực giác — chúng thừa khi trình biên dịch đã biết. Nếu hàm nằm trong cùng đơn vị dịch (hoặc bạn bật LTO), trình biên dịch thường tự suy ra tính thuần và tự tối ưu; thuộc tính chỉ thêm giá trị khi hàm mờ với nơi gọi — định nghĩa ở file khác, không có LTO, hoặc ở một thư viện. Đó là nơi chúng đáng công, và cũng là nơi một lời hứa sai gây hại nhất, vì trình biên dịch không có cách nào tự kiểm chứng.
Hệ quả thứ ba là bài học đo lường mang theo: một thuộc tính, như mọi lời hứa với trình biên dịch, có hai giá: tốc độ khi đúng và sự đúng đắn khi sai. Con số mang theo: const trên một hàm bất biến cho trình biên dịch kéo lời gọi ra khỏi vòng — biến 371 ms thành 0 ms — nhưng đánh nhầm const cho hàm đọc bộ nhớ khiến nó gộp hai lời gọi thành một và trả về giá trị cũ (0 thay vì 99); dùng const cho toán thuần, pure cho hàm đọc-không-ghi, và để dành chúng cho các hàm mờ mà trình biên dịch không tự suy ra được. Lời hứa mạnh mua tốc độ lớn, nhưng bạn phải giữ đúng lời — trình biên dịch sẽ không kiểm hộ.
Thử ba mươi giây
Viết một hàm nhỏ ở một file riêng, ví dụ long f(long x){ ... vài phép tính ...; }, và một main gọi nó trong một vòng lặp với đối số bất biến. Biên dịch hai file riêng rồi liên kết (gcc -O2 -c f.c && gcc -O2 -c main.c && gcc -O2 *.o), đo thời gian. Giờ thêm __attribute__((const)) vào khai báo của f trong main.c và làm lại: nếu vòng lặp của bạn có đối số bất biến, bạn sẽ thấy thời gian rơi thẳng xuống gần không, vì trình biên dịch kéo lời gọi ra ngoài. Rồi thử điều nguy hiểm: đánh const cho một hàm đọc biến toàn cục, đổi biến đó giữa hai lời gọi, và in ra — bạn sẽ thấy hai giá trị bằng nhau ở nơi lẽ ra phải khác. Đó là toàn bộ sức mạnh và toàn bộ hiểm họa của một lời hứa nằm gọn trong một dòng khai báo.