Hình dung may một bộ vest đo ni đóng giày cho đúng cơ thể bạn. Nó ôm hoàn hảo, đẹp nhất có thể — trên bạn. Nhưng đưa cho người khác mặc thì rộng chật lung tung, vô dụng. -march=native của gcc đúng là bộ vest đo ni đó: nó cắt mã cho đúng con CPU đang biên dịch, nhanh hết cỡ trên máy đó, nhưng mất tính "mặc vừa mọi người". Và trong lần đo này, tôi còn gặp một chuyện trớ trêu hơn: người thợ may thậm chí không khâu xong nổi bộ vest.
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 đo trong container gcc:13 (ARM), 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 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à chỗ tôi nhầm: 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. Người thợ may không khâu xong nổi bộ vest. 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ề đến code hằng ngày
Điều đầ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.
Điều 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.
Và cái căng thẳng "cắt riêng cho một máy hay chạy được mọi máy" này chính là bài toán trung tâm của phân phối phần mềm, ở mọi hệ sinh thái. Đây là chỗ máy ảo lật ngược thế cờ so với C: JVM và .NET ship bytecode trung lập kiến trúc, rồi JIT lúc chạy phát ra đúng lệnh CPU thật đang có (AVX-512 nếu máy hỗ trợ, không thì bản khác) — bạn có cả di động lẫn lệnh chuyên dụng, đúng cái mà C phải chọn một lần lúc biên dịch. Go chọn baseline bảo thủ mặc định (GOAMD64=v1) để binary chạy khắp nơi, và cho bạn nâng bằng biến môi trường nếu biết máy đích. Ngay cả Docker image cũng mang đúng vết thương này: một image build -march=native trên máy CI mới có thể SIGILL trên node production cũ hơn — lỗi kinh điển của việc trộn "build một nơi, chạy nơi khác". Mẫu số chung: mã càng cắt riêng cho một máy càng nhanh nhưng càng kén nơi chạy; giải pháp trưởng thành là hoãn quyết định "dùng lệnh nào" tới lúc chạy (JIT, runtime dispatch), thay vì đóng đinh nó lúc build.
Muốn tự thấy thì 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.