CPU hiện đại có những lệnh xử lý nhiều số cùng một lúc — gọi là SIMD (Single Instruction, Multiple Data). Một lệnh add v0.4s trên aarch64 cộng bốn cặp số 32-bit song song, thay cho bốn lệnh cộng riêng. Và điều tuyệt vời: -O3 tự động biến vòng lặp thường của bạn thành các lệnh vector này, không cần bạn viết gì đặc biệt. Bài này đo mức tăng thông lượng — và phát hiện -O3 từ chối vector hóa đúng cái vòng lặp tôi mong nó tăng tốc nhất, vì một lý do đã gặp ở bài gấp hằng số.

Vector hóa SIMD

SIMD: một lệnh, bốn số

Thanh ghi vector NEON trên aarch64 rộng 128 bit, chứa được 4 số int32, 4 float32, hay 2 double. Một lệnh vector như add v0.4s, v1.4s, v2.4s cộng cả bốn làn (lane) cùng một nhịp. Với một vòng lặp xử lý mảng, điều này về lý thuyết cho thông lượng gấp 4. Tự động vector hóa (auto-vectorization) là khi trình biên dịch nhận ra một vòng lặp có thể làm theo lô như vậy và tự sinh lệnh vector — bật mặc định ở -O3.

Nhưng trình dịch chỉ vector hóa khi chứng minh được việc đó không đổi kết quả. Vector hóa một vòng lặp cộng dồn nghĩa là cộng 4 làn song song rồi gộp lại ở cuối — tức đổi thứ tự các phép cộng. Và đây là chỗ mọi thứ rẽ nhánh theo kiểu dữ liệu.

Đo: int vector hóa, float thì không

Tôi viết ba vòng lặp và đếm số lệnh NEON (nhận theo hậu tố làn .4s, .2d...) mà -O3 sinh ra, kèm đo thời gian:

Vòng lặp Kiểu NEON ở -O2 NEON ở -O3
s += a[i] (cộng dồn) int 0 4
s += a[i] (cộng dồn) float ~0 ~0
c[i]=a[i]+b[i] float 0 1

Và thời gian (300 nghìn lượt qua mảng 4096 phần tử):

int  sum:  -O2 = 353 ms   -O3 = 318 ms
float sum: -O2 = 798 ms   -O3 = 811 ms   (không nhanh hơn!)
float sum: -O3 -ffast-math = 187 ms      (4.3 lần!)

Vòng cộng dồn int được vector hóa ở -O3 (0 lên 4 lệnh NEON). Vòng cộng theo từng phần tử c[i]=a[i]+b[i] (float) cũng được vector hóa. Nhưng vòng cộng dồn float thì -O3 không vector hóa — thời gian y hệt -O2 (798 so với 811 ms). Tôi đã kỳ vọng ngược lại, nên đi đọc assembly.

Một lần tôi đo hớ: float không kết hợp nên -O3 từ chối

Tôi đinh ninh -O3 sẽ tự vector hóa vòng cộng dồn mảng float — đây là loại vòng lặp SIMD "kinh điển" nhất. Đo ra nó không nhanh hơn -O2 chút nào. Con số bảng trên ghi "NEON = 1" cho float sum ở -O3, thoạt nhìn tưởng "có một lệnh vector, vậy là vector hóa rồi". Nhưng đọc assembly thân vòng lặp mới rõ:

	movi	v0.2s, #0      ; lệnh NEON duy nhất — chỉ để đặt 0
	...
	fadd	s0, s0, s1      ; thân vòng lặp: cộng VÔ HƯỚNG từng số một

Lệnh NEON duy nhất chỉ là movi v0 để khởi tạo 0 — một con mồi nhử. Thân vòng lặp thật sự dùng fadd s0, s0, s1: cộng vô hướng từng phần tử, đúng như -O2. Không hề có vector hóa. Nếu tôi tin con số đếm "NEON = 1" mà không đọc thân vòng, tôi đã kết luận sai.

Lý do -O3 từ chối: vector hóa cộng dồn là cộng 4 làn song song rồi gộp — đổi thứ tự cộng. Với int, phép cộng có tính kết hợp ((a+b)+c == a+(b+c)), nên đổi thứ tự an toàn, kết quả không đổi → -O3 vector hóa thoải mái. Với float, phép cộng IEEE 754 không kết hợp (đổi thứ tự đổi kết quả do làm tròn, đúng cái đã đo ở bài gấp hằng số: 1e20+1-1e20) — nên vector hóa sẽ cho ra một con số khác, và -O3 không được phép đổi kết quả của bạn, nên nó từ chối. Bật -ffast-math (tôi nói với trình dịch "tôi chấp nhận đổi thứ tự và sai số làm tròn") mới mở khóa: float sum từ 798 xuống 187 ms, nhanh 4,3 lần.

Bài học đo lường: SIMD không tự đến chỉ vì bạn bật -O3 — nó phụ thuộc vào việc đổi thứ tự phép tính có làm đổi kết quả không, mà điều đó lại phụ thuộc vào kiểu dữ liệu. Cùng một hình dạng vòng lặp s += a[i], int được vector hóa còn float thì không, chỉ vì tính kết hợp. Và như mọi khi, con số đếm ("NEON = 1") có thể lừa; chỉ đọc thân vòng lặp trong assembly mới biết nó thật sự vô hướng hay vector. (Còn vòng c[i]=a[i]+b[i] vector hóa được cả với float vì nó không cộng dồn — mỗi ô ra độc lập, không có thứ tự nào để mà đổi.)

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

Hệ quả đầu tiên là đừng cho rằng -O3 tự lo hết SIMD cho mã số thực của bạn. Nếu bạn có một vòng cộng dồn/tích lũy trên float hay double (tính tổng, tích vô hướng, chuẩn vector — cực kỳ phổ biến trong đồ họa, học máy, khoa học), -O3 sẽ không vector hóa nó theo mặc định. Bạn phải chủ động: bật -ffast-math (nếu chấp nhận sai số làm tròn thay đổi — thường được trong đồ họa/ML nhưng nguy hiểm trong tài chính/khoa học chính xác), hoặc dùng nhiều biến tích lũy bằng tay, hoặc viết intrinsics.

Hệ quả thứ hai là hiểu -ffast-math đánh đổi cái gì. Nó cho phép trình dịch coi số thực như số toán học kết hợp được — mở khóa vector hóa và nhiều tối ưu — nhưng đổi lại kết quả có thể khác ở chữ số cuối, và các giá trị đặc biệt (NaN, vô cùng, -0.0) không còn được xử lý đúng chuẩn. Đây là lựa chọn phải cân nhắc theo từng miền, không phải cờ bật bừa. Với int, không có đánh đổi này — vector hóa luôn an toàn.

Hệ quả thứ ba nối lại xuyên sê-ri: tính không kết hợp của số thực là sợi chỉ đỏ chặn hàng loạt tối ưu đổi thứ tự — gấp hằng số (bài 9) không rút gọn 1e20+1-1e20 thành 1, mở vòng lặp (bài 10) không tự tách chuỗi cộng dồn float, và giờ SIMD không vector hóa cộng dồn float. Cùng một nguyên nhân, ba biểu hiện. Con số mang theo: -O3 tự vector hóa vòng lặp khi đổi thứ tự an toàn — cộng dồn int (có kết hợp) và phép theo-từng-phần-tử thì được, cộng dồn float thì KHÔNG vì đổi thứ tự đổi kết quả IEEE, chỉ -ffast-math mới mở khóa (đo được 4,3 lần) — và đừng tin con số đếm lệnh, hãy đọc thân vòng lặp xem nó vô hướng hay vector. SIMD là một trong những tối ưu mạnh nhất, nhưng nó chỉ đến khi trình dịch chắc rằng nó không phản bội kết quả của bạn.

Thử ba mươi giây

Viết hai vòng lặp cộng dồn mảng: một trên int, một trên float. Dịch cả hai với gcc -O3 -S file.c -o o3.s và tìm trong assembly các lệnh có hậu tố làn như .4s (NEON) hay %ymm/%xmm (x86). Bạn sẽ thấy vòng int có chúng, còn vòng float chỉ có phép cộng vô hướng. Giờ thêm -ffast-math, dịch lại vòng float: các lệnh vector xuất hiện. Chỉ vài lần đọc .s là bạn thấy tận mắt vì sao "bật -O3 là có SIMD" chỉ đúng một nửa — nửa còn lại nằm ở việc trình dịch có được phép đổi thứ tự phép tính của bạn hay không.