• ใน Linux 7.0 มีการถอดโหมด preemption PREEMPT_NONE ซึ่งเคยเป็นค่าเริ่มต้นของเซิร์ฟเวอร์แบบเดิมออก ส่งผลให้เกิด regression ด้านประสิทธิภาพอย่างรุนแรงบนฮาร์ดแวร์ชุดเดิม โดย throughput ของ PostgreSQL ลดลงเหลือครึ่งหนึ่ง
  • วิศวกรของ AWS รัน pgbench บนเครื่อง Graviton4 แบบ 96-vCPU และพบว่าเมื่อเทียบกับ Linux 6.x แล้ว บน Linux 7.0 จำนวนทรานแซกชันต่อวินาทีลดลงจาก 98,565 เหลือ 50,751 และ CPU 55% ถูกใช้ไปกับฟังก์ชัน spinlock เดียว
  • spinlock ที่ใช้ปกป้องการเข้าถึง shared buffer pool ของ PostgreSQL เมื่อรวมกับ minor page fault ของหน้าเมมโมรีขนาด 4KB จะทำให้ถ้า scheduler preempt ระหว่างที่ยังถือ lock อยู่ backend ที่รอทั้งหมดจะหมุนกิน CPU ไปเปล่า ๆ
  • เมื่อเปิดใช้ Huge Pages (2MB หรือ 1GB) จำนวน page fault ที่เป็นไปได้จะลดจาก 31 ล้านครั้งเหลือเพียงหลักหมื่นถึงหลักร้อย จึงแก้ปัญหา regression นี้ได้
  • ฝั่งเคอร์เนลเสนอให้ใช้ Restartable Sequences (rseq) แต่ชุมชน PostgreSQL มองว่าการที่การอัปเกรดเคอร์เนลทำให้ประสิทธิภาพถดถอยแบบนี้ขัดกับหลักการที่ว่า "ไม่ทำให้ userspace พัง"

อาการของปัญหา

  • วิศวกร AWS ชื่อ Salvatore Dipietro รัน pgbench บนโปรเซสเซอร์ 96-vCPU Graviton4 โดยทดสอบโหลดแบบขนานสูงด้วย scale factor 8,470 (ตารางประมาณ 847 ล้านแถว), ไคลเอนต์ 1,024 ตัว, และ 96 threads
  • throughput ลดลงเกือบครึ่ง จาก 98,565 TPS บน Linux 6.x เหลือ 50,751 TPS บน Linux 7.0
  • ผลการทำ profiling ด้วย perf พบว่า 55.60% ของเวลา CPU ถูกใช้ภายในฟังก์ชัน s_lock
    • เส้นทางการเรียก: StartReadBufferGetVictimBufferStrategyGetBuffers_lock

Preemption คืออะไร

  • การที่ scheduler ของ OS หยุด thread ที่กำลังทำงานอยู่แล้วส่ง CPU ให้ thread อื่น คือ preemption
  • ก่อน Linux 7.0 มีอยู่ 3 ตัวเลือก
    • PREEMPT_NONE: แทบจะไม่หยุด thread จนกว่า thread จะยอมคืน CPU เอง (syscall, I/O block, sleep) เป็นค่าเริ่มต้นของเซิร์ฟเวอร์แบบดั้งเดิม ทำให้มี context switch น้อยและ throughput สูง
    • PREEMPT_FULL: สามารถหยุด thread ที่กำลังรันอยู่ได้เกือบทุกจุดที่ปลอดภัย ช่วยลดเวลา response แต่เพิ่ม overhead จาก context switch เป็นค่าเริ่มต้นของเดสก์ท็อปแบบดั้งเดิม
    • PREEMPT_LAZY: ทางสายกลางที่เพิ่มเข้ามาใน Linux 6.12 โดยรอจุดเปลี่ยนตามธรรมชาติแต่ยังยอมให้ preempt ได้เมื่อจำเป็น ออกแบบมาให้พฤติกรรมด้าน throughput ใกล้เคียง PREEMPT_NONE
  • ใน Linux 7.0 มีการ ถอด PREEMPT_NONE ออกจากสถาปัตยกรรม CPU รุ่นใหม่ จึงเหลือเพียง PREEMPT_FULL และ PREEMPT_LAZY
    • แม้ PREEMPT_LAZY จะทำหน้าที่แทนได้สำหรับซอฟต์แวร์เซิร์ฟเวอร์ส่วนใหญ่ แต่กับ PostgreSQL กลับเกิด ความแตกต่างที่ร้ายแรง

การจัดการหน่วยความจำของ PostgreSQL

  • PostgreSQL ใช้ data page ขนาดคงที่ (ค่าเริ่มต้น 8KB) เป็นหน่วยเก็บข้อมูลพื้นฐาน โดยทั้งแถวของตาราง, โหนดของ B-tree index, metadata และอื่น ๆ ล้วนเก็บอยู่ในหน้าเหล่านี้
  • เพื่อลดการอ่านดิสก์ จึงมีการแคช data page ที่เพิ่งอ่านล่าสุดไว้ในพื้นที่หน่วยความจำร่วมขนาดใหญ่ชื่อ shared buffer pool
  • เมื่อมีไคลเอนต์เชื่อมต่อ จะมีการสร้าง backend process เฉพาะขึ้นมา และหาก page ที่ต้องใช้ยังไม่อยู่ใน buffer pool ก็ต้องอ่านจากดิสก์แล้วหาบัฟเฟอร์ว่างหรือบัฟเฟอร์ที่ไล่ออกได้
    • ฟังก์ชันที่รับผิดชอบการเลือกบัฟเฟอร์นี้คือ StrategyGetBuffer

spinlock ของ PostgreSQL

  • spinlock คือกลไก lock ที่ไม่หลับรอ แต่จะวนลูปตรวจสอบต่อเนื่องขณะรอ lock
    • ใน critical section ที่สั้นมาก การหมุนรอมักมีประสิทธิภาพกว่าการทำให้ thread หลับแล้วปลุกกลับมา
  • สมมติฐานสำคัญคือ thread ที่ถือ lock จะปล่อย lock ได้อย่างรวดเร็วมาก
  • StrategyGetBuffer ใช้ global spinlock เพียงตัวเดียว เพื่อปกป้องการเลือกบัฟเฟอร์
    • ในสภาพแวดล้อม 96-vCPU และไคลเอนต์ 1,024 ตัว backend ทั้งหมดต้องแย่ง lock เดียวกัน

หน่วยความจำเสมือนและ TLB

  • ทุก process ใช้ virtual memory address และฮาร์ดแวร์จะแปลงเป็น physical address ผ่าน page table ที่เป็นโครงสร้างต้นไม้หลายชั้น
  • การไล่ page table ทุกครั้งนั้นช้า CPU จึงมี TLB (Translation Lookaside Buffer) สำหรับแคชผลการแปลงล่าสุด
    • ถ้า TLB hit จะเข้าถึงได้เร็ว แต่ถ้า TLB miss จะต้องทำ page-table walk จึงเสียเวลาเพิ่ม
  • Linux ใช้หลักการ lazy allocation โดยเมื่อจอง virtual memory แล้วจะยังไม่ผูกกับ physical page จริงจนกว่าจะมีการเข้าถึงครั้งแรก
    • เมื่อเข้าถึงครั้งแรกจะเกิด minor page fault: เคอร์เนลจะจัดสรร physical page และบันทึก mapping ซึ่งช้ากว่าการอ่าน/เขียนปกติระดับหลายไมโครวินาที

ปัญหาของหน้า 4KB

  • ใน benchmark ตั้งค่า shared_buffers ไว้ที่ 120GB ซึ่งเมื่อใช้หน้าเมมโมรีขนาด 4KB จะเท่ากับมี memory page ราว 31 ล้านหน้า หรือก็คือมี first-access page fault ที่อาจเกิดขึ้นได้ 31 ล้านครั้ง
  • ใน benchmark ระยะยาวที่ใช้ shared buffer pool ขนาด 120GB พื้นที่หน่วยความจำใหม่ยังคงเข้าสู่ working set อยู่เรื่อย ๆ ทำให้ page fault ไม่ได้เกิดเฉพาะตอนเริ่มต้น แต่เกิดต่อเนื่องตลอด
  • หาก StrategyGetBuffer เข้าใช้ shared memory ระหว่างที่ถือ spinlock อยู่ และพื้นที่นั้นยังไม่ถูกแมป จะเกิด minor page fault
  • PREEMPT_NONE (ก่อน Linux 7.0): แม้ backend A จะเข้าไปใน page-fault handler ก็ยังมีโอกาสน้อยที่จะถูก schedule out ก่อน fault จะถูกจัดการเสร็จ เพราะหลีกเลี่ยงจุด reschedule แบบสมัครใจ ทำให้เวลารอนานขึ้นแต่ความเสียหายยังจำกัด
  • PREEMPT_LAZY (หลัง Linux 7.0): scheduler สามารถ preempt backend A ภายใน page-fault handler แล้วสลับไป schedule process อื่น ได้ แม้การจัดการ fault จะเสร็จแล้วก็ยังต้องรอเพิ่มอีก t จนกว่า scheduler จะคืนสิทธิ์การทำงาน
    • เวลารอเพิ่มนี้ไม่ใช่แค่ t ธรรมดา แต่ขยายเป็น จำนวน backend ทั้งหมดที่กำลังหมุนรอ × t ในรูปของการสูญเปล่า CPU
    • บนเครื่อง 96-vCPU ที่มี backend หลายร้อยตัว ผลคูณนี้ร้ายแรงมาก จนสุดท้าย CPU 56% ถูกใช้ไปกับ s_lock

การแก้ปัญหาด้วย Huge Pages

  • เมื่อ shared_buffers เท่ากับ 120GB หากเปลี่ยน ขนาดของ memory page จำนวน page fault ที่เป็นไปได้จะลดลงอย่างมาก
    • หน้า 4KB: ~31,000,000 page fault ที่อาจเกิดขึ้น
    • 2MB Huge Pages: ~61,440
    • 1GB Huge Pages: ~120
  • การเพิ่มขนาด page ไม่ได้ช่วยแค่ลด page fault แต่ยัง ลดแรงกดดันต่อ TLB ด้วย เพราะใช้ TLB entry น้อยลงมากเพื่อครอบคลุมหน่วยความจำเท่าเดิม จึงลดทั้ง TLB miss และ page-table walk
  • ทำให้ StrategyGetBuffer ไม่เกิด fault ระหว่างถือ lock ผู้ถือ lock จึงทำงานเสร็จได้เร็ว และ backend อื่นรอเพียงระดับไมโครวินาทีแทนมิลลิวินาที จึง แก้ regression ได้
  • ใน PostgreSQL การตั้งค่า huge pages ควบคุมด้วยพารามิเตอร์ huge_pages
    • รองรับ 3 ค่า คือ off, on, try (ค่าเริ่มต้น)
    • try จะใช้ huge pages ถ้าทำได้ และถ้าทำไม่ได้จะ fallback ไปใช้ 4KB แบบเงียบ ๆ จึงมีความเสี่ยงที่จะไม่รู้ว่าตั้งค่าผิด
    • หากตั้งเป็น on เมื่อไม่สามารถใช้ huge pages ได้ PostgreSQL จะเริ่มต้นไม่สำเร็จ ทำให้รู้ปัญหาได้ทันที
  • ข้อแลกเปลี่ยน: huge pages ใช้วิธีจัดสรรและจองล่วงหน้า ดังนั้นแม้ PostgreSQL จะไม่ได้ใช้ทั้งหมด หน่วยความจำส่วนนั้นก็จะไม่สามารถนำไปใช้กับส่วนอื่นของระบบได้ และถ้าใช้เพียงบางส่วนของ page ที่เหลือก็จะสูญเปล่า แต่ใน production ที่ใช้ shared_buffers ขนาดใหญ่ โดยมากถือว่าคุ้มที่จะยอมรับข้อแลกเปลี่ยนนี้

สิ่งที่จะเกิดขึ้นต่อไป

  • Peter Zijlstra วิศวกรเคอร์เนลของ Intel ผู้มีส่วนออกแบบการเปลี่ยนแปลงด้าน preemption เสนอให้ PostgreSQL ใช้ Restartable Sequences (rseq)
    • rseq เป็นฟีเจอร์ของเคอร์เนล Linux ที่ทำให้โค้ด userspace ตรวจจับได้ว่ามีการ preempt หรือ migration ระหว่าง critical section หรือไม่ และสามารถเริ่มช่วงนั้นใหม่ได้
    • หากนำ rseq มาใช้กับเส้นทาง spinlock ของ PostgreSQL ก็อาจหลีกเลี่ยงสถานการณ์ที่ผู้ถือ lock ถูก preempt แล้วทำให้ backend ที่รอทั้งหมดล่าช้าได้
  • ปฏิกิริยาจากชุมชน PostgreSQL เป็นไปในทางลบ
    • ยากที่จะยอมรับว่าต้องนำฟีเจอร์เคอร์เนลเพิ่มเติมมาใช้เพื่อเรียกคืนประสิทธิภาพที่เคยได้มา "ฟรี" ก่อน Linux 7.0
    • และมองว่านี่ขัดกับหลักการเก่าแก่ของเคอร์เนลที่ว่า "ไม่ทำให้ userspace พัง" (ซอฟต์แวร์ที่ทำงานปกติก่อนอัปเกรดเคอร์เนล ก็ควรยังทำงานปกติหลังอัปเกรด)

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น