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()
- ในทางกลับกัน
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_t
00=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 ความคิดเห็น
น่าทึ่งมาก
ผมคิดว่ามันก็ไม่ใช่คำพูดที่ผิดไปเสียทีเดียว แต่ในกรณีที่เกี่ยวพันกับเคอร์เนล ผมคิดว่าแค่จะสังเกตให้รู้ว่ามันช้าก็คงยากมากแล้ว
เรื่องแบบนี้จะค้นพบในโปรเจกต์ได้อย่างไรบ้างครับ? ดูแล้วน่าจะยากที่จะรู้ได้แค่เพราะรัน AI..
พอได้เห็นกรณีแบบนี้แล้ว ก็ทำให้ผมคิดว่าอยากเรียนรู้และอยากลองมีประสบการณ์แบบนี้ด้วยตัวเองให้ได้ครับ
จริง ๆ แล้วแค่ยกเครื่องโค้ดใหม่ทั้งหมดแล้วยังจะให้ดีขึ้นสัก 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 ก็ยอดเยี่ยมจริงๆ