รูปแบบการเข้าถึงข้อมูลที่ทำให้ CPU หงุดหงิดได้จริง ๆ
(blog.weineng.me)- แม้จะเป็นลูปบวกค่าจำนวนเต็มแบบเดียวกัน แต่เพียงเปลี่ยนลำดับการเข้าถึง เวลารันก็เปลี่ยนไปมาก และการทดลองแสดงให้เห็นว่าสามารถสร้างลำดับเรียงสับเปลี่ยนที่ ช้ากว่าการเข้าถึงแบบสุ่มมากกว่า 30% ได้
- การเข้าถึงแบบเชิงเส้นเร็วที่ 132.75 ล้านไซเคิล ขณะที่การเข้าถึงแบบสุ่มทำให้ CPU คาดเดาตำแหน่งถัดไปได้ยาก จึงช้าลงถึง 1.57 พันล้านไซเคิล
- เมื่ออาศัยขอบเขตของ cache line และหน้าเพจเพื่อเว้นระยะการเข้าถึง ความสามารถของ prefetcher และการนำ cache กลับมาใช้ซ้ำจะลดลง และด้วยคุณสมบัติของ set-associative cache ทำให้ความจุ L1d ที่ใช้งานได้จริงลดเหลือราว 768B
- เมื่อตั้ง page stride เป็น 8 ความเป็น locality ของ cache line ของ PTE ก็เสียไปด้วย ทำให้วัดได้ 2.06 พันล้านไซเคิล และกลายเป็นรูปแบบที่แย่กว่าการสับแบบสุ่มธรรมดา
- รูปแบบประมาณค่าที่จงใจให้เกิดการชนกันของ DRAM bank/row ช้าลงอีกเล็กน้อยเป็น 2.08 พันล้านไซเคิล แต่การแมป DRAM จาก physical address ขึ้นกับแพลตฟอร์ม จึงควบคุมได้ไม่สมบูรณ์
เงื่อนไขการทดลองและเกณฑ์อ้างอิง
- เป้าหมายคือเปลี่ยนเฉพาะลำดับเรียงสับเปลี่ยน
positionsในฟังก์ชันaccumulatorที่ตายตัวซึ่งใช้บวกจำนวนเต็มในอาร์เรย์dataเพื่อให้ได้ เวลารันที่ช้าที่สุด - ไม่นับเวลาสร้าง
positionsและวัดเฉพาะเวลารันของฟังก์ชันสะสมผลด้วยตัวนับไซเคิลrdtsc - ขนาดข้อมูลคือจำนวนเต็ม
uint32_tจำนวน2^26ค่า- ใช้ทั้งหมด 65,536 เพจ
- แต่ละเพจมีขนาด 4,096 ไบต์
- ในแต่ละเพจมีจำนวนเต็ม 1,024 ค่า
- huge pages ถูกปิดใช้งาน
- เครื่องที่ใช้วัดเป็นระบบ x86_64 บน Intel Core Ultra 7 268V
- CPU 8 ตัว
- L1d รวม 320KiB, L1i รวม 512KiB
- L2 รวม 14MiB
- L3 12MiB
- โค้ดทั้งหมดอยู่ใน GitHub repository และตัวอย่างการรันคือ
g++ -std=c++2a -O3 slowest.cc && taskset -c 3 sudo ./a.out - ลูปหลักเป็นรูปแบบง่าย ๆ ที่บวก
data[pos]ตามลำดับที่positions[i]ชี้ไป
ความต่างระหว่างการเข้าถึงแบบเชิงเส้นกับแบบสุ่ม
- รูปแบบ
linearที่ง่ายที่สุดคือpositions[i] = iซึ่งเข้าถึงอาร์เรย์จากต้นไปท้ายตามลำดับ - การเข้าถึงแบบเชิงเส้นใช้เวลา 132,752,394 ไซเคิล และถือว่าเร็วที่สุดกลุ่มหนึ่ง เพราะ CPU ถูกปรับแต่งมาอย่างมากสำหรับการเข้าถึงตามลำดับ
- หากใช้
fisher_yates_shuffleเพื่อสร้างpositionsเป็นลำดับสุ่ม CPU จะคาดเดาข้อมูลที่จะถูกเข้าถึงถัดไปได้ยากขึ้น - การเข้าถึงแบบสุ่มใช้เวลา 1,572,108,618 ไซเคิล ช้ากว่าแบบเชิงเส้นมากกว่า 10 เท่า
- จากนั้นการทดลองจึงไปต่อเพื่อดูว่าสามารถจงใจสร้างลำดับที่แย่กว่าสุ่มได้หรือไม่
เว้นระยะการเข้าถึงตาม cache line และหน้าเพจ
- รูปแบบที่ทำให้แย่ลงแบบแรกคือจัด
positionsให้การเข้าถึงต่อเนื่องเว้นห่างกันทีละ cache line เสมอ - รูปแบบนี้ใช้จำนวนเต็มขนาด 4 ไบต์เพียง 1 ค่าใน cache line ขนาด 64 ไบต์ ก่อนจะย้ายไป cache line ถัดไป
- เมื่อวนกลับมายัง cache line เดิม ข้อมูลที่พอจะนำกลับมาใช้ซ้ำได้มีโอกาสสูงที่จะถูกไล่ออกจาก cache ไปแล้ว
- การเข้าถึงแบบเว้นทีละ cache line ใช้เวลา 718,804,156 ไซเคิล ช้ากว่าการสแกนเชิงเส้นมากกว่า 4 เท่า
- แต่ถึงอย่างนั้น hardware prefetcher ก็ยังสามารถตรวจจับรูปแบบสตรีมมิงง่าย ๆ ของ
dataและดึง cache line ในอนาคตมาไว้ล่วงหน้าได้ - hardware data prefetcher ของ Intel หลายรุ่นไม่ทำ prefetch ข้ามขอบเขตเพจ 4KiB
- รูปแบบถัดไปจึงขยายระยะการเข้าถึงให้ห่างกันทีละ ทั้งเพจ แทนที่จะเป็น cache line
- แต่ละเพจมีขนาด 4,096 ไบต์
- เข้าถึง offset เดียวกันของแต่ละเพจก่อน ในรูป
page_index * elements_per_page + element_index
- การเข้าถึงแบบเว้นทีละเพจใช้เวลา 1,411,153,154 ไซเคิล ซึ่งช้าลงมาก
set-associative cache และระยะการนำกลับมาใช้ซ้ำ
- แคช L1d ต่อคอร์ของเครื่องทดลองมีขนาด 48KB แบบ 12-way และมี 64 set
- เนื่องจาก L1d มี 64 set ดังนั้นที่อยู่
AและA + 4096ไบต์จะถูกแมปไปยัง L1d set เดียวกัน- 4,096 ไบต์เท่ากับ
64 sets * 64-byte cache line
- 4,096 ไบต์เท่ากับ
- stride ระดับเพจทำให้ลูปชั้นในแต่ละรอบไม่กระจายตัวไปทั่วทั้ง 64 set แต่กลับไปชน set เดิม ซ้ำ ๆ
- ในหนึ่ง set มีได้เพียง 12 way ดังนั้นหากมี cache line ที่ยัง active แข่งกันเกิน 12 รายการ CPU จะต้องขับ cache line ออกและโหลดกลับซ้ำไปมา
- แม้ L1d จะมีความจุรวม 48KB แต่ในรูปแบบการเข้าถึงนี้ ความจุ L1d ที่ใช้ประโยชน์ได้จริงมีเพียงระดับ 768B ซึ่งคือ
12 ways * 64B - รูปแบบ
separated_by_a_pageจะเข้าถึง 65,536 cache line ก่อนจะกลับมาที่ cache line เดิม ทำให้ระยะการนำ cache line กลับมาใช้ซ้ำเท่ากับ 65,536 - รูปแบบ
separated_by_a_page_and_cachelineที่แยกถึงระดับ cache line ภายในเพจด้วย จะเพิ่มระยะการใช้ซ้ำเป็น65536 pages * 4096 page size / 64 cacheline size - แต่ผลลัพธ์ออกมาเกือบเท่ากับการเข้าถึงระดับเพจ คือ 1,408,519,172 ไซเคิล
- การทดลองรันบนคอร์ 3 และคอร์นี้มี L2 ขนาด 2.5MB กับ L1 ขนาด 48KB
- การวนครบ 65,536 เพจหนึ่งรอบหมายถึงมีการแตะข้อมูล 4MB
- ขนาดนี้มากกว่าความจุ private L1/L2 ของคอร์นั้น
- จึงมีโอกาสต่ำที่ cache line ที่ต้องใช้ครั้งถัดไปจะยังคงอยู่ใน private cache
- การใช้ประโยชน์จาก private cache ซ้ำพอจะคาดหวังได้ก็ต่อเมื่อระยะการใช้ซ้ำของ cache line ต่ำกว่าประมาณ 4 หมื่น หรือราว
((2560+48)*1024/64)
กวนทั้ง page stride, PTE และ DRAM
- รูปแบบดัดแปลงถัดไปคือ
separated_by_stride_pages_and_cachelineซึ่งเข้าถึงโดยเว้นไม่ใช่เพจติดกัน แต่เว้นทุก N เพจ - เมื่อลองหลายค่า stride พบว่า page stride เท่ากับ 8 ให้ผลแย่ที่สุด และช้ากว่าการเข้าถึงแบบสุ่ม
- เมื่อรันเดี่ยวด้วย
-DSTRIDE=8จะใช้เวลา 2,058,425,640 ไซเคิล - หนึ่งในเหตุผลที่เป็นไปได้ของจุดพีกที่ stride 8 คือเรื่อง การแปลงที่อยู่
- การเข้าถึง virtual address ต้องให้ MMU แปลงเป็น physical address
- การแปลงนี้ใช้ page table entry (PTE)
- PTE มีขนาด 8 ไบต์ และหนึ่ง cache line บรรจุ PTE ได้ 8 รายการ
- เมื่อใช้ stride 8 เพจ ดูเหมือนว่าทุกครั้งจะต้องดึงไม่เพียง cache line ของข้อมูล แต่ยังรวมถึง cache line แยกต่างหากสำหรับ page mapping ด้วย
- ขั้นสุดท้ายคือความพยายามรบกวนการทำงานของ DRAM controller ด้วย
- DRAM ประกอบด้วย channel, rank, chip, bank, row และ column
- ภายใน bank มีหลาย row
- หากต้องการเข้าถึง row หนึ่ง จะต้อง activate row นั้นและคัดลอกไปยัง row buffer
- หากใน bank เดียวกันต้องการเข้าถึงอีก row หนึ่ง จะต้องปิด row เดิมด้วย precharge แล้ว activate row ใหม่
- หากสลับเข้าถึงหลาย row ภายใน bank เดียวกัน จะเกิด row-buffer conflict และทำให้ DRAM controller ตอบสนองช้าลง
- ในทางกลับกัน หากเข้าถึง row ที่เปิดอยู่แล้วจะเป็น row-buffer hit และหากใช้งานหลาย bank พร้อมกัน memory controller ก็สามารถซ้อนงานบางส่วนได้
- กลยุทธ์ในการสร้างรูปแบบช้าคือดังนี้
- แปลง virtual page number เป็น physical frame number (PFN) ผ่าน page table
- คงค่า page offset ไว้เพื่อประกอบเป็น physical address
- ตีความ physical address เป็น DRAM channel, rank, bank group, bank, row และ column
- เข้าถึงคนละ row ใน bank เดียวกันซ้ำ ๆ เพื่อบังคับให้เกือบทุกคำขอต้อง precharge และ activate
- แต่การแมประหว่าง physical address ไปยัง DRAM channel, rank, bank และ row ไม่ได้มีเอกสารเปิดเผย และขึ้นกับแพลตฟอร์ม
- จากงานวิจัย DRAMA และการทดลองภายใน จึงประมาณค่าด้วย bank group 4 กลุ่ม, กลุ่มละ bank 4 ตัว และ
DRAM_ROW_SHIFT = 18 - รูปแบบที่คำนึงถึง DRAM bank/row conflict ด้วย ใช้เวลา 2,082,308,014 ไซเคิล ซึ่งช้ากว่า stride 8 อย่างสม่ำเสมอเล็กน้อย แต่ช่องว่างไม่มาก
- การที่ไม่สามารถสร้าง row conflict แบบสมบูรณ์ได้ น่าจะเกิดจากข้อจำกัดหลายอย่าง
- การประมาณค่า bank group hash, bank hash และ row shift อาจไม่แม่นยำ
- stride 8 เพจหมายถึงการเข้าถึงห่างกันราว 32KB จึงยากที่จะยังอยู่ใน DRAM row เดียวกัน
- เนื่องจาก Intel ใช้ bank hashing ในทางปฏิบัติจึงอาจกระจายไปใช้หลาย bank พร้อมกัน
- ตัวอย่างผลการรันทั้งหมดมีดังนี้
linear: 132,752,394fisher_yates_shuffle: 1,572,108,618separated_by_a_cacheline: 718,804,156separated_by_a_page: 1,411,153,154separated_by_a_page_and_cacheline: 1,408,519,172stride=8 separated_by_stride_pages_and_cacheline: 2,058,425,640separated_by_stride_bank_conflicts_and_cacheline: 2,082,308,014
- เมื่อทำให้ cache, prefetcher, การใช้ cache line ซ้ำ, การเข้าถึง PTE และคุณลักษณะของ DRAM bank/row แย่ลงตามลำดับ ก็สามารถสร้างรูปแบบการบวกที่ ช้ากว่าการเข้าถึงแบบสุ่ม 33% ได้
- ยังมีวิธีทำให้การสะสมผลช้าลงได้อีก เช่นการสลับเข้าโหมดประหยัดพลังงาน แต่สิ่งนั้นเป็นประเด็นแยกจากการทดลองที่เปลี่ยนเฉพาะรูปแบบการเข้าถึง
1 ความคิดเห็น
ความคิดเห็นบน Lobste.rs
อ่านแล้วสนุกดี เพราะรู้สึกว่า งานวิจัยพัฒนาภายในของ Windows 11 หน้าตาเป็นแบบนี้สินะ /s
ผมได้เรียนรู้เรื่องนี้ตอนสร้าง https://github.com/ob/cache
ประโยคหนึ่งบอกว่าเห็นเทคนิคที่ใช้สร้างตัวเลข latency ครั้งแรกในแบบฝึกหัด 5.2 หน้า 476 ของ
Computer Architecture: A Quantitative Approachส่วนอีกประโยคบอกว่าไอเดียมาจาก วิทยานิพนธ์ปริญญาเอกของ Rafael Héctor Saavedra‑Barreraอยากตรวจสอบว่าพูดถึงคนละเรื่องกัน ขัดแย้งกันเอง หรือเป็นเรื่องเดียวกันและ Saavedra-Barrera ถูกอ้างถึงใน CA:AQA
ยังไม่ชัดด้วยว่าโปรแกรม Claude ถูกกันออกจากการเขียน README หรือไม่ และมันก็แสดงเป็นผู้มีส่วนร่วมในรีโปด้วย เลยอยากยืนยันก่อนว่า reference นั้นมีอยู่จริงหรือเปล่า
ถ้าอยากทดลอง การเข้าถึงลำดับชั้นแคชแบบกำหนดเอง ผมขอแนะนำซิมูเลเตอร์ที่ผมทำไว้ด้วย
https://github.com/TheCloudlet/Stratum
สงสัยว่าแยก overhead ของการคำนวณอินเด็กซ์ออกจากต้นทุนการเข้าถึงจริงอย่างไร
positionsไปด้วย ผมใช้แมโครที่รันresetกับarrange_positionsก่อน แล้วใส่แค่accumulator(data, positions)ไว้ระหว่างrdtsc_start()กับrdtsc_end()จากนั้นพิมพ์ผลลัพธ์และตรวจสอบ
sum == linear_scan_sumพร้อมใช้do_not_optimize(sum)เพื่อกันไม่ให้การปรับแต่งคอมไพเลอร์ตัดทิ้งถ้าจะลองด้วย รูปแบบการเข้าถึงข้อมูล แบบที่อาจารย์สอนมา ก็คงต้องเริ่มจากสร้างคลาส
SafeNumber.javaแล้วต้องมีเมมเบอร์add(SafeNumber b)เทอมหน้าคงได้เรียนสถาปัตยกรรมที่เอาสิ่งนี้ไปวางหลัง Redis เพื่อให้ได้ประสิทธิภาพระดับเว็บสเกล
ต้องขอบคุณ Claude ที่ทำให้ผมลองย้าย benchmark ไปเป็น Java ได้ โดย
Java SafeNumber[]ช้ากว่า C++uint32_t[]ประมาณ 3.6 เท่า ในการเข้าถึงแบบเชิงเส้น และในการสับแบบ Fisher-Yates ช้ากว่า C++ แบบเชิงเส้น 52.2 เท่าถ้าดูเวลาแบบ absolute สำหรับ 67 ล้านองค์ประกอบ C++ แบบเชิงเส้นคือ 10,258,584ns, Java แบบเชิงเส้น 36,740,667ns, C++ แบบสับ 264,856,042ns, Java แบบสับ 535,724,208ns และน่าประทับใจที่มันอยู่ในระดับ “แค่ 4 เท่า” กว่าที่คิด