Cả sê-ri này ta khai thác song song bằng cách chia việc cho nhiều lõi — luồng, khóa, atomic. Nhưng có một tầng song song khác, nằm ngay trong một lõi: vector hóa (SIMD), nơi một lệnh xử lý nhiều phần tử cùng lúc. Câu hỏi thực tế: khi cần tăng tốc một vòng lặp số học, chọn cái nào — vector hóa, đa luồng, hay cả hai? Trực giác phổ biến nói "SIMD cho Nx, luồng cho số-lõi lần, nhân lại là được tích". Tôi đo trong container gcc:13 (10 lõi, ARM NEON), và phát hiện câu trả lời hoàn toàn phụ thuộc vào nút thắt của vòng lặp.

Vector hóa vs đa luồng

Hai tầng song song, trực giao

Vector hóa (SIMD — Single Instruction Multiple Data): CPU có thanh ghi rộng chứa nhiều phần tử (NEON của ARM: 4 float mỗi thanh ghi), và một lệnh làm phép tính trên cả gói. Điểm hay: nó xảy ra trong một lõi, không luồng, không khóa, không đồng bộ — gần như miễn phí nếu trình biên dịch tự làm được (-O3 -march=native). Đa luồng: chia dữ liệu cho nhiều lõi, mỗi lõi một phần. Được tới số-lõi lần, nhưng gánh chi phí tạo luồng, đồng bộ, và băng thông bộ nhớ chung.

Hai tầng này trực giao: bạn có thể vector hóa bên trong mỗi luồng, nên về lý thuyết kết hợp cả hai cho tích của hai hệ số. Tôi đo hai vòng lặp khác nhau để kiểm.

Đo: cùng ý tưởng, hai kết cục

Vòng thứ nhất, saxpy (a[i] = 2.5*a[i] + b[i]) trên 64 triệu float — rất ít phép tính, rất nhiều truy cập bộ nhớ:

scalar 1 luồng : 38,2 ms
vector 1 luồng : 34,1 ms   -> SIMD chỉ 1,12x (dù asm CÓ lệnh vector!)
10 luồng       :  8,4 ms   -> luồng 4,5x (không phải 10x)

Vector hóa gần như vô ích (1,12×) — dù kiểm mã máy thấy rõ lệnh NEON. Vì sao? Vòng này memory-bound: đọc/ghi hàng trăm MB, còn phép tính (2.5*a+b) tí xíu. SIMD tăng tốc số học, nhưng khi nút thắt là băng thông, làm phép tính rộng hơn chẳng giúp gì — dữ liệu vẫn phải chảy qua cùng một đường tới RAM. Và đa luồng cũng chỉ 4,5× (không phải 10×), chạm đúng trần băng thông mà bài scaling đã đo.

Vòng thứ hai, compute-bound — mỗi phần tử chạy một đa thức 64 phép nhân-cộng (nhiều FLOP, ít byte):

scalar 1 luồng : 41,8 ms
vector 1 luồng : 16,8 ms   -> SIMD 2,49x (NGAY trên một lõi!)
vector 10 luồng:  3,5 ms   -> ~12x so scalar 1 luồng

Giờ SIMD ăn tiền thật: 2,49× chỉ với một cờ trình biên dịch, không thêm một luồng nào, không một dòng đồng bộ. Vì nút thắt là tính toán, và SIMD làm 4 phép cùng lúc. Kết hợp SIMD với 10 luồng cho ~12× so với scalar một luồng — hai hệ số nhân lên (dù không hoàn hảo, vì luồng vẫn chỉ ~4,86× do băng thông). Cùng một ý tưởng song song hóa, hai vòng lặp cho hai kết cục trái ngược.

Một lần tôi đo hớ: SIMD không phải lúc nào cũng nhân

Tôi vào đo với niềm tin gọn gàng: "vector hóa cho tôi ~4× (NEON 4 float), đa luồng cho ~10× (10 lõi), kết hợp là ~40×". Vòng saxpy đập tan điều đó: SIMD cho 1,12×, không phải 4×; và đa luồng cho 4,5×, không phải 10×. Nhân hai số thực tế lại (1,12 × 4,5 ≈ 5×) còn xa "40×" tưởng tượng. Không phải SIMD hỏng — nó chạy đúng, mã máy có lệnh vector — mà vì nút thắt của vòng đó không phải số học. Khi băng thông bộ nhớ là trần, cả làm-phép-rộng-hơn (SIMD) lẫn thêm-lõi (luồng) đều đập đầu vào cùng một giới hạn.

Bài học đo lường: tăng tốc phụ thuộc bạn đang bị chặn bởi , không phải bạn ném kỹ thuật nào vào. Vòng compute-bound (nhiều FLOP mỗi byte) hưởng lợi từ SIMD ngay lập tức và gần như miễn phí — nên nó thường là thứ đáng thử trước, rẻ hơn cả đa luồng (không cần lo đua tranh, đồng bộ hay khóa ẩn). Vòng memory-bound thì cả SIMD lẫn luồng đều chạm trần băng thông; ở đó, tối ưu đúng là giảm lượng dữ liệu di chuyển (bố cục bộ nhớ, cache blocking), không phải làm phép tính rộng hơn hay thêm lõi. Chỉ đo tỉ số FLOP-trên-byte, hoặc đơn giản thử từng kỹ thuật và xem cái nào thật sự nhanh lên, mới cho biết bạn ở phía nào.

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

Hệ quả đầu tiên: thử vector hóa trước khi thử đa luồng cho vòng số học compute-bound. Nó gần như miễn phí — một cờ -O3 -march=native, không luồng, không khóa — và cho 2-4× ngay trên một lõi. Bật cờ, kiểm mã máy có lệnh SIMD, và đo. Đây là tăng tốc rẻ nhất trong sách, thường bị bỏ qua vì người ta nhảy thẳng tới luồng.

Hệ quả thứ hai: biết vòng của bạn compute-bound hay memory-bound trước khi chọn. Đếm sơ bộ FLOP trên mỗi byte đọc/ghi: nhiều phép tính mỗi phần tử → compute-bound → SIMD (rồi kết hợp luồng) thắng lớn. Ít phép tính, chủ yếu chép/cộng mảng lớn → memory-bound → cả SIMD lẫn luồng đều bị băng thông chặn; hãy giảm dữ liệu di chuyển thay vì tăng bề rộng tính toán.

Hệ quả thứ ba là tinh thần đo lường: tăng tốc phụ thuộc nút thắt, không phải kỹ thuật. Con số mang theo: vector hóa (SIMD, một lõi) và đa luồng (nhiều lõi) là hai tầng song song trực giao, kết hợp được — nhưng hiệu quả tùy nút thắt: vòng memory-bound (saxpy 64M) chỉ được 1,12× từ SIMD dù asm có lệnh vector và 4,5× từ 10 luồng (băng thông chặn), trong khi vòng compute-bound (64 FLOP/phần tử) được 2,49× từ SIMD ngay trên một lõi và ~12× khi kết hợp; nên đo nút thắt trước — compute-bound thì SIMD trước (rẻ nhất), memory-bound thì cả hai chạm trần băng thông. "SIMD × luồng = tích" chỉ đúng khi compute-bound.

Thử ba mươi giây

Lấy vòng lặp số học nặng nhất của bạn và hỏi: mỗi phần tử làm bao nhiêu phép tính trên bao nhiêu byte đọc/ghi? Nếu nhiều phép tính mỗi byte (một đa thức, một hàm lượng giác, một phép biến đổi phức tạp), bạn compute-bound — bật -O3 -march=native, kiểm mã máy có lệnh SIMD, và bạn có thể được 2-4× gần như miễn phí trước khi động tới một luồng nào. Nếu chỉ chép/cộng những mảng lớn (ít phép tính mỗi byte), bạn memory-bound — và cả SIMD lẫn thêm lõi sẽ đập vào trần băng thông; việc đáng làm là giảm dữ liệu di chuyển. Ba mươi giây ước lượng FLOP-trên-byte đó cho bạn biết nên ném kỹ thuật nào vào — và cứu bạn khỏi thất vọng vì "vector hóa mà chẳng nhanh hơn".