2 điểm bởi GN⁺ 2024-03-11 | 1 bình luận | Chia sẻ qua WhatsApp
  • Nút thắt cổ chai của 1BRC là phân tích cực nhanh 1 tỷ giá trị nhiệt độ trong CSV, và mã SWAR của merykitty từ Quân Anh Mai đã gây chú ý khi biến nhiệt độ thành số nguyên bằng các phép toán ALU cố định không cần if
  • Đoạn mã này dùng SWAR (SIMD Within A Register), xử lý đồng thời 8 byte chứa trong một long, tức xử lý nhiều ký tự gần như song song ngay trong thanh ghi CPU thông thường
  • Luồng xử lý lần lượt là phát hiện dấu âm, loại bỏ dấu, tìm vị trí dấu thập phân, căn chỉnh về dạng XY.Z, chuyển ký tự ASCII thành chữ số, phép nhân ma thuật, rồi áp dụng dấu
  • Định dạng đầu vào có 4 dạng -XX.X, -X.X, X.X, XX.X; dựa trên vị trí dấu thập phân, các byte được dịch chuyển để đưa các chuỗi có độ dài khác nhau về cùng một bố cục bit
  • Thay vì dùng nhánh và vòng lặp, mã tận dụng dày đặc đặc tính mã ASCII, bù hai, mặt nạ bit và tính chất dịch-cộng của phép nhân để đạt hiệu năng phân tích rất cao

Phân tích nhiệt độ, nút thắt cổ chai trong 1BRC

  • Trong One Billion Row Challenge(1BRC), việc phân tích cực nhanh các giá trị nhiệt độ trong tệp CSV nổi lên như nút thắt cổ chai cốt lõi
  • Chỉ riêng các tối ưu hóa trước đó cũng đã giúp mã Java song song kiểu thông dụng tăng tốc từ 71 giây xuống 1,7 giây
  • Định dạng nhiệt độ thì đơn giản, nhưng khi phải phân tích 1 tỷ giá trị trong chưa tới 1 giây, ngay cả chi phí rất nhỏ cũng tích lũy thành đáng kể
    • Các định dạng có thể có là -XX.X, -X.X, X.X, XX.X
  • Những người tham gia ban đầu dùng Double.parseDouble(), nhưng về sau đã xuất hiện các bộ phân tích tùy biến không vòng lặp
  • Một phần lời giải của Quân Anh Mai tại @merykitty xử lý bằng cách đọc một lượt một tệp mà không cần if, và lan rộng như một thành phần gần như tiêu chuẩn trong các lời giải 1BRC hàng đầu
  • Người chiến thắng Thomas Wuerthinger đã ghi nhận Quân Anh là một phần của nhóm đóng góp cho lời giải của mình

Mã của merykitty làm gì

  • Mã nhận vào một long chứa 8 byte đầu vào CSV và trả về giá trị nhiệt độ nguyên, bằng 10 lần nhiệt độ thực tế
  • Đầu vào được đọc trực tiếp từ tệp CSV mmap thông qua đọc bộ nhớ native; phần đó là một mối quan tâm riêng nên được tách ra
  • Phép tính gồm 18 thao tác ALU theo thứ tự cố định
    • dịch bit, AND, NOT, XOR
    • cộng, trừ, nhân
    • Long.numberOfTrailingZeros()
  • numberOfTrailingZeros() dùng lệnh CPU đặc biệt thông qua intrinsic của trình biên dịch JDK
  • Vì nó không dùng các lệnh SIMD chuyên dụng mà xử lý nhiều byte bằng thanh ghi và lệnh CPU thông thường, nên đây là phương pháp SWAR
  • Mã ví dụ đã được chỉnh nhẹ để dễ đọc hơn so với bản gốc; bản gốc nằm tại CalculateAverage_merykitty.java

Toàn bộ các bước xử lý

  • Mã phân tích nhiệt độ theo thứ tự sau
    • kiểm tra xem ký tự đầu tiên có phải - hay không để phát hiện số âm
    • nếu có ký tự dấu, biến byte đó thành 0
    • tìm vị trí của dấu thập phân .
    • dịch các bit trong long để các chữ số khớp với mẫu XY.Z
    • chuyển ký tự ASCII thành giá trị số thực tế
    • nhân từng chữ số với trọng số 1x, 10x, 100x rồi cộng lại
    • cuối cùng áp dụng dấu
  • Bề ngoài giống một bài toán phân tích ở mức cao, nhưng từng bước đều được thực hiện chỉ bằng các phép toán ALU

Bước 1: phát hiện dấu trừ

  • Việc phát hiện dấu bắt đầu với đoạn mã sau
long negatedInput = ~inputData;
long broadcastSign = (negatedInput << 59) >> 63;
  • Nếu đổi cách nhìn theo thứ tự mô tả thì có thể xem như ( ~(inputData << 59) ) >> 63
  • Trong ASCII, dấu trừ -bit 4 bằng 0, còn các ký tự chữ số thì bit đó bằng 1; mã tận dụng tính chất này
  • Khi dịch đầu vào sang trái 59 bit, bit phân biệt của ký tự đầu tiên được đưa lên bit cao nhất
  • Sau khi đảo bit bằng NOT rồi thực hiện dịch phải số học 63 bit, bit cao nhất được lan ra toàn bộ long
  • Kết quả broadcastSign sẽ có mọi bit bằng 1 nếu có dấu trừ, và mọi bit bằng 0 nếu không có

Bước 2: loại bỏ ký tự dấu

  • Vì trạng thái âm/dương đã được lưu trong broadcastSign, nên ký tự dấu được loại khỏi dữ liệu đầu vào
long maskToRemoveSign = ~(broadcastSign & 0xFF);
long withSignRemoved = inputData & maskToRemoveSign;
  • Nếu broadcastSign toàn 1, thì broadcastSign & 0xFF sẽ chỉ giữ 8 bit thấp nhất ở mức 1
  • Đảo nó bằng NOT sẽ tạo ra một mặt nạ mà chỉ 8 bit thấp nhất là 0
  • AND với inputData sẽ xóa ký tự - ở byte thấp nhất
  • Nếu không có dấu trừ, broadcastSign bằng 0 nên mặt nạ sẽ là toàn 1 và các byte chữ số được giữ nguyên

Bước 3: tìm vị trí dấu thập phân

  • Vị trí dấu thập phân được tính bằng đoạn mã sau
int dotPos = Long.numberOfTrailingZeros(negatedInput & DOT_DETECTOR);
  • Ký tự . cũng giống dấu trừ ở chỗ bit 4 bằng 0
  • Để chỉ kiểm tra bit 4 tại các vị trí có thể xuất hiện dấu thập phân, mã dùng mặt nạ DOT_DETECTOR = 0x10101000
  • Trong negatedInput, tức đầu vào đã bị đảo bit, bit tương ứng với vị trí dấu thập phân sẽ thành 1
  • Long.numberOfTrailingZeros() trả về vị trí của bit 1 đó
  • Với ví dụ -10.8, dấu thập phân nằm ở vị trí bit 28 nên dotPos = 28

Bước 4: căn chỉnh về mẫu cố định

  • Dựa trên vị trí dấu thập phân, đầu vào được dịch sang trái để luôn khớp cùng một mẫu
long alignedToTemplate = withSignRemoved << (28 - dotPos);
  • Mẫu mục tiêu là như sau
0 0 0 Z . Y X 0
  • Ở đây X là hàng chục, Y là hàng đơn vị, Z là chữ số đầu tiên sau dấu thập phân
  • 0 ở đây không phải ASCII "0" mà là byte có giá trị 0
  • Sau khi bỏ dấu, đầu vào có thể rơi vào một trong bốn bố cục sau
    • 0 0 0 Z . Y X 0
    • 0 0 0 0 Z . Y 0
    • 0 0 0 0 Z . Y X
    • 0 0 0 0 0 Z . Y
  • Với -10.8, vì đã có dotPos = 28 nên độ dịch là 0
  • Với -7.7, dấu thập phân ở bit 20 nên đầu vào được dịch trái 8 bit, tức 1 byte, để đặt 0 vào vị trí của X

Bước 5: chuyển chữ số ASCII thành giá trị số

  • Sau khi căn chỉnh, mã chỉ giữ lại phần giá trị số từ các ký tự ASCII
long digits = alignedToTemplate & ASCII_TO_DIGIT_MASK;
  • Các chữ số ASCII từ 0 đến 9 có mã hexa từ 0x30 đến 0x39
  • Chỉ cần giữ lại 4 bit thấp là mã ký tự sẽ trở thành giá trị số thực tế
  • Một mặt nạ có F tại đúng các vị trí chữ số trong mẫu sẽ được áp dụng
0 0 0 Z . Y X 0
000000F000F0F00
  • Với ví dụ -10.8, sau khi áp mặt nạ chỉ còn lại các giá trị thể hiện Z=8, Y=0, X=1

Bước 6: cộng gộp giá trị hàng bằng phép nhân ma thuật

  • Giá trị tuyệt đối cuối cùng cần được tính là 100 * X + 10 * Y + Z
  • Mã tận dụng tính chất phép nhân là tổ hợp của dịch và cộng để xử lý việc tính trọng số của nhiều chữ số chỉ bằng một phép nhân
  • Trước hết, nếu chỉ nghĩ đến X + Y + Z, ta có thể cộng các bản dịch của digits ở vị trí bit 0, 16 và 24 để gom tổng vào một vùng bit cụ thể
  • Tổ hợp dịch-cộng này có thể được biểu diễn bằng phép nhân sau
0x1 + 0x10000 + 0x1000000
  • Trong thực tế, trọng số của từng chữ số khác nhau nên MAGIC_MULTIPLIER được tạo như sau
MAGIC_MULTIPLIER = 0x1 + 10 * 0x10000 + 100 * 0x1000000;
  • Công thức tính là
absValue = ((digits * MAGIC_MULTIPLIER) >>> 32) & 0x3FF;
  • 0x3FF là mặt nạ để tách riêng kết quả rộng 10 bit
  • 100 * X có thể lớn tới mức 10 bit và chồng lấn với các bit lân cận, không gian bit cần thiết vẫn được đảm bảo nhờ tính chất hai bit bên phải của Y * 100 luôn bằng 0
  • merykitty để lại chú thích // That was close :) cho phần này

Bước 7: áp dụng dấu mà không cần nhánh

  • Đến đây ta đã có giá trị tuyệt đối absValue và thông tin dấu trong broadcastSign
  • broadcastSign hoạt động như 0 nếu số dương, và như -1 nếu số âm
  • Trong bù hai, số âm được biểu diễn bằng công thức sau
-n = NOT(n) + 1
  • XOR có thể dùng như một phép NOT có điều kiện
    • n XOR -1NOT(n)
    • n XOR 0n
  • Phần +1 tùy chọn được xử lý bằng -broadcastSign
temperature = (absValue ^ broadcastSign) - broadcastSign;
  • Kết quả là không cần if: số dương giữ nguyên, số âm được chuyển thành giá trị âm theo bù hai

Phần thưởng: tính vị trí bắt đầu của dòng CSV kế tiếp

  • Trong toàn bộ lời giải 1BRC, còn cần phải tính rẻ vị trí bắt đầu của dòng CSV tiếp theo
  • Vì sau dấu thập phân luôn có đúng một chữ số thập phân rồi tới ký tự xuống dòng, vị trí bắt đầu dòng kế tiếp được suy ra từ vị trí dấu thập phân
  • dotPos là vị trí theo bit, nên để chia cho 8 mã dùng dịch phải 3 bit
nextLineStart = (dotPos >>> 3) + 3;
  • +3 là để trỏ đến byte đầu tiên sau dấu thập phân, chữ số thập phân và ký tự xuống dòng

Kết luận

  • Mã SWAR của merykitty phân tích thống nhất bốn định dạng chuỗi nhiệt độ chỉ bằng các phép toán bit cố định
  • Cốt lõi nằm ở đặc tính bit của mã ASCII, việc căn chỉnh theo vị trí dấu thập phân, trích xuất chữ số bằng mặt nạ, cộng gộp giá trị hàng bằng phép nhân, và áp dụng dấu dựa trên bù hai
  • Khi tách thành từng bước thì có thể lần theo cách nó hoạt động, nhưng việc ghép được tất cả những điều này chỉ trong vài ngày của một thử thách trực tuyến vẫn là điều đặc biệt ấn tượng

1 bình luận

 
GN⁺ 2024-03-11
Ý kiến trên Hacker News
  • Phần giải thích từng bước thật sự rất xuất sắc
    Hơn 2 năm trước, tôi đã nhận ra rằng byte array view var handle khá phù hợp để tạo các routine SWAR hiệu quả trong Java/Scala
    Ở đây cũng có nhiều ví dụ dùng SWAR như phân tích chuỗi Base16/64, java.time.*, phân tích trực tiếp giá trị số từ mảng byte: https://github.com/plokhotnyuk/jsoniter-scala/blob/master/js...
  • Bài viết hay và là một lời giải tuyệt vời trong ngữ cảnh đoạn mã, nhưng cách này giả định dữ liệu có định dạng hợp lệ
    Giá trị lớn của một parser đã được rèn giũa trong thực tế nằm ở kiểm tra lỗi và phục hồi một cách hiệu quả
    • Sẽ rất thú vị nếu phân tích xem đầu vào sai có thể ảnh hưởng đến đầu ra theo cách nào
      Và tôi cũng tò mò cần bao nhiêu công sức để phát hiện và trả về một giá trị lỗi sentinel nào đó như phong cách code hiện tại
      Dù chưa thú vị đến mức tôi tự làm thử ;-)
  • Kỹ thuật nhân từng chữ số trong bitfield số với lũy thừa của 10 tương ứng rồi dùng MUL để dịch/cộng là một cách khá quen thuộc
    Xem bài của Lemire: https://lemire.me/blog/2023/11/28/parsing-8-bit-integers-qui...
  • Theo bài viết, SWAR là SIMD Within A Register
  • Nếu thích những nội dung kiểu này, bài báo simdjson cũng dùng các kỹ thuật tương tự, được viết rất tốt và có ví dụ hay
    Bài báo: https://arxiv.org/abs/1902.08318
    Github: https://github.com/simdjson/simdjson
    • Cái này không phải SWAR, nhưng tôi hiểu vì sao nó thú vị
  • Có ai giải thích được vì sao BRC không bị nghẽn cổ chai I/O không? Tôi không hiểu vì sao CPU lại là nút thắt
    • Trên các hệ thống hiện đại, I/O đĩa cục bộ không còn là nút thắt nữa: https://benhoyt.com/writings/io-is-no-longer-the-bottleneck/
      Hơn nữa, 1BRC chính thức đã ghi rõ rằng để loại hoàn toàn tốc độ I/O, kết quả được đánh giá trên RAM disk: https://github.com/gunnarmorling/1brc?tab=readme-ov-file#eva...
      “Programs are run from a RAM disk (i.o. the IO overhead for loading the file from disk is not relevant)”
    • Về bối cảnh, có một cuộc phỏng vấn với Daniel Lemire. Ông là người đã xây dựng cả sự nghiệp dựa trên quan sát rằng I/O không phải lúc nào cũng là nút thắt: https://corecursive.com/frontiers-of-performance-with-daniel...
    • Tôi chưa xem kỹ bài toán này, nhưng có thể bắt đầu theo hướng ngược lại: vì sao bạn nghĩ I/O bộ nhớ là nút thắt?
      Theo hiểu biết hạn chế của tôi, một file văn bản lớn được đưa tuần tự vào L1 và mỗi giá trị được đọc một lần. Trên đa số bộ xử lý, có thể thực hiện hai lần đọc như vậy mỗi chu kỳ. Phần chậm sẽ là đưa dữ liệu từ RAM vào L1, nhưng đọc tuần tự thì khá nhanh
      Sau đó mỗi lần đọc được xử lý. Nhìn qua, trong phiên bản đã tối ưu, có vẻ mất khoảng 4 chu kỳ. Sau đó cần ghi kết quả vào đâu đó, và có lẽ trước đó còn cần một hoặc hai lần đọc ngẫu nhiên. Bạn xem phần này là nút thắt I/O à?
      Tôi không nói việc bị giới hạn bởi CPU là hiển nhiên, nhưng điều ngược lại cũng có vẻ không hiển nhiên
      Sửa: tôi đã không tính đến khả năng ý bạn là “I/O đĩa”. Như những người khác đã nói, ở đây về cơ bản nó không phải yếu tố đáng kể
    • Bài test chạy bằng memfs. File và mọi thứ đều đã nằm trong RAM ngay từ lúc bắt đầu
    • Dataset đủ nhỏ để nằm trong page cache của nhân Linux, và benchmark được lặp 5 lần liên tiếp, nên lần lặp đầu tiên có thể bị nghẽn bởi I/O đĩa, nhưng 4 lần còn lại thì không
      Tức là toàn bộ dữ liệu nằm trong RAM, chính xác hơn là trong page cache
  • Tôi từng dùng SWAR khá hiệu quả trên 68000. Xử lý 4 byte song song bằng một lệnh
    Nếu nhớ không nhầm thì xử lý overflow khá khó. Tôi rất thích bài này
  • Có câu “Điều thật sự bí ẩn là một người làm một mình, chỉ làm thử thách online có phần thưởng là áo thun và cốc cà phê trong vài ngày nhẹ nhàng, lại tạo ra được tất cả những thứ này”, nhưng vì sao đó lại là bí ẩn?
    Vẫn còn những người thực sự biết lập trình CPU và hiểu mình đang làm gì
    Bí ẩn thật sự là phần lớn những người tự gọi mình là lập trình viên lại thiếu hiểu biết sâu, thậm chí dường như còn không biết rằng mình thiếu hụt nghiêm trọng
  • Trong C# không cần dùng các mẹo SWAR kiểu này. Thay vào đó, nó cung cấp API SIMD đa nền tảng hạng nhất
    Việc nó thực sự hoạt động tốt có thể thấy ở lời giải C# có vẻ là nhanh nhất trong số các lời giải 1BRC đã công bố cho đến nay: https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-...
  • Có thể vector hóa cái này bằng SSE không? Phần lớn xử lý cốt lõi có vẻ có thể làm bằng vector gồm bốn số nguyên 32-bit
    Vấn đề là chi phí dựng vector ban đầu và trích xuất kết quả có quá lớn hay không
    • Có thể, và nhiều triển khai 1BRC khác cũng đã làm vậy
      Tuy nhiên tôi nghi ngờ HotSpot có tự làm được không, và còn một chuyện riêng là phần lớn submission 1BRC được chạy bằng Graal để giảm overhead khởi động
      SSE2 cơ bản không có phép nhân 32-bit hay 64-bit nên phép nhân 32×32→64-bit là vấn đề, nhưng SSE4.1 đã thêm đúng pmuldq cần thiết. Tuy nhiên kết quả là 64-bit, nên để xử lý cả vector số nguyên 32-bit thì cần thực hiện thao tác này hai lần
    • Trường nhiệt độ bị trộn với trường tên nên có vẻ khó thu thêm lợi ích từ SSE
      Ngoài ra trường nhiệt độ có độ dài biến thiên, nên ngay cả khi được lưu theo cột cũng có khả năng không có lợi
      Tuy vậy, SSE đã được áp dụng thành công cho việc tìm dấu phân cách giữa tên và nhiệt độ
    • Loại code này có vẻ sẽ được tự động vector hóa, dù là ngay từ đầu hay sau khi HotSpot phát hiện hotspot