Có thể làm nhanh hơn tìm kiếm nhị phân
(lemire.me)- Tìm kiếm nhị phân thường được dùng để tìm giá trị trong mảng đã sắp xếp, nhưng nó chỉ so sánh một giá trị mỗi lần, trong khi CPU hiện đại có thể so sánh nhiều giá trị đồng thời trong một lệnh, nên nếu tận dụng khả năng này thì có thể tăng tốc độ tìm kiếm đáng kể
- SIMD Quad là một thuật toán tìm kiếm phân cấp: chia mảng thành các khối 16 phần tử, nhanh chóng thu hẹp vị trí khối bằng tìm kiếm nội suy bậc 4, rồi dùng lệnh SIMD để so sánh đồng thời 16 phần tử trong khối
- Trong benchmark, trên Intel với warm cache thuật toán này cho hiệu năng nhanh hơn hơn 2 lần so với tìm kiếm nhị phân; trên Apple với cold cache cũng nhanh hơn hơn 2 lần, và ở mọi điều kiện đo đều tốt hơn
std::binary_search - Thay vì chia đôi như tìm kiếm nhị phân, nếu chia thành 4 phần thì số lệnh có tăng nhẹ, nhưng trên các mảng lớn của Intel nó tận dụng memory-level parallelism tốt hơn nên hiệu năng cold cache được cải thiện
- Các thuật toán giáo khoa được thiết kế mà không giả định data parallelism và memory parallelism của CPU ngày nay, nên việc thiết kế lại để phản ánh đặc tính phần cứng có thể mang lại cải thiện hiệu năng thực tế
Ý tưởng cốt lõi
- Tìm kiếm nhị phân có cấu trúc chỉ so sánh một giá trị tại mỗi thời điểm, nhưng các bộ xử lý ARM 64-bit và x64 hiện đại có thể so sánh đồng thời 8 số nguyên 16-bit trong một lệnh
- Nếu tận dụng khả năng phần cứng này, có thể chuyển đơn vị tìm kiếm từ từng phần tử riêng lẻ sang theo khối, qua đó giảm mạnh số lần so sánh
- Thay vì chia mảng làm hai, nếu chia làm 4 (tìm kiếm bậc 4) thì số lệnh có thể tăng nhẹ, nhưng nút thắt có thể không nằm ở số lệnh, đồng thời còn tận dụng memory-level parallelism tốt hơn
Cách cơ bản để kiểm tra phần tử trong mảng đã sắp xếp
- Cách đơn giản nhất để kiểm tra sự tồn tại của một giá trị trong mảng đã sắp xếp là tìm kiếm tuyến tính, duyệt từng giá trị một; trong C++ có thể đạt hiệu quả tương tự bằng
std::find - Với mảng lớn, tìm kiếm nhị phân nhanh hơn, lặp lại quá trình loại bỏ nửa trên hoặc nửa dưới dựa trên giá trị ở giữa đoạn tìm kiếm
std::binary_searchcủa C++ trả về giá trị boolean cho biết phần tử có tồn tại hay không- Định dạng Roaring Bitmap dùng các mảng số nguyên 16-bit có kích thước từ 1 đến 4096 và sử dụng tìm kiếm nhị phân để kiểm tra sự tồn tại của giá trị
Cấu trúc thuật toán SIMD Quad
- Chia mảng đã sắp xếp của các số nguyên không dấu 16-bit thành các khối kích thước cố định gồm 16 phần tử
- Dùng phần tử cuối của mỗi khối làm khóa nội suy để thu hẹp phạm vi xuống một khối duy nhất có khả năng chứa giá trị đích, sau đó kiểm tra đồng thời 16 phần tử trong khối đó bằng SIMD
- Các bước hoạt động:
- Nếu số phần tử nhỏ hơn 16 thì tìm kiếm tuyến tính trên toàn bộ mảng
- Chia mảng thành các khối liên tiếp 16 phần tử, số khối tổng cộng là
num_blocks = cardinality / 16 - Dùng phần tử cuối khối làm khóa, so sánh các điểm 1/4 trong phạm vi tìm kiếm hiện tại với giá trị đích rồi điều chỉnh
base - Nếu là khối hợp lệ thì trên ARM dùng NEON, còn trên x64 dùng SSE2 để nạp 16 phần tử và thực hiện so sánh bằng nhau song song
- Các phần tử còn lại không nằm trong khối đầy đủ sẽ được tìm tuyến tính
Cách benchmark
- Với mỗi kích thước mảng từ 2 đến 4096, tạo 100.000 mảng đã sắp xếp của số nguyên không dấu 16-bit
- Với mỗi kích thước, thực hiện 10 triệu truy vấn kiểm tra phần tử theo hai chế độ
- chế độ cold: mỗi truy vấn tìm trên một mảng khác nhau để mô phỏng cache miss
- chế độ warm: tìm trên cùng một mảng 100 lần liên tiếp để mô phỏng cache hit
- Chỉ số đo là thời gian truy vấn trung bình; các đối tượng so sánh là tìm kiếm tuyến tính (
std::find), tìm kiếm nhị phân (std::binary_search) và SIMD Quad - Hệ thống đo là Apple M4 (Apple LLVM) và Intel Emerald Rapids (GCC)
Kết quả đo
- Khi mảng lớn hơn, tìm kiếm nhị phân thắng rõ rệt so với tìm kiếm tuyến tính; trong cold cache, tìm kiếm tuyến tính càng bất lợi hơn vì phải truy cập nhiều dữ liệu hơn
- Nền tảng Intel: trong warm cache, SIMD Quad nhanh hơn hơn 2 lần so với tìm kiếm nhị phân; trong cold cache, mức lợi ích nhỏ hơn
- Nền tảng Apple: trong cold cache, SIMD Quad nhanh hơn hơn 2 lần; trong warm cache, mức lợi ích bị giới hạn hơn
- Trong mọi trường hợp, SIMD Quad đều nhanh hơn
std::binary_search - Phần SIMD giảm lượng công việc bằng các lệnh chuyên dụng, đồng thời ít lệnh và ít nhánh hơn, nên nguyên nhân cải thiện tốc độ khá rõ ràng
Hiệu quả của tìm kiếm bậc 4
- Ngoài ra còn so sánh với phiên bản SIMD binary, giữ nguyên tối ưu SIMD nhưng thay tìm kiếm nội suy bậc 4 bằng tìm kiếm nhị phân
- Trên nền tảng Apple, hiệu quả của cách tiếp cận bậc 4 là không lớn
- Trên nền tảng Intel, trong tình huống cold cache với mảng lớn, cách tiếp cận bậc 4 là một tối ưu hóa có ý nghĩa
- Trên máy chủ Intel, tìm kiếm bậc 4 tận dụng memory-level parallelism tốt hơn
Các điểm chính trong triển khai
- Hàm
simd_quadnhận mảnguint16_t, số phần tửcardinality, giá trị cần tìmpos, và trả về boolean gapđược cố định là 16; nếucardinality < gapthì dùng vòng lặp đơn giản để tìm- Vòng lặp tìm khối, khi
n > 3, sẽ đọc và so sánh giá trị cuối khối tại các điểm 1/4, 2/4, 3/4, rồi di chuyểnbasetheo tổng của ba kết quả so sánh - Khối được chọn sẽ được so sánh song song 16 phần tử theo hai cụm bằng
vceqq_u16của ARM NEON hoặc_mm_cmpeq_epi16của x64 SSE2 - Với đoạn phần tử còn lại, hàm trả về kết quả
v == postại điểm đầu tiên thỏav >= pos
Kết luận
- Tìm kiếm nhị phân kiểu giáo khoa là một thuật toán ổn, nhưng hoàn toàn có thể làm nhanh hơn theo cách có ý nghĩa trong hiệu năng thực tế
- Nhiều thuật toán chuẩn vốn không được thiết kế với giả định về mức độ song song rất cao của máy tính hiện đại
- SIMD Quad là một cách tiếp cận nhằm tận dụng cả memory-level parallelism lẫn data parallelism
- Có thể vẫn còn những thuật toán tốt hơn, và cần các cách tiếp cận sáng tạo hơn
- Mã nguồn
- Faster intersections between sorted arrays with shotgun
1 bình luận
Ý kiến trên Hacker News
Tách biệt với câu chuyện tối ưu phần cứng mức thấp mà Daniel Lemire nói tới, tìm kiếm nhị phân hay các biến thể triển khai của nó chỉ là tốt nhất khi ta không biết gì ngoài việc dữ liệu đã được sắp xếp/đơn điệu
Nếu có thông tin trước về phân bố dữ liệu thì có thể dùng nó để tạo ra thuật toán nhanh hơn rất nhiều. Ví dụ là cách con người lật từ điển giấy đến một cụm trang phù hợp nhanh hơn so với tìm kiếm nhị phân thuần túy
Về mặt toán học, tìm kiếm trên danh sách đã sắp xếp gần với việc dùng một thuật toán điều khiển vòng kín để tìm hàm nghịch đảo của một hàm đơn điệu, và ta thậm chí có thể xây dựng hàm chi phí rồi dùng gradient descent hoặc các biến thể tăng tốc. Nói rộng hơn, thay vì lấy lời giải từ một mô tả bị trừu tượng hóa quá mức, việc tận dụng thêm thông tin về bài toán cụ thể luôn hiệu quả hơn, và có thể đem lại cải thiện tốc độ có khả năng mở rộng lớn hơn nhiều so với các cải thiện hệ số hằng số nhờ tận dụng phần cứng tốt hơn
Số vòng lặp cố định tốt cho bộ dự đoán nhánh, và vòng lặp lõi cũng được tối ưu rất chặt cho phần cứng đích, chẳng hạn tránh phép nhân. Mọi nỗ lực làm cho nó “thông minh” hơn đều khiến vòng lặp tệ đi và không mang lại lợi ích. Định dạng mảng struct có kích thước 12, và thường thì N nhỏ nên lại càng khó
Tôi đã không bookmark lại nên khoảng mỗi năm hai lần lại đi tìm lại. Theo truyền thuyết thì tôi vẫn còn đang tìm
Đó là lý do B-tree là tiêu chuẩn trong cơ sở dữ liệu. Dữ liệu có thể là bất cứ thứ gì, và nếu bạn nạp vào cả đống dòng mới thì đặc tính của nó có thể thay đổi mạnh bất cứ lúc nào
Bạn có thể xây dựng thuật toán tăng tốc truy vấn bằng thứ gì đó như gradient descent, nhưng B-tree vốn đã rất nhanh rồi, lại còn có nhiều ưu điểm như hiệu năng tệ nhất có thể dự đoán được, nhu cầu I/O dễ đoán, quét theo khoảng, duyệt theo thứ tự đã sắp xếp, hỗ trợ điều kiện tiền tố
Có thể tạo ra thuật toán truy vấn hiệu quả hơn cho một số phân bố dữ liệu nhất định, nhưng thường phải đánh đổi mất các thuộc tính quan trọng. Một thuật toán khác có thể đưa ra ước lượng ban đầu gần hơn, nhưng lại chậm khi tìm tới phần tử cuối cùng; hoặc trung bình nhanh hơn nhưng hiệu năng tệ nhất quá tệ nên không dùng được
Ngay cả với từ điển giấy, sau ước lượng đầu tiên thì bạn gần như cũng dùng tìm kiếm nhị phân. Số bước được cắt giảm nhờ ước lượng ban đầu chỉ là vài lần. Khi đã tới đúng cụm trang thì người ta lại lướt tuyến tính nhiều hơn mức cần thiết; dùng tìm kiếm nhị phân nghiêm ngặt có thể nhanh hơn, nhưng còn phải cân bằng với thời gian lật trang
Ed Kmett sau đó đã phát triển ý tưởng đó thành thư viện Haskell discrimination[2], và bài nói chuyện kỹ thuật liên quan[3] cũng rất thú vị
[1]: https://dl.acm.org/doi/epdf/10.1145/1411203.1411220
[2]: https://hackage.haskell.org/package/discrimination
[3]: https://www.youtube.com/watch?v=cB8DapKQz-I
“Tìm kiếm tứ phân” rốt cuộc chẳng phải chỉ là bung một bước của vòng lặp tìm kiếm nhị phân sao? Để tìm khoảng có chứa phần tử, số phép so sánh có vẻ xấp xỉ nhau, chỉ là trông giống như xử lý 4 nhánh một lần thay vì 2. Có vẻ bung vòng lặp cũng cho hiệu quả tương tự
Xét theo góc nhìn của bộ xử lý thì mọi vòng lặp thực ra đều đã được bung ở một mức nào đó, chỉ còn lại phần overhead nhỏ của bản thân vòng lặp. Trong tìm kiếm nhị phân, chi phí chính là lấy dữ liệu từ bộ nhớ hoặc cache, nên cuộc chiến thực sự là làm sao yêu cầu dữ liệu sẽ cần về sau càng sớm càng tốt để phải chờ ít hơn
Khác biệt của thuật toán như tìm kiếm tứ phân là thay vì chỉ nạp trước thật sâu một phía của mỗi nhánh, nó prefetch nông cho mọi khả năng có thể xảy ra. Cuối cùng thì prefetch sẽ cần đến chắc chắn được phát ra, và lượng băng thông bị dùng cho dữ liệu không nằm trên đường thực thi thực tế cũng ít hơn đôi chút
Nếu muốn dự đoán hiệu năng thực tế của thuật toán tìm kiếm thì “số phép so sánh” gần như là thước đo vô dụng. Nút thắt không phải là bạn làm được bao nhiêu phép so sánh, mà là bạn tận dụng băng thông bộ nhớ và cache tốt đến đâu. Nếu tính cả cách nhánh hoạt động bên trong CPU hiện đại thì cũng có thể xem đây là một dạng bung vòng lặp
Dù sao thì cả hai vẫn đều là O(log N) với hệ số hằng số khác nhau. Trong lớp học thuật toán, các hệ số hằng số không mấy quan trọng, nhưng ngoài đời chúng có thể tác động khá lớn
Gần đây tôi cũng viết một bài[1] về tìm kiếm mũ[2]. Đây là thuật toán dùng khi phải lặp đi lặp lại việc tìm kiếm nhị phân trong một mảng mà chính các phần tử cần tìm cũng đã được sắp xếp, và trong workload của chúng tôi nó cho mức tăng tốc 8x
[1] https://lalitm.com/post/exponential-search/
[2] https://en.wikipedia.org/wiki/Exponential_search
HEAD /users/1 -> 200 OK
HEAD /users/2 -> 200 OK
HEAD /users/4 -> 200 OK
...
HEAD /users/2048 -> 200 OK
HEAD /users/4096 -> 404 Not Found
Sau đó chỉ cần tìm kiếm nhị phân giữa 2048 và 4096 là có thể tìm ra người dùng mới nhất và tiện thể biết luôn số lượng người dùng. Đây là thông tin hữu ích nếu bạn đang nghiên cứu một công ty SaaS cạnh tranh
CPU luôn truy cập cả cache line, thường là 64 byte, trong một lần, nên có vẻ sẽ tốt hơn nếu tìm kiếm trên cả cache line. Khi dữ liệu đã nằm trong CPU thì gần như miễn phí
Vì vậy tôi muốn thử cách trong tìm kiếm “nhị phân”, ta kiểm tra mọi giá trị của cache line ở giữa, nếu không có giá trị khớp thì đi sang trái/phải. Việc tìm trong cache line có thể làm bằng một lệnh SIMD 512 bit. Một cache line 64 byte tương đương 32 số nguyên 16 bit, nên có thể nhanh hơn gần 32 lần so với tìm kiếm nhị phân đơn giản, hoặc ít nhất là giảm 32 lần truy cập bộ nhớ, thứ sẽ chi phối trong hầu hết chương trình thực tế
Nếu dùng khóa 4 byte và con trỏ con 4 byte, hoặc chỉ số mảng, thì một nút nội bộ có thể chứa 7 khóa, 8 con trỏ con và 1 con trỏ tiếp theo, vừa khít một cache line 64 byte. Với 1 triệu phần tử, độ sâu cây giảm từ khoảng 20 xuống khoảng 7, và vài tầng trên cùng nhiều khả năng sẽ nằm sẵn trong cache
Nếu chịu khó thêm một chút thì có thể dùng SIMD để tăng tốc tìm kiếm bên trong nút B-tree, nhưng phụ thuộc dữ liệu rất lớn
Với mảng nhỏ hơn, tìm kiếm tuyến tính có giá trị sentinel ở cuối vốn đã rất khó bị đánh bại. Tuy vậy, lý do nhận định này hơi mơ hồ là vì điều kiện “nhỏ” quá mơ hồ nên khó cảm nhận
Nhìn chung tôi rất thích bài này. Nó đào rất sâu vào một câu hỏi mà tôi vẫn hay thắc mắc, lại còn kèm các thí nghiệm loại trừ rất hữu ích
Tôi từng làm vài thử nghiệm tìm kiếm trên mảng nhỏ, khoảng 16~32 phần tử, và tìm kiếm nhị phân là một trong những cách tệ nhất vì có quá nhiều nhánh. Cách nhanh nhất cho mảng nhỏ là tìm kiếm tuyến tính không nhánh
Tức là không thoát vòng lặp giữa chừng mà quét qua toàn bộ phần tử. Ví dụ nếu muốn biết một số có trong mảng hay không thì gộp kết quả kiểm tra của mọi phần tử bằng phép OR logic. Tôi không dùng SIMD, nhưng với mảng nhỏ thì nhánh rất đắt nên cứ kiểm tra hết mọi phần tử mà không rẽ nhánh lại nhanh hơn
Phần giải thích thuật toán hơi khiến tôi bối rối
Phần SIMD chỉ được dùng ở bước cuối, tức khi tìm trong 16 phần tử cuối cùng
Phần tìm kiếm tứ phân là kiểm tra 3 điểm để tạo ra 4 nhánh, nhưng mục tiêu là tìm đúng block chứ không chỉ đúng key
Chi tiết này khá thú vị: tác giả dùng phần tử cuối cùng của mỗi block cho bước tìm kiếm tứ phân. Tôi tò mò không biết thuật toán sẽ thay đổi thế nào nếu dùng phần tử đầu tiên hoặc một phần tử tùy ý
Trực giác cốt lõi ở đây là so sánh đa điểm bằng SIMD có vẻ còn dùng được ở quy mô lớn hơn bước cuối
Đại ý là: a) dùng gather để lấy nhiều phần tử tại 16 vị trí cách đều nhau b) so sánh song song bằng lệnh SIMD c) tập trung vào block đúng d) nếu block đủ nhỏ thì chuyển sang tìm kiếm tuyến tính, còn không thì lặp lại chu kỳ gather/so sánh
Lệnh gather đọc từ bộ nhớ không liên tục, và với tìm kiếm nhị phân thì đúng là nó đọc nhiều hơn mức cần thiết. Dù vậy, nếu đổi lại được khả năng so sánh đa hướng và gộp 4 bước tìm kiếm nhị phân thành 1 thì có lẽ sẽ thắng trên bảng lớn
Cũng không phải mọi kiểu dữ liệu đều hỗ trợ so sánh đủ 16 hướng. Tìm kiếm float64 ngay cả với AVX-512 cũng chỉ giới hạn ở 8 hướng, còn int32 và float32 thì có thể 16 hướng
Tôi nghĩ tìm kiếm nhị phân thực tế nhiều khả năng còn nhanh hơn cách dựa trên gather
Các thuật toán khoa học máy tính cổ điển thực chất được “thiết kế” cho CPU gần như không có tính song song. Không có nhiều lõi, không có Hyper-threading, không có lệnh SIMD; mọi lần truy cập bộ nhớ đều có cùng độ trễ nên cũng không có khái niệm trễ cache L1/L2/L3; và giả định dữ liệu là tổng quát/ngẫu nhiên
Chỉ cần rời khỏi một trong các giả định đó là đã có rất nhiều tinh chỉnh giúp tăng hiệu năng
Các thuật toán cổ điển là điểm xuất phát rất tốt để sau đó phát triển lời giải tối ưu hơn khi ta biết thêm về dạng dữ liệu cụ thể hoặc tính chất/tính năng của CPU cụ thể
Khi đi tới đầu nhọn của tối ưu hóa thì thường bạn sẽ nhìn vào cách dữ liệu được lưu trong bộ nhớ và được truy cập ra sao, cũng như liệu thay đổi để cải thiện chỗ này có làm hại các bước sau hay không. Ở một công ty cũ của tôi từng có người tối ưu một đoạn mã quá lâu, và kết quả là tối ưu đó làm đẩy nhiều thông tin cần cho phần sau ra khỏi cache, khiến toàn bộ ứng dụng chậm đi
Điều này có lẽ gần với quy tắc lập trình thứ 5 của Rob Pike, tức một cách diễn đạt lại điều Fred Brooks đã nói trong The Mythical Man Month. Tham khảo: https://www.cs.unc.edu/~stotts/COMP590-059-f24/robsrules.htm...
Hồi tuổi teen, tôi từng dành cả cuối tuần nghĩ rằng nếu tìm kiếm nhị phân tốt vì mỗi bước giảm không gian tìm kiếm đi một nửa, thì tìm kiếm tam phân hẳn còn tốt hơn vì giảm xuống còn một phần ba
Thay vì chỉ so sánh một giá trị ở giữa, ta so sánh giá trị ở mốc 1/3, và nếu còn quá thấp thì lại so sánh giá trị ở mốc 2/3
Nhưng rồi tôi nghĩ rằng mỗi bước tuy giảm theo 1/3 thay vì 1/2 như tìm kiếm nhị phân, nhưng số phép so sánh trung bình lại là 3/2 lần, nên rốt cuộc là như nhau
Sửa: xem câu trả lời của zamadatix thì thực ra trong 2/3 trường hợp phải so sánh 2 lần, nên trung bình là 5/3 lần
Phần ba đầu: 1 lần so sánh
Phần ba thứ hai: 2 lần so sánh
Phần ba thứ ba: 2 lần so sánh
(1+2+2)/3 = trung bình 5/3 lần so sánh. Cảm giác “so sánh 1 hoặc 2 lần” dễ khiến người ta tưởng là 50%, nhưng thực tế xác suất dừng ở phép đầu là 1/3, còn phải so sánh 2 lần là 2/3
Vì vậy có thể chỉ ra rằng tổng số phép so sánh trung bình khiến tìm kiếm tam phân tệ hơn một chút: 5/3*Log_3[n] = 1.052... * Log_2[n]
Tức là số bước ít hơn, nhưng để đi hết thì trung bình lại cần nhiều phép so sánh hơn. Điều này nhìn chung đúng với mọi kiểu tìm kiếm dạng này, tức khi số nhánh phân chia lớn hơn 2. Tất nhiên còn có vài giả định như giá trị cần tìm phân bố đều và chi phí phép toán được lý tưởng hóa, và đây chính là điểm mà bài viết gốc bắt đầu đi vào
Chỉ là không phải dưới dạng tam phân của thuật toán tìm kiếm nhị phân. Thứ đó thực ra không phải tìm kiếm tam phân thật, mà gần giống một kiểu tìm kiếm nhị phân lệch. So sánh vốn có bản chất nhị phân, nên mọi thuật toán tìm kiếm dựa trên so sánh đều là một dạng tìm kiếm nhị phân, và việc chọn điểm không phải phần tử giữa sẽ kém hiệu quả hơn về độ phức tạp thuật toán. Tuy vậy trên phần cứng thực tế, trong một số điều kiện nào đó nó vẫn có thể tốt hơn. Để có tam phân thật sự thì phép toán cơ bản phải là so sánh 3 hướng
Điểm trở nên thú vị là khi xét tới “hiệu quả cơ số”[1]. Lựa chọn tốt nhất là số tự nhiên gần e nhất, tức 3. Nó cũng liên quan đến tìm kiếm trên cây, nơi cây tam phân có thể tốt hơn cây nhị phân
[1] https://en.wikipedia.org/wiki/Optimal_radix_choice
Nên có thể ý tưởng đó không khả thi trên CPU thời ấy, nhưng lại có tiềm năng hơn trên CPU ngày nay