Hãy tưởng tượng bạn đưa cho một người thợ dịch cực giỏi một công thức nấu ăn và bảo họ làm theo. Bạn nghĩ họ sẽ lần lượt từng bước như bạn viết. Nhưng người thợ này thuộc lòng cả nghìn mẹo: thấy công thức bảo "khuấy 100 vòng" mà kết quả đã biết trước, họ ghi luôn kết quả; thấy phép chia rườm rà, họ thay bằng một phép nhân nhẩm nhanh hơn; thấy có thể làm bốn việc một lúc thì gộp lại. Món ăn ra đúng, nhưng nếu bạn nhìn cách họ làm, nó chẳng giống công thức của bạn chút nào. Compiler đúng là người thợ đó — và cái duy nhất cho bạn biết họ thật sự làm gì không phải là công thức, mà là xem tận tay họ nấu.

Suốt 42 phần của sê-ri này, mỗi lần tôi khẳng định điều gì về tối ưu — "gcc vector hóa vòng này", "phép chia thành nhân ma thuật", "hàm bị inline", "nhánh bị loại" — đều dựa trên đúng một việc: đọc assembly compiler sinh ra. Đây là kỹ năng nền của cả sê-ri. Và tôi muốn dành hẳn một bài cho nó, vì bài học ở đây sâu hơn mọi con số đo được: bạn không thể đoán compiler làm gì từ nguồn C, bạn phải đọc mã máy nó thật sự sinh ra. Tôi làm việc đó trong container gcc:13 trên host ARM.

Bốn cách nhìn vào mã máy

Có bốn cửa để nhìn vào assembly, và tôi dùng cả bốn tùy lúc. gcc -S -O2 file.c sinh file .s — assembly văn bản, đúng thứ compiler tạo ra. Thêm -fverbose-asm thì mỗi lệnh được chú thích tên biến C bên cạnh, dễ lần hơn hẳn. objdump -d tháo ngược mã máy trong file .o (hay nhị phân đã build) thành assembly — tôi dùng cách này nhiều nhất cả sê-ri, vì nó cho thấy đúng thứ chạy thật, không phải bản trung gian. Và godbolt.org (Compiler Explorer) tô màu nguồn↔assembly cạnh nhau, tuyệt để thử nhanh trên trình duyệt — dù trong sê-ri này tôi đo offline trong container.

Đừng lo phải thuộc cả tập lệnh. Vài mẫu nhận diện là đủ để đọc được 90% những gì bạn cần. Một vòng lặp là một nhãn cộng với một nhánh lùi về chính nhãn đó (b.ne, b.gt). Vector hóa (SIMD) lộ ra ở các thanh ghi v0.4s hay q0 — đó là NEON trên ARM. Một lời gọi hàm là bl <tên>; không thấy nó, nghĩa là hàm đã bị inline. Một phép chia thật là sdiv, nhưng nếu bạn thấy smull kèm vài lệnh dịch bit thì đó là nhân ma thuật thay cho chia hằng số. Và một mov với hằng số to nghĩa là giá trị đã được tính sẵn ngay lúc biên dịch. Nhớ chừng đó mẫu, phần còn lại tra khi cần.

Ba hàm, ba lần tôi đoán sai

Tôi làm một thí nghiệm nhỏ với chính mình: viết ba hàm C, đoán trước assembly ra sao, rồi đọc thật để đối chiếu. Biên dịch gcc -O2.

Hàm A trông rõ ràng là một vòng cộng dồn:

int sum_1_100(void){ int s=0; for(int i=1;i<=100;i++) s+=i; return s; }

Tôi đoán: một vòng chạy 100 bước. objdump in ra:

mov  w0, #0x13ba    // #5050
ret

Không có vòng nào cả. Compiler tự tính 1+2+...+100 = 5050 ngay lúc biên dịch rồi trả về đúng con số đó. Cả vòng lặp bốc hơi không để lại dấu vết.

Hàm B là một phép chia:

int div7(int x){ return x / 7; }

Tôi đoán một lệnh sdiv, hiển nhiên. Đọc thật:

mov   w1, #0x2493 ; movk w1, #0x9249, lsl 16   // hằng 0x92492493
smull x1, w0, w1  ; lsr ; add ; asr ; sub

Không hề có sdiv. Phép chia đã biến thành một phép nhân với hằng số ma thuật 0x92492493 cộng vài lệnh dịch bit — đúng cái strength reduction mà sê-ri từng mổ, vì chia cho hằng đắt hơn nhân nhiều lần.

Hàm C cộng một mảng:

long sum_arr(const int *a, int n){ long s=0; for(int i=0;i<n;i++) s+=a[i]; return s; }

Ở -O2 tôi đọc ra một vòng scalar thuần túy: ldrsw nạp một phần tử, add cộng vào, b.ne lặp lại — không một lệnh SIMD nào. Nhưng chỉ cần đổi sang -O3, cùng hàm đó vector hóa hẳn: ldr q1 nạp bốn số một lúc, saddw/saddw2 cộng-mở-rộng (int→long), addp gộp cuối — 5 lệnh SIMD. Cùng một dòng nguồn, mã máy khác hẳn chỉ vì một chữ số trong cờ tối ưu.

Ba hàm, ba lần tôi đoán sai. Vòng lặp thành một literal. Phép chia thành phép nhân. Vòng scalar thành vector — hoặc không, tùy một cờ duy nhất. Không hàm nào có assembly "trông giống" nguồn của nó.

Vì sao tôi luôn đọc, không đoán

Cái niềm tin ngầm mà hầu như ai mới học cũng có — kể cả tôi ngày xưa — là: "đọc nguồn C là đủ đoán được máy làm gì, assembly khớp một-đối-một với cấu trúc nguồn". Ba hàm trên đủ để chôn niềm tin ấy. Compiler biến đổi code xa đến mức cấu trúc nguồn chỉ còn là ý định, không phải mã thực thi.

Đó chính là lý do suốt sê-ri tôi luôn mở objdump ra thay vì tự tin phán. Nếu tôi chỉ nhìn nguồn rồi nói "vòng này chạy 100 lần" hay "đây là một phép chia", tôi đã sai ba lần trên ba. Mọi giả định về tối ưu — của bạn, của tài liệu, của một bài blog kể cả bài này — đều phải cúi đầu trước cái mà objdump in ra. Khi tôi viết "gcc gập hằng ở đây" hay "chỗ này không vector hóa", đó không phải suy đoán; đó là vì tôi đã đọc mã máy và thấy tận mắt.

Nhưng đọc assembly không có nghĩa là đọc tất cả, và đây là chỗ nhiều người bỏ cuộc vì tưởng phải hiểu từng lệnh của cả chương trình. Không cần. Kỹ thuật thực dụng là đọc có trọng tâm: chỉ mở assembly của một hàm mà bạn đã đo được là điểm nóng, và trong đó chỉ nhìn thân vòng lặp, vì đó là nơi thời gian dồn vào. Với objdump, tôi khoanh vùng từ nhãn hàm tới ret đầu tiên; với vòng lặp, tôi đếm số lệnh giữa nhãn và nhánh lùi. gcc -S -fverbose-asm giúp thêm bằng cách ghi tên biến C cạnh mỗi lệnh, nên bạn khớp được "dòng này là s += a[i]" mà không phải giải mã từng thanh ghi. Mục tiêu không phải đọc hết — mà là trả lời một câu hỏi cụ thể: "compiler có làm cái tối ưu tôi mong không?". Câu hỏi rõ khiến việc đọc nhanh và có ích, thay vì lạc trong một biển ký hiệu.

Về đến code hằng ngày

Điều đầu tiên, và cũng thực dụng nhất: khi nghi ngờ compiler làm gì, đọc assembly — đừng suy từ nguồn. gcc -S -O2 hay objdump -d mất mười giây và cho câu trả lời chắc chắn, thay cho một giờ tranh cãi "chắc là nó vector hóa". Điều này đặc biệt đáng giá khi bạn đang tối ưu một điểm nóng: đọc assembly cho biết compiler đã làm gì, để bạn thấy còn gì chưa làm mà bạn cần giúp một tay (thêm restrict, đổi thứ tự truy cập, bật một cờ).

Điều thứ hai: đọc assembly là cách kiểm chứng mọi lời khuyên tối ưu, kể cả bài này. Đừng tin "làm X thì nhanh hơn" cho tới khi bạn thấy mã máy thật sự đổi. Rất nhiều "mẹo tối ưu" trên mạng đã lỗi thời vì compiler nay tự làm hết rồi; chỉ assembly ở đúng phiên bản, đúng cờ, đúng máy của bạn mới nói thật.

Và điều này không riêng gì C. Bạn muốn biết JVM thật sự làm gì với vòng lặp Java nóng của mình? Nó cũng có cửa để nhìn: chạy với -XX:+PrintAssembly (kèm hsdis) để xem JIT dịch bytecode nóng ra mã máy nào — và bạn sẽ thấy JIT cũng gập hằng, cũng vector hóa, cũng inline y như gcc, chỉ là lúc chạy chứ không lúc biên dịch. Go thì gọn hơn nữa: go tool compile -S hoặc go tool objdump in thẳng assembly Plan 9 của hàm bạn viết. Ngay cả CSDL cũng cùng tinh thần: một câu SQL bạn viết chỉ là ý định, còn EXPLAIN ANALYZE mới cho biết bộ tối ưu hóa thật sự chọn quét tuần tự hay dùng index, ước lượng lệch bao nhiêu so với thực tế. 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à luôn có một công cụ để nhìn vào hiện thực đó thay vì đoán.

Muốn tự thấy thì lấy ngay ba hàm trong bài — sum_1_100, div7, sum_arr — hoặc bất cứ hàm nhỏ nào của bạn, chạy gcc -O2 -S -o - t.c để in assembly ra màn hình, và tìm ba thứ: có mov với một hằng số lớn không (compiler đã gập sẵn)? có sdiv hay là smull cộng dịch bit (chia thành nhân)? có thanh ghi v0.4s/q0 không (vector hóa)? Rồi đổi -O2 thành -O3 và so lại — sẽ có hàm đổi hẳn. Ba mươi giây đó dạy bạn một điều mà không tài liệu nào dạy nhanh bằng: nguồn của bạn là ý định, assembly mới là hiện thực, và khoảng cách giữa hai thứ chính là toàn bộ công việc của compiler.