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.

Nghĩ tới một người thợ đóng dấu. Cách thường: cầm con dấu, đóng lên từng phong bì một. Cách SIMD: dùng một tấm bản khắc bốn con dấu cùng lúc, một cú ấn xong bốn phong bì. Nhanh gấp bốn — với điều kiện bốn phong bì xếp sẵn cạnh nhau và không cái nào phải chờ mực của cái trước khô. Đó đúng là điều kiện của vector hóa. Trên host ARM này, thanh ghi 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 làm tấm bản bốn dấu ấy cho 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 (con dấu sau phải chờ mực con dấu trước).
  • 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× — tấm bản bốn dấu chạy hết công suất.

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.

Hai niềm tin đối nghịch, cùng sai

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". Con số bác 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 nếu vì thấy con số 3,9× mà tin "SIMD luôn tăng tốc N lần theo độ rộng vector, cứ vector hóa là nhanh gấp 4/8" thì cũng nửa đúng thôi. 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.

Về đến code hằng ngày

Việc đầ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.

Việc 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.

Điều đáng nói là SIMD không còn là chuyện riêng của người viết C nữa — nó len lên tận tầng cao, và biết giới hạn của nó giúp hiểu vì sao. Java có Vector API (jdk.incubator.vector) để lập trình viên vector hóa tường minh khi JIT không tự làm; và cả cơn sốt hiệu năng của numpy/pandas/PyTorch thực chất là "đẩy vòng lặp xuống một kernel C/Fortran đã vector hóa sẵn" — đó là lý do một phép trên cả mảng numpy nhanh hơn vòng for Python cả trăm lần, nhưng một phép hồi quy (mỗi bước cần bước trước) trong numpy lại không nhanh lên được, đúng như kernel C ở trên. Database phân tích cột (ClickHouse, DuckDB) cũng chạy các toán tử vector hóa trên từng khối cột. Nguyên tắc xuyên suốt ở mọi tầng: vector hóa cần dữ liệu song song được và liền mạch; gặp phụ thuộc tuần tự thì nó bó tay, dù bạn ở C hay Python.

Muốn tự thấy thì 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 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.