Suốt 42 phần của sê-ri, mỗi khẳng định 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 được kiểm bằng một công cụ duy nhất: đọc assembly compiler sinh ra. Đây là kỹ năng nền, và cũng là bài học đo lường sâu nhất: 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. Phần này chỉ cách làm, và chứng minh vì sao đoán từ nguồn gần như luôn sai.
Bốn cách lấy assembly, và cách đọc
Có bốn cách nhìn vào mã máy. gcc -S -O2 file.c sinh ra file .s — assembly văn bản, đúng những gì compiler tạo. gcc -S -O2 -fverbose-asm thêm chú thích tên biến bên cạnh mỗi lệnh, dễ lần hơn. objdump -d file.o (hoặc trên nhị phân đã build) tháo ngược mã máy thành assembly — tôi dùng cách này cả sê-ri vì nó cho thấy đúng thứ chạy thật. Và godbolt.org (Compiler Explorer) là công cụ online tô màu nguồn↔assembly cạnh nhau, tuyệt để thử nhanh (dù ở đây tôi đo offline trong container).
Đọc assembly không cần thuộc lòng cả tập lệnh. Vài mẫu nhận diện là đủ: một vòng lặp là một nhãn cộng một nhánh lùi về nhãn đó (b.ne, b.gt); SIMD/vector hóa hiện ra ở các thanh ghi v0.4s hay q0 (NEON trên ARM); một lời gọi là bl <tên> — không thấy nó nghĩa là hàm đã bị inline; một phép chia là sdiv, còn nếu thấy smull cộng 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ố nghĩa là giá trị đã được gập sẵn lúc biên dịch.
Đo: đoán ba hàm, đọc ra ba bất ngờ
Tôi viết ba hàm C nhỏ, đoán trước assembly, rồi đọc thật để đối chiếu. Biên dịch gcc -O2.
Hàm A, trông như một vòng lặp cộng dồn:
int sum_1_100(void){ int s=0; for(int i=1;i<=100;i++) s+=i; return s; }
Đoán: một vòng chạy 100 bước. Đọc objdump:
mov w0, #0x13ba // #5050
ret
Không có vòng nào. Compiler tính 1+2+...+100 = 5050 ngay lúc biên dịch và trả về literal. Cả vòng lặp bốc hơi.
Hàm B, một phép chia:
int div7(int x){ return x / 7; }
Đoán: một lệnh sdiv. Đọc thật:
mov w1, #0x2493 ; movk w1, #0x9249, lsl 16 // hằng 0x92492493
smull x1, w0, w1 ; lsr ; add ; asr ; sub
Không có sdiv. Phép chia thành một phép nhân với hằng số ma thuật 0x92492493 cộng vài dịch bit — strength reduction, vì chia cho hằng đắt hơn nhân nhiều.
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, đọc ra một vòng scalar thuần: ldrsw nạp một phần tử, add cộng vào, b.ne lặp — 0 lệnh SIMD. Nhưng đổi sang -O3, cùng hàm đó vector hóa: 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 nguồn, mã máy khác hẳn tùy mức tối ưu.
Một lần tôi đo hớ: đoán từ nguồn luôn sai
Tôi vào đo với niềm tin ngầm mà hầu như ai cũng có lúc mới học: "đọc nguồn C là đủ đoán được máy làm gì; assembly khớp cấu trúc nguồn một-đối-một". Cả ba hàm phá tan niềm tin đó. 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ờ. Không hàm nào có assembly "trông giống" nguồn của nó.
Đây là lý do vì sao suốt sê-ri tôi luôn đọc thay vì đoán. Nếu tôi chỉ nhìn nguồn và 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. 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. Bài học đo lường tối hậu: assembly là sự thật cuối cùng. 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 — đều phải cúi đầu trước cái 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.
Đọc có trọng tâm, đừng đọc tất cả
Một lưu ý để đọc assembly không thành nỗi ám ảnh: bạn không cần hiểu từng lệnh của cả chương trình. Kỹ thuật thực dụng là đọc có trọng tâm — chỉ mở assembly của một hàm bạn quan tâm (điểm nóng đã đo được), và chỉ nhìn thân vòng lặp trong đó, vì đó là nơi thời gian dồn vào. Với objdump, tôi luôn 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?". Một câu hỏi rõ khiến việc đọc assembly nhanh và có ích, thay vì một biển ký hiệu.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: 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 khi tối ưu một điểm nóng: đọc assembly cho bạn biết compiler đã làm gì, để bạn biết còn gì chưa làm mà bạn cần giúp (thêm restrict, đổi thứ tự, bật cờ).
Hệ quả 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 đổi. Nhiều "mẹo tối ưu" trên mạng đã lỗi thời vì compiler nay tự làm; chỉ assembly ở đúng phiên bản, đúng cờ, đúng máy của bạn mới nói thật.
Hệ quả thứ ba là tinh thần của cả sê-ri: đo và đọc, đừng đoán. Con số mang theo: assembly compiler sinh ra thường khác xa cấu trúc nguồn — gcc gập hằng (vòng cộng 1..100 thành mov #5050), đổi chia thành nhân ma thuật (x/7 thành smull, không sdiv), inline, và vector hóa (cộng mảng scalar ở -O2 nhưng vector saddw ở -O3); chỉ ĐỌC assembly (gcc -S / objdump -d) mới biết chắc tối ưu nào thật sự xảy ra, đoán từ nguồn C gần như luôn sai. Đây là công cụ nền để kiểm mọi giả định của 42 phần trước — và của bất cứ điều gì bạn nghe về tối ưu.
Thử ba mươi giây
Lấy 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, và chạy gcc -O2 -S -o - t.c để in assembly ra màn hình. 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 có 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 — bạn sẽ thấy có hàm đổi hẳn. Ba mươi giây đó dạy bạn đ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à công việc của compiler.