Suốt bốn mươi tư phần trước, tôi luôn nói "trình biên dịch" như thể có một cái duy nhất. Thực ra có nhiều, và hai cái phổ biến nhất cho C là gcc và clang — cùng nhận một ngôn ngữ, cùng sinh mã máy, nhưng là hai bộ máy khác nhau, chọn tối ưu và chọn lệnh theo cách riêng. Phần cuối này biên dịch cùng những đoạn mã bằng cả hai, đo, đọc assembly — và khép lại sê-ri bằng đúng cái bẫy mà nó cảnh báo từ đầu, lần này tôi rơi vào cả hai chân.
Đo: mỗi bên thắng một số việc
Tôi viết sáu vòng lặp khác kiểu, biên dịch mỗi cái bằng gcc -O2 và clang -O2 trên cùng máy aarch64 (gcc 13.4, clang 14), ghim một lõi, lấy nhỏ nhất trong bảy lần:
| Vòng lặp | gcc | clang |
|---|---|---|
| A — tổng bình phương int (vector hóa được) | 3,1 ms | 1,4 ms |
| B — đếm số nguyên tố (nhiều nhánh rẽ) | 5,0 ms | 5,1 ms |
| C — chuỗi phụ thuộc dấu phẩy động | 7,0 ms | 6,9 ms |
| D — đếm bit thủ công (popcount) | 4,4 ms | 1,5 ms |
| E — tìm max (reduction) | 2,1 ms | 0,5 ms |
| strlen (gọi thư viện) | 1,0 ms | 1,0 ms |
Bức tranh rõ: clang thắng đậm ở các vòng vector hóa được (A gấp 2,2 lần, E gấp 4 lần) — nó vector hóa mạnh tay hơn gcc trên bộ này. Nhưng ở vòng nhiều nhánh rẽ (đếm số nguyên tố) và chuỗi phụ thuộc (mỗi bước cần kết quả bước trước) thì hai bên hòa — vì ở đó có rất ít chỗ để tối ưu khác đi. Và strlen thì hòa tuyệt đối, vì cả hai đều gọi cùng một hàm strlen tối ưu sẵn của libc, chẳng bên nào tự sinh mã.
Một lần tôi đo hớ: sai cả kết luận lẫn lời giải thích
Nhìn cột clang thắng ba vòng, tôi định gõ ngay câu kết luận gọn gàng cho phần cuối sê-ri: "clang nhanh hơn gcc". Nhưng dừng lại một nhịp thì thấy nó là đúng cái bẫy mà cả sê-ri cảnh báo. Đây chỉ là sáu vòng lặp nhỏ, trên một kiến trúc (aarch64), với một cặp phiên bản (gcc 13 / clang 14). Ba vòng hòa cho thấy kết quả phụ thuộc loại mã; và ai theo dõi hai dự án đều biết chúng nhảy cóc nhau qua từng phiên bản — gcc dẫn ở bản này, clang vượt lên ở bản kia, và trên x86 hay trên mã thật quy mô lớn thứ tự có thể lật. "Trình nào nhanh hơn" đơn giản là câu hỏi sai; câu đúng là "nhanh hơn ở việc gì, trên kiến trúc nào, phiên bản nào".
Nhưng cái đo hớ thứ hai mới thật sự đáng nhớ, và nó đóng đinh chủ đề của cả loạt bài. Tôi định giải thích vì sao clang thắng vòng đếm bit: chắc chắn clang đã nhận diện được mẫu "đếm bit" (popcount) và thay cả vòng lặp bằng một lệnh phần cứng cnt, còn gcc thì cặm cụi chạy vòng. Nghe rất hợp lý. Rồi tôi đọc assembly để xác nhận — và nó ngược hẳn:
gcc kD: có dùng lệnh cnt (đếm bit phần cứng) -> mà CHẬM (4,4 ms)
clang kD: KHÔNG dùng cnt -> mà nhanh gấp 3 (1,5 ms)
Chính gcc mới là bên dùng lệnh cnt, và nó vẫn chậm hơn; clang không dùng cnt mà vẫn nhanh gấp ba (nhiều khả năng nhờ cách nó tổ chức và xử lý song song vòng lặp ngoài trên nhiều từ, chứ không nhờ mẹo một-lệnh mà tôi đoán). Không chỉ kết luận "clang nhanh hơn" của tôi vội vàng, mà cả lời giải thích cho một kết quả cụ thể cũng sai — sai cho tới khi tôi nhìn vào mã máy thật.
Đây là bài học gói trọn bốn mươi lăm phần. Trình biên dịch không phải một cái hộp dịch mã một-đổi-một; nó là một bộ máy ra hàng loạt quyết định — tối ưu, bố cục, chọn lệnh, và mỗi lựa chọn là một chỗ mà "mã tôi viết" và "mã thật sự chạy" tách nhau. "Nhanh" là vô nghĩa nếu chưa đo đúng việc, đúng máy, đúng phiên bản. Và cách duy nhất để biết trình biên dịch thật sự làm gì — chứ không phải điều bạn đoán nó làm — là đọc assembly.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chọn trình biên dịch (và tin số của nó) theo phép đo trên mã của bạn, không theo danh tiếng. Nếu hiệu năng quan trọng, hãy build dự án của bạn bằng cả gcc lẫn clang, chạy đúng tải thật của bạn, và đo — kết quả có thể bất ngờ theo cả hai chiều, và có thể đổi khi bạn nâng phiên bản. Đừng để một bài benchmark trên mạng (hay một bài blog, kể cả bài này) quyết định thay bạn: con số của họ là về mã của họ, trên máy của họ.
Hệ quả thứ hai: hai trình biên dịch là hai bộ kiểm tra độc lập, và điều đó có giá trị ngoài tốc độ. gcc và clang cảnh báo những thứ hơi khác nhau, bắt những lỗi hơi khác nhau, và có những sanitizer/công cụ mạnh ở những chỗ khác nhau (clang có MemorySanitizer mà gcc không có; gcc đôi khi tối ưu tốt hơn ở vài mẫu). Build bằng cả hai và bật -Wall -Wextra ở cả hai là một cách rẻ để lộ ra nhiều vấn đề hơn — đúng tinh thần "lưới nhiều tầng" ở bài cảnh báo và bài sanitizer.
Hệ quả thứ ba là bài học đo lường khép lại cả sê-ri: đừng đoán trình biên dịch làm gì — đo, và đọc assembly. Con số mang theo, cũng là con số mang theo của toàn loạt bài: cùng một đoạn mã, gcc và clang sinh ra kết quả khác nhau về tốc độ tùy loại vòng lặp (clang thắng 2-4 lần ở vòng vector hóa được, hòa ở vòng nhánh rẽ và gọi thư viện), nên không có "trình nào nhanh hơn" chung; và ngay cả lời giải thích cho một kết quả — như "clang thắng nhờ lệnh cnt" — cũng sai cho tới khi đọc mã máy (hóa ra gcc mới dùng cnt mà vẫn chậm). Trình biên dịch là một bộ máy tích cực biến đổi mã của bạn, không phải người phiên dịch trung thành; và mỗi khi bạn muốn biết nó thực sự làm gì với đoạn code của mình, câu trả lời không nằm ở trực giác hay danh tiếng, mà ở phép đo và ở mã máy nó sinh ra. Đó là toàn bộ tinh thần của "Trình biên dịch cho lập trình viên".
Thử ba mươi giây
Lấy một hàm nhỏ mà bạn quan tâm, biên dịch bằng cả hai — gcc -O2 -S -o g.s f.c và clang -O2 -S -o c.s f.c — rồi mở hai file assembly cạnh nhau. Bạn sẽ thấy chúng chọn lệnh khác nhau, sắp thanh ghi khác nhau, đôi khi một bên vector hóa còn bên kia không. Rồi nếu có một vòng lặp nóng, đo thời gian cả hai bản (nhớ ghim lõi, lấy trung vị). Nếu hai con số khác nhau, đừng vội kết "trình này nhanh hơn" — hãy đọc assembly xem vì sao, và nhớ rằng câu trả lời chỉ đúng cho đoạn mã này, kiến trúc này, phiên bản này. Đó là kỹ năng mà cả bốn mươi lăm phần này rèn: không tin đồn, không đoán — đo, và đọc mã máy.