ThreadMXBean.getCurrentThreadUserTime() của OpenJDK được thay thế việc phân tích file /proc bằng lời gọi clock_gettime(), đạt cải thiện hiệu năng tối đa 400 lần
- Cách triển khai cũ đi qua đường I/O phức tạp: mở, đọc và phân tích file
/proc/self/task/<tid>/stat
- Cách triển khai mới tận dụng mã hóa bit
clockid_t của nhân Linux để điều chỉnh các bit thấp của ID lấy từ pthread_getcpuclockid(), qua đó truy vấn trực tiếp chỉ thời gian user
- Kết quả benchmark cho thấy thời gian gọi trung bình giảm từ 11μs → 279ns; sau đó khi áp dụng fast-path của kernel thì cải thiện thêm khoảng 13%
- Đây là ví dụ cho thấy có thể tối ưu vượt ra ngoài ràng buộc của POSIX bằng cách hiểu ABI nội bộ của Linux
Vấn đề của cách triển khai cũ
getCurrentThreadUserTime() mở file /proc/self/task/<tid>/stat và phân tích trường thứ 13 và 14 để tính thời gian CPU user
- Cần nhiều bước xử lý: tạo đường dẫn file, mở file, đọc bộ đệm, phân tích chuỗi, gọi
sscanf()
- Vì tên lệnh có thể chứa dấu ngoặc, mã còn phải dùng logic phức tạp với
strrchr() để tìm ký tự ) cuối cùng
- Trong khi đó,
getCurrentThreadCpuTime() chỉ cần một lời gọi duy nhất clock_gettime(CLOCK_THREAD_CPUTIME_ID)
- Theo báo cáo lỗi năm 2018 (JDK-8210452), chênh lệch tốc độ giữa hai phương thức này lên tới 30~400 lần
So sánh đường truy cập /proc và đường clock_gettime()
- Cách dùng
/proc bao gồm nhiều system call và việc tạo chuỗi trong kernel, như open(), read(), sscanf(), close()
- Cách dùng
clock_gettime() là một system call duy nhất, đọc trực tiếp giá trị thời gian từ cấu trúc sched_entity
- Khi có tải song song, truy cập
/proc bị chậm hơn do tranh chấp khóa trong kernel
Cách triển khai mới
- Chuẩn POSIX quy định
CLOCK_THREAD_CPUTIME_ID phải trả về thời gian user + system
- Nhân Linux mã hóa loại đồng hồ trong các bit thấp của
clockid_t
00=PROF, 01=VIRT(chỉ user), 10=SCHED(user+system)
- Nếu đổi các bit thấp của
clockid lấy từ pthread_getcpuclockid() thành 01, có thể chuyển sang đồng hồ chỉ tính thời gian user
- Mã mới loại bỏ I/O file và bước phân tích chuỗi, chỉ còn gọi
clock_gettime() để trả về thời gian user
Kết quả đo hiệu năng
- Trước khi sửa, thời gian gọi trung bình là 11.186μs; sau khi sửa còn 0.279μs, tức cải thiện khoảng 40 lần
- Đo trong môi trường 16 luồng, khớp với khoảng 30~400 lần đã được báo cáo trước đó
- Trên CPU profile, các system call liên quan đến mở/đóng file biến mất, chỉ còn lại một lời gọi
clock_gettime() duy nhất
Tối ưu bổ sung bằng fast-path của kernel
- Kernel cung cấp fast-path để truy cập trực tiếp luồng hiện tại khi
clockid được mã hóa với PID=0
- Nếu JVM tự dựng
clockid thay vì gọi pthread_getcpuclockid(), rồi chèn PID=0 vào đó, có thể bỏ qua bước tra cứu radix tree
- Khi dùng
clockid được dựng thủ công, thời gian trung bình giảm từ 81.7ns → 70.8ns, tức cải thiện thêm khoảng 13%
- Tuy nhiên, cách này phụ thuộc vào chi tiết triển khai nội bộ của kernel như kích thước
clockid_t, nên có rủi ro giảm tính dễ đọc và khả năng tương thích
Kết luận và bài học
- Việc xóa 40 dòng mã đã xóa bỏ khoảng cách hiệu năng gấp 400 lần, đạt được mà không cần tính năng kernel mới, chỉ nhờ tận dụng chi tiết của ABI hiện có
- Bài viết nhấn mạnh giá trị của việc đọc sâu mã nguồn kernel: POSIX bảo đảm tính di động, nhưng mã kernel cho thấy giới hạn của những gì có thể làm được
- Tầm quan trọng của việc xem xét lại các giả định cũ: phân tích
/proc trước đây từng hợp lý, nhưng nay đã trở nên kém hiệu quả
- Thay đổi này sẽ có trong JDK 26 (dự kiến phát hành vào tháng 3/2026), mang lại cải thiện hiệu năng tự động khi gọi
ThreadMXBean.getCurrentThreadUserTime()
5 bình luận
Thật ấn tượng.
Tôi nghĩ câu này không hẳn là sai hoàn toàn, nhưng trong trường hợp có liên quan đến kernel thì ngay cả việc nhận ra nguyên nhân chậm cũng hẳn đã là chuyện thực sự khó khăn.
Làm sao có thể phát hiện những thứ như thế này trong dự án nhỉ? Có vẻ như chỉ chạy AI thì cũng khó mà biết được..
Nhìn những trường hợp như vậy, tôi cũng nghĩ rằng mình muốn học hỏi và nhất định tự mình trải nghiệm thử.
Thực ra, ngay cả khi viết lại toàn bộ mã mà muốn cải thiện 2~3 lần cũng đã là việc khó rồi, nên việc chỉ đổi vài dòng mà tăng hiệu năng tối đa tới 400 lần thực sự quá ấn tượng.
Ý kiến trên Hacker News
Tôi nhận ra rằng câu hỏi “thời gian CPU mà thread này đã dùng là bao nhiêu?” hóa ra là một phép toán quá đắt đỏ hơn tôi tưởng nhiều
Nếu không có mốc chuẩn ở cấp độ đồng hồ nguyên tử, tôi nghĩ rất khó để khẳng định các con số tuyệt đối
clock_gettime()tránh context switch thông qua vDSO. Vì vậy dấu vết của nó cũng hiện lên trên flamegraphCLOCK_VIRThayCLOCK_SCHEDthì vẫn cần gọi syscallCLOCK_THREAD_CPUTIME_IDrốt cuộc vẫn đi xuống kernel, vì nó cần tham chiếu đến task structCó thể xem mã nguồn kernel liên quan tại posix-cpu-timers.c,
cputime.c,
gettimeofday.c
PERF_COUNT_SW_TASK_CLOCKthì có thể đo ở mức khoảng 8nsCách làm là đọc từ shared page qua
perf_event_mmap_page, rồi tính delta bằng lệnh gọirdtscCách này chưa được tài liệu hóa tốt và gần như không có triển khai mã nguồn mở nào
perf_eventvà quyền hạn khá cao nên có vẻ phù hợp hơn với các thread sống lâuseqlock. Có phải để tránh context switch xảy ra giữa giá trị trong page vàrdtschay khôngCó vẻ cấu trúc là sau
rdtscsẽ kiểm tra lại giá trị trong page, nếu đã thay đổi thì thử lạiNhân tiện,
clock_gettimecũng là một virtual syscall dựa trên vdsoclock_gettimekhông phải syscall mà dùng vdsoChỉ nhìn code thì có vẻ ổn, nhưng khi xem flamegraph thì nhiều lúc lại thấy kiểu “Cái gì đây?!”
Tôi đã phát hiện đủ loại vấn đề như khởi tạo không phải static initialization, hay một lệnh gọi logger chỉ một dòng nhưng lại gây ra serialization đắt đỏ
Bình thường tôi dùng trình tạo HTML của async-profiler, nhưng lần này tôi dùng công cụ của Brendan để có một file SVG đơn lẻ
/proc, profiling bằng eBPF, và lịch sử của ABI user-space vốn thiếu tài liệuChi tiết hơn tôi đã tổng hợp trong bài blog của tôi
Tweet nguồn
Blog của Jaromir cũng rất hay