Hàm `tolower()` được triển khai bằng AVX-512
(dotat.at)- Đây là một thử nghiệm xử lý theo từng 64 byte bằng AVX-512-BW cho tác vụ sao chép chuỗi đồng thời chuyển chữ hoa ASCII sang chữ thường, nhằm khai thác hiệu năng SIMD ngay cả với chuỗi nhỏ
- Trọng tâm của phần triển khai là so sánh từng byte xem có nằm trong khoảng từ
'A'đến'Z'hay không, rồi thực hiện phép toán mask cộng thêm'a' - 'A'chỉ tại các vị trí tương ứng - Phần đuôi còn lại của chuỗi ngắn và chuỗi dài được xử lý bằng masked load/store, giúp giảm chi phí xử lý các mẩu nhỏ mà mã SIMD thường gặp
- Kết quả đo với bản sao khoảng 1MiB theo các chunk từ 1 byte đến 1KiB trên Clang 16, Debian 11, AMD Ryzen 9 7950X cho thấy
tolower64luôn thuộc nhóm nhanh trong các đối tượng so sánh - Trên Zen 4, AVX-512-BW tỏ ra rất phù hợp cho xử lý chuỗi, nhưng ARM SVE và RISC-V Vector extension chưa được kiểm chứng trực tiếp một cách chi tiết
Tạo tolower() 64 byte bằng AVX-512-BW
- Mục tiêu là triển khai một kernel
tolower()bằng SIMD để sao chép chuỗi đồng thời chuyển các ký tự ASCII viết hoa sang viết thường - AVX-512-BW là phần mở rộng hỗ trợ phép toán theo byte và word, hiện có thể dùng trên các bộ xử lý AMD Zen gần đây
- AVX-512 được chia thành nhiều phần mở rộng nên việc hỗ trợ khá phức tạp
- Hỗ trợ phía Intel được đánh giá là đặc biệt thiếu nhất quán
- ARM SVE cũng cung cấp masked load/store theo byte rất phù hợp cho xử lý chuỗi
- Có trên các lõi big-ARM Neoverse gần đây, ví dụ Amazon Graviton
- Không thể dùng trên Apple Silicon
- RISC-V Vector extension cũng có phong cách tương tự ARM SVE và có thể dùng trên nhiều máy tính bo mạch đơn cỡ nhỏ
Cách tolower64() hoạt động
tolower64()là một kernel dựa trên AVX-512 xử lý 64 byte mỗi lần- Trước tiên, nạp các giá trị chuẩn vào thanh ghi vector chứa 64 byte
'A''Z''a' - 'A'
- So sánh vector ký tự đầu vào
cvới'A'và'Z'để tạo hai mask 64 bit tương ứng- các vị trí mà
c >= 'A' - các vị trí mà
c <= 'Z'
- các vị trí mà
- Kết hợp hai mask bằng
_kand_mask64()để tạo maskis_upperchỉ đánh dấu các vị trí chữ hoa - Cuối cùng áp dụng
_mm512_mask_add_epi8()- các byte có
is_upperlà false sẽ giữ nguyênc - các byte có
is_upperlà true sẽ thànhc + ('a' - 'A')
- các byte có
Xử lý chuỗi dài và chuỗi ngắn
- Phần lớn chuỗi dài được xử lý bằng vector load/store không căn chỉnh thông thường
_mm512_loadu_epi8()tolower64()_mm512_storeu_epi8()
- Các chuỗi ngắn và phần đuôi còn lại của chuỗi dài dùng masked unaligned load/store
- Mask được tạo sao cho chỉ
lenbit thấp là bậtuint64_t len_bits = (~0ULL) >> (64 - len)- đưa vào thanh ghi mask SIMD bằng
_cvtu64_mask64(len_bits)
_mm512_maskz_loadu_epi8()sẽ điền 0 vào các vị trí trong thanh ghi đích nơi mask bị tắt_mm512_mask_storeu_epi8()chỉ ghi các vị trí có mask bật- Đây là chìa khóa để xử lý nhanh các mẩu chuỗi nhỏ
Điều kiện benchmark và các đối tượng so sánh
- Benchmark được chạy trên Clang 16, Debian 11 và AMD Ryzen 9 7950X
- Khối lượng đo là khoảng bản sao 1MiB, với độ dài chunk thay đổi từ 1 byte đến 1KiB
- Để phản ánh chênh lệch căn chỉnh giữa chuỗi nguồn và đích, có chèn vài byte giữa các chuỗi này, và các byte đó không được tính vào lượng 1MiB đo đạc
- L2 cache của Ryzen 9 7950X là 1MiB mỗi lõi nên mỗi lần chạy thử được kỳ vọng sẽ tràn sang cả L3 cache
- Mỗi hàm được biên dịch riêng để tránh nhiễu do inline và code motion
- Trong mã thực tế, việc khuyến khích inline có lẽ phổ biến hơn là ngăn nó
Kết quả: hiệu năng mượt của tolower64
- Màu hồng,
tolower64, nhìn chung luôn nằm trong nhóm nhanh nhất trong các hàm được thử- Khi độ dài là 65 byte, hiệu năng giảm nhẹ do chuyển sang vector thứ hai
- Đường hiệu năng tăng nhanh và không có hố sâu rõ rệt, cho thấy masked load/store rất hiệu quả khi xử lý các mẩu chuỗi ngắn
- Màu xanh lá,
copybytes64, là phiên bảnmemcpydùng AVX-512 theo cách tương tự- Không nhanh hơn
tolower64quá nhiều - Clang mới nhận ra ngữ nghĩa của hàm này và viết lại hoàn toàn, nên nó được biên dịch bằng Clang 11
- Không nhanh hơn
- Màu cam,
copybytes1, là phiên bảnmemcpytheo từng byte- Được biên dịch bằng Clang 11
- Cho thấy heuristic tự động vector hóa của Clang 11 tương đối kém với các mẩu chuỗi nhỏ hơn 256 byte
- Màu đỏ,
tolower, là baseline gọitolower()chuẩn của<ctype.h>và rất chậm - Màu tím,
tolower1, làtolower()theo từng byte được biên dịch bằng Clang 16- Tự động vector hóa của Clang 16 đã tốt hơn nhiều so với Clang 11
- Vẫn chậm hơn phiên bản viết tay và sinh ra mã phức tạp hơn nhiều
- Khả năng xử lý mẩu chuỗi ngắn không tốt bằng
tolower64, nên đồ thị hiệu năng dao động nhọn
- Màu nâu,
tolower8, làtolower()SWAR từ bài trước- Clang có thử tự động vector hóa nhưng hàm quá phức tạp nên kết quả không tốt
- Dù được biên dịch bằng Clang 16, nó vẫn xuất hiện vách hiệu năng kiểu Clang 11 ở mốc 256 byte
- Màu xanh dương,
memcpy, gọimemcpycủa glibc- Ban đầu nhanh nhưng có đoạn giảm xuống còn khoảng một nửa tốc độ của
copybytes64 - Nguyên nhân vẫn chưa được xác định
- Ban đầu nhanh nhưng có đoạn giảm xuống còn khoảng một nửa tốc độ của
Kết luận và mã nguồn
- AVX-512-BW rất phù hợp để xử lý chuỗi, đặc biệt là chuỗi ngắn
- Trên Zen 4 nó rất nhanh, và các hàm intrinsic cũng tương đối dễ dùng
- Đặc điểm nổi bật nhất là hiệu năng mượt
- Hầu như không thấy các hố hiệu năng do tự động vector hóa chuyển sang mã scalar khi gặp mẩu chuỗi nhỏ
- Do không thể tiếp cận thuận tiện thiết bị hỗ trợ ARM SVE hay RISC-V Vector extension, tác giả chưa thể khảo sát chi tiết hai phần mở rộng này
- Có thể xem mã tại kho git trên trang web
1 bình luận
Các ý kiến trên Hacker News
Thủ thuật “unsafe read beyond of death” dù được phần cứng cho phép, vẫn bị xem là hành vi không xác định trong mô hình bộ nhớ của Rust và LLVM
Cũng như các hành vi không xác định khác, trình biên dịch có thể giả định trong quá trình tối ưu hóa rằng “chuyện đó không xảy ra”, dẫn đến kết quả ngoài dự kiến; muốn né thì phải dùng inline assembly
https://github.com/ogxd/gxhash/issues/82
Có vẻ không quá khó để hỗ trợ kiểu load “các giá trị vượt ngoài phạm vi cấp phát được đọc như phần tử không xác định, và chỉ là hành vi không xác định khi phần cứng không thích”; thậm chí bên trong chỉ là bí danh của lệnh assembly tương ứng cũng đủ
Xa hơn nữa, sẽ tốt nếu sau mọi vùng cấp phát như
malloc, stack, hằng số, v.v. đều được bảo đảm có tối thiểu khoảng 64 byte địa chỉ không gây fault, nhưng việc này phức tạp hơn nhiều vì cần nhiều thành phần phối hợpVới allocator tùy chỉnh thì chuyện này không đáng kể, nhưng khi đó khó dùng mã SIMD cho dữ liệu nằm ngoài heap tùy chỉnh, và vẫn bị ràng buộc bởi một khả năng segfault rất nhỏ
Sanitizer hay Valgrind vẫn hữu ích. Vì các giá trị vượt phạm vi có thể được theo dõi như giá trị chưa xác định, và có thể báo lỗi khi thực sự được sử dụng
Tôi tò mò điều gì sẽ xảy ra nếu đọc vào trang chưa được ánh xạ hoặc vùng nhớ được bảo vệ, và vì chưa xem mã nên không rõ bảo đảm căn chỉnh có tránh được việc này hay không
Hành vi không xác định là thuật ngữ chuyên môn của chuẩn C nên việc khái quát hóa như vậy tự nó đã kỳ lạ; ANSI C không cho phép rõ ràng giả định đó, còn ISO C tuy mở hơn nhưng cũng không biện minh cụ thể cho giả định ấy
Tôi cho rằng kiểu giải thích “UB = có thể giả định là không thể xảy ra” gần như là hù dọa khá thiếu trung thực
Nhìn đoạn mã gọn gàng và hiệu năng cao trong bài, tôi tò mò triển khai AVX512 của AMD và AVX10 sắp tới của Intel sẽ cạnh tranh ra sao
Trọng tâm của AVX10 có vẻ là giải quyết tình trạng P-core/E-core của Intel, còn AMD dường như chọn cách tiếp cận tốt hơn: tùy trường hợp dùng triển khai toàn bề rộng trên Zen5 hoặc xử lý 256-bit hai lần trên Zen4 và Zen5 mobile, trong khi vẫn giữ API mượt mà
Mức tăng hiệu năng lớn trong bài cũng đều là kết quả từ nhân Zen4, và AVX512 có rất nhiều ưu điểm, nên thật bực khi Intel đã hạn chế nó quá mức để phân khúc thị trường, trên thực tế ngăn cản việc áp dụng trong mã client phổ thông
Thị trường đã nhiều lần từ chối việc rẽ nhánh đường đi mã theo từng CPU, và các triển khai SIMD thật sự quan trọng thường là mẫu số chung thấp nhất
AVX10.1/256 và AVX512VL có một tập con chung, nên khi đủ thời gian trôi qua và phần lớn CPU đều hỗ trợ, mọi người sẽ nhắm tới phần đó
AMD sẽ tiếp tục có những chiến thắng dễ dàng trong một số ứng dụng benchmark được cập nhật để hỗ trợ AVX512, nhưng nếu Intel giữ kế hoạch AVX10, cuối cùng AMD nhiều khả năng cũng sẽ dùng rộng rãi pipeline SIMD xử lý hai lần để hỗ trợ AVX10/256 hiệu quả trong khi vẫn duy trì tương thích AVX512
Intel đã có rất nhiều lựa chọn tệ trong 10 năm qua, nhưng chia cắt thị trường bằng tập lệnh là một trong những điều tệ nhất. Họ đã tự giết động lực và sự quan tâm dành cho các đổi mới mới nhất; các tính năng như thao tác mask quan trọng hơn nhiều so với độ rộng, nên tôi mong họ đưa AVX10/256 vào toàn bộ dòng sản phẩm
Cụm từ đó có một ý nghĩa cụ thể, nhưng không khớp với cách hoạt động thực tế
Zen 4 chỉ giải mã các phép toán trên thanh ghi ZMM thành nhiều vi thao tác rồi lập lịch chúng lên các đơn vị 256-bit đang rảnh; riêng shuffle toàn bề rộng 512-bit được xử lý đặc biệt bằng phần cứng chuyên dụng để tránh mô phỏng đắt đỏ
Vì vậy Zen 4, với 4 đơn vị SIMD 256-bit, vẫn hoạt động như một nhân 2×512-bit mạnh mẽ; triển khai này hoàn toàn không phải cách làm rẻ tiền, và có khả năng là hình thái tốt nhất từng có trên phần cứng tiêu dùng cho đến nay
Họ đã có nhiều năm để sửa, và thật khó chịu khi dù AMD hỗ trợ thì vẫn không được thị trường đón nhận vì thị phần; đáng tiếc là AVX10 có vẻ sẽ giúp Intel níu giữ thế giới lâu hơn
Trên desktop, tôi muốn thấy các nhân tốt hơn, nhiều nhân hơn, và một tập lệnh được chuẩn hóa tốt mở ra các tính năng hữu ích như SIMD rộng, float16, gather/scatter; AMD đang làm khá tốt
Trong khi đó Intel lại đặt các nhân yếu cạnh các nhân khá tốt, giới hạn các nhân tốt để phù hợp với nhân yếu, ra mắt CPU có cùng số nhân qua nhiều thế hệ, dùng nhân yếu để làm như thể số nhân nhiều hơn, tung ra quá nhiều biến thể tập lệnh đến mức khó hình thành một tập chung hữu ích, và còn bỏ cả hỗ trợ tập lệnh mà họ từng có vẻ đã hứa hẹn
Sở thích của các hãng sản xuất desktop từng là Intel ở thập niên 90, AMD đầu những năm 2000, Intel cuối những năm 2000 và thập niên 2010, còn giờ lại là AMD. Tôi tò mò Intel sẽ làm gì để lấy lại nền tảng ngoài việc cản trở đối thủ, và cạnh tranh phải tiếp tục để một bên không trở nên quá tự mãn
Tài liệu đọc cho vui: http://www.unicode.org/Public/3.1-Update1/CaseFolding-4.txt
Ví dụ
"straße".upper()sẽ thành'STRASSE'Ngoài ra, nếu không chỉ định locale, khi chuyển qua lại chữ i không dấu chấm của nhóm ngôn ngữ Turkic giữa chữ hoa/chữ thường,
'ı'.upper().lower()sẽ thành'i'và bị hỏngCó nhiều giao thức ASCII không phân biệt hoa/thường, và chúng thường xuất hiện trên hot path của nhiều server
Trường hợp trước thường chỉ cần ASCII thuần với mã hóa 8-bit là đủ, nhưng trường hợp sau thì phức tạp hơn
Địa chỉ DNS là ví dụ dễ hiểu: về mặt kỹ thuật có thể chứa gần như mọi Unicode, nhưng khi phân giải DNS thực tế nó được chuyển thành một tập con ASCII rất hạn chế, và quá trình phân giải đó không phân biệt chữ hoa/thường
Tất nhiên cũng có những ngôn ngữ lập trình hỗ trợ toàn bộ hệ chữ của Unicode nhưng identifier lại không phân biệt hoa/thường. Nếu bạn đang phải xử lý thứ như vậy thì xin chia buồn
maßeđổi thànhMASSE, trong tiếng Đức còn có chữ Eszett viết hoa là ẞNó vẫn chưa được triển khai rộng rãi và cũng ít font hỗ trợ, nhưng về mặt lý thuyết thì giờ đã tồn tại
Tôi thấy phần giải thích “cộng mask” trong bài có vẻ sai
Chẳng phải khi
is_upperlà false thì mới cộng, còn true thì sao chép nguyên trạng sao?to_upperbị đặt ngược, đáng ra nên gọi làto_lowerCảm ơn đã chỉ ra chỗ gây nhầm lẫn; tôi đã sửa bài và code
tolowerChữ hoa A là
0x40, chữ thường là0x60, nên việc cộng 0x20 phải xảy ra khiis_upperlà trueCác kiểu tối ưu hóa SWAR như vậy thường chỉ hữu ích khi chuỗi được căn chỉnh theo địa chỉ 8 byte
Áp dụng thuật toán SWAR cho chuỗi không được căn chỉnh thường sẽ chậm hơn thuật toán gốc
Nếu tách thành 3 bước: xử lý phần đầu cho tới địa chỉ đã căn chỉnh, xử lý phần thân đã căn chỉnh, rồi xử lý phần đuôi dưới 8 byte, thì số lệnh sẽ tăng thêm
Có một trường hợp và benchmark tương tự với tuyên bố sai rằng
utf8.IsValidtrong Go nhanh hơn tại đây: https://github.com/sugawarayuuta/charcoal/pull/1Phép toán bộ nhớ luôn được căn chỉnh và dùng toàn bộ kích thước vector, nhưng có thể áp mask chỉ cho các phần tử hợp lệ
Ngay cả khi phép toán bộ nhớ vector có mask không được căn chỉnh và bắc qua một page chưa được ánh xạ hoặc được bảo vệ, nếu lane tương ứng bị tắt bằng mask thì sẽ không phát sinh fault
Với các phép toán như
strlen()khi chưa biết trước độ dài, cũng có lệnh load đặc biệt để rút ngắn độ dài vector ngay trước phần tử đầu tiên sẽ gây faultPhép cộng bằng mask trông khá hay. Sẽ thật tốt nếu trong .NET intrinsic có thể thao tác trực tiếp các thanh ghi mask của AVX512, nhưng hiện tại phải dựa vào “idiom được nhận diện”
Nếu phân tích vòng lặp lõi do tác giả tạo bằng GCC với uiCA(CQA/MAQAO) trên Ice Lake thì ra khoảng 32B/cycle; quy đổi ở 3GHz, nếu giả định không có nút thắt bộ nhớ, gần như là 96GiB/s. Tất nhiên với kiểu thuật toán này thì truy cập bộ nhớ lúc nào cũng là nút thắt
Tuy vậy có vẻ vẫn chưa thật sát mức tận dụng tối ưu; dùng Clang thì nhờ kết quả unroll được bung tốt hơn và chọn lệnh tốt hơn, có thể đạt tới 42.67B/cycle. Có lẽ cả cache L2 cũng khó duy trì thông lượng như vậy, nhưng cũng thú vị khi việc chuyển hoa/thường cho chuỗi độ dài trung bình có thể xong trong khoảng thời gian ánh sáng màn hình chạm tới giác mạc
Vài tháng trước tôi đã triển khai một phép chuyển đổi chữ hoa/thường ASCII trong UTF-8 tương tự bằng C#: https://github.com/U8String/U8String/blob/main/Sources/U8Str...
Vì chuỗi ngắn chiếm phần lớn trong hầu hết codebase, việc chuyển đổi unroll cho độ dài dưới mức vector hóa là quan trọng, và
switchđược biên dịch thành jump table cùng branchless fall-throughHiện tại tôi chỉ dùng tới 256-bit, vì trên các máy như Zen 3 hoặc 4, vốn chỉ có đơn vị SIMD 256×4, như vậy đã bão hòa rồi. Có ví dụ so sánh cạnh nhau với bản C ở đây: https://godbolt.org/z/eTGYhTPan
Trên AVX512 có vẻ cũng có thể dùng
vpternlogdđể chuyển đổi bằng 3 lệnh; khi còn dùng được phần cứng AVX512, .NET đã tối ưu như vậy với độ rộng 256-bit + AVX512VL, nhưng giờ kỳ lạ là không tái hiện được với độ rộng 512-bitChắc cũng sẽ thấy thử nghiệm SWAR thất bại ở phần dispatch bằng
switch; tôi thắc mắc giấy phép của bài viết là gì. Nếu vượt qua test suite thì tôi muốn mang về dùngNghĩ về cấu trúc của hai compiler thì có thể hiểu được, nhưng kết quả có lúc tốt hơn, có lúc lại thiệt hơn
Vài năm trước, khi làm một dự án vector hóa cao bắt buộc phải biên dịch được bằng cả hai, cuối cùng tôi phải giữ trong repository cả bản tham chiếu C lẫn inline assembly cho một số target cụ thể và các file
.SMakefile trở nên lộn xộn, còn test suite thì phải đưa cả benchmark vào, khiến gánh nặng bảo trì rất lớn; vì vậy tôi đi đến kết luận rằng việc dùng intrinsic như một phương tiện cấp thấp tốt hơn auto-vectorization cần phải cực kỳ thận trọng
Ví dụ: ở https://godbolt.org/z/T4Pjhrz5d output của GCC đúng như dự đoán, nhưng output của Clang thì gây bất ngờ và thực tế chậm hơn. Nếu chạy trong vòng lặp, theo uiCA là 7 chu kỳ so với 4 chu kỳ của GCC, và điều này cũng lộ rõ trong benchmark của ứng dụng thực tế nơi hàm này chạy hàng tỷ lần trong một thuật toán brute-force
Tôi nhớ khi nhìn vào codebase LLVM cũng từng thấy vấn đề rằng Clang 16 có thể hoàn toàn không phát ra một số lệnh AVX-512 dạng mask do tái cấu trúc nội bộ
Tôi không nhắm tới hiệu năng tối đa có thể; ban đầu chỉ muốn xem có chạy được không, và việc lần thử đầu tiên ra khá tốt là phần thưởng thêm
Mối quan tâm chính là các chuỗi ngắn hơn thanh ghi vector và loại bỏ các vùng trũng trên biểu đồ thông lượng
Nếu theo liên kết tới mã ở cuối bài blog thì có thông tin giấy phép; trừ phần MPL-2.0 vốn viết cho BIND, còn lại là 0BSD hoặc MIT-0
(x >= 'a' && x <= 'z')thành dạng(x - 'a') < ..., nhờ đó bớt được một lệnhDo cách mã hóa opcode kỳ lạ, đôi khi cả thao tác load thanh ghi cũng giảm
Tôi không biết swar là gì
Thường chỉ kỹ thuật pack nhiều phần tử vào một thanh ghi và dùng nó gần như SIMD mà không cần lệnh SIMD tường minh
Ví dụ, nếu đưa các số 31-bit và 32-bit vào một thanh ghi 64-bit rồi chừa 1 bit cho carry, ta có thể thực hiện hai phép cộng chỉ bằng một phép cộng 64-bit
Trong game, người ta đã pack các giá trị RGB(A) vào số nguyên 32-bit để dùng các thủ thuật như vậy cho đồ họa, và ScummVM cũng có đoạn mã nội suy 2 pixel RGB 16-bit trong một giá trị 32-bit, tổng cộng 6 thành phần: https://github.com/scummvm/scummvm/blob/master/graphics/scal...
Sau khi Unicode xuất hiện, khái niệm chữ hoa và chữ thường đã trở thành một vũng lầy
Muốn làm cho đúng thì cần rất nhiều dữ liệu
Nếu bạn đang làm một tác vụ mà chuyện có hoàn thành kịp hay không phụ thuộc vào tốc độ chạy ASCII
tolower, thì có lẽ tốt hơn là nên thay đổi cuộc chơi và thay đổi các điều kiện tiên quyếtTrước đây tôi từng thêm viền đen quanh ảnh để tránh hoàn toàn vấn đề SIMD đọc vượt quá buffer
Cách đó hoạt động rất tốt và về tốc độ có thể thắng một số triển khai OpenCV, nhưng không phải lúc nào ta cũng kiểm soát hoàn toàn được input như vậy
Tôi tò mò không biết đã thử kiểu này chưa. Kết quả auto-vectorization trông khá gọn
https://godbolt.org/z/1c5joKK5n
tolower1. Xem các bullet bên dưới biểu đồ là được