โจทย์ท้าทาย 1 พันล้านแถว
(morling.dev)- One Billion Row Challenge (1BRC) ที่จัดขึ้นตลอดเดือนมกราคม 2024 คือชาเลนจ์ด้านประสิทธิภาพที่แข่งขันกันว่า Java จะทำงานได้เร็วแค่ไหนในการประมวลผลไฟล์ข้อความ 1 พันล้านแถว
- อินพุตเป็นข้อความธรรมดาในรูปแบบ
station;temperatureแต่ต้องคำนวณ อุณหภูมิต่ำสุด·เฉลี่ย·สูงสุด ของแต่ละสถานี และแสดงผลอย่างถูกต้องตามลำดับชื่อ - อนุญาตให้ใช้ได้เฉพาะ Java โดยสามารถใช้ดิสทริบิวชันจาก SDKMan และ Early Access build จาก openjdk.net ได้ แต่ห้ามใช้ external dependency
- ผู้เข้าร่วมส่งผลงานผ่าน pull request ไปยัง รีโพซิทอรี 1brc บน GitHub และสามารถใช้ implementation พื้นฐานที่มีให้เพื่อเทียบรูปแบบคำตอบและประสิทธิภาพได้
- การประเมินใช้สภาพแวดล้อม Hetzner Cloud CCX33 เดียวกัน โดยรัน 5 ครั้งแล้วตัดสถิติที่ช้าที่สุดและเร็วที่สุดออก ก่อนนำค่าเฉลี่ยของ 3 ครั้งที่เหลือมาจัดอันดับบนลีดเดอร์บอร์ด
โจทย์ Java สำหรับสรุปผล 1 พันล้านแถวให้เร็วที่สุด
- One Billion Row Challenge เป็น ชาเลนจ์ด้านประสิทธิภาพของ Java ที่จัดขึ้นตั้งแต่วันที่ 1 มกราคมถึง 31 มกราคม 2024
- ผู้เข้าร่วมต้องเขียนโปรแกรม Java ที่อ่านค่าการวัดอุณหภูมิจากไฟล์ข้อความ แล้วคำนวณ อุณหภูมิต่ำสุด·เฉลี่ย·สูงสุด ของแต่ละสถานีตรวจอากาศ
- แก่นของความยากคือไฟล์อินพุตมีขนาด 1,000,000,000 แถว
- อินพุตมีโครงสร้างเรียบง่าย โดยหนึ่งแถวมีค่าการวัดหนึ่งรายการ
- ตัวอย่าง:
Hamburg;12.0 - ตัวอย่าง:
Bulawayo;8.9 - ตัวอย่าง:
Palembang;38.8
- ตัวอย่าง:
- เอาต์พุตต้องเรียงชื่อตามลำดับตัวอักษร และแสดงค่า
min/mean/maxของแต่ละสถานี- ตัวอย่าง:
{Abha=5.0/18.0/27.4, Abidjan=15.7/26.0/34.1, ...}
- ตัวอย่าง:
กติกาการส่งและสภาพแวดล้อมการรัน
- เป้าหมายคือการสร้าง implementation Java ที่เร็วที่สุด สำหรับงานเดียวกัน
- การปรับแต่งสามารถใช้ virtual thread, Vector API และ SIMD, การปรับแต่ง GC, AOT compilation เป็นต้น
- กติกาพื้นฐานมีดังนี้
- ผลงานที่ส่งต้องเขียนด้วย Java
- สามารถใช้ดิสทริบิวชัน Java จาก SDKMan และ Early Access build จาก openjdk.net ได้
- อนุญาตให้ใช้ EA build ของโปรเจกต์ OpenJDK อย่าง Valhalla ได้ด้วย
- ห้ามใช้ external dependency
- ผู้เข้าร่วมต้องโคลน รีโพซิทอรี 1brc และส่ง implementation ตามคำแนะนำใน README
- มี implementation พื้นฐาน ให้ใช้เป็นเกณฑ์เปรียบเทียบและตรวจสอบรูปแบบคำตอบ
- การส่งผลงานทำโดยเปิด pull request ไปยัง upstream repository
วิธีคำนวณลีดเดอร์บอร์ดและการแบ่งปันในชุมชน
- การประเมินดำเนินการบนอินสแตนซ์ Hetzner Cloud CCX33
- สเปกคือ 8 dedicated vCPU, 32 GB RAM
- ใช้โปรแกรม
timeวัดเวลาแบบ end-to-end - ผลงานแต่ละชิ้นจะถูกรันต่อเนื่อง 5 ครั้ง
- ตัดผลการรันที่ช้าที่สุดและเร็วที่สุดออก
- นำค่าเฉลี่ยเวลาของ 3 ครั้งที่เหลือมาเป็นผลลัพธ์ของผลงานนั้น
- ผลลัพธ์จะถูกเพิ่มเข้า leaderboard
- การพูดคุยเรื่องเทคนิคการปรับแต่งดำเนินต่อใน discussion ของรีโพซิทอรีบน GitHub
- ยังมี Show & Tell สำหรับแชร์ implementation ที่เขียนด้วยภาษาอื่นนอกเหนือจาก Java ด้วย โดยมีการแชร์ 1BRC เวอร์ชัน Rust, Go, C++ เป็นต้น
2 ความคิดเห็น
ความคิดเห็นบน Hacker News
โซลูชันที่ดูเหมือนจะมีประสิทธิภาพดีที่สุดในตอนนี้ [0] ไม่ได้คำนึงถึง การชนกันของแฮช ดังนั้นถ้ามีเมืองต่างกันมากพอในชุดข้อมูล ก็น่าจะให้ผลลัพธ์ผิดได้
สงสัยว่าผมพลาดอะไรไปหรือเปล่า
[0] https://github.com/gunnarmorling/1brc/blob/main/src/main/jav...
ตอนนี้ได้ลบรายการเหล่านั้นออกจากตารางอันดับแล้ว และผู้เขียนทั้งสองกำลังแก้ไข submission ของตน จึงจะถูกเพิ่มกลับเข้ามาในภายหลัง
[0] https://twitter.com/mtopolnik/status/1742652716919251052
ผมคิดว่าแนวทางต่อไปนี้น่าจะประมวลผลทั้งหมดได้ภายใน 0.3 วินาที
อุณหภูมิมีทศนิยมหนึ่งตำแหน่ง ดังนั้นโดยทั่วไปค่าประมาณ 400 ค่าก็น่าจะพอ และชื่อสถานที่ก็มีจำนวนจำกัดประมาณ 400 ชื่อ จึงสามารถสร้างตาราง lookup สำหรับชุดค่าผสม อุณหภูมิ×ชื่อสถานที่ ได้ประมาณ 160,000 รายการ
สร้าง state machine อัตโนมัติที่แม้ 160,000 รายการนี้จะอยู่ในตำแหน่งหมุนใด ๆ ภายในรีจิสเตอร์ 4 ไบต์ ก็จะ map ไปยัง bucket เฉพาะใน hash table ได้ และในรีจิสเตอร์สถานะ 32 บิต ให้ทำการ lookup ตารางเปลี่ยนสถานะและ XOR กับ 4 ไบต์ถัดไปในทุก cycle
จากนั้นก็ไล่กวาดข้อมูลทั้งหมดด้วยความเร็วของหน่วยความจำและเพิ่ม counter ตามสถานะได้เลย และเพราะมีสถานะแค่ 65K counter จึงอยู่ใน cache ได้
ถ้าเป็น AVX512 จะสามารถรัน state machine แบบ 32 บิตแบบนี้ได้ขนานกัน 512 ตัวต่อคอร์ ดังนั้นการคำนวณไม่น่าจะเป็นคอขวด
อุณหภูมิที่สูง/ต่ำหรือชื่อสถานที่ที่ไม่รู้จักซึ่งไม่ map ไปยัง bucket ที่ถูกต้อง ก็ส่งต่อไปยังโค้ดช้า และการจัดการค่าต่ำสุด/สูงสุดก็ทำผ่าน escape แบบนี้ได้ ซึ่งจะเกิดขึ้นแค่ไม่กี่พันครั้ง
วิธีนี้สามารถทำงานด้วยความเร็วของหน่วยความจำได้ด้วยคอร์เดียวที่ใช้ AVX512 ผมจึงคิดว่าไม่มีประโยชน์ที่จะกระจายไปหลายคอร์
สิ่งที่ต้องมีคือ hash table ขนาด 400 รายการ, ค่าทศนิยม 3 ค่า ได้แก่ ต่ำสุด·เฉลี่ย·สูงสุดที่อัปเดตระหว่างรัน และจำนวนเต็มหนึ่งตัวสำหรับนับเพื่ออัปเดตค่าเฉลี่ย
แม้ใช้ 16 ไบต์สำหรับชื่อ ทั้งหมดก็ยังอยู่ภายใน 16KB
เวลารันจะถูกครอบงำด้วย I/O และถัดมาก็น่าจะเป็นการ parse JSON
ชิปเซิร์ฟเวอร์ x86 รุ่นใหม่ส่วนใหญ่สามารถ retire SIMD load ได้ 2 ครั้งต่อ clock ดังนั้นตาม AVX2 ที่ 1GHz จะได้ราว 32GB/s จึงไม่จำเป็นต้องมี AVX-512 เพื่อรีดแบนด์วิดท์ต่อคอร์ให้สูงสุดเสมอไป
แต่ถ้าอ่านจาก DRAM ก็น่าจะตันเร็วกว่านั้นมาก โดยในเซิร์ฟเวอร์ทั่วไปมักจะอยู่แถว 10–16GB/s
ตราบใดที่ข้อมูลส่วนใหญ่ล้นไปอยู่ใน RAM throughput ของคอร์เดียวจะตกลงมาก และในงาน streaming ขนาดใหญ่ parallelism แบบหลายคอร์แทบจะให้ประโยชน์เสมอ
ตรวจสอบได้ง่าย ๆ ด้วยการจัดสรรบล็อกหน่วยความจำที่ใหญ่กว่า L3 cache มาก ๆ, ทำ page fault ไว้ล่วงหน้า แล้วรัน vector load ที่ unroll แล้ว (AVX2/AVX-512) ใน loop ที่แน่น
อีกอย่างก็สงสัยว่าจะตีความรีจิสเตอร์สถานะอย่างไร ถ้า XOR กับอินพุต 4 ไบต์ สำหรับชื่อสถานที่ที่ไม่คาดไว้ มันอาจกลายเป็นค่าใดก็ได้จากค่าที่เป็นไปได้ราว 4.7 พันล้านค่า
แม้เป็นชื่อสถานที่ที่คาดไว้ ถ้ายาวกว่า 4 ไบต์ ก็ต้องมีหลายสถานะสำหรับแต่ละชื่อเพื่อแยกจากชื่ออื่นที่มี prefix ร่วมกันไม่ใช่หรือ
กฎระบุไว้ว่าแม้ตัวสร้างข้อมูลจะใช้ชุดชื่อสถานีตรวจวัดแบบคงที่ แต่โซลูชันใด ๆ ก็ต้องทำงานได้กับชื่อสถานีตรวจวัด UTF-8 ใด ๆ
แทนที่จะทิ้งการรันที่ช้าที่สุดและเร็วที่สุดแล้วใช้ค่าเฉลี่ยของอีกสามครั้ง ผมคิดว่าทิ้งสองครั้งที่ช้า หรือยอมรับค่าที่เร็วที่สุดไปเลยน่าจะดีกว่า
ผมไม่เห็นเหตุผลที่สมเหตุสมผลในการทิ้งผลการรันที่ดี
แต่ถ้าในตัวโปรแกรมมีสาเหตุของความไม่กำหนดแน่นอนแม้เพียงเล็กน้อย ซึ่งพบได้บ่อยกว่าที่คิด เวลาเร็วที่สุดก็มีโอกาสจะไม่เป็นตัวแทนที่ดี
บทความที่เกี่ยวข้อง https://tratt.net/laurie/blog/2019/minimum_times_tend_to_mis... เขียนไว้ดี
จากมุมมองที่เคร่งกับกติกา ตอนรันครั้งแรกอาจอยากให้เปิด ดีมอนเบื้องหลัง โหลดไฟล์ทั้งหมดขึ้นหน่วยความจำแล้วตรึงไว้ จากนั้นดึงแคชมาเตรียมไว้ล่วงหน้า เพื่อให้การรันครั้งถัด ๆ ไปแทบเหลือแค่การสแกนแบบเชิงเส้น
การคำนวณผลลัพธ์ล่วงหน้าตั้งแต่การรันครั้งแรกก็ดูเหมือนจะทำได้ ขึ้นกับว่าจะตีความกติกาให้ยืดได้แค่ไหน และอาจพาร์สตัวเลขไว้ล่วงหน้าเป็นรูปแบบที่หนาแน่นกว่า แล้วให้การรันครั้งต่อไปอ่านเป็นผลรวมสะสมได้ทันที
แม้จะไม่ตรงกับเจตนาของการแข่งขันเลย แต่ดูจากกติกาที่เห็นก็เหมือนไม่ได้ห้ามไว้
ถ้าไม่ชอบการคำนวณล่วงหน้า ก็ยังมีลูกเล่นอย่างการเรียงอินพุตล่วงหน้า พาร์สล่วงหน้า หรือบีบอัด·เรียงลำดับ·จัดวางหน่วยความจำแบบเรียงแล้วได้
สุดโต่งกว่านั้นคือแพตช์สคริปต์
calculate_timeให้คืนค่า 0 วินาที และคืนค่า 9999 ให้คู่แข่งก็ยังได้ตั้งแต่การไม่อ่านอินพุตเลยแล้วฮาร์ดโค้ดคำตอบเป็นบรรทัดเดียว ไปจนถึงการประมวลผลโดยสมมติว่าไม่รู้เนื้อหาไฟล์ มี พื้นที่สีเทาของการคำนวณล่วงหน้า อยู่สักพันล้านระดับ
มันอาจกลายเป็นการแข่งขันตัดสินว่าอะไรคือการคำนวณล่วงหน้าที่เป็นธรรมและอะไรไม่ใช่
นั่นจึงเป็นเหตุผลที่การแข่งขันแมชชีนเลิร์นนิงไม่เปิดเผยข้อมูลชุดสุดท้ายให้ผู้เข้าแข่งขันเห็น
มีระบุว่าการคำนวณต้องเกิดขึ้นตอนรันแอปพลิเคชัน และห้ามประมวลผลไฟล์ที่ใช้วัดผลในช่วง build แล้วฝังผลลัพธ์ลงในไบนารี
ผมสงสัยว่านี่เป็นปัญหาที่ถูกจำกัดด้วย ความเร็วดิสก์ อย่างเดียวหรือเปล่า ไม่แน่ใจว่าการปรับแต่งอย่าง SIMD หรือมัลติเธรดจะมีความหมายไหม
แม้จะขึ้นกับจำนวนสถานีตรวจวัดที่ต่างกันและวิธี lookup แฮช แต่เมื่อเทียบกับ I/O แล้วก็ยังสงสัยว่าจะวัดผลได้ชัดเจนแค่ไหน
ระบบที่ออกแบบโดยสมมติฮาร์ดแวร์สมัยใหม่ใช้ประโยชน์จากจุดนี้ และ redpanda.com ที่ผมทำงานอยู่ก็เป็นตัวอย่างหนึ่ง
การพาร์สเป็นส่วนใหญ่ของเวลาในการคำนวณ และเทคนิค SIMD อย่าง SWAR สำหรับค้นหาตัวคั่นก็ช่วยได้
ถ้าอยากดูการทำอัลกอริทึมแบบนี้ที่สะอาดตา Stringzilla น่าสนใจ: https://github.com/ashvardanian/StringZilla
ส่วนประเด็นที่ไฟล์จะถูกแคชไว้ในหน่วยความจำทั้งหมดหลังรันครั้งแรก ผมตอบไว้ที่นี่: https://news.ycombinator.com/item?id=38864034
โดยทั่วไปเซิร์ฟเวอร์มีเลน PCIe พอสำหรับเสียบ SSD แบบนี้ 15 ตัว ดังนั้นแบนด์วิดท์ I/O ของเซิร์ฟเวอร์จึงอยู่ในระดับใกล้เคียงกับแบนด์วิดท์หน่วยความจำ
เซิร์ฟเวอร์ที่แพงกว่าจะมีเลนที่เร็วกว่าและมากกว่า เช่น PCIe 5.0
ไฟล์นี้มี 1 พันล้านแถว เมื่อบีบอัดแล้วประมาณ 1GB และหลังจากการรันแรกที่ทิ้งไปก็จะอยู่ในหน่วยความจำ ดังนั้นในสถานการณ์นี้แบนด์วิดท์ I/O จึงไม่สำคัญ
ใน GitHub repository ระบุว่าแบบไม่บีบอัดมีขนาด 12GB ซึ่งก็ยังยืนยันได้ว่าแบนด์วิดท์ I/O ไม่ใช่เรื่องสำคัญ
ใจความคือดิสก์แทบไม่ค่อยเป็นคอขวด
ตัวอย่างเช่น บน Linux ถ้าใช้ ext2 ก็มีโอกาสสูงที่จะ cache ไฟล์ทั้งหมดหลังรันครั้งแรก แต่บน ZFS อาจไม่เป็นเช่นนั้น
แบบนั้นตัวเลขจะออกมาเรียงจากหลักต่ำไปหลักสูง แล้วตามด้วยตัวคั่นและสตริง จากนั้นทำต่อไปจนเจอ EOF หรืออักขระขึ้นบรรทัดใหม่
ตามกติกา submission ต้องทำงานถูกต้องกับทุกอินพุต แต่ดูเหมือนจะหมายความว่าสามารถ และคงควร ปรับจูนให้เข้ากับอินพุตเฉพาะที่สร้างด้วย
create_measurements.shได้ตัวอย่างเช่น submission ที่ใช้ perfect hash function ให้เหมาะกับชุดสถานีตรวจวัดที่กำหนดมาก็จินตนาการได้
เพื่อป้องกัน การปรับแต่งแบบ overfit
ไบต์ที่มากกว่า 127 หมายถึงอักขระ UTF-8 แบบหลายไบต์
ลองเทียบความเร็ว awk กับ Java เล่น ๆ
เป็นสคริปต์ที่ใช้
awk -F';'สะสมผลรวม จำนวน ค่าต่ำสุด ค่าสูงสุดแยกตามสถานีตรวจวัด แล้วคำนวณค่าเฉลี่ยใน END ก่อนพิมพ์ออกมาวิธีคือใช้
file_fdwทำไฟล์ CSV เป็นตารางภายนอก แล้วคำนวณMIN,AVG,MAXด้วยGROUP BY station_nameใน
clickhouse localอ่านfile('measurements.txt', 'CSV', 'station String, t Float32')แล้วจัดกลุ่มตามสถานีตรวจวัดเพื่อหาmin,max,avgโดยรันด้วยmax_threads = 8เวลาส่วนใหญ่ใช้ไปกับการพาร์สไฟล์
sumอาจมีค่าค่อนข้างใหญ่ จึงควรใช้ ค่าเฉลี่ยแบบสตรีมมิงเช่นวิธี
new_mean = ((n*old_mean)+temp)/(n+1)เป็นชาเลนจ์ที่น่าสนใจ แต่น่าเสียดายที่จำกัดเฉพาะ Java รอคอยวันที่ผู้คนเริ่มสร้าง JVM bytecode ด้วยมือกันเอง
[0] https://github.com/gunnarmorling/1brc/discussions
สนุกดี ให้ความรู้สึกเหมือนงานต่อหลัง Advent of Code
ถ้าจะเปรียบเทียบระหว่างภาษาอย่างเป็นธรรม ก็ควรรวม make และเวลา build ด้วย ผมไม่ได้ใช้ Java/Maven มาหลายปีแล้ว แต่พอเห็น
./mvnw clean verifyดาวน์โหลดต่อเนื่องมาถึง 2 นาที ก็ทำให้นึกเหตุผลออกอีกครั้งและถ้าเป็นเครื่องมือ build แบบ incremental compilation แล้ว Gradle เร็วกว่า
ควรบวกสัดส่วนที่เหมาะสมของเวลาที่ใช้เรียนเขียนโปรแกรมเข้าไปด้วย
ในชาเลนจ์แบบนี้ เวอร์ชันที่ไร้เดียงสามาก ๆ มีโอกาสชนะสูง ซึ่งผมคิดว่าไม่เพียงไม่สมจริง แต่ยังผิดจากเจตนาของชาเลนจ์ด้วย
cleanเหมือนทิ้ง cache แล้วมาบอกว่าช้า
เขียนไว้ว่าห้ามใช้ dependency ภายนอก
ในวิชา C ของมหาวิทยาลัยเทคนิคเช็กเคยมีงานที่คล้ายกันมาก
งานส่งของนักศึกษาทุกคนถูกประเมินบน ตารางอันดับ อย่างต่อเนื่อง และนักศึกษาจำนวนมากใช้เวลาหลายสิบชั่วโมงในการ optimize เพื่อให้ได้คะแนนพิเศษสำหรับผลการเรียนที่ดีกว่า หรือเอาจริง ๆ ก็คือคะแนนสถานะ
อันดับ 1 ใช้เวลา 6 วินาทีเอง.. น่าทึ่งมาก