- SIMD không phải là một kỹ thuật phức tạp chỉ dành cho phần mềm hiệu năng cao nhất, mà là một phương tiện tối ưu hóa thường ngày giúp tăng tốc các vòng lặp thông thường bằng cách xử lý dữ liệu liên tiếp theo nhiều giá trị cùng lúc
- Mã SIMD điển hình tuân theo cấu trúc 5 bước: broadcast hằng số, duyệt theo đơn vị vector, tính toán song song, thu gọn/lưu kết quả và xử lý phần đuôi scalar
- Vòng lặp tìm kiếm code point của Ghostty so sánh 4, 8 hoặc 16 giá trị
u32 cùng lúc, về lý thuyết có thể tăng thông lượng xử lý lên tối đa 4 lần trên ARM NEON, 8 lần trên AVX2 và 16 lần trên AVX-512
- Tổng thông lượng xử lý của terminal trên desktop Intel AVX2 đã nhanh hơn khoảng 5 lần; nếu không có độ rộng vector được hỗ trợ hoặc còn dữ liệu đầu vào dư, vòng lặp scalar hiện có sẽ xử lý toàn bộ đầu vào hoặc phần còn lại
- Tự động vector hóa của trình biên dịch có thể bỏ lỡ cơ hội ngay cả với các vòng lặp đơn giản, vì vậy trước tiên hãy kiểm tra đầu ra đã tối ưu hóa; với các hot loop quan trọng, SIMD tường minh có thể giúp giữ cho hành vi và hiệu năng dự đoán được
SIMD làm gì
- SIMD cho phép CPU xử lý nhiều giá trị song song bằng một lệnh duy nhất
- Thay vì so sánh từng byte một, có thể so sánh 4, 8 hoặc nhiều byte hơn cùng lúc
- Các vòng lặp như
for (byte in bytes), for (character in string), for (value in array) có cơ hội được chuyển thành xử lý theo độ rộng vector
- Nếu dữ liệu có hàng trăm, hàng nghìn hoặc hàng triệu byte, có thể đạt mức tăng tốc cục bộ 4 lần, 8 lần hoặc hơn tùy theo độ rộng song song
- Nếu dữ liệu chỉ có vài phần tử hoặc vài chục phần tử thì không đáng áp dụng SIMD
- simdutf và simdjson dùng các kỹ thuật SIMD phức tạp, nhưng SIMD thường ngày không cần phải phức tạp đến mức đó
- Ví dụ dùng Zig, nhưng cấu trúc 5 bước áp dụng được cho các ngôn ngữ khác; cách mỗi ngôn ngữ hỗ trợ lệnh SIMD sẽ khác nhau
Cấu trúc 5 bước lặp lại
- Broadcast các hằng số cần thiết ra mọi lane và khởi tạo bộ tích lũy vector nếu cần
- Duyệt đầu vào theo từng khối có kích thước bằng độ rộng vector
- Thực hiện phép so sánh hoặc phép toán số học song song trên tất cả lane
- Thu gọn hoặc lưu kết quả vector theo thuật toán
- Phần còn lại không vừa một vector đầy đủ được xử lý bằng đuôi scalar (scalar tail), tức vòng lặp thông thường hiện có
- Khi đã quen với cấu trúc này, bạn có thể phân rã vòng lặp thông thường thành cùng 5 bước, khiến việc viết SIMD cũng đơn giản như vòng lặp scalar
- Nếu không thể biểu diễn đơn giản bằng cấu trúc này, tạm thời bỏ qua việc áp dụng SIMD thường là lựa chọn phù hợp
Vòng lặp tìm kiếm thực tế của Ghostty
- Ghostty tiêu thụ dữ liệu trong mảng code point đã giải mã cho đến khi gặp giá trị nhỏ hơn hoặc bằng
0xF
- Phần lớn dữ liệu terminal là ký tự thông thường để xuất ra, nên chúng được xử lý theo nhóm
- Vòng lặp tìm điểm kết thúc của đoạn có thể xuất tiếp theo nhanh nhất có thể
- Bản cài đặt scalar ban đầu kiểm tra từng code point một
while (end < cps.len and cps[end] > 0xF) end += 1;
- Bản cài đặt vector dùng vector thông thường, không dùng intrinsic riêng cho CPU, và dài hơn bản scalar 12 dòng mã
- Mức tăng thông lượng kỳ vọng tương ứng với số lane của vector
- ARM NEON và Apple Silicon: tối đa 4 lần
- AVX2, được hầu hết CPU x86 hiện đại hỗ trợ: tối đa 8 lần
- AVX-512, được một số CPU Intel và AMD Zen 4 trở lên hỗ trợ: tối đa 16 lần
- Trên desktop Intel AVX2, tổng thông lượng đo từ đầu vào chương trình terminal đến trạng thái terminal cuối cùng nhanh hơn khoảng 5 lần
- Không đạt được toàn bộ mức tăng tốc lý thuyết do các công việc xung quanh SIMD
- Ký tự điều khiển C0 vẫn tồn tại sau
0xF, nhưng 0xF là ngưỡng được dùng trong đường mã Ghostty này
- ESC và các chuỗi điều khiển khác được xử lý ở đường riêng
Bước 1: Broadcast hằng số
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
simd.lanes(u32) của Ghostty trả về số lượng u32 mà CPU đích có thể xử lý đồng thời
- Mỗi giá trị được gọi là một lane
- ARM trả về 4, AVX2 trả về 8, AVX-512 trả về 16
- Nếu không có kích thước vector có thể dùng, trả về
null để bỏ qua mã SIMD
@Vector(lanes, u32) tạo kiểu vector với số lane tương ứng
- Nếu
lanes là 8, một V chứa 8 giá trị u32 có thể xử lý song song
- Phép so sánh vector cần cả hai phía đều là vector, nên
@splat(0xF) sao chép 0xF vào mọi lane
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
- Thuật toán này không cần bộ tích lũy vector, nhưng các thuật toán khác có thể khởi tạo bộ tích lũy ở bước này
Bước 2: Duyệt từng vector một
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
- Nếu
lanes là 8, vòng lặp chỉ chạy khi còn ít nhất 8 giá trị và nạp 8 giá trị vào values
- Sau mỗi lần lặp, tăng
end không phải 1 mà bằng số lane
- Vì phải nạp được một vector đầy đủ, nếu chỉ còn 5 giá trị thì không đọc vector 8 lane
- Các giá trị không vừa vào vector sẽ được đuôi scalar ở bước 5 xử lý
Bước 3: So sánh song song trên mọi lane
const greater_than_threshold = values > threshold;
- Vì cả
values và threshold đều là vector, toán tử > so sánh từng lane tương ứng bằng một phép toán vector
- Với 8 lane, 8 phép so sánh tương đương
cps[end] > 0xF được thực hiện song song
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold: { 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }
- Không có vòng lặp bên trong tường minh; kết quả là vector chứa boolean theo từng lane
- Không chỉ so sánh, cấu trúc tương tự cũng áp dụng được cho các phép toán mà kiểu vector hỗ trợ như cộng, nhân, giá trị nhỏ nhất, giá trị lớn nhất
- Bản thân phép so sánh là một phép toán vector, nhưng việc nạp vector, thu gọn kết quả và tìm lane thất bại cần thêm lệnh
Bước 4: Thu gọn kết quả vector
if (@reduce(.And, greater_than_threshold)) continue;
@reduce(.And, ...) kết hợp mọi boolean bằng and để tạo một boolean duy nhất
- Nếu mọi lane đều
true, chuyển sang vector tiếp theo; nếu có bất kỳ lane nào false, tìm vị trí chính xác đã thất bại
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
@bitCast chuyển vector boolean thành mặt nạ số nguyên, mỗi lane 1 bit
1 nghĩa là giá trị lớn hơn 0xF
0 nghĩa là phép so sánh thất bại
- Khi đảo mặt nạ, phép so sánh thất bại trở thành
1, và @ctz đếm số bit 0 trước bit 1 đầu tiên
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
greater_than_threshold: { true, true, true, false, true, true, true, true }
mask: { 1, 1, 1, 0, 1, 1, 1, 1 }
~mask: { 0, 0, 0, 1, 0, 0, 0, 0 }
- Trong ví dụ này,
@ctz(~mask) trả về 3, đưa end đến lane số 3 nơi có ký tự điều khiển đầu tiên 0x0A
- Thu gọn kết quả là phần khác nhau nhiều nhất giữa các thuật toán trong 5 bước
- Tính tổng có thể thu gọn bộ tích lũy vector thành một số duy nhất
- Chuyển đổi có thể lưu toàn bộ vector vào buffer đầu ra
- Tìm kiếm này tạo bit mask để tìm vị trí của một lane cụ thể
Bước 5: Xử lý đuôi scalar
while (end < cps.len and cps[end] > 0xF) end += 1;
- Nếu độ dài đầu vào không phải bội số chính xác của độ rộng vector, vòng lặp scalar ban đầu sẽ xử lý phần còn lại
- Sau vòng lặp vector 8 lane, có thể còn từ 0 đến 7 giá trị
- Trên CPU nơi
simd.lanes(u32) là null, đoạn SIMD bị bỏ qua và vòng lặp scalar xử lý toàn bộ đầu vào
- Bản cài đặt ban đầu đồng thời đảm nhiệm xử lý phần còn lại và fallback tương thích
- Vector thông thường chỉ loại bỏ cú pháp riêng theo CPU, chứ không loại bỏ việc sinh mã riêng theo CPU
- Zig chuyển các phép toán vector thành tập lệnh được bật trên đích
Những gì tự động vector hóa bỏ lỡ
- Trình biên dịch có thể tự động vector hóa mã đơn giản, chẳng hạn vòng lặp số học đều đặn không có luồng điều khiển phức tạp
- Trước khi viết SIMD thủ công, hãy biên dịch phiên bản scalar với tùy chọn tối ưu hóa và kiểm tra mã được sinh ra
- Các trình biên dịch production thường xuyên bỏ lỡ cơ hội vector hóa; tự động vector hóa đã được nghiên cứu trong nhiều thập kỷ, nhưng nghiên cứu gần đây cũng xuất phát từ vấn đề này
- Nếu một vòng lặp quan trọng đến mức mức tăng tốc 5 lần có ý nghĩa, bạn có thể viết vector hóa tường minh để giữ hành vi dự đoán được
- Có thể tránh việc một thay đổi mã không liên quan hoặc cập nhật trình biên dịch âm thầm biến vòng lặp vector trở lại thành vòng lặp scalar
Phạm vi SIMD mà lập trình viên nên nắm
- Khi phát hiện một hot loop tìm kiếm, so sánh, đếm hoặc chuyển đổi lượng lớn dữ liệu liên tiếp, bạn nên cân nhắc xử lý theo độ rộng vector
- SIMD thường ngày tuân theo dạng đều đặn: chuẩn bị hằng số, nạp vector, tính toán song song, thu gọn kết quả và đuôi scalar
- Nếu ngôn ngữ hỗ trợ SIMD tốt, bạn có thể cải thiện hiệu năng mà không cần trực tiếp biết assembly hay chi tiết riêng của từng CPU
- Mức độ cần thiết với mọi lập trình viên không phải là các kỹ thuật phức tạp kiểu
simdutf/simdjson, mà là khả năng nhận ra cơ hội áp dụng SIMD và tận dụng cấu trúc chung
1 bình luận
Ý kiến trên Hacker News
Bài viết hay, nhưng mở đầu bằng việc nói rằng SIMD dễ hiểu và dễ viết như vòng lặp for, rồi ngay ví dụ đầu tiên đã biến một dòng mã scalar thành 12 dòng thì không thuyết phục lắm
Thà nói thẳng rằng SIMD khó, nhưng kết quả xứng đáng thì tốt hơn. Nếu nhắm tới người mới, ngay từ bước 1 không nên dùng các thuật ngữ riêng của SIMD như
broadcastmà không giải thích; còn bước 5 giải thích xử lý phần đuôi scalar là một cách tổ chức tốtPhải xác định phần cứng xử lý được bao nhiêu phần tử mỗi lần, gom công việc theo kích thước đó, bung kết quả ra lại, xử lý riêng các phần tử còn dư, và cả hằng số cũng phải tạo thành vector được nhân bản. Từng việc không khó, nhưng làm tăng khối lượng công việc nên trở nên rườm rà
Tính năng tôi thích nhất là
par(; ; ), nơi trình biên dịch có thể tự động song song hóa vòng lặp for dưới một số điều kiện biên nhất địnhNếu chủ đề thực sự phức tạp, cần chia nó thành các phần nhỏ hơn và đơn giản hơn, rồi sắp xếp trình tự tốt để người học leo được đường cong học tập dốc và thấy rằng nó đáng công
Lời khuyên tốt hơn là mọi người nên biết lập trình mảng. Tối ưu hóa SIMD phần lớn cần lối tư duy đó, còn các kỹ thuật chỉ chuyên cho packed SIMD thì bất ngờ là khá hiếm
Lập trình mảng giúp trình biên dịch dễ tự động vector hóa, nên thường tạo ra mã có hiệu năng tốt dù không trực tiếp dùng SIMD
Tôi không có nhiều kinh nghiệm, nhưng Julia có vẻ là thứ gần nhất với một ngôn ngữ hiện đại hơn, biểu đạt tốt hơn và có năng lực vector hóa tương tự
Mấy ngày gần đây tôi đã tối ưu các phép toán ma trận của một dự án tin sinh học bằng AVX-512, và rất hài lòng
Với phần lớn ứng dụng, nút thắt là quá trình đọc tập dữ liệu lớn từ bộ nhớ, nên thay vì đọc lặp lại cho nhiều phép toán, có thể dùng thanh ghi AVX và kernel hợp nhất để xử lý tất cả trong một lần. Tăng tốc 5 lần là chuyện thường; tôi đã dùng intrinsic trực tiếp, nhưng nếu dùng crate
widethì các phép toán thông thường trở nên rất đơn giản: https://docs.rs/wide/latest/wide/Tuyệt đại đa số lập trình viên hoàn toàn không cần học SIMD. Tôi thắc mắc vì sao lại khiến mọi người hiểu nhầm rằng mọi lập trình viên phải biết thứ này mới là lập trình viên thật sự
Nên đổi tiêu đề thành “mọi người nên biết khi nào SIMD không được áp dụng”
Trình biên dịch hiện đại vector hóa rất tốt, nhưng chỉ cần một giả định hoặc một nhánh phụ thuộc dữ liệu là có thể đột ngột lùi về mã scalar. Biết cách kiểm tra báo cáo tối ưu hóa của trình biên dịch có thể còn giá trị hơn cách viết SIMD
Nếu chỉ xác định được vấn đề thì rốt cuộc cũng chỉ dừng ở “tiếc thật”
Năm ngoái, khi làm một bộ tổng hợp âm thanh, tôi bắt đầu học SIMD trên x86 và ARM: https://github.com/seclorum/SIMDSynth
Cấu trúc synth đa âm sắc, đa âm rất phù hợp để học các nguyên lý SIMD vì nó áp dụng cùng một xử lý lên nhiều luồng dữ liệu. Tuy nhiên, việc gỡ lỗi khá khó, tôi rất cần một trình mô phỏng giúp hiểu trạng thái của từng pipeline xử lý, và có lẽ việc khảo sát công cụ SIMD cũng sẽ cần một khoản đầu tư lớn nữa
Bài viết hay và tôi mong nhiều ngôn ngữ hơn hỗ trợ SIMD, nhưng khi hai ngôn ngữ phổ biến nhất không hỗ trợ SIMD nguyên sinh, cách nói “mọi lập trình viên nên biết” nghe hơi lạ
Ngay cả khi không tự viết SIMD hoặc định giao cho AI làm, bạn vẫn nên biết tác vụ nào có thể được SIMD tăng tốc trên phần cứng nào. Nhờ đó có thể thiết kế thuật toán và cấu trúc mã sao cho có thể áp dụng SIMD
Ảnh hưởng của phụ thuộc dữ liệu, chi phí khi tăng độ rộng phần tử vector và cách tránh, cách biến điều kiện và nhánh thành mask, các đặc tính như “không có lệnh chia” sẽ dễ nắm bắt hơn nhiều nếu bạn từng trực tiếp dùng SIMD dù chỉ một chút
Nó hoạt động tốt khi kiểm tra hoặc chuyển đổi dữ liệu lớn, liên tục trong một lần, nhưng nếu cứ vài byte đầu vào lại phải đưa ra quyết định, nó có thể ngang hoặc chậm hơn cách scalar. SIMD không phải là nút tăng tốc ma thuật
Đây là video hữu ích trong đó Casey Muratori giải thích cách đội ngũ phát triển The Witness giải quyết một vấn đề hiệu năng thực tế bằng SIMD: https://www.youtube.com/watch?v=Ge3aKEmZcqY
Đây là một ví dụ tốt về tích hợp dọc vì hiệu năng: sau khi hiểu vì sao các abstraction thông thường tồn tại và vì sao chúng cần mang tính tổng quát, nó cho thấy trong một trường hợp sử dụng cụ thể, việc tích hợp dọc từ định nghĩa vấn đề đến SIMD có thể đem lại lợi ích lớn ra sao
Trước khi đi vào các tối ưu hóa vi mô như SIMD, cần nghiêm túc xem xét cấu trúc dữ liệu và mẫu truy cập trước
Trước đây tôi từng áp dụng SIMD cho mã Zig, nhưng mô hình cấu trúc dữ liệu lại đi ngược hoàn toàn với hướng tối ưu hóa; chẳng khác nào lắp lốp đua hiệu năng cao cho một chiếc xe cũ nát có động cơ hỏng. Đó là kiểu tối ưu hóa vội vàng điển hình: không đo hiệu năng, cũng không cân nhắc vị trí cấp phát bộ nhớ
Giờ đây tôi nhìn dữ liệu như các bảng SQL và thiết kế cấu trúc xoay quanh các khóa chính tiềm năng cùng mẫu truy cập. Trước đây tôi dùng cây trỏ tới các struct khác trên heap, nên phải gánh đủ nhược điểm của danh sách liên kết, phân mảnh từ nhiều vector trên heap, và chi phí tạo/hủy chậm; chỉ riêng
Dropcũng đã chiếm một phần đáng kể thời gian chạyVì cây lúc nào cũng có thể được tuyến tính hóa, tôi xem xét các mẫu truy cập/chèn, liệu đó có thực sự là cây hay là một loại đồ thị khác, và nên lưu bằng
Vechay một struct gồm nhiềuVec. Kết quả là mã nhanh hơn và đơn giản hơn; dữ liệu được gom vào các mảng đồng nhất, giúp trình biên dịch dễ tận dụng tối ưu hóa SIMD và cache L1 hơn, và khi cần cũng có thể tự viết mã SIMD không rẽ nhánhTài liệu liên quan: https://hn.algolia.com/?dateRange=all&page=0&prefix=true&que...
Ở các đoạn nghẽn cổ chai thực sự, cần tránh cấp phát bộ nhớ, tra cứu bảng hàm ảo và tham chiếu gián tiếp quá mức. Ngay cả
vectorcủa C++ cũng không phải lúc nào là lựa chọn tốt nhất nếu nó có thể kích hoạt các lần cấp phát ngoài dự kiếnNgược lại, mã theo hướng dữ liệu gần như luôn hỗ trợ threading và SIMD một cách dễ dàng
Điều thú vị là cuối cùng mã CPU cũng được viết theo phong cách GPU, và một cách làm là dùng struct của các mảng kiểu Parquet thay vì mảng các đối tượng