Mặc định, gcc sinh mã cho một CPU "chung" (baseline) của kiến trúc — chạy được trên mọi con chip cùng họ, kể cả loại cũ. Nhưng CPU của bạn có thể mới hơn, với những lệnh chuyên dụng mà baseline không dùng tới. Hai cờ mở khóa chúng: -march bảo compiler dùng tập lệnh của một CPU cụ thể, -mtune chỉ điều chỉnh cách sắp lịch cho CPU đó. Lời khuyên phổ biến là "cứ -march=native cho nhanh". Tôi vào đo, và thấy nó vừa cho một cú tăng tốc thật, vừa cho một cú vấp ngã ngay tại chỗ.
march dùng lệnh mới, mtune chỉ sắp lại
-march=<cpu> (hay -march=native để dò đúng CPU đang build) cho phép compiler phát ra các lệnh chỉ có trên CPU đó — trên ARM là NEON mở rộng, dotprod (nhân-cộng int8 theo lô), fma, và nhiều thứ nữa. Mã sinh ra có thể nhanh hơn, nhưng đánh đổi bằng tính di động: nó chỉ chạy trên chip có đúng tập lệnh ấy; đưa sang chip cũ hơn thiếu lệnh sẽ gặp SIGILL (illegal instruction — lệnh không hợp lệ).
-mtune=<cpu> khiêm tốn hơn: nó không dùng lệnh mới, chỉ điều chỉnh heuristic sắp lịch lệnh (thứ tự, độ trễ, căn lề) cho vi kiến trúc đó. Mã vẫn là baseline, chạy khắp nơi — chỉ được sắp khéo hơn cho một CPU. An toàn, nhưng lợi thường nhỏ.
Tôi đo trong container gcc:13 (ARM AArch64) một kernel dễ hưởng lợi: tích vô hướng hai vector int8, tích lũy vào int32 — đúng mẫu mà lệnh dotprod (sdot) được sinh ra để làm.
Đo: dotprod nhanh 2,66 lần, nhưng native làm vỡ build
Trước hết, cho -march đúng và nhất quán (-march=armv8.2-a+dotprod, đặt cả compiler lẫn assembler cùng biết về dotprod), so với baseline -O3:
-O3 baseline (mã chung) : 0,1138 ns/phần tử (objdump: mla — vector nhân-cộng thường)
-march=armv8.2-a+dotprod : 0,0428 ns/phần tử (objdump: sdot — dotprod) -> nhanh 2,66 lần
-march đúng cho một cú tăng tốc thật và lớn: một lệnh sdot làm bốn phép nhân-cộng int8 cùng lúc, nên vòng lặp nhanh 2,66 lần bản baseline (vốn đã tự vector hóa bằng mla). Với kernel phù hợp, tập lệnh chuyên dụng là cú nhảy hiệu năng có thật.
Nhưng khi tôi thử -march=native — cách "cứ bật cho nhanh" mà ai cũng khuyên — điều bất ngờ xảy ra:
gcc -O3 -march=native ma.c
Error: selected processor does not support `sdot v0.4s,v1.16b,v2.16b'
-> BUILD THẤT BẠI
gcc dò CPU, thấy có dotprod, và phát ra lệnh sdot — nhưng assembler trong toolchain này lại không chấp nhận nó (vì -march=native không truyền nhất quán tới assembler trong môi trường container ảo hóa này), nên build hỏng ngay tại chỗ. Đây là đo hớ của tôi: tôi tưởng -march=native "luôn nhanh hơn, cứ bật". Đo ra nó không những không phải luôn thắng, mà ở đây còn không biên dịch nổi — rủi ro mong manh của -march hiện ra ngay lúc build, thậm chí trước cả cái SIGILL lúc chạy mà lý thuyết cảnh báo. Cùng lúc, -mtune=native (chỉ chỉnh lịch) build và chạy bình thường nhưng lợi gần như bằng không trên kernel này.
Cách có cả tốc độ lẫn di động
Nếu bạn muốn cả hai — dùng lệnh chuyên dụng khi máy có, mà binary vẫn chạy được ở máy không có — thì -march toàn cục là công cụ sai. Cách đúng là chọn phiên bản lúc chạy (runtime dispatch): biên dịch nhiều bản của hàm nóng, mỗi bản cho một tập lệnh, rồi lúc khởi động hỏi CPU thật hỗ trợ gì và trỏ tới bản phù hợp. gcc hỗ trợ sẵn qua function multiversioning (__attribute__((target_clones("default","dotprod"))) hay target("...")), và nhiều thư viện hiệu năng cao (BLAS, giải mã video, nén) làm đúng thế: một binary duy nhất mang nhiều bản kernel, tự chọn bản nhanh nhất máy đích chạy được.
Đây cũng là chỗ builtin và intrinsics gặp lại chủ đề này: viết một bản intrinsics dùng lệnh mới, gói trong một hàm có bảo vệ tính năng, rồi để bản baseline lo phần còn lại. Cách này phức tạp hơn một cờ -march, nhưng nó là cách duy nhất vừa nhanh trên máy mới vừa không vỡ trên máy cũ — thứ mà -march=native không cho bạn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: -march=native chỉ an toàn khi build đúng trên máy sẽ chạy. Nếu bạn biên dịch trên chính con máy chủ sẽ chạy binary (một dịch vụ nội bộ, một build tại chỗ), -march=native là món hời — nó mở khóa mọi lệnh máy đó có. Nhưng nếu binary được phân phối (đóng gói, gửi cho người dùng, chạy trên cụm máy hỗn tạp), -march=native là mìn: một máy đích cũ hơn thiếu lệnh sẽ SIGILL, và như tôi vừa thấy, đôi khi build còn hỏng trước đó. Cho binary phân phối, hãy nhắm một baseline đủ rộng (hoặc dùng dispatch theo runtime — chọn phiên bản hàm theo CPU thật).
Hệ quả thứ hai: -mtune là lựa chọn an toàn khi bạn muốn "tối ưu cho CPU X mà vẫn chạy nơi khác". Vì nó không thêm lệnh mới, mã vẫn di động; bạn chỉ nhận phần lợi sắp lịch. Lợi đó thường nhỏ (như tôi đo, ~0 trên kernel nghẽn bộ nhớ), nhưng nó không bao giờ làm vỡ tính di động — một đánh đổi hoàn toàn khác -march.
Hệ quả thứ ba là bài học đo lường quen thuộc: lợi của -march tùy kernel, và cái giá là rủi ro thật, không phải lý thuyết. Kernel int8-dot của tôi hưởng 2,66 lần từ dotprod; nhưng bản int8 tích lũy long ở lần đo đầu (nghẽn băng thông bộ nhớ) thì -march không đổi gì — baseline đã đủ. Bạn không biết mình ở ca nào nếu không đo. Con số mang theo: -march= dùng tập lệnh CPU đó (nhanh cho kernel hợp — dotprod/sdot cho tích int8 nhanh 2,66 lần: 0,0428 so 0,1138 ns) nhưng mất tính di động và có rủi ro thật: -march=native ở container này còn làm VỠ BUILD vì assembler từ chối lệnh gcc phát ra, chưa nói tới SIGILL lúc chạy trên máy thiếu lệnh; -mtune= chỉ chỉnh lịch, an toàn di động nhưng lợi thường nhỏ. Dùng -march khi build đúng máy đích; dùng -mtune hoặc baseline khi phân phối.
Thử ba mươi giây
Lấy một kernel số học nhỏ (tích vô hướng, tổng mảng), build hai lần: gcc -O3 t.c -o a và gcc -O3 -march=native t.c -o b. Chạy time ./a và time ./b — với kernel hợp, bản native có thể nhanh hơn rõ; với kernel nghẽn bộ nhớ, gần như bằng nhau. Rồi objdump -d a và objdump -d b, tìm hàm nóng và so lệnh: bản native có thể có lệnh chuyên dụng (sdot, fma) mà bản kia không. Quan trọng nhất, thử mang binary b sang một máy CPU khác/cũ hơn (hoặc một container --platform khác) và chạy — nếu nó SIGILL, bạn vừa tận mắt thấy cái giá di động của -march. Ba mươi giây đó cho bạn thấy -march=native không phải công tắc "cho nhanh" vô hại, mà là một đánh đổi tốc-độ-lấy-di-động phải cân theo nơi binary sẽ chạy.