Series trước tôi đã đo SIMD từ phía thuật toán: một vòng lặp đúng dạng được vector hóa nhanh gấp mấy lần, nhưng chỉ một thay đổi nhỏ là trình biên dịch bỏ cuộc. Bài này nhìn cùng thứ đó từ phía trình biên dịch và cờ: cờ nào bật vector hóa tự động, làm sao biết chắc nó xảy ra, và một niềm tin phổ biến hóa ra đã lỗi thời. Câu trả lời không đến từ phỏng đoán mà từ hai thứ: đồng hồ, và chính lời trình biên dịch tự khai.

Vector hóa tự động

Vector hóa tự động và câu hỏi "cờ nào"

Vector hóa tự động (auto-vectorization) là việc trình biên dịch tự nhận ra một vòng lặp có thể xử lý nhiều phần tử cùng lúc bằng lệnh SIMD, và tự sinh mã đó — bạn không viết một dòng intrinsic nào. Trên ARM, tập lệnh NEON có thanh ghi 128 bit chứa bốn số float, nên một lệnh fmul vector nhân bốn cặp float đồng thời. Vòng c[i] = a[i] * b[i] lý tưởng cho việc này: các phần tử độc lập, dữ liệu liền mạch.

Câu hỏi thực dụng của lập trình viên: cờ biên dịch nào bật nó? Câu trả lời "ai cũng biết" là "phải -O3". Tôi đo trên GCC 13 với đúng vòng nhân mảng float đó, biên dịch ở -O1, -O2, -O3, và thấy câu trả lời đó sai.

Đo: -O2 đã vector hóa, -O3 không thêm gì

Tôi đo thời gian mỗi phần tử (nhớ chặn dead-code bằng sink, và cho mảng đủ lớn để không bị hoisting), đếm số lệnh vector NEON thật (những lệnh dùng thanh ghi kiểu v0.4s — "4 số 32-bit") trong hàm nóng:

Vòng c[i]=a[i]*b[i] float (gcc 13, AArch64):
  mức | thời gian   | lệnh vector NEON trong mul()
  -O1 | 0,252 ns    | 0   (mã scalar, không vector)
  -O2 | 0,129 ns    | 26  (v.4s — đã vector hóa!)
  -O3 | 0,127 ns    | 27  (gần y hệt -O2)

-O1 sinh mã scalar thuần: 0 lệnh vector, và chậm nhất (0,252 ns). Nhưng -O2 đã vector hóa — 26 lệnh NEON v.4s, và nhanh gấp gần hai lần -O1. Còn -O3? Cho mã gần như y hệt -O2 (27 so với 26 lệnh, 0,127 so với 0,129 ns — trong nhiễu). Với vòng này, -O3 không thêm gì vào vector hóa mà -O2 đã làm.

(Ghi chú trung thực: tăng tốc chỉ ~2 lần chứ không phải 4 lần như bề rộng NEON gợi ý, vì vòng này nghẽn băng thông bộ nhớ — đọc/ghi ba mảng lớn — đúng như bài SIMD Series trước đã đo. Vector hóa cắt số lệnh tính, nhưng không cắt được lưu lượng bộ nhớ.)

Một lần tôi đo hớ: "cần -O3" là niềm tin lỗi thời

Định kiến của tôi — và của rất nhiều tài liệu cũ — là "muốn vector hóa tự động thì phải bật -O3, -O2 không làm". Đo bác bỏ điều đó thẳng thừng: -O2 sinh ra 26 lệnh NEON và nhanh gấp đôi mã scalar. Sự thật là từ GCC 12, -O2 đã bật -ftree-vectorize (bộ vector hóa) với một mô hình chi phí ("very cheap") thận trọng — nó vector hóa những vòng mà lợi ích rõ ràng. -O3 không phải là công tắc bật/tắt vector hóa; nó chỉ nới mô hình chi phí, cho phép vector hóa mạnh tay hơn ở những vòng biên (như vòng có số lần lặp không chia hết cho bề rộng vector, hay cần thêm mã kiểm tra). Với vòng đơn giản của tôi, cả hai đều vector hóa như nhau, nên -O2-O3 cho kết quả trùng — đúng như bài mức -O đã thấy -O2-O3 thường sát nhau.

Nhưng chỗ đo hớ quan trọng hơn không phải "cờ nào", mà là đừng đoán, hãy hỏi trình biên dịch. GCC có cờ -fopt-info-vec: bật nó, trình biên dịch tự in ra vòng nào được vector hóa. Với vòng của tôi, nó nói thẳng:

vec.c:6: optimized: loop vectorized using 16 byte vectors
(-O1: 0 vòng vector hóa | -O2: 2 vòng vector hóa)

"16 byte vectors" = 128 bit = đúng bốn float NEON. Ở -O1, cùng cờ đó báo 0 vòng được vector hóa; ở -O2, báo 2 vòng. Còn -fopt-info-vec-missed thì ngược lại — nó liệt kê những vòng trượt vector hóa và lý do (phụ thuộc chuỗi, truy cập không liền, số lần lặp không rõ...). Đây là điều thay đổi hoàn toàn cách làm việc: thay vì đoán "chắc nó vector hóa rồi" dựa trên mức -O, bạn đọc chính lời trình biên dịch. Nó không bao giờ nói dối về việc nó đã làm gì.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: -O2 là đủ cho vector hóa trong đa số trường hợp — đừng mặc định phải -O3. Với GCC 12+ và Clang (vốn vector hóa từ -O2 lâu nay), bản build -O2 phát hành của bạn đã được vector hóa ở những vòng đủ điều kiện. -O3 đáng thử cho các nhân tính toán nặng nơi mô hình chi phí thoáng hơn giúp thêm — nhưng phải đo, vì như bài này thấy, nhiều khi nó không đổi gì, và đôi khi mã phình ra còn hại (như bài mức -O đã bàn).

Hệ quả thứ hai, và là thay đổi thói quen lớn nhất: dùng -fopt-info-vec để biết chắc, đừng phỏng đoán. Nếu bạn có một vòng nóng và muốn nó được vector hóa, biên dịch với -fopt-info-vec và đọc: nếu nó im lặng (hoặc hiện trong -fopt-info-vec-missed), vòng đó đang chạy scalar, và khoảng cách 2-4 lần đang nằm im. Đây là công cụ chẩn đoán mà mọi người viết code hiệu năng nên biết, và nó biến câu hỏi "code của tôi có được vector hóa không?" từ một phỏng đoán thành một sự thật đọc được.

Hệ quả thứ ba là bài học đo lường của cả sê-ri, ở dạng cụ thể: kiến thức về trình biên dịch có hạn sử dụng — hãy đo lại trên phiên bản bạn đang dùng. "Cần -O3 để vector hóa" từng đúng với GCC cũ, nhưng GCC 12 đổi mặc định, và giờ nó sai. Con số mang theo: trên gcc 13, -O2 đã vector hóa (26 lệnh NEON, nhanh gấp ~2 lần -O1 scalar), -O3 cho mã y hệt; và cách biết chắc vòng nào được vector hóa là đọc -fopt-info-vec — compiler tự in "loop vectorized using 16 byte vectors" — chứ không phải đoán theo mức -O. Đừng tin một quy tắc ngón tay cái về cờ trình biên dịch mà không kiểm lại; trình biên dịch thay đổi, và nó luôn sẵn lòng tự khai nếu bạn hỏi đúng cờ.

Thử ba mươi giây

Lấy một vòng lặp cộng hay nhân mảng đơn giản trong code của bạn, biên dịch với gcc -O2 -fopt-info-vec -c file.c (hoặc clang -O2 -Rpass=loop-vectorize), và đọc dòng thông báo: nếu thấy "loop vectorized", vòng đó đang chạy SIMD. Rồi thử -O1 -fopt-info-vec để thấy nó im lặng — không vector hóa. Cuối cùng, nếu tò mò vì sao một vòng không được vector hóa, thêm -fopt-info-vec-missed và đọc lý do: bạn sẽ thấy chính xác cái gì đã chặn (một phụ thuộc, một nhánh, một truy cập không liền) — và biết cần sửa gì để mở khóa 2-4 lần tốc độ, thay vì đoán mò.