bài về vector hóa SIMD, trình biên dịch tự biến vòng lặp thành lệnh vector. Nội tại SIMD (SIMD intrinsics) là hướng ngược lại: bạn tự viết thẳng lệnh vector bằng những hàm ánh xạ một-một tới phần cứng — vaddq_f32 trên NEON của aarch64, _mm_add_ps trên SSE của x86. Câu hỏi thực tế: viết tay có nhanh hơn để trình biên dịch tự lo không? Bài này đo cả hai trong container, và câu trả lời chia làm hai nửa rõ rệt — với một nửa làm tôi phải bỏ lại định kiến.

Nội tại SIMD

Intrinsic là gì

Một intrinsic là một hàm mà trình biên dịch dịch thẳng thành đúng một lệnh vector, không phải một lời gọi hàm thật. vaddq_f32(a, b) sinh ra lệnh fadd v.4s (cộng bốn số float 32-bit cùng lúc); vld1q_f32(p) sinh ldr q (nạp 128 bit). Bạn viết mã C nhìn gần như assembly, kiểm soát chính xác từng lệnh vector, thay vì hy vọng bộ tự vector hóa của trình biên dịch nhận ra vòng lặp và làm hộ. Nghe như con đường chắc chắn tới hiệu năng đỉnh. Nhưng "chắc chắn hơn tự động" không có nghĩa là "nhanh hơn tự động", và đó là chỗ tôi đo hớ.

Đo: hai phép tính, hai kết cục

Tôi so bản viết tay bằng NEON với bản C thường (để gcc -O3 tự vector hóa), trên hai phép tính khác nhau, ghim một lõi, lấy nhỏ nhất trong 9 lần.

Phép một — cộng từng phần tử c[i] = a[i] + b[i] trên 8 triệu phần tử:

Bản Thời gian
C thường (-O3 tự vector hóa) 1,02 ms
NEON viết tay 1,00 ms

Bằng nhau — chênh nhau nằm trong nhiễu. Đọc objdump cả hai thì rõ vì sao: bản C thường ở -O3 đã sinh ldr q0; fadd v0.4s, v0.4s, v1.4s — đúng y hệt lệnh mà intrinsic của tôi sinh ra. Trình biên dịch tự nhận ra vòng lặp cộng từng phần tử là vector hóa được và làm luôn; công viết tay của tôi chẳng thêm được một mảy may nào.

Phép hai — tổng một mảng float s += a[i]:

Bản Thời gian
C thường (-O3) 5,11 ms
NEON viết tay (4 làn) 1,28 ms

Ở đây intrinsic thắng 4 lần. Lý do đã gặp ở bài fast-math: cộng float không kết hợp, nên trình biên dịch từ chối đổi thứ tự để vector hóa tổng — objdump cho thấy bản C thường dùng fadd s vô hướng, một luỹ tích tuần tự. Bản NEON của tôi dùng bốn luỹ tích song song (fadd v.4s) — nó chạy được vì tôi đã tự quyết định chấp nhận đổi thứ tự cộng.

Một lần tôi đo hớ: viết tay không "luôn thắng"

Tôi vào bài với niềm tin của dân tối ưu: tự viết SIMD bằng intrinsic thì luôn nhanh hơn để trình biên dịch tự lo — vì mình kiểm soát từng lệnh, còn bộ tự động thì "hên xui". Tôi định lấy phép cộng từng phần tử làm ví dụ mở màn cho cái thắng đó.

Phép đo dập tắt định kiến ngay lập tức: bản intrinsic 1,00 ms, bản C thường 1,02 ms — bằng nhau. Mở objdump ra, hai bên là cùng một lệnh vector fadd v.4s. Bộ tự vector hóa của gcc -O3 đã làm chính xác điều tôi tưởng chỉ mình làm được. Công sức viết tay, sự phức tạp thêm vào, cái mã khó đọc hơn — đổi lại con số không. Đây lặp lại đúng chủ đề của cả sê-ri: một công cụ thủ công là thừa khi trình biên dịch đã tự làm tốt — như gợi ý nhánh thừa khi heuristic đã đoán đúng, như thuộc tính const thừa khi trình biên dịch tự suy ra tính thuần.

Intrinsic chỉ thật sự kiếm được chỗ đứng ở phép thứ hai — tổng float — nơi trình biên dịch từ chối vector hóa vì lý do đúng đắn (không kết hợp), và viết tay ép được nó chạy nhanh 4 lần. Nhưng ngay cả cái thắng đó cũng có điều kiện ẩn: khi tôi dùng NEON để cộng bốn làn song song, tôi đã ngầm nhận trách nhiệm sắp lại thứ tự cộng — đúng cái -ffast-math làm, nhưng khoanh gọn trong một hàm thay vì bật cho cả chương trình. Kết quả tổng của tôi khác bản tuần tự vài chữ số cuối, và đó là tôi chịu, không phải trình biên dịch.

Bài học đo lường: "viết tay nhanh hơn" là một khẳng định phải đo, không phải tin sẵn. Nó thừa ở chỗ bộ tự động đã làm (phần lớn vòng lặp đơn giản), đáng ở chỗ bộ tự động từ chối hoặc bỏ sót. Và cái giá của viết tay không chỉ là công: intrinsic NEON chỉ chạy trên aarch64, _mm_ chỉ chạy trên x86 — mã SIMD viết tay không khả chuyển, trong khi mã C thường được trình biên dịch vector hóa lại cho bất kỳ kiến trúc đích nào khi biên dịch lại.

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

Hệ quả đầu tiên: thử để trình biên dịch tự vector hóa trước, đo, rồi mới viết tay chỗ nó thua. Đừng mặc định viết intrinsic vì nghĩ "nhanh hơn". Viết vòng lặp C sạch, biên dịch -O3, đọc objdump (hoặc dùng -fopt-info-vec để trình biên dịch báo nó đã vector hóa cái gì). Nếu nó đã sinh lệnh vector và đủ nhanh — xong, bạn có mã ngắn, khả chuyển, dễ đọc, không tốn công. Chỉ khi phép đo cho thấy nó không vector hóa (tổng float, vòng có phụ thuộc, gather/scatter) thì intrinsic mới xứng đáng.

Hệ quả thứ hai: hiểu vì sao trình biên dịch từ chối, để biết mình đang đánh đổi gì. Nó không vector hóa tổng float vì bảo toàn ngữ nghĩa IEEE; khi bạn viết tay bốn làn, bạn đang chọn đổi ngữ nghĩa đó lấy tốc độ, giống fast-math nhưng cục bộ. Điều đó ổn nếu bài toán chịu được sai khác làm tròn — nhưng phải là một quyết định có ý thức, không phải tác dụng phụ vô tình của việc "tối ưu". Với vòng có phụ thuộc dữ liệu thật (mỗi vòng cần kết quả vòng trước), viết tay bằng intrinsic có thể cho kết quả sai nếu bạn không cẩn thận — trình biên dịch từ chối chính vì nó thấy sự phụ thuộc đó.

Hệ quả thứ ba là bài học mang theo, và cái giá dễ quên nhất: khả chuyển. Con số mang theo: với phép cộng từng phần tử, intrinsic NEON viết tay nhanh y hệt bản để -O3 tự vector hóa (1,00 so 1,02 ms, cùng một lệnh fadd v.4s) — thừa; chỉ ở tổng float, nơi trình biên dịch từ chối đổi thứ tự, intrinsic mới thắng 4 lần — nhưng đổi lấy việc mất tính khả chuyển (NEON chỉ aarch64, mm chỉ x86) và tự gánh trách nhiệm ngữ nghĩa. Đo trước, đọc assembly, rồi mới viết tay đúng chỗ đáng — đừng viết SIMD tay theo linh cảm rằng nó "chắc chắn nhanh hơn".

Thử ba mươi giây

Viết một vòng lặp cộng từng phần tử đơn giản — void add(float*c, const float*a, const float*b, int n){ for(int i=0;i<n;i++) c[i]=a[i]+b[i]; } — và biên dịch gcc -O3 -S, rồi tìm trong assembly các lệnh vector (fadd v trên ARM, addps/vaddps trên x86). Nhiều khả năng bạn sẽ thấy chúng ngay: trình biên dịch đã tự vector hóa mà bạn chẳng viết một intrinsic nào. Thêm cờ -fopt-info-vec để nó in ra thẳng dòng nào đã được vector hóa. Giờ thử một vòng tổng float s += a[i] và làm lại: bạn sẽ thấy nó không vector hóa (chỉ fadd s vô hướng) — đó chính là chỗ, và là chỗ duy nhất trong ví dụ này, mà viết tay bằng intrinsic mua được tốc độ thật.