Thử thách 1 tỷ dòng
(morling.dev)- One Billion Row Challenge (1BRC), diễn ra trong tháng 1/2024, là một thử thách hiệu năng nhằm xử lý tệp văn bản 1 tỷ dòng và so tài xem Java có thể nhanh đến đâu
- Dữ liệu đầu vào là văn bản đơn giản theo định dạng
station;temperature, nhưng phải tính chính xác nhiệt độ thấp nhất, trung bình và cao nhất theo từng trạm quan trắc, rồi xuất theo thứ tự tên - Phần triển khai chỉ cho phép dùng Java; có thể dùng các bản phân phối từ SDKMan và các bản Early Access của openjdk.net, nhưng cấm phụ thuộc bên ngoài
- Người tham gia nộp bài bằng pull request vào kho 1brc trên GitHub, và có thể dùng bản triển khai cơ sở được cung cấp để so sánh định dạng đáp án cũng như hiệu năng
- Việc đánh giá được chạy 5 lần trong cùng môi trường Hetzner Cloud CCX33, sau đó loại bỏ kết quả nhanh nhất và chậm nhất, lấy trung bình 3 lần còn lại để xếp hạng trên leaderboard
Bài toán Java tổng hợp 1 tỷ dòng nhanh nhất
- One Billion Row Challenge là thử thách hiệu năng Java diễn ra từ ngày 1/1 đến 31/1/2024
- Người tham gia viết chương trình Java đọc các giá trị đo nhiệt độ từ tệp văn bản và tính nhiệt độ thấp nhất, trung bình và cao nhất cho từng trạm khí tượng
- Điểm khó cốt lõi nằm ở việc tệp đầu vào có 1.000.000.000 dòng
- Đầu vào có cấu trúc đơn giản, mỗi dòng chứa một giá trị đo
- Ví dụ:
Hamburg;12.0 - Ví dụ:
Bulawayo;8.9 - Ví dụ:
Palembang;38.8
- Ví dụ:
- Đầu ra phải sắp xếp tên trạm quan trắc theo thứ tự alphabet và hiển thị các giá trị
min/mean/maxcủa từng trạm- Ví dụ:
{Abha=5.0/18.0/27.4, Abidjan=15.7/26.0/34.1, ...}
- Ví dụ:
Quy tắc nộp bài và môi trường chạy
- Mục tiêu là tạo ra bản triển khai Java nhanh nhất thực hiện cùng một tác vụ
- Có thể tận dụng luồng ảo, Vector API và SIMD, tối ưu GC, biên dịch AOT, v.v. để tối ưu
- Các quy tắc cơ bản như sau
- Bài nộp phải được viết bằng Java
- Có thể sử dụng các bản phân phối Java do SDKMan cung cấp và các bản Early Access từ openjdk.net
- Các bản EA của những dự án OpenJDK như Valhalla cũng được phép dùng
- Không được sử dụng phụ thuộc bên ngoài
- Người tham gia clone kho 1brc và nộp phần triển khai theo hướng dẫn trong README
- Bản triển khai cơ sở được cung cấp để làm mốc so sánh và kiểm tra định dạng đáp án
- Việc nộp bài được thực hiện bằng cách mở pull request lên kho upstream
Cách tính leaderboard và chia sẻ trong cộng đồng
- Việc đánh giá được thực hiện trên instance Hetzner Cloud CCX33
- Cấu hình là 8 dedicated vCPU, 32 GB RAM
- Đo thời gian chạy end-to-end bằng chương trình
time - Mỗi bài nộp được chạy liên tiếp 5 lần
- Lần chạy chậm nhất và nhanh nhất sẽ bị loại
- Trung bình thời gian của 3 lần chạy còn lại là kết quả của bài nộp đó
- Kết quả được thêm vào leaderboard
- Thảo luận về các kỹ thuật tối ưu tiếp tục diễn ra trong mục discussion của kho GitHub
- Ngoài ra còn có mục Show & Tell để chia sẻ các bản triển khai bằng ngôn ngữ ngoài Java; các bản 1BRC bằng Rust, Go, C++... đã được chia sẻ tại đây
2 bình luận
Ý kiến trên Hacker News
Lời giải hiện có vẻ nhanh nhất [0] dường như không tính đến va chạm băm, nên nếu trong bộ dữ liệu có đủ nhiều thành phố khác nhau thì có thể sẽ cho kết quả sai
Không biết có phải tôi đang bỏ sót điều gì không
[0] https://github.com/gunnarmorling/1brc/blob/main/src/main/jav...
Tạm thời các mục đó đã bị gỡ khỏi bảng xếp hạng, và hai tác giả đang sửa bài nộp nên sau đó sẽ được thêm lại
[0] https://twitter.com/mtopolnik/status/1742652716919251052
Tôi nghĩ với cách tiếp cận sau thì có thể xử lý toàn bộ trong 0,3 giây
Nhiệt độ chỉ có một chữ số thập phân nên trong thực tế chỉ cần khoảng 400 giá trị, và địa danh cũng hữu hạn ở khoảng 400 cái, vậy có thể tạo một bảng tra khoảng 160 nghìn tổ hợp nhiệt độ × địa danh
Có thể tự động sinh một máy trạng thái ánh xạ 160 nghìn giá trị này vào các bucket duy nhất của bảng băm, bất kể chúng nằm ở vị trí xoay nào trong thanh ghi 4 byte, rồi trên thanh ghi trạng thái 32 bit thực hiện mỗi chu kỳ một lần tra bảng chuyển trạng thái và XOR với 4 byte tiếp theo
Chỉ cần quét toàn bộ dữ liệu ở tốc độ bộ nhớ và tăng bộ đếm theo từng trạng thái; vì chỉ có 65K trạng thái nên các bộ đếm sẽ nằm gọn trong cache
Với AVX512, có thể chạy song song 512 máy trạng thái 32 bit như vậy trên mỗi lõi, nên tính toán có lẽ sẽ không phải nút thắt cổ chai
Các nhiệt độ quá cao/thấp hoặc địa danh không xác định không ánh xạ vào bucket hợp lệ thì chuyển sang mã chậm, và việc xử lý giá trị nhỏ nhất/lớn nhất cũng có thể làm qua kiểu escape này nên chỉ xảy ra vài nghìn lần
Tôi nghĩ cách này có thể chạy ở tốc độ bộ nhớ chỉ với một lõi AVX512, nên không có nhiều lợi ích khi chia ra nhiều lõi
Chỉ cần một bảng băm 400 mục, ba giá trị dấu phẩy động cho min/avg/max đang chạy, và một số nguyên đếm để cập nhật giá trị trung bình
Kể cả nếu dùng 16 byte cho tên thì toàn bộ vẫn nằm trong 16KB
Thời gian chạy sẽ chủ yếu bị chi phối bởi I/O, rồi đến phân tích JSON
Hầu hết chip máy chủ x86 hiện đại có thể retire 2 SIMD load mỗi chu kỳ, nên với AVX2 ở 1GHz có thể đạt khoảng 32GB/s; vì vậy không nhất thiết phải có AVX-512 để tối đa hóa băng thông trên mỗi lõi
Nhưng nếu đọc từ DRAM thì sẽ chạm trần sớm hơn nhiều, thường là quanh mức 10–16GB/s trên máy chủ thông thường
Miễn là phần lớn dữ liệu vẫn tràn ra RAM, thông lượng trên một lõi sẽ giảm mạnh, và với các tác vụ streaming lớn thì gần như lúc nào song song đa lõi cũng có lợi
Có thể dễ dàng kiểm chứng bằng cách cấp phát một khối nhớ lớn hơn nhiều so với cache L3, tạo page fault trước, rồi chạy vector load đã được unroll trong một vòng lặp chặt (AVX2/AVX-512)
Tôi cũng thắc mắc sẽ diễn giải thanh ghi trạng thái ra sao. Khi XOR với 4 byte đầu vào thì với các địa danh không mong đợi, trên thực tế nó có thể trở thành bất kỳ giá trị nào trong khoảng 4,7 tỷ khả năng
Ngay cả với các địa danh đã dự kiến, nếu dài hơn 4 byte thì chẳng phải sẽ cần nhiều trạng thái cho từng tên để phân biệt với các tên khác có cùng tiền tố sao?
Quy tắc nói rằng dù trình tạo dữ liệu có dùng một tập tên trạm cố định, mọi lời giải vẫn phải hoạt động với các tên trạm UTF-8 tùy ý
Thay vì bỏ lần chạy chậm nhất và nhanh nhất rồi lấy trung bình của ba lần còn lại, tôi nghĩ nên bỏ hai lần chậm nhất hoặc đơn giản là chấp nhận giá trị nhanh nhất
Tôi không thấy có lý do chính đáng nào để loại bỏ kết quả chạy tốt
Nhưng nếu trong bản thân chương trình có dù chỉ một chút nguồn gây không xác định, điều này thực ra khá phổ biến, thì thời gian tốt nhất có thể không mang tính đại diện
Bài viết này liên quan khá hay: https://tratt.net/laurie/blog/2019/minimum_times_tend_to_mis...
Theo cách nhìn quá bám luật, ở lần chạy đầu tiên người ta sẽ muốn bật một daemon chạy nền, nạp toàn bộ tệp vào bộ nhớ rồi giữ cố định, sau đó còn kéo sẵn cả cache để các lần chạy tiếp theo về cơ bản chỉ còn quét tuyến tính
Việc tính trước kết quả ngay từ lần chạy đầu cũng có vẻ khả thi, tùy việc diễn giải luật theo hướng nới rộng đến đâu; thậm chí có thể parse số trước sang định dạng dày đặc hơn rồi ở các lần chạy sau đọc thẳng để cộng dồn
Điều này hoàn toàn không đúng với tinh thần cuộc thi, nhưng theo phần luật nhìn thấy thì có vẻ cũng không bị cấm
Nếu không thích tính toán trước, vẫn có thể dùng các tiểu xảo như sắp xếp đầu vào trước, parse trước, hoặc nén·sắp xếp·bố trí bộ nhớ đã sắp xếp
Cực đoan hơn nữa, còn có thể vá script
calculate_timeđể nó trả về 0 giây cho mình và trả về 9999 cho đối thủTừ việc không thèm đọc đầu vào mà hardcode đáp án thành một dòng, cho tới việc xử lý dưới giả định không biết nội dung tệp, đều tồn tại vùng xám của tiền tính toán dài cỡ 1 tỷ nấc
Cuộc thi có thể biến thành chuyện phân xử cái gì là tiền tính toán công bằng và cái gì thì không
Vì thế các cuộc thi machine learning không cho người tham gia xem dữ liệu cuối cùng
Phép tính phải diễn ra tại thời điểm ứng dụng chạy, và không được xử lý tệp đo ở thời điểm build rồi nhúng kết quả vào binary
Có vẻ đây chẳng phải chỉ là bài toán bị khóa bởi tốc độ đĩa sao. Tôi nghi ngờ các tối ưu như SIMD hay multithreading có thực sự có ý nghĩa không
Dù còn tùy số lượng trạm khác nhau và cách tra cứu hash, tôi vẫn hoài nghi liệu chúng có đủ lớn để đo ra được so với chi phí I/O hay không
Các hệ thống được thiết kế với giả định phần cứng hiện đại đều tận dụng điểm này, và redpanda.com nơi tôi làm việc cũng là một ví dụ
Parse chiếm phần lớn thời gian tính toán, và các kỹ thuật SIMD kiểu SWAR để tìm dấu phân cách có thể hữu ích
Nếu muốn xem một triển khai gọn gàng của các thuật toán như vậy thì Stringzilla rất đáng xem: https://github.com/ashvardanian/StringZilla
Về điểm tệp sẽ được cache hoàn toàn trong bộ nhớ sau lần chạy đầu, tôi đã trả lời ở đây: https://news.ycombinator.com/item?id=38864034
Máy chủ thông thường có đủ lane PCIe để cắm 15 SSD như vậy, nên băng thông I/O của server gần ngang với băng thông bộ nhớ
Máy chủ đắt tiền hơn còn có lane nhanh hơn như PCIe 5.0 và nhiều lane hơn
Tệp này có 1 tỷ dòng nên khi nén chỉ khoảng 1GB, và sau lần chạy bỏ đi đầu tiên thì nó đã nằm trong bộ nhớ, nên trong kịch bản này băng thông I/O không quan trọng
Kho GitHub ghi là 12GB chưa nén, nhưng điều đó vẫn chỉ xác nhận rằng băng thông I/O không quan trọng
Ý chính là hiếm khi đĩa mới là nút thắt
Ví dụ trên Linux dùng ext2 thì sau lần chạy đầu rất có thể toàn bộ tệp sẽ được cache, nhưng với ZFS thì có thể không
Khi đó chữ số sẽ xuất hiện theo thứ tự từ hàng thấp lên hàng cao, sau đó là dấu phân cách và chuỗi, rồi cứ thế cho đến khi gặp EOF hoặc ký tự xuống dòng
Theo luật, bài nộp phải hoạt động đúng với mọi đầu vào, nhưng đồng thời dường như cũng có nghĩa là có thể, và có lẽ nên, tinh chỉnh cho đầu vào cụ thể được tạo bởi
create_measurements.shVí dụ, hoàn toàn có thể tưởng tượng ra một bài nộp dùng hàm băm hoàn hảo được tối ưu cho đúng tập trạm đã cho
Như vậy mới tránh được các tối ưu kiểu overfitting
Byte lớn hơn 127 có nghĩa là ký tự UTF-8 đa byte
Cho vui nên tôi thử so tốc độ awk với Java
Đây là script dùng
awk -F';'để cộng dồn tổng, số lượng, giá trị nhỏ nhất, lớn nhất theo từng trạm, rồi đến END thì tính trung bình và in raCách làm là dùng
file_fdwtạo bảng ngoài từ tệp CSV rồiGROUP BY station_nameđể tínhMIN,AVG,MAXTrong
clickhouse local, nó đọcfile('measurements.txt', 'CSV', 'station String, t Float32'), nhóm theo từng trạm để tínhmin,max,avg, và chạy vớimax_threads = 8Phần lớn thời gian được dùng cho việc parse tệp
sumcó thể khá lớn nên dùng trung bình luồng sẽ tốt hơnVí dụ như
new_mean = ((n*old_mean)+temp)/(n+1)Một thử thách thú vị, nhưng khá tiếc vì chỉ dành cho Java. Hóng đến lúc mọi người bắt đầu tự viết bytecode JVM bằng tay
[0] https://github.com/gunnarmorling/1brc/discussions
Khá vui. Cảm giác như phần hậu tiệc của Advent of Code
Nếu muốn so sánh công bằng giữa các ngôn ngữ thì nên tính cả
makevà thời gian build. Tôi đã không dùng Java/Maven vài năm rồi, nhưng thấy./mvnw clean verifytải xuống suốt 2 phút thì lại nhớ ra lý doVà trong các công cụ build biên dịch tăng dần thì Gradle nhanh hơn
Cũng nên cộng thêm một tỷ lệ phù hợp của thời gian bỏ ra để học lập trình
Trong kiểu thử thách này, rất có thể một phiên bản cực kỳ ngây thơ sẽ thắng, điều đó không thực tế và theo tôi cũng đi ngược lại mục đích của thử thách
cleanKhác nào vứt cache đi rồi than là chậm
Vì có ghi là không được dùng phụ thuộc bên ngoài
Trong môn C ở Đại học Kỹ thuật Séc từng có một bài tập rất giống thế này
Tất cả bài nộp của sinh viên liên tục được chấm trên bảng xếp hạng, và để kiếm thêm điểm cho thành tích tốt hơn, thực chất là điểm địa vị, rất nhiều sinh viên đã dành hàng chục giờ để tối ưu hóa
Hạng nhất là 6 giây nhỉ.. thật đáng kinh ngạc