Hình dung một gian bếp. Muốn thái rau nhanh hơn, bạn có hai cách: đưa mỗi đầu bếp một con dao to hơn chém bốn củ một nhát (vector hóa), hoặc thuê thêm đầu bếp (đa luồng). Cả hai đều tăng tốc — nếu nút thắt là việc thái. Nhưng nếu rau chỉ nhỏ giọt qua đúng một cái cửa kho hẹp, thì dao to hay đông người cũng vô ích: ai cũng đứng chờ ở cửa. Tăng tốc một vòng lặp số học y hệt vậy — và cả hai kỹ thuật chỉ ăn tiền khi nút thắt đúng là "việc thái", không phải "cái cửa kho".
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.
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 — con dao to chém bốn củ một nhát. Đ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 — thêm đầu bếp. Đượ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, đúng cái cửa kho hẹp. 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 (việc thái), 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ôi trường đo: container gcc:13 trên host ARM AArch64 NEON, -O3, 64M float, mỗi số lấy min sau warmup. Con số đổi theo máy; điều bền vững là: memory-bound thì SIMD lẫn luồng đều chạm trần băng thông, compute-bound thì cả hai nhân lên.)
Hai niềm tin đối nghịch, cùng sai
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×; đa luồng cho 4,5×, không phải 10×; nhân hai số thực tế (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 lẫn thêm-lõi đều đập đầu vào cùng một giới hạn.
Nhưng có một niềm tin ngược cũng sai, dễ rút ra từ vòng saxpy: "SIMD chả ăn thua, cứ nhảy thẳng tới đa luồng". Vòng compute-bound bác lại: SIMD cho 2,49× ngay trên một lõi, chỉ với một cờ biên dịch — rẻ hơn hẳn đa luồng (không cần lo đua tranh, đồng bộ hay khóa ẩn), nên nó thường là thứ đáng thử trước. Sự thật nằm ở nút thắt, không ở kỹ thuật: vòng compute-bound (nhiều FLOP mỗi byte) hưởng SIMD gần như miễn phí và nhân được với luồng; vòng memory-bound (ít FLOP, chép mảng lớn) thì cả hai chạm trần băng thông, và tối ưu đúng là giảm dữ liệu di chuyển (bố cục bộ nhớ, cache blocking), không phải làm phép rộng hơn hay thêm lõi. Chỉ đo tỉ số FLOP-trên-byte, hoặc thử từng kỹ thuật và xem cái nào thật sự nhanh lên, mới biết bạn ở phía nào.
Về đến code hằng ngày
Điều đầ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.
Điều 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.
Chỗ này cái tỉ số FLOP-trên-byte chính là roofline model mà dân HPC dùng để dự đoán ngay một vòng lặp sẽ đụng trần tính toán hay trần băng thông — đúng cái bài này đo bằng tay. Ở tầng cao, Java có Vector API (JEP incubator) và JIT tự vector hóa vòng đơn giản; numpy nhanh chính vì gọi xuống các kernel SIMD dưới nền; và các database phân tích như ClickHouse, DuckDB dựa nặng vào SIMD trên bố cục cột (columnar) cho các phép compute-bound — nhưng chính chúng cũng biết rằng với quét bảng lớn thì băng thông bộ nhớ mới là trần thật, nên tối ưu bố cục dữ liệu (nén, cột) quan trọng ngang SIMD. Mẫu số chung: gian-bếp-và-cửa-kho — dao to và đông bếp chỉ thắng khi việc thái là nút thắt; khi cửa kho hẹp, phải mở rộng cửa (giảm/gom dữ liệu), không phải mài dao.
Muốn tự thấy thì 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à 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. Vài phút ướ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".