Phần trước cho thấy CPU chạy nhiều lệnh độc lập song song. SIMD (Single Instruction, Multiple Data) đẩy ý tưởng đó xa hơn: một lệnh duy nhất xử lý nhiều phần tử cùng lúc, nhờ các thanh ghi rộng. Trên host ARM này, NEON rộng 128 bit — gói 4 số float (32-bit) hoặc 2 số double (64-bit) vào một lệnh; x86 AVX2 gói 8 float trong 256-bit. Điều tuyệt là trình biên dịch có thể tự động vector hóa vòng lặp của bạn. Nhưng "tự động" không có nghĩa "luôn luôn". Tôi đo trong container gcc:13 trên ARM, và con số cho thấy SIMD cho tốc độ gần 4 lần — khi nó thật sự vector hóa được.
Một lệnh, nhiều phần tử — nếu vòng lặp cho phép
Ý tưởng SIMD: thay vì cộng từng cặp số một lần, một lệnh vector cộng 4 cặp float cùng lúc. Nếu một vòng lặp làm cùng phép tính lên từng phần tử độc lập (out[i] = f(a[i])), trình biên dịch có thể gom 4 lần lặp thành một lệnh vector — về lý thuyết nhanh gần 4 lần.
Nhưng không phải vòng nào cũng vector hóa được:
- Phụ thuộc vòng lặp: nếu
y[i]cần kết quảy[i-1](một hồi quy/recurrence), các phần tử không độc lập — không thể tính 4 cái cùng lúc, nên không vector hóa được. Đây là chuỗi phụ thuộc như phần ILP, nhưng ở đây nó chặn hẳn SIMD. - Nhánh trong vòng, truy cập không liền, con trỏ có thể trùng (aliasing): đều làm trình biên dịch không dám (hoặc không thể) vector hóa.
Và một điểm dễ vấp: -O2 mặc định của g++ vector hóa hạn chế; thường cần -O3 hoặc -march/-mcpu để bật vector hóa đầy đủ. Đừng cho rằng "code số + -O2 = đã SIMD".
Đo: 3,9 lần khi vector hóa được, 0 lần khi có phụ thuộc
Tôi chạy hai kernel trên 16.384 số float (trong cache, để compute-bound), biên dịch hai cách — vô hướng (scalar) và vector hóa:
16384 float trong cache x4000, g++, host ARM (NEON 128-bit = 4 float/lệnh):
A. KERNEL VECTOR HÓA ĐƯỢC (poly 3 FMA mỗi phần tử, độc lập):
scalar (-O2 -fno-tree-vectorize) : 0,232 ns/phần tử
vector (-O3 -mcpu=native) : 0,059 ns/phần tử
-> nhanh ~3,9 lần
C. KERNEL KHÔNG VECTOR HÓA ĐƯỢC (hồi quy y[i] = y[i-1]*b + a[i]):
scalar : 0,740 ns/phần tử
vector : 0,741 ns/phần tử
-> 0 lần (phụ thuộc vòng lặp chặn SIMD)
Nhìn A — kernel vector hóa được: cùng một đoạn code tính đa thức bậc ba cho từng phần tử, bản scalar mất 0,232 ns còn bản vector chỉ 0,059 ns — nhanh 3,9 lần. Con số này không ngẫu nhiên: NEON gói đúng 4 float vào một lệnh, nên một lệnh vector làm việc của ~4 lệnh scalar. Vì các phần tử độc lập và dữ liệu nằm trong cache (compute-bound), SIMD gần như đạt trọn tỷ lệ lý thuyết 4×.
Nhìn C — kernel không vector hóa được: một bộ lọc hồi quy, mỗi y[i] cần y[i-1]. Bản scalar (0,740 ns) và bản vector (0,741 ns) bằng nhau y hệt — trình biên dịch không thể vector hóa vì phần tử sau phụ thuộc phần tử trước. Dù bạn bật -O3 -mcpu=native, nó vẫn sinh mã scalar. Cùng cờ vector hóa, cùng kiểu dữ liệu, nhưng cấu trúc phụ thuộc quyết định SIMD có áp được hay không.
Một lần tôi đo hớ: "compiler -O2 vector hóa hết" và "SIMD luôn tăng N lần"
Tôi vào đo với một niềm tin lạc quan: "trình biên dịch hiện đại ở -O2 tự vector hóa hết mọi vòng lặp số, mình không cần lo". Đo phá tan theo hai hướng. Thứ nhất, kernel hồi quy không hề được vector hóa (0,74 ns cả scalar lẫn vector) vì phụ thuộc vòng lặp — trình biên dịch không thể phá luật toán học để làm song song. Thứ hai, ngay cả kernel vector hóa được, -O2 mặc định của g++ vector hóa hạn chế; tôi phải -O3/-mcpu mới bật đầy đủ. "Compiler lo hết" là một giả định nguy hiểm — nhiều vòng lặp âm thầm chạy scalar dù bạn tưởng đã SIMD, và cách duy nhất chắc chắn là xem assembly hoặc đo.
Nhưng đo cũng chỉnh một niềm tin ngược mà người mê SIMD dễ có: "SIMD luôn tăng tốc N lần theo độ rộng vector, cứ vector hóa là nhanh gấp 4/8". Nửa đúng: kernel A đạt gần trọn 4× vì nó vector hóa được và compute-bound (dữ liệu trong cache, nhiều phép tính mỗi phần tử). Nhưng nếu vòng lặp memory-bound (đọc dữ liệu mới từ RAM là nút cổ chai), SIMD không đạt N× vì băng thông bộ nhớ mới là giới hạn, không phải số lệnh. Thêm nữa, phần đuôi lẻ (n không chia hết cho độ rộng vector) và chi phí gom/rải dữ liệu không liền đều ăn bớt lợi ích. SIMD nhanh N× chỉ trong điều kiện lý tưởng.
Bài học đo lường: SIMD gói nhiều phần tử vào một lệnh (ARM NEON 128-bit = 4 float / 2 double; x86 AVX2 = 8 float) — kernel vector hóa được + compute-bound đạt gần N× (đo: poly 3 FMA scalar 0,232 vs vector 0,059 ns = 3,9x, NEON 4 float). NHƯNG: vòng có PHỤ THUỘC (y[i] cần y[i-1]) KHÔNG vector hóa được (0x, scalar≈vector); -O2 mặc định g++ vector hóa hạn chế (cần -O3/-mcpu); và memory-bound / đuôi lẻ / gom-rải làm không đạt N×. 'Compiler tự lo hết' và 'SIMD luôn N×' đều sai. Nếu tôi tin "compiler lo hết" tôi để vòng chạy scalar mà tưởng SIMD; nếu tin "luôn N×" tôi kỳ vọng sai ở workload memory-bound.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: muốn SIMD, viết vòng lặp để trình biên dịch vector hóa được — và kiểm tra nó có làm không. Giữ phần tử độc lập (tránh phụ thuộc vòng lặp), dữ liệu liền mạch (SoA thay AoS), không nhánh trong vòng nóng, và báo cho trình biên dịch con trỏ không trùng (__restrict). Bật -O3 (hoặc -march=native/-mcpu=native) và xem báo cáo vector hóa (-fopt-info-vec) hoặc assembly để chắc chắn — đừng đoán.
Hệ quả thứ hai: kỳ vọng đúng lợi ích — N× chỉ khi compute-bound. Nếu kernel của bạn đọc/ghi nhiều và tính ít (memory-bound), SIMD giúp ít vì băng thông bộ nhớ mới là nút cổ chai — lúc đó tối ưu bố cục dữ liệu và cache quan trọng hơn. SIMD tỏa sáng khi có nhiều phép tính trên dữ liệu trong cache (xử lý ảnh, tín hiệu, ML, vật lý). Với vòng phụ thuộc, phải thiết kế lại thuật toán (song song hóa) trước khi mong SIMD.
Hệ quả thứ ba là tinh thần đo lường: "tự động" của trình biên dịch có giới hạn, và tốc độ thật phụ thuộc cả thuật toán lẫn phần cứng. Con số mang theo: SIMD đạt gần N× (đo 3,9x, NEON 4 float) khi vòng vector hóa được + compute-bound + dữ liệu liền; vòng phụ thuộc 0×, memory-bound < N×, và -O2 mặc định thường chưa vector đủ. Kiểm assembly/-fopt-info, đừng cho rằng đã SIMD. Vector hóa là một trong những tăng tốc lớn nhất có sẵn — nhưng nó thất bại âm thầm, nên phải đo và kiểm.
Thử ba mươi giây
Viết một vòng lặp tính đa thức cho từng phần tử một mảng float (out[i] = ((a[i]*c0+c1)*a[i]+c2)*a[i]+c3), giữ mảng đủ nhỏ để nằm trong cache. Biên dịch hai cách và bấm giờ: -O2 -fno-tree-vectorize (scalar) và -O3 -march=native (vector). Bạn sẽ thấy bản vector nhanh khoảng 4 lần trên x86 (8 float thì tới 8 lần) hoặc gần 4 lần trên ARM NEON — đúng bằng số phần tử mỗi lệnh, vì kernel này compute-bound và độc lập. Giờ đổi sang một vòng hồi quy (y[i] = y[i-1]*0.99f + a[i]) và đo lại cả hai: lần này chúng bằng nhau, vì phụ thuộc vòng lặp khiến trình biên dịch không vector hóa nổi — dù bạn bật mọi cờ. Cuối cùng, thêm -fopt-info-vec khi biên dịch để thấy trình biên dịch nói nó vector hóa được vòng nào, bỏ qua vòng nào. Ba mươi giây đó cho bạn thấy điều mà "compiler -O2 lo hết SIMD" giấu đi: vector hóa là một phép biến đổi có điều kiện, nó cần vòng lặp của bạn cho phép, và nó lặng lẽ không làm khi không thể — nên muốn hưởng SIMD, phải viết code để nó vector hóa được và kiểm tra rằng nó đã làm.