3 คะแนน โดย GN⁺ 2024-03-18 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • Fedora และ Ubuntu 24.04 LTS เปลี่ยนกลับมาสร้างคอมโพเนนต์ของระบบปฏิบัติการอย่าง libc โดย รวมเฟรมพอยน์เตอร์ไว้เป็นค่าเริ่มต้น ทำให้การทำโปรไฟล์บน Linux และ Flame Graph มองเห็นสแตกได้สมบูรณ์ขึ้น
  • หากไม่มีเฟรมพอยน์เตอร์ profiler อาจหยุด stack walking ที่ชั้นไลบรารีอย่าง libc หรือเดินตามเฟรมผิด ทำให้ CPU และ Off-CPU Flame Graph เสียรูป
  • การเปลี่ยนแปลงของ gcc บน i386 ในปี 2004 เป็นการเลือกเพื่อให้ได้รีจิสเตอร์เพิ่มอีกหนึ่งตัว แต่เมื่อแนวทางนี้แพร่ไปถึง x86-64 ก็ทิ้งต้นทุนระยะยาวไว้กับ system profiler และ observability ที่อิง eBPF
  • จากประสบการณ์ใช้งานกับ Java และ libc ที่ Netflix ต้นทุนมักอยู่ที่ ต่ำกว่า 1% โดยบางรายงานอยู่ที่ 1~2% และในไมโครบेंช์มาร์กเฉพาะทางหรือเวิร์กโหลดผิดปกติอาจขึ้นไปถึง 10%
  • แม้จะมีทางเลือกอย่าง LBR, DWARF, eBPF stack walker, ORC, SFrames และ Shadow Stacks แต่วิธีที่ใช้งานได้จริงที่สุดตอนนี้ในการหาจุดปรับปรุงประสิทธิภาพในโปรดักชัน คือเปิดใช้เฟรมพอยน์เตอร์เป็นค่าเริ่มต้น

ทำไม Flame Graph ถึงเสียรูป

  • CPU Flame Graph อาจดูเหมือนปกติจากภายนอก แต่ในระบบที่คอมไพล์ libc โดยไม่มีเฟรมพอยน์เตอร์ ตัวอย่างบางส่วนอาจไปรวมผิดที่อยู่เหนือ [unknown]
    • ในตัวอย่าง มี 15% ของตัวอย่างทางฝั่งซ้ายที่อยู่ผิดตำแหน่ง และเฟรมของแอปพลิเคชันหายไป
    • profiler เดินผ่านเฟรมของเคอร์เนล ไปถึง syscall และ libc syscall wrapper แล้วล้มเหลวในการ resolve สัญลักษณ์ของเฟรมถัดไป
  • สาเหตุคือการปรับแต่งของคอมไพเลอร์ทำให้ รีจิสเตอร์เฟรมพอยน์เตอร์ ไม่ได้ถูกใช้เป็นจุดอ้างอิงของสแตกเฟรม แต่ถูกนำไปเก็บข้อมูลแทน
    • profiler ไม่สามารถรู้ได้ว่าค่านั้นเป็นเพียงตัวเลขธรรมดาหรือไม่ จึงพยายามตีความเป็นที่อยู่ของฟังก์ชัน
    • ถ้าค่านั้นไม่ได้ชี้ไปยังเฟรมถัดไป การทำ stack walking จะหยุดลง
    • ถ้าบังเอิญดูเหมือนพอยน์เตอร์ที่ใช้ได้ ก็อาจเกิด junk frame ที่ไม่ถูกต้อง
    • ถ้าค่าดังกล่าวชี้กลับมาที่ตัวเอง ก็อาจเกิดกอง junk frame ไปจนชนขีดจำกัดจำนวนเฟรมสูงสุดของ perf
  • Off-CPU Flame Graph มักมีฟังก์ชัน libc read/write และ mutex ปรากฏจำนวนมาก จึงเสียรูปหนักกว่าเมื่อไม่มีเฟรมพอยน์เตอร์
  • หากตัวแอปพลิเคชันเองถูกบิลด์โดยไม่มีเฟรมพอยน์เตอร์ด้วย ไม่ใช่แค่ไลบรารี แต่การติดตามสแตกทั้งชุดก็จะไม่น่าเชื่อถือ

เฟรมพอยน์เตอร์ให้ข้อมูลอะไร

  • x86-64 ABI กำหนดว่า %rbp สามารถใช้เป็น base pointer ของสแตกเฟรม หรือก็คือเฟรมพอยน์เตอร์
  • profiler และ debugger ภายนอกอย่าง Linux perf และ eBPF ใช้ข้อมูลนี้ในการ ติดตามสแตก และนำผลไปแสดงเป็น Flame Graph
  • ใน x86-64 ABI มีเชิงอรรถว่าการใช้ %rbp เป็นทางเลือก
    • ถ้าใช้งาน %rsp ในการอ้างอิงสแตกเฟรม จะลดคำสั่งลงได้สองคำสั่งใน prologue และ epilogue
    • และยังใช้ %rbp เป็นรีจิสเตอร์เอนกประสงค์เพิ่มได้อีกหนึ่งตัว

การถอดออกในปี 2004 และผลกระทบตลอด 20 ปี

  • ในปี 2004 gcc เปลี่ยนให้ i386 backend ทำงานแบบเทียบเท่ากับ -fomit-frame-pointer -ffixed-ebp โดยปริยาย
  • i386 มีรีจิสเตอร์เอนกประสงค์เพียง 4 ตัว ดังนั้นการปลด %ebp ออกมาทำให้มีรีจิสเตอร์ใช้งานเพิ่มขึ้นอย่างมีนัยสำคัญ
    • เหตุผลของการเปลี่ยนแปลงยังรวมถึงความต้องการทำผลงานให้ดีกว่าคอมไพเลอร์ icc ของ Intel
    • ตอนนั้นยังมองว่า debugger รองรับวิธี stack walking แบบอื่นอยู่แล้ว จึงไม่น่าจะมีปัญหา
  • การเปลี่ยนแปลงนี้ถูกนำไปใช้กับ x86-64 ด้วย
    • x86-64 มีรีจิสเตอร์มากกว่า 12 ตัว ทำให้ประโยชน์จากการได้รีจิสเตอร์เพิ่มอีกหนึ่งตัวไม่ได้มากเท่า i386
    • system profiler ยุคปัจจุบันอย่าง eBPF ยังไม่มีอยู่ในตอนนั้น และแนวทางนี้ทำให้เครื่องมือเหล่านี้พังได้
  • Eric Schrock เคยเห็นตั้งแต่ปี 2004 ว่าการได้รีจิสเตอร์เอนกประสงค์ตัวที่ 17 บน amd64 ไม่คุ้มกับการสูญเสียความสามารถในการดีบัก
    • เขายังให้ความเห็นว่าถ้าเริ่มคอมไพล์ /usr/bin โดยไม่มีเฟรมพอยน์เตอร์แล้ว สุดท้ายจะควบคุมไม่อยู่
    • และบน Linux แนวโน้มนี้ก็ลามไปไกลกว่าทั้ง /usr/bin สู่ /usr/lib และโค้ดแอปพลิเคชัน

ประสบการณ์ใช้งานกับ Java, libc และ Netflix

  • ตอนเข้าร่วม Netflix ในปี 2014 สแตกของแอปพลิเคชันใน Java พังทั้งหมดเพราะ Java ยังรองรับเฟรมพอยน์เตอร์ไม่ดีพอ
  • จึงมีการทำแพตช์สำหรับ JVM c2 compiler และ Oracle นำไปปรับปรุงใหม่ก่อนเพิ่มเป็นออปชัน -XX:+PreserveFramePointer ใน JDK8u60
  • การเปลี่ยนแปลงฝั่ง Java ทำให้ค้นพบจุดปรับปรุงประสิทธิภาพหลายอย่างในโค้ดแอปพลิเคชันได้ แต่ libc ยังคงทำให้ตัวอย่าง CPU บางส่วนและ Off-CPU Flame Graph ส่วนใหญ่เสียรูป
  • หลังจากนั้นจึงคอมไพล์ libc สำหรับโปรดักชันเองโดยรวมเฟรมพอยน์เตอร์ไว้ และร่วมมือกับ Canonical ทำ libc แบบ prebuilt สำหรับ Ubuntu ด้วย
    • ช่วงหนึ่งมีการแนะนำให้ใช้ libc6-prof
    • โดย libc6-prof ก็คือ libc6 ที่รวมเฟรมพอยน์เตอร์ไว้

ค่าใช้จ่ายด้านประสิทธิภาพและกรณียกเว้น

  • ระหว่างการใช้งานจริงในโปรดักชัน ค่าใช้จ่ายจากการเพิ่มเฟรมพอยน์เตอร์ให้ libc และ Java มักอยู่ที่ ต่ำกว่า 1%
  • มีกรณียกเว้นที่แอปพลิเคชันหนึ่งมีโอเวอร์เฮดถึง 10%
    • เป็นแอปพลิเคชันลักษณะเฉพาะที่สร้าง stack trace ยาวเกิน 1000 เฟรมผ่าน Groovy
    • แม้แต่ Linux perf ก็จัดการไม่ไหว จน Arnaldo Carvalho de Melo จาก Red Hat ต้องเพิ่ม sysctl kernel.perf_event_max_stack เพื่อรองรับเวิร์กโหลดของ Netflix
    • สภาพแวดล้อมนั้นเป็นเครื่องเสมือนที่ไม่มีฟีเจอร์ profiling ฮาร์ดแวร์ระดับล่าง จึงยืนยันไม่ได้ว่า 10% ทั้งหมดเกิดจากเฟรมพอยน์เตอร์เพียงอย่างเดียว
  • รายงานอื่น ๆ ก็พบต้นทุนราว 1% หรือ 2%
  • ไมโครบेंช์มาร์กอาจแย่ลงได้ถึง 10%
    • เมื่อฟังก์ชันขนาดเล็กถูกรันซ้ำในลูป คำสั่งที่เพิ่มเข้ามาอาจไปกระทบความอุ่นของ L1 cache หรือขอบเขต cache line
    • ในกรณีนี้ ไม่ใช่แค่เฟรมพอยน์เตอร์ แต่การเพิ่มอะไรก็ตามเข้าไปใน hot function ก็อาจให้ผลคล้ายกัน
  • เบนช์มาร์ก Python scimark_sparse_mat_mult ก็อาจขึ้นไปถึง 10% ได้เช่นกัน
    • จากการวิเคราะห์ของ Andrii Nakryiko เป็นกรณีพิเศษที่ gcc ใช้ %rbp relative offset แทน %rsp offset ในฟังก์ชันขนาดใหญ่ ทำให้ต้องใช้จำนวนไบต์มากขึ้นและเกิดปัญหาด้านประสิทธิภาพ
    • ภายหลังยังมีข่าวว่า Python ถูกแก้ไขเพื่อให้กลับมาเปิดเฟรมพอยน์เตอร์เป็นค่าเริ่มต้นได้
  • จุดปรับปรุงประสิทธิภาพที่ค้นพบได้จากเฟรมพอยน์เตอร์มีตั้งแต่ 5% ถึง 500% จึงมองว่าต้นทุนที่มักต่ำกว่า 1% นั้นสมเหตุสมผล
  • ถ้าเป็นอุปกรณ์ที่ไม่ต้องการความสามารถด้าน profiling หรือ debugging ก็ยังคอมไพล์โดยไม่มีเฟรมพอยน์เตอร์ได้ แต่เป้าหมายหลักคือ enterprise Linux และแบ็กเอนด์เซิร์ฟเวอร์

Fedora และ Ubuntu เปิดใช้เป็นค่าเริ่มต้น

  • บริษัทขนาดใหญ่อย่าง Meta, Google และ Netflix ใช้ libc ที่รวมเฟรมพอยน์เตอร์ไว้เองอยู่แล้วเพื่อให้ทำ profiling ได้
  • ความพยายามครั้งแรกในการผลักดันให้ Fedora เปิดใช้เป็นค่าเริ่มต้นใน upstream นำไปสู่การถกเถียงยาวนาน
    • การสนทนาใน Fedora กลายเป็นเธรดที่มีข้อความถึง 116 รายการ
    • ผู้ร่วมอภิปรายคนหนึ่งถึงกับเรียกร้องให้ Meta หรือ Netflix จัดหาโครงสร้างพื้นฐาน side repository สำหรับการทดสอบ การวัดประสิทธิภาพ และการวัดขนาดโค้ด
    • Jonathan Corbet สรุปประเด็นนี้ไว้ใน Fedora's tempest in a stack frame
  • หลังจากนั้น Fedora กลับมาทบทวนข้อเสนออีกครั้งและยอมรับ ทำให้เป็นดิสทริบิวชันแรกที่เปิดใช้เฟรมพอยน์เตอร์อีกครั้ง
  • Ubuntu ก็ประกาศ frame pointers by default ใน Ubuntu 24.04 LTS
  • ยังมีข่าวเสริมว่า Arch Linux กำลังเดินหน้าเปิดใช้เฟรมพอยน์เตอร์เช่นกัน
  • แม้การเดินสแตกของไลบรารีระบบจะดีขึ้นจากการเปลี่ยนแปลงนี้ แต่รันไทม์ของแอปพลิเคชันอาจยังต้องตั้งค่าเพิ่มเติม
    • Java มีออปชัน -XX:+PreserveFramePointer
    • ส่วน Go เปิดใช้การรองรับเฟรมพอยน์เตอร์เป็นค่าเริ่มต้นมาหลายปีแล้ว

ตัวเลือกสำหรับ stack walking หลังยุคเฟรมพอยน์เตอร์

  • LBR(Last Branch Record): ฟีเจอร์ฮาร์ดแวร์ของ Intel ที่จำกัดเพียง 16 หรือ 32 เฟรม จึงไม่พอสำหรับสแตกของแอปพลิเคชันส่วนใหญ่ แต่ยังพอใช้เป็นทางเลือกสุดท้ายเพื่อเก็บข้อมูลสแตกบางส่วนได้
  • BTS(Branch Trace Store): ฟีเจอร์ของ Intel ที่ข้อจำกัดด้านความลึกของสแตกน้อยกว่า แต่มีต้นทุนจากการ load/store หน่วยความจำและการจัดการ interrupt เมื่อ BTS buffer overflow
  • AET(Architectural Event Trace): tracer ที่อิง JTAG สามารถติดตามเหตุการณ์ระดับล่างของ CPU, BIOS และอุปกรณ์ และดูเหมือนจะใช้กับ stack trace ได้ด้วย แต่ผู้เขียนยังไม่เคยใช้งานโดยตรง
  • DWARF: รูปแบบ binary debuginfo ที่ debugger ใช้มานาน
    • มีผู้ชี้ว่ามีงาน JIT-to-DWARF สำหรับรันไทม์แบบ JIT อยู่ด้วย
    • แต่สำหรับ Java JVM ในโปรดักชันที่ c2 ทำงานหนักตลอดเวลา ผู้เขียนไม่คิดว่าจะใช้งานได้จริง
    • และตัวการทำ DWARF stack walking เองก็มีต้นทุนสูง
  • eBPF stack walking: tracer ภายนอกสามารถเดินภายในรันไทม์อย่าง JVM ได้โดยไม่ต้องพึ่งการรองรับจากรันไทม์
    • แต่อาจต้องอ่านข้อมูลจาก user space ภายในรันไทม์จำนวนมาก ทำให้โอเวอร์เฮดสูง
    • และเพราะเปราะบางต่อการเปลี่ยนแปลงของรันไทม์ จึงเหมาะกว่าที่จะถูกแจกจ่ายและดูแลพร้อมกับ language code base เอง
  • ORC(oops rewind capability): stack unwinder แบบเบาของ Linux kernel ที่ทำให้เคอร์เนลรุ่นใหม่ถอดเฟรมพอยน์เตอร์ออกได้โดยยังคง stack walking ไว้ได้
  • SFrames(Stack Frames): วิธีทำ user stack unwinding แบบเบาที่อิงแนวคิด ORC
  • Shadow Stacks: ฟีเจอร์ความปลอดภัยของ Intel และ AMD ที่ push ที่อยู่ return ของฟังก์ชันลงบนฮาร์ดแวร์สแตกแยกต่างหากเพื่อตรวจสอบตอน return และก็ดูเหมือนจะนำมาใช้กับ stack trace ได้เช่นกัน

สิ่งที่เปลี่ยนไปในตอนนี้ทันที

  • เหตุผลเดิมของการละเฟรมพอยน์เตอร์ในปี 2004 ไม่ว่าจะเป็นข้อได้เปรียบด้านประสิทธิภาพบน i386 มุมมองเรื่องความเข้ากันได้กับ debugger ในเวลานั้น หรือการแข่งขันกับ icc ไม่สอดคล้องกับสถานการณ์ปี 2024 อีกแล้ว
  • การถอดเฟรมพอยน์เตอร์ออกจาก x86-64 เองก็ถูกมองมาตั้งแต่ตอนนั้นแล้วว่าให้ประโยชน์ไม่คุ้มกับการสูญเสียความสามารถในการดีบัก
  • เมื่อ Fedora และ Ubuntu นำเฟรมพอยน์เตอร์กลับมา ผู้ใช้รีลีสปี 2024 จะอ่าน CPU Flame Graph ได้เข้าใจง่ายขึ้น และสามารถใช้ Off-CPU Flame Graph ได้อย่างถูกต้องเป็นครั้งแรก
  • continuous profiler ก็จะเก็บโปรไฟล์ที่สมบูรณ์ขึ้นได้ง่ายขึ้น โดยไม่ต้องขอให้ลูกค้าเปลี่ยนระบบปฏิบัติการ
  • ในอนาคตอาจมีวิธีอย่าง SFrames หรือ Shadow Stacks ที่ทำให้กลับไปติดตามสแตกได้โดยไม่ต้องใช้เฟรมพอยน์เตอร์ แต่สิ่งที่ปรับปรุงได้จริงในตอนนี้คือเปิดใช้เฟรมพอยน์เตอร์เป็นค่าเริ่มต้น

1 ความคิดเห็น

 
GN⁺ 2024-03-18
ความคิดเห็นใน Hacker News
  • จำได้ว่าช่วงต้นทศวรรษ 2000 ตอนที่ การละเว้นสแตกเฟรมพอยน์เตอร์ เริ่มแพร่หลาย
    ตอนนั้นเรียนวิทยาการคอมพิวเตอร์อยู่ที่มหาวิทยาลัยในประเทศโลกที่สามที่ยากจน และเพราะคอมพิวเตอร์เก่าและช้า งานส่วนใหญ่จึงใช้คอมไพเลอร์มากกว่าอินเทอร์พรีเตอร์
    มีวิชาที่น่าสนใจมากมาย ตั้งแต่โครงสร้างข้อมูลระดับต่ำ คอมไพเลอร์ รูทีนเชิงตัวเลขภาษาแอสเซมบลี ไปจนถึงไดรเวอร์อุปกรณ์ของ Minix และเมื่อโปรแกรมทำงานแปลก ๆ ก็จะใช้ gdb เข้าไปดีบักโดย ไล่ตามสแตก เองในระดับแอสเซมบลี
    แต่จู่ ๆ -fomit-frame-pointer ก็ฮิตขึ้นมา ทำให้สแตกเทรซเริ่มไม่สมเหตุสมผล และการดีบัก segfault หรือ illegal instruction ก็ยากขึ้นมาก
    สุดท้ายเพื่อเลี่ยงเซสชันดีบักที่พัง เลยหันไปใช้ Python กับแทบทุกอย่าง แม้เสียประสิทธิภาพไปหนึ่งถึงสองหลัก แต่การได้เรียน Python ก็เป็นประโยชน์ในภายหลัง

    • สงสัยว่าไม่รู้จัก -fno-omit-frame-pointer หรือเปล่า
  • ดีใจที่มีการพูดถึง Fedora การรักษาสถานะให้ เฟรมพอยน์เตอร์ เปิดไว้ทั่วทั้งดิสโทรเป็นการต่อสู้ที่ค่อนข้างเหนื่อย
    ตัวอย่าง: https://pagure.io/fesco/issue/3084
    ยังมีตำนานอยู่เรื่อย ๆ ว่า overhead ของเฟรมพอยน์เตอร์สูง ซึ่งมาจากกรณี Python หนึ่งที่ช้าลง +10% และตอนนี้แก้แล้ว
    overhead ที่วัดได้จริงต่ำกว่า 1% และประโยชน์ที่ได้ในบางแอปพลิเคชันนั้นสูงกว่ามาก

    • คำกล่าวว่า “overhead ที่วัดได้จริงต่ำกว่า 1%” ดูเหมือนพูดเกินไป overhead ขึ้นกับ เวิร์กโหลด ที่วัดอย่างมาก จึงนำไปใช้ทั่วไปได้ยาก
      และก็ไม่ค่อยสอดคล้องกับการวัดฝั่งเคอร์เนล Linux ซึ่งฝั่งนั้นมองว่าอยู่ในช่วง 5~10%: https://lore.kernel.org/lkml/20170602104048.jkkzssljsompjdwy...
      เมื่อเปิดเฟรมพอยน์เตอร์ในเวิร์กโหลดหลากหลายอย่าง เช่น netperf, ไมโครเบนช์มาร์กของ page allocator, pgbench, sqlite เกิด overhead 5~10% และประเด็นสำคัญคือเห็นผลกระทบกับ PostgreSQL และ SQLite
      DBMS เป็นหนึ่งในวิธีที่ดีในการกดดันระบบอย่างหนัก
    • หากต้องการใช้ flame graph ให้ถูกต้องใน OCaml 5 ต้องใช้เฟรมพอยน์เตอร์ หรือใช้ LBR ที่ความลึกจำกัดและอาจใช้ไม่ได้ขึ้นกับ CPU หรือไม่ก็ต้องแก้ perf/eBPF ขนานใหญ่เพื่อจัดการกับสแตกสองชุดของ OCaml
      OCaml 5 ใช้สแตกแยกกันสำหรับโค้ด OCaml และโค้ด C โดย GDB สามารถเชื่อมทั้งสองเข้าด้วยกันด้วยข้อมูล DWARF ได้ แต่ call graph แบบ DWARF ของ perf ทำไม่ได้: https://github.com/ocaml/ocaml/issues/12563#issuecomment-193...
      ถ้าต้องการเหตุผลว่าทำไมควรรักษาเฟรมพอยน์เตอร์ไว้ในรีลีสถัด ๆ ไป ก็ยก OCaml 5 เป็นตัวอย่างได้
      เพิ่งรู้ว่าบน Fedora 39 เปิดเฟรมพอยน์เตอร์เป็นค่าเริ่มต้นแล้ว และโดยปกติผมยังโปรไฟล์บนระบบคล้าย CentOS 7 เป็นหลัก ซึ่งยังใช้ perf record --call-graph dwarf -F 47 -a
    • บน 32 บิต เฟรมพอยน์เตอร์ยังเป็นปัญหาอยู่ จึงไม่เหมาะกับพื้นที่อย่าง IoT ในปัจจุบัน
      เหตุผลที่เอาเฟรมพอยน์เตอร์ออกไม่ใช่ตำนาน แต่เกิดจากความจริงในยุคก่อน 64 บิต และก็ไม่ได้เก่าแก่อะไรขนาดนั้น
      แม้ตอนนี้ ถ้าต้องการชุบชีวิตระบบ 64 บิตเก่า ๆ การปรับแต่งแบบนี้ก็ยังมีความหมาย
      ตามอุดมคติแล้ว ควรเป็นค่าเริ่มต้นแม้ในระบบที่ความปลอดภัยสำคัญ และไม่ใช่ทุกอย่างจำเป็นต้องปรับให้เหมาะกับ “ความสามารถในการสังเกต”
    • overhead แบบ “ต่ำกว่า 1%” สะสมกันจนทำให้ความรู้สึกในการใช้คอมพิวเตอร์ช้าลงอย่างวัดได้เมื่อเทียบกับ 30 ปีก่อน overhead เล็ก ๆ แบบนั้นสุดท้ายก็สะสมอยู่ดี
  • สิ่งหนึ่งที่ Apple ทำได้ดีบน ARM คือทำให้ เฟรมพอยน์เตอร์ x29 ชี้ไปยังเรคคอร์ดเฟรมที่ถูกต้องเสมอ
    ฟังก์ชันบางแบบ เช่น leaf function หรือ tail call อาจไม่สร้างรายการในลิสต์ แต่ผลคือแม้ไม่มีข้อมูลดีบัก สแตกเทรซก็ยังมีความหมายเสมอ
    https://developer.apple.com/documentation/xcode/writing-arm6...

    • บนแพลตฟอร์ม Apple มักมีปัญหาความตีความได้อีกแบบหนึ่ง แอป Objective-C / Swift มักมีบล็อกและคลอเชอร์ที่ซ้อนกันลึก ทำให้ backtrace กระจัดกระจายข้ามหลายเธรดอยู่บ่อย ๆ และยังไม่รู้วิธีแก้ที่ดี
  • ในปี 2005 ผมอยู่ฝั่งตรงข้ามที่ Google และความคิดตอนนั้นเรียบง่าย
    ต่อให้ $BIG_COMPANY ตัดสินใจคอมไพล์ทุกอย่างด้วยเฟรมพอยน์เตอร์ ชุมชนที่เหลือก็จะไม่ทำตาม ดังนั้นเราจะต้องถกเถียงต่อไปกับชุมชนที่ใหญ่กว่ามากและไม่มีทางชนะ
    สุดท้ายการถกเถียงนั้นก็กลายเป็น การถกเถียงยาวเกือบ 20 ปี และภายหลังผมได้เขียนแพตช์ให้ libunwind ทำงานใน gperftools แล้วก็ลงเอยด้วยการดูแล libunwind อยู่หลายปี
    ตอนนี้ย้ายไปสายคอมพิวติงอื่นและกลายเป็นผู้สังเกตการณ์เชิงรับแล้ว แต่การอ่านประวัติศาสตร์จากมุมมองของฝั่งตรงข้ามก็น่าสนใจ

    • สงสัยว่าการเพิ่มเฟรมพอยน์เตอร์จะทำให้ติดขัดได้อย่างไร ไม่รู้ว่ามันก่อปัญหาเชิงฟังก์ชันแบบไหน
  • ถ้าจะส่งต่อ RBP ให้เป็นเฟรมพอยน์เตอร์ จะมี สแต็กสองชุด ไปเลยก็ได้
    ชุดหนึ่งให้ RBP ชี้และเก็บ activation frame ส่วนอีกชุดให้ RSP ชี้และเก็บเฉพาะที่อยู่สำหรับ return
    แบบนั้น call stack ก็จะเป็นอาร์เรย์แบน ๆ ของที่อยู่สำหรับ return ตามความหมายตรงตัว จึงไม่จำเป็นต้อง “เดินสแต็ก” อีกต่อไป
    ไม่รู้เลยว่าทำไมตั้งแต่แรกถึงต้องเก็บที่อยู่สำหรับ return ไว้ใกล้ ๆ ตัวแปรโลคัล และข้อเสียก็ดูเยอะมาก

    • เพราะการจัดการพื้นที่เก็บข้อมูลจะเรียบง่ายขึ้น สแต็กเฟรมเป็นเพียง bump pointer ง่าย ๆ ที่อยู่ในแคชเสมอ และเวลา overflow ก็ต้องการแค่ guard page หน้าเดียว
      วิธีที่เสนอจะต้องใช้ guard page สองหน้า การจัดการสแต็กก็เพิ่มเป็นสองเท่า และโอกาสเกิด cache miss ก็เพิ่มเป็นสองเท่าด้วย
    • ฟีเจอร์ของ CPU ที่บทความพูดถึงสั้น ๆ อย่าง shadow stack ค่อนข้างคล้ายกับแนวทางนี้ แต่จุดประสงค์หลักอยู่ด้านความปลอดภัย
    • ก็สงสัยเหมือนกันว่าทำไมสแต็กถึงโตไปใน “ทิศทางที่ผิด” จนโปรแกรมที่ทำงานผิดพลาดก่อปัญหาด้านความปลอดภัยได้
      รู้เหตุผลอยู่ แต่เหมือนหลาย ๆ เรื่อง มันสมเหตุสมผลครั้งสุดท้ายก็คงราว 30 ปีก่อน และผลกระทบของมันก็น่าสนใจดี
    • ดูเหมือนคุณพร้อมจะชอบ Forth แล้วล่ะ ;-)
      แปลกที่บทความ Wikipedia ดูจะไม่ได้ชูให้ชัดว่า Forth เข้าถึงได้ทั้งสแต็กพารามิเตอร์และสแต็กคืนค่าซึ่งเป็นคุณลักษณะสำคัญของโมเดล
      https://en.wikipedia.org/wiki/Forth_(programming_language)
    • ข้อดีของการเก็บที่อยู่สำหรับ return ไว้ที่อื่นยังไม่ชัดเจน เว้นแต่ฮาร์ดแวร์จะรองรับอะไรอย่าง shadow stack
      ต้องแสดงให้เห็นว่าต้นทุนของการย้ายไปอีก page หนึ่งและจัดการพอยน์เตอร์สองตัวนั้นถูกกว่า stack cookie/protector อย่างมีนัยสำคัญ ซึ่งให้การป้องกันเฉพาะจุดที่จำเป็นอยู่แล้ว
      และก็ไม่ได้มีข้อได้เปรียบด้านความปลอดภัยจริง ๆ เหนือ stack protector ปัจจุบัน ถ้ามี arbitrary read/write ได้ สุดท้ายก็อาจนำไปสู่การข้ามผ่าน control-flow integrity ได้อยู่ดี
  • Virgil ไม่ใช้เฟรมพอยน์เตอร์ ถ้าไม่มี การจัดสรรสแต็กแบบไดนามิก ขนาดเฟรมของฟังก์ชันหนึ่ง ๆ ก็จะคงที่ และหาได้ด้วยการค้นตารางไบนารีแบบง่าย ๆ
    เทคนิคของ Virgil ใช้ช่วงที่อิง page index เพิ่มเติม เพื่อจำกัดขอบเขตการค้นหาให้เหลือการเปรียบเทียบเพียงไม่กี่ครั้งโดยเฉลี่ย รวมข้อมูลสำหรับ unwinding กับ stackmap สำหรับ GC เข้าด้วยกัน และใช้พื้นที่น้อยมาก
    โค้ดหลักอยู่ที่ https://github.com/titzer/virgil/blob/master/rt/native/Nativ... และโค้ดที่เหลือในไดเรกทอรีเดียวกันเป็นการ implement การถอดรหัสเมทาดาทา
    ผมเห็นว่าเฟรมพอยน์เตอร์มีความหมายเฉพาะในกรณีที่ขนาดเฟรมเป็นไดนามิก นั่นคือเมื่อมีการจัดสรรข้อมูลบนสแต็กเท่านั้น
    ทั้งที่กลไกแบบสแตติกก็เพียงพอแล้ว การใช้กลไกแบบไดนามิกจึงดูแปลก และดูเหมือนส่วนใหญ่เป็นเพราะไม่มีข้อตกลงร่วมกันเรื่อง ABI สำหรับการเข้ารหัสเมทาดาทาหรือรูทีน unwinding
    ตัวเลขที่วัดได้ 1~2% น่าเชื่อถือ และอยู่ในระดับใกล้เคียงกับต้นทุนของการตรวจขอบเขตอาร์เรย์
    การให้ความสำคัญเป็นพิเศษกับต้นทุน 1% สำหรับการดีบักและโปรไฟลิง แต่กลับต่อต้านการเพิ่มชั้นความปลอดภัย เป็นลำดับความสำคัญที่แปลกมาก

    • C ก็เพิ่มการตรวจขอบเขตได้ แต่ต้นทุนสูงกว่า 1~2% มาก
      std::vector ของ C++ ปิดการตรวจขอบเขตเป็นค่าเริ่มต้น ซึ่งผมมองว่าเป็นเพราะ C++ ถูกออกแบบโดยคนที่บ้าสุด ๆ และเพื่อคนแบบนั้น
      นอกนั้นนึกภาษาอื่นที่ไม่มีการตรวจขอบเขตไม่ออกทันที
  • เป็นบทความที่ดี ตอนที่เฟรมพอยน์เตอร์หายไปก็รู้สึกเสียดาย
    ผู้คนจำนวนมากต้องทุกข์กับ การไม่มีเฟรมพอยน์เตอร์ มานาน ไม่ใช่แค่ในระบบอื่น ๆ แต่รวมถึง Linux ด้วย และพยายามรักษามันไว้ในสภาพแวดล้อมให้มากที่สุดเท่าที่ทำได้
    การได้เห็น Linux กระแสหลักนำมันกลับมาให้ความรู้สึกเหมือนได้รับการยอมรับ แต่ในขณะเดียวกันก็น่าหงุดหงิดอยู่บ้าง

    • ถามด้วยความอยากรู้จริง ๆ รู้ว่า unwinding สแต็กด้วย DWARF นั้นยุ่งยาก แต่ไม่เข้าใจว่ามันแย่ขนาดทำให้โค้ดทั้งระบบช้าลงได้อย่างไร
      ที่มันช้าในตระกูล Debian เป็นเพราะเหตุผลด้านไลเซนส์ทำให้ perf แพ็กเกจมาเฉพาะเส้นทาง unwinding ที่ช้า แต่ถ้าเครื่องมือดี ก็แทบไม่รู้สึกถึงความต่าง
      อยากรู้ว่าผมพลาดอะไรไป
  • โดยรวมแล้วเห็นด้วยกับเฟรมพอยน์เตอร์ แต่มีสิ่งที่สังเกตได้จากการทำงานในด้านนี้มาหลายปี
    การคลี่สแตกที่อิงเฟรมพอยน์เตอร์จำนวนมากไม่ได้คำนึงถึงปัญหาที่ไม่มีอยู่ในข้อมูลการคลี่ของ DWARF การตั้งค่าเฟรมไม่ได้เป็นแบบอะตอมิก แต่ประกอบด้วยคำสั่งสองคำสั่งคือ push $rbp และ mov $rsp $rbp และถ้าสแนปช็อตถูกถ่ายระหว่าง push ก็อาจพลาดเฟรมของพาเรนต์ได้
    อาจบรรเทาได้ด้วยการตรวจสอบโค้ด แต่ก็อาจมี push %rbp ที่ไม่เกี่ยวกับสแตกเฟรมอยู่ด้วย จึงดูใกล้เคียงกับฮิวริสติกมากกว่า
    ยังได้พัฒนาแนวทางคลี่ในเคอร์เนลแบบรวดเร็วที่อิง BPF ซึ่ง Brendan กล่าวถึงด้วย: https://web.archive.org/web/20231222054207/https://www.polar...
    วิธีนี้ไม่ได้ใช้ DWARF CFI ตรง ๆ แต่แปลงเป็นรูปแบบเข้าถึงแบบสุ่มที่ใช้ได้ใน BPF
    ตอนนี้รองรับเฉพาะส่วน JIT ที่มีเฟรมพอยน์เตอร์ แต่คิดว่าน่าจะทำให้การคลี่ของ JVM interpreter ประสานกับการคลี่แบบเนทีฟได้
    ตามอุดมคติ การเปิดใช้เฟรมพอยน์เตอร์ควรทำเป็นรายกรณี และการเบนช์มาร์กคือหัวใจสำคัญ
    การแลกเปลี่ยนระหว่างประสิทธิภาพ ความสามารถในการสังเกตการณ์ และตัวชี้วัดทางธุรกิจ อาจแตกต่างกันมากตามลักษณะของอุตสาหกรรมและซอฟต์แวร์
    ฝั่ง Fedora ทำงานเรื่องนี้ได้ยอดเยี่ยมและเคร่งครัดมาก
    อีกทั้งระบบ build ที่สามารถเปลี่ยนค่าตั้งนี้ได้ทั้งระบบ รวมถึงไลบรารีที่พึ่งพา มีประโยชน์มากไม่ใช่แค่สำหรับการทดสอบ แต่รวมถึงการนำไปใช้ใน production ด้วย
    สุดท้ายนี้ ตั้งตารอ SFrame ที่ Indu กำลังทำอยู่ น่าจะช่วยแก้ปัญหาปัจจุบันได้มาก พร้อมทั้งให้ผู้ใช้เลือกได้ว่าจะใช้เฟรมพอยน์เตอร์หรือไม่ แต่กว่าจะมีอินฟราสตรักเจอร์พร้อมและทุกคนอัปเกรดคงใช้เวลาหลายปี

    • หากต้องการได้ flame graph ที่ดี ต้องเปิดเฟรมพอยน์เตอร์ทั่วทั้งดิสทริบิวชัน Linux
      ถ้าจะเข้าใจว่าเกิดอะไรขึ้นจริง ๆ จำเป็นต้องมี การวิเคราะห์ทั้งระบบ และภายใต้โครงสร้างของดิสทริบิวชัน Linux แบบไบนารีในปัจจุบันอย่าง Fedora หรือ Debian แทบไม่มีทางเลือกอื่นที่เป็นไปได้จริง
    • ทำได้ด้วยคำสั่งเดียวเช่นกัน: ENTER N,0 จะจองพื้นที่สแตกขนาด N สำหรับตัวแปรโลคัล และโดยคร่าว ๆ เทียบได้กับ PUSH EBP, MOV ESP,ESP, SUB SP,N
      แต่จำไม่ได้ว่า ENTER มีใน x86-64 หรือไม่
      ถึงอย่างนั้น ระหว่าง CALL กับการตั้งค่าเฟรมก็ยังไม่เป็นอะตอมิกอยู่ดี ดังนั้นถ้าสแนปช็อตถูกถ่ายหลัง CALL แต่ก่อน ENTER ก็จะไม่ได้การตั้งค่าเฟรม
      เหตุผลที่ ENTER ไม่ค่อยถูกใช้คือถูกมองว่าช้าเกินไป
      LEAVE ถูกใช้เพราะเร็วเท่ากับหรือเร็วกว่า sequence ของคำสั่งที่มาแทน แต่ ENTER นั้น operand ตัวที่สองทำลายประสิทธิภาพ
      operand นั้นมีไว้ให้ฟังก์ชันซ้อนเข้าถึงสแตกเฟรมระดับบน และมีต้นทุนการใช้งานสูงมาก
    • การตั้งค่าเฟรมที่ไม่เป็นอะตอมิกเป็นปัญหาสำหรับ CPU profiler อย่างแน่นอน แต่ไม่เป็นปัญหาสำหรับ allocation profiling, Off-CPU profiling หรือ profiling แบบอื่นที่ไม่ได้อิง interrupt
    • ฝั่งการคลี่ของ JVM ก็มีความคืบหน้าที่ดีเช่นกัน
  • น่าสนใจที่เพิ่งได้รู้ว่าภูเขา [unknown] ที่เห็นในโปรไฟล์เกิดขึ้นได้อย่างไร
    อย่างไรก็ตาม การให้เหตุผลรองรับนั้นไม่ง่ายนัก ส่วนต่างประสิทธิภาพ 2% ถือว่าค่อนข้างมากจริง ๆ
    ถ้าควบคุมได้ละเอียดขึ้นว่าจะใส่เฟรมพอยน์เตอร์หรือไม่ก็คงดี
    หากมี profiling ที่ละเอียด ก็น่าจะตัดสินได้ว่าฟังก์ชันหรือหน่วยคอมไพล์ใดจำเป็นต้องมีเฟรมพอยน์เตอร์
    คงไม่น่าแปลกใจถ้าผลออกมาว่ามีงานเพียงส่วนน้อยที่ช้าลงอย่างมากจากการใส่เฟรมพอยน์เตอร์ ส่วนที่เหลือแทบไม่ได้รับผลกระทบ

    • ส่วนต่างประสิทธิภาพ 2% ไม่ได้มากขนาดนั้น โดยเฉพาะถ้าการทำ profiling ช่วยหาคอขวดและทำให้ปรับปรุงได้มากกว่า 10%
    • ใน GCC สามารถเปิดปิดเป็นรายฟังก์ชันได้โดยใส่ attribute ที่การประกาศฟังก์ชัน แต่ใน LLVM ใช้ไม่ได้
      __attribute__((optimize("no-omit-frame-pointer")))
      __attribute__((optimize("omit-frame-pointer")))
    • ในแอปพลิเคชันจริง ต้นทุนด้านประสิทธิภาพอาจน้อยกว่า 2% มาก
      เบนช์มาร์กแบบนี้ค่อนข้างประดิษฐ์ จึงไม่ควรเชื่อทั้งหมด และแอปพลิเคชันในโลกจริงมักให้ผลต่างกันมาก
      การทำ profiling สำคัญ และเคยทำ profiling โค้ดอย่างละเอียดจนทำให้หลายช่วงเร็วขึ้นได้ถึง 20%
      หากเป็นแอปพลิเคชันที่ไวต่อการสูญเสียประสิทธิภาพมาก ก็เปิดเฟรมพอยน์เตอร์และทำ profiling ในแล็บ จากนั้นค่อยละไว้ในเวอร์ชันที่ส่งให้ลูกค้า
    • overhead ที่วัดได้ต่ำกว่า 1% เล็กน้อย ในอดีตเคยมีกรณีหายากที่เฟรมพอยน์เตอร์ทำให้ประสิทธิภาพแย่ลงมาก แต่ตอนนี้แก้ไขแล้ว
    • โดยปกติแล้วน้อยกว่า 2% มาก
  • การรองรับโค้ด JIT น่าเสียดายที่ยังไม่ดี แต่ LLVM มี hook ที่ยอดเยี่ยมสำหรับบันทึกเมธอดแต่ละตัวที่สร้างขึ้นและที่อยู่ของมัน
    ดังนั้นจึงทำ mixed-mode stack unwinding แบบง่าย ๆ ได้ค่อนข้างสะดวก แต่ส่วนใหญ่ทำได้ภายในโปรเซส
    ตระกูล DNN ของ Intel ดูเหมือนจะ dump ข้อมูลลงไฟล์สาธารณะที่ perf อ่านได้ แต่เคอร์เนลของ oneDNN เองกลับนำ RBP มาใช้ซ้ำตลอด จึงแทบใช้ประโยชน์ไม่ได้
    ข้อความในบทความที่ว่า “JIT runtime อย่าง Java JVM ไม่มีข้อมูล DWARF” ก็น่าประหลาดใจเช่นกัน
    สงสัยว่ามันถูกปิดไว้โดยดีฟอลต์ หรือว่าใช้งานไม่ได้จริง ๆ ตามตัวอักษร
    ลองค้นดูก็มักไปเจอเนื้อหาที่ต้องการรวมฝั่ง JNI/C เข้าไว้ใน JVM stack: https://github.com/async-profiler/async-profiler/issues/215