Ở bài trước ta thấy một nhánh khó đoán làm CPU trả giá bằng những lần đoán sai. Bài này đi sâu hơn một mức vào chính con chip: SIMD — một lệnh máy xử lý nhiều phần tử cùng lúc. Nghe như phép màu miễn phí: bật -O3 là trình biên dịch tự làm, vòng lặp nhanh gấp mấy lần mà không phải viết thêm dòng nào. Tôi vào bài với đúng niềm tin đó, đo ra một nửa là thật, một nửa là bẫy.
SIMD: một lệnh, nhiều phần tử
CPU thường chạy theo kiểu SISD (Single Instruction, Single Data): một lệnh add cộng một số với một số. SIMD (Single Instruction, Multiple Data) thêm những thanh ghi rộng chứa nhiều giá trị cạnh nhau, và những lệnh xử lý cả gói một lần. Trên ARM (chip Apple mà máy đo này chạy), tập lệnh NEON có thanh ghi 128 bit — vừa đúng 4 số float 32 bit. Một lệnh fmul trên NEON nhân bốn cặp float đồng thời. Trên x86 thì AVX cho thanh ghi 256 bit (8 float) hay AVX-512 (16 float).
Điều đẹp là bạn thường không phải viết lệnh NEON bằng tay. Trình biên dịch có bộ tự động vector hóa (auto-vectorizer): gặp một vòng lặp đủ đơn giản, nó tự gộp nhiều lần lặp thành các lệnh SIMD. Bật lên bằng -O3 (hoặc -O2 -ftree-vectorize) với GCC. Lý thuyết hứa hẹn: một vòng cộng hay nhân mảng có thể nhanh gần bằng số phần tử mỗi thanh ghi — 4 lần với NEON, 8 với AVX. Câu hỏi đo được: thật sự nhanh mấy lần, và có phải vòng nào cũng được hưởng không?
Đo: bật -O3, nhanh 3,9 lần
Tôi viết một vòng nhân từng phần tử c[i] = a[i] * b[i] trên mảng float, n = 16384 (mỗi mảng 64 KB, đủ nhỏ để nằm gọn trong cache — tôi muốn đo tính toán, không đo băng thông RAM), lặp 60000 lần. Cùng một hàm, biên dịch hai cách bằng thuộc tính hàm của GCC: một bản optimize("O2","no-tree-vectorize") (ép scalar), một bản optimize("O3") (cho vector hóa). Kết quả ổn định qua hai lần chạy:
c[i] = a[i] * b[i] (n=16384, lặp 60000)
SCALAR (tắt vector hóa) : 245 ms
VECTOR (-O3, NEON) : 63 ms -> nhanh 3,9 lần
Gần đúng 4 lần — đúng bằng số float mà một thanh ghi NEON chứa. Vế hứa hẹn của SIMD là thật: không viết một dòng NEON nào, chỉ đổi cờ biên dịch, vòng nhân mảng nhanh gấp gần bốn. Nếu vòng của bạn đúng dạng, đây là món quà lớn nhất mà một cờ biên dịch có thể tặng.
Chữ nếu trong câu trên là toàn bộ phần còn lại của bài.
Một lần tôi đo hớ: -O3 không tự làm mọi vòng nhanh
Sau con số 3,9 lần, tôi suýt kết luận gọn: "bật -O3, vòng lặp nào cũng được SIMD tăng tốc, xong". Đó là chỗ tôi đo hớ. Tôi lấy đúng vòng vừa rồi, đổi một chi tiết nhỏ trông vô hại, giữ nguyên -O3, và đo lại.
Vòng thứ nhất — các phần tử độc lập, mỗi kết quả không cần kết quả nào khác:
c[i] = a[i] + b[i] -> 63 ms (vector hóa được)
Vòng thứ hai — chỉ đổi thành một phụ thuộc mang tính chuỗi, mỗi phần tử cần phần tử ngay trước nó (kiểu tổng tích lũy, prefix sum):
c[i] = c[i-1] + b[i]*0.5 -> 729 ms (KHÔNG vector hóa được)
Cùng -O3, cùng kích thước mảng, cùng số vòng lặp. Vòng độc lập chạy 63 ms; vòng phụ thuộc chuỗi chạy 729 ms — chậm 11,6 lần. Và không hề có một cảnh báo nào: compiler không báo "tôi không vector hóa được vòng này", nó lặng lẽ sinh mã scalar và đi tiếp.
Vì sao? SIMD cộng bốn phần tử cùng một lúc. Nhưng để tính c[i] trong vòng thứ hai, tôi phải có c[i-1] trước đã — mà c[i-1] lại cần c[i-2]... Các lần lặp xếp thành một chuỗi xích, không thể làm bốn cái song song vì cái sau chờ cái trước. Auto-vectorizer nhìn thấy phụ thuộc đó và từ chối — vector hóa ở đây sẽ cho kết quả sai. Tệ hơn, chuỗi phụ thuộc còn phá cả pipeline của CPU (mỗi phép cộng float phải chờ phép trước xong), nên nó không chỉ mất cái lợi 4 lần mà còn chậm hơn cả bản scalar độc lập — ra tận 11,6 lần.
Bài học: -O3 không phải cái công tắc "làm mọi thứ nhanh". Nó chỉ vector hóa được những vòng có đúng hình dạng — các lần lặp độc lập, dữ liệu nằm liền mạch, không nhánh phức tạp. Đổi một chi tiết nhỏ ra ngoài khuôn đó là mất sạch, mà mất im lặng.
Ba thứ giết chết vector hóa
Phụ thuộc chuỗi ở trên là một trong ba kẻ phá phổ biến. Cả ba đều có điểm chung: chúng phá vỡ giả định "bốn phần tử này có thể xử lý cùng lúc, độc lập".
Phụ thuộc mang tính chuỗi. a[i] = a[i-1] + x, tổng tích lũy, bộ lọc IIR, bất cứ công thức nào mà bước sau đọc kết quả bước trước. Mỗi phần tử phải chờ phần tử trước — không có gì để làm song song.
Nhánh phụ thuộc dữ liệu. Một if bên trong vòng mà điều kiện đổi theo từng phần tử (if (a[i] > 0) c[i] = ...) khiến bốn phần tử trong một gói có thể đi hai hướng khác nhau — SIMD không rẽ nhánh riêng cho từng làn được. (Có kỹ thuật masking để lách, nhưng auto-vectorizer thường bỏ cuộc với nhánh phức tạp.) Đây chính là lý do bài về dự đoán nhánh nối thẳng vào đây: vòng không nhánh không chỉ tránh đoán sai, nó còn cho phép vector hóa.
Truy cập không liền mạch. SIMD nạp nhanh nhất khi bốn phần tử nằm sát nhau trong bộ nhớ để bê nguyên một dòng cache vào thanh ghi. Nếu bạn nhảy cóc — a[idx[i]] (gián tiếp), hay đọc một trường trong mảng cấu trúc (AoS) — thì phải gom góp từng phần tử một, và cái lợi tan biến. Đây là mối nối với bài AoS so với SoA: bố cục SoA (mỗi trường một mảng liền) chính là bố cục mà SIMD cần để nạp cả gói một lần.
Ba kẻ phá này giải thích vì sao SIMD được gọi là mong manh: nó đòi hỏi cùng lúc dữ liệu liền mạch, không phụ thuộc chéo, và không nhánh — vắng một trong ba là compiler rút lui, không kèn không trống.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: bật -O3 là bước đầu, không phải dấu chấm hết. Cờ ấy mở cửa cho auto-vectorizer, nhưng nó chỉ tăng tốc được những vòng đủ điều kiện. Cách kiểm chắc chắn không phải là tin, mà là hỏi chính compiler: với GCC, -fopt-info-vec in ra vòng nào đã được vector hóa, -fopt-info-vec-missed in ra vòng nào trượt và vì sao. Một vòng nóng mà bạn tưởng đã SIMD nhưng thực ra trượt là khoảng cách 4 tới 10 lần đang nằm im — và không con số nào tự lộ ra nếu bạn không hỏi.
Hệ quả thứ hai: muốn SIMD thì viết vòng thân thiện với nó, đừng chỉ trông vào cờ. Ba nguyên tắc, và cả ba đều là những bài trước của sê-ri này cộng lại. Bố cục dữ liệu liền mạch — SoA thay vì AoS — để nạp cả gói. Bỏ nhánh trong vòng nóng — như bài dự đoán nhánh — để bốn làn đi cùng đường. Và tránh phụ thuộc mang tính chuỗi: nếu bài toán vốn là tổng tích lũy, thường có cách viết lại bằng nhiều tổng riêng phần (mỗi thanh ghi một tổng) rồi gộp cuối cùng, để các lần lặp lại độc lập. Viết vòng đúng khuôn thì compiler tự tặng bạn 4 lần; viết sai khuôn thì -O3 đứng nhìn.
Hệ quả thứ ba: khi cần chắc chắn, có SIMD viết tay. Auto-vectorizer là kẻ nhát gan có lý do — nó chỉ vector hóa khi chứng minh được là an toàn. Khi bạn biết chắc dữ liệu của mình an toàn mà compiler không dám (ví dụ nó sợ hai con trỏ chồng lấn — thêm restrict thường gỡ được), bạn có thể viết thẳng bằng intrinsics (vmulq_f32 trên NEON, _mm256_mul_ps trên AVX). Đổi lại là mã khó đọc và khóa vào một kiến trúc. Với phần lớn trường hợp, viết vòng thân thiện rồi để -O3 lo là đủ và bền hơn; intrinsics để dành cho nhân trong cùng của thư viện nóng.
Con số mang theo: một vòng nhân mảng float độc lập được -O3 vector hóa tự động nhanh 3,9 lần nhờ NEON làm 4 float mỗi lệnh — nhưng SIMD mong manh: thêm một phụ thuộc chuỗi (hay một nhánh, hay truy cập không liền) là compiler lặng lẽ bỏ vector hóa, tụt về scalar, ở đây chậm 11,6 lần mà không một cảnh báo. SIMD không phải phép màu của cờ biên dịch; nó là phần thưởng cho vòng lặp viết đúng dạng. Và cách duy nhất biết vòng của mình có đúng dạng không, như mọi bài trong sê-ri, là đo — hoặc hỏi thẳng compiler — chứ đừng đoán.
Thử ba mươi giây
Lấy một vòng nóng bất kỳ trong mã của bạn, biên dịch với GCC hoặc Clang kèm -O3 -fopt-info-vec và đọc dòng thông báo: vòng nào in ra "loop vectorized" là bạn đang được SIMD tăng tốc; vòng nào im lặng (hoặc hiện trong -fopt-info-vec-missed) là đang chạy scalar. Rồi thử một thí nghiệm nhỏ: lấy một vòng cộng mảng độc lập, đo thời gian; đổi nó thành a[i] = a[i-1] + b[i] (tổng tích lũy), đo lại. Nếu bạn thấy vòng thứ hai chậm hẳn một khoảng lớn dù cùng -O3, bạn vừa tận mắt thấy auto-vectorizer bỏ cuộc — và hiểu vì sao "cứ bật -O3 là nhanh" chỉ đúng một nửa.