Suốt bốn mươi tư phần trước, tôi cứ nói "trình biên dịch" như thể chỉ có một cái. Thật ra có nhiều, và hai cái phổ biến nhất cho C là gcc và clang. Chúng nhận cùng một ngôn ngữ, sinh ra cùng loại mã máy — nhưng bên trong là hai bộ máy khác hẳn nhau, mỗi cái chọn tối ưu và chọn lệnh theo cách riêng. Phần khép sê-ri này, tôi mang cùng những đoạn mã cho cả hai biên dịch, đo, rồi đọc assembly. Và tôi kết lại loạt bài bằng đúng cái bẫy mà nó cảnh báo ngay từ phần đầu — lần này tôi ngã vào cả hai chân cùng lúc.

Đ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 từng 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 con số nhỏ nhất trong bảy lần chạy:

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 khá rõ ngay từ cái nhìn đầu. clang thắng đậm ở những vòng vector hóa được: A nhanh gấp 2,2 lần, E gấp 4 lần — trên bộ mã này nó vector hóa mạnh tay hơn gcc. 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), hai bên hòa — đơn giản vì ở đó chẳng còn mấy chỗ để tối ưu khác đi. Còn strlen thì hòa tuyệt đối: cả hai đều gọi đúng một hàm strlen đã tối ưu sẵn của libc, không bên nào tự sinh mã.

Câu kết luận tôi suýt gõ — và lời giải thích tôi đã đoán sai

Nhìn cột clang thắng ba vòng, tay tôi đã định gõ ngay một câu kết gọn ghẽ cho phần cuối sê-ri: "clang nhanh hơn gcc". Nhưng dừng lại một nhịp thôi là thấy đó chính là cái bẫy cả loạt bài 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ự hoàn toàn có thể lật ngược. "Trình nào nhanh hơn" là câu hỏi sai; câu đúng phải 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 tôi đoán hụt 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 ra 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 hợp lý đến mức tôi gần như không buồn kiểm. Rồi tôi mở assembly ra 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, mà vẫn chậm hơn; clang không đụng tới 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 phải mẹo một-lệnh mà tôi tưởng tượng ra. Không chỉ kết luận "clang nhanh hơn" của tôi là vội, mà cả lời giải thích cho một kết quả cụ thể cũng sai bét — sai cho tới đúng cái giây 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 rời 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ề đến code hằng ngày

Điều đầu tiên: chọn trình biên dịch (và tin con số của nó) theo phép đo trên mã của bạn, đừ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, rồi đ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ọ.

Điều thứ hai: hai trình biên dịch là hai bộ kiểm tra độc lập, và điều đó đáng giá ngoài chuyện 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à mạnh ở những công cụ 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.

Và điều cuối, cũng 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. Gói lại bằng số: cùng một đoạn mã, gcc và clang cho tốc độ khác nhau 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".

Chuyện này không đóng khung trong C hay trong gcc/clang. Ai viết Java lâu rồi sẽ nhận ra đúng bài học đó ở JIT: hai bản HotSpot khác nhau, hay C1 so với C2, cũng sinh mã máy khác nhau cho cùng bytecode, và bạn chỉ biết chắc khi bật -XX:+PrintAssembly mà đọc, chứ không phải đoán từ mã Java. Go có gc so với gccgo — hai trình cho ra nhị phân khác tính nết. Ngay cả cơ sở dữ liệu cũng vậy: cùng một câu SQL, hai phiên bản PostgreSQL có thể chọn kế hoạch khác nhau, và EXPLAIN ANALYZE mới là thứ nói thật thay cho trực giác. Mẫu số chung ở mọi tầng: thứ bạn viết là ý định, thứ máy chạy là hiện thực, và giữa hai cái đó luôn có một trình biên dịch (hay một bộ tối ưu) đang tự ý quyết định — cách duy nhất để biết nó quyết gì là mở công cụ ra nhìn.

Muốn tự thấy thì 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, xếp thanh ghi khác nhau, đôi khi một bên vector hóa còn bên kia thì không. 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ị). Hai con số lệch 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.