แพตช์ 40 บรรทัดที่ลบช่องว่างด้านประสิทธิภาพ 400 เท่า
(questdb.com)ThreadMXBean.getCurrentThreadUserTime()ของ OpenJDK ถูกเปลี่ยนจากการ พาร์สไฟล์/procมาเป็นการเรียกclock_gettime()และทำให้ประสิทธิภาพดีขึ้นได้สูงสุด 400 เท่า- อิมพลีเมนเทชันเดิมต้องผ่าน เส้นทาง I/O ที่ซับซ้อน โดยเปิด อ่าน และพาร์สไฟล์
/proc/self/task/<tid>/stat - อิมพลีเมนเทชันใหม่ใช้ การเข้ารหัสบิตของ
clockid_tใน Linux kernel โดยปรับบิตล่างของ ID ที่ได้จากpthread_getcpuclockid()เพื่อ ดึงเฉพาะ user time โดยตรง - ผลเบนช์มาร์กแสดงว่าเวลาเฉลี่ยต่อการเรียกลดลงจาก 11μs → 279ns และหลังเพิ่ม kernel fast-path แล้วยัง ดีขึ้นอีกราว 13%
- เป็นกรณีศึกษาที่แสดงให้เห็นว่าสามารถทำ optimization ได้ด้วย ความเข้าใจ ABI ภายในของ Linux ที่ก้าวข้ามข้อจำกัดของ POSIX
ปัญหาของอิมพลีเมนเทชันเดิม
getCurrentThreadUserTime()จะเปิดไฟล์/proc/self/task/<tid>/statแล้วพาร์ส ฟิลด์ที่ 13 และ 14 เพื่อคำนวณค่า CPU user time- ต้องมีหลายขั้นตอน เช่น สร้าง path ของไฟล์ เปิดไฟล์ อ่านบัฟเฟอร์ พาร์สสตริง และเรียก
sscanf() - เนื่องจากชื่อคำสั่งอาจมีวงเล็บอยู่ด้วย จึงมี logic ที่ซับซ้อนในการหา
)ตัวสุดท้ายด้วยstrrchr()
- ต้องมีหลายขั้นตอน เช่น สร้าง path ของไฟล์ เปิดไฟล์ อ่านบัฟเฟอร์ พาร์สสตริง และเรียก
- ในทางกลับกัน
getCurrentThreadCpuTime()ใช้เพียงการเรียกclock_gettime(CLOCK_THREAD_CPUTIME_ID)ครั้งเดียว - ตามรายงานบั๊กในปี 2018 (JDK-8210452) ความเร็วของสองเมธอดนี้ต่างกันถึง 30~400 เท่า
เปรียบเทียบเส้นทางการเข้าถึง /proc กับเส้นทาง clock_gettime()
- วิธีแบบ
/procมีทั้งopen(),read(),sscanf(),close()และยังรวมถึง การสร้างสตริงภายในเคอร์เนลหลายส่วน - วิธีแบบ
clock_gettime()เป็น system call เพียงครั้งเดียว ที่อ่านค่าจากโครงสร้างsched_entityโดยตรง - เมื่อมีโหลดแบบขนาน การเข้าถึง
/procจะยิ่งช้าลงจาก การแข่งขันแย่ง kernel lock
วิธีอิมพลีเมนเทชันใหม่
- มาตรฐาน POSIX กำหนดว่า
CLOCK_THREAD_CPUTIME_IDต้องคืนค่า user+system time - Linux kernel เข้ารหัส ชนิดของนาฬิกา ไว้ในบิตล่างของ
clockid_t00=PROF,01=VIRT(เฉพาะ user),10=SCHED(user+system)
- หากเปลี่ยนบิตล่างของ
clockidที่ได้จากpthread_getcpuclockid()ให้เป็น01ก็จะเปลี่ยนเป็น นาฬิกาที่ให้เฉพาะ user time ได้ - โค้ดใหม่จึงตัด file I/O และการพาร์สออกไป แล้วคืนค่า user time ด้วยการเรียก
clock_gettime()อย่างเดียว
ผลการวัดประสิทธิภาพ
- ก่อนแก้ เวลาเฉลี่ยต่อการเรียกคือ 11.186μs, หลังแก้เหลือ 0.279μs หรือดีขึ้นราว 40 เท่า
- วัดในสภาพแวดล้อม 16 เธรด และสอดคล้องกับช่วง 30~400 เท่าที่เคยมีรายงานไว้
- จาก CPU profile จะเห็นว่า system call ที่เกี่ยวกับการเปิด-ปิดไฟล์หายไป และเหลือเพียงการเรียก
clock_gettime()ครั้งเดียว
การเพิ่มประสิทธิภาพเพิ่มเติมด้วย kernel fast-path
- เคอร์เนลมี fast-path สำหรับกรณีที่ใน
clockidมีการเข้ารหัส PID=0 ซึ่งจะเข้าถึงเธรดปัจจุบันได้ทันที - หาก JVM สร้าง
clockidขึ้นเองโดยตรงแทนpthread_getcpuclockid()และใส่ PID=0 ก็จะ ข้ามการค้นหา radix tree ได้ - เมื่อใช้
clockidที่ประกอบเอง เวลาเฉลี่ยลดจาก 81.7ns → 70.8ns หรือดีขึ้นเพิ่มอีกราว 13% - อย่างไรก็ตาม วิธีนี้พึ่งพารายละเอียดภายในของเคอร์เนล เช่น ขนาดของ
clockid_tจึงมีความเสี่ยงด้าน ความอ่านง่ายและความเข้ากันได้
บทสรุปและข้อคิด
- การลบโค้ด 40 บรรทัดช่วย ลบช่องว่างด้านประสิทธิภาพ 400 เท่า ได้ โดยไม่ต้องเพิ่มฟีเจอร์ใหม่ในเคอร์เนล แต่อาศัยเพียง การใช้รายละเอียดของ ABI ที่มีอยู่เดิม
- ตอกย้ำ คุณค่าของการอ่านซอร์สโค้ดเคอร์เนล: POSIX ให้การรับประกันเรื่อง portability แต่โค้ดของเคอร์เนลแสดงให้เห็น ขอบเขตของสิ่งที่เป็นไปได้
- ความสำคัญของการทบทวนสมมติฐานเดิม: การพาร์ส
/procเคยสมเหตุสมผลในอดีต แต่ปัจจุบันไม่มีประสิทธิภาพแล้ว - การเปลี่ยนแปลงนี้จะถูกรวมใน JDK 26 (มีกำหนดออกในเดือนมีนาคม 2026) และจะช่วยเพิ่มประสิทธิภาพโดยอัตโนมัติเมื่อเรียก
ThreadMXBean.getCurrentThreadUserTime()
5 ความคิดเห็น
จริง ๆ แล้วแค่ยกเครื่องโค้ดใหม่ทั้งหมดแล้วยังจะให้ดีขึ้นสัก 2~3 เท่าก็เป็นเรื่องยากอยู่แล้ว แต่นี่แค่เปลี่ยนไม่กี่บรรทัดแล้วดีขึ้นได้สูงสุดถึง 400 เท่า น่าทึ่งจริง ๆ ครับ
ความคิดเห็นจาก Hacker News
แล้วก็พบว่าคำถามว่า “เวลาใช้งาน CPU ของเธรดนี้เท่าไร?” เป็นการคำนวณที่ แพงมากเกินคาด
ถ้าไม่มีมาตรฐานอ้างอิงระดับนาฬิกาอะตอม ก็รู้สึกว่ายากที่จะอ้างตัวเลขแบบสัมบูรณ์ได้
clock_gettime()หลีกเลี่ยง context switch ผ่าน vDSO ได้ เลยเห็นร่องรอยนี้ใน flamegraph ด้วยCLOCK_VIRTหรือCLOCK_SCHEDยังต้อง เรียก syscall อยู่CLOCK_THREAD_CPUTIME_IDสุดท้ายก็ต้องลงไปที่เคอร์เนล เพราะต้องอ้างอิง task structดูซอร์สเคอร์เนลที่เกี่ยวข้องได้ที่ posix-cpu-timers.c,
cputime.c,
gettimeofday.c
PERF_COUNT_SW_TASK_CLOCKก็สามารถวัดได้ที่ระดับประมาณ 8nsวิธีคืออ่านจาก shared page ผ่าน
perf_event_mmap_pageแล้วคำนวณเดลต้าด้วยการเรียกrdtscเรื่องนี้มีเอกสารไม่มากและแทบไม่มี implementation แบบโอเพนซอร์ส
perf_eventต้องมีการตั้งค่าและสิทธิ์ค่อนข้างเยอะseqlockเพื่อป้องกันไม่ให้เกิด context switch ระหว่างค่าของหน้าเพจกับrdtscหรือไม่น่าจะเป็นโครงสร้างที่เช็กค่าหน้าเพจอีกครั้งหลัง
rdtscแล้วถ้ามีการเปลี่ยนก็ลองใหม่อนึ่ง
clock_gettimeเองก็เป็น virtual syscall ที่อาศัย vdsoclock_gettimeไม่ใช่ syscall แต่ใช้ vdsoเวลาดูจากโค้ดอย่างเดียวอาจเหมือนปกติดี แต่พอดู flamegraph แล้วมักจะเจอความรู้สึกแบบ “นี่มันอะไรเนี่ย?!”
เคยเจอปัญหาหลายอย่าง เช่น การทำ initialization ที่ไม่ใช่ static initialization หรือการเรียก logger แค่บรรทัดเดียวแต่กลับก่อให้เกิด serialization ที่มีต้นทุนสูง
ปกติผมจะใช้ตัวสร้าง HTML ของ async-profiler แต่ครั้งนี้ใช้เครื่องมือของ Brendan เพื่อให้ได้ SVG ไฟล์เดียว
/proc, การทำ eBPF profiling และประวัติของ user-space ABI ที่เอกสารมีน้อยรายละเอียดเพิ่มเติมสรุปไว้ใน บล็อกโพสต์ของผม
ทวีตต้นทาง
บล็อกของ Jaromir ก็ยอดเยี่ยมจริงๆ
เรื่องแบบนี้จะค้นพบในโปรเจกต์ได้อย่างไรบ้างครับ? ดูแล้วน่าจะยากที่จะรู้ได้แค่เพราะรัน AI..
พอได้เห็นกรณีแบบนี้แล้ว ก็ทำให้ผมคิดว่าอยากเรียนรู้และอยากลองมีประสบการณ์แบบนี้ด้วยตัวเองให้ได้ครับ
น่าทึ่งมาก
ผมคิดว่ามันก็ไม่ใช่คำพูดที่ผิดไปเสียทีเดียว แต่ในกรณีที่เกี่ยวพันกับเคอร์เนล ผมคิดว่าแค่จะสังเกตให้รู้ว่ามันช้าก็คงยากมากแล้ว