- En OpenJDK,
ThreadMXBean.getCurrentThreadUserTime() fue reemplazado para usar una llamada a clock_gettime() en lugar de parsear archivos de /proc, logrando hasta 400 veces más rendimiento
- La implementación anterior recorría una ruta compleja de E/S abriendo, leyendo y parseando el archivo
/proc/self/task/<tid>/stat
- La nueva implementación aprovecha la codificación de bits de
clockid_t del kernel de Linux para ajustar los bits bajos del ID obtenido con pthread_getcpuclockid() y consultar directamente solo el tiempo de usuario
- Los benchmarks muestran que el tiempo promedio por llamada bajó de 11μs a 279ns y, tras aplicar luego el fast-path del kernel, hubo una mejora adicional de alrededor del 13%
- Es un caso que muestra cómo se puede optimizar entendiendo el ABI interno de Linux, más allá de las restricciones de POSIX
Problemas de la implementación anterior
getCurrentThreadUserTime() abría el archivo /proc/self/task/<tid>/stat y parseaba los campos 13 y 14 para calcular el tiempo de CPU en modo usuario
- Requería varias etapas: construir la ruta del archivo, abrirlo, leer un búfer, parsear cadenas y llamar a
sscanf()
- También incluía una lógica compleja con
strrchr() para encontrar el último ) porque el nombre del comando puede contener paréntesis
- En cambio,
getCurrentThreadCpuTime() solo hacía una llamada a clock_gettime(CLOCK_THREAD_CPUTIME_ID)
- Según un reporte de bug de 2018 (JDK-8210452), la diferencia de velocidad entre ambos métodos llegaba a ser de 30 a 400 veces
Comparación entre la ruta de acceso a /proc y la ruta con clock_gettime()
- El método basado en
/proc incluía múltiples llamadas al sistema y generación de cadenas dentro del kernel, como open(), read(), sscanf() y close()
- El método con
clock_gettime() realiza una única llamada al sistema y lee directamente el valor de tiempo desde la estructura sched_entity
- Bajo carga paralela, el acceso a
/proc sufría más latencia por la contención de locks dentro del kernel
Nueva implementación
- El estándar POSIX define que
CLOCK_THREAD_CPUTIME_ID debe devolver tiempo de usuario + tiempo de sistema
- El kernel de Linux codifica el tipo de reloj en los bits bajos de
clockid_t
00=PROF, 01=VIRT (solo usuario), 10=SCHED (usuario + sistema)
- Si se cambian a
01 los bits bajos del clockid obtenido con pthread_getcpuclockid(), se puede convertir en un reloj dedicado solo al tiempo de usuario
- El nuevo código elimina la E/S de archivos y el parseo, y devuelve el tiempo de usuario únicamente con una llamada a
clock_gettime()
Resultados de rendimiento
- Antes del cambio, el tiempo promedio por llamada era de 11.186μs; después, 0.279μs, una mejora de alrededor de 40 veces
- La medición se hizo en un entorno de 16 hilos y coincide con el rango original reportado de 30 a 400 veces
- En el perfil de CPU desaparecieron las llamadas al sistema relacionadas con abrir y cerrar archivos, quedando solo una llamada a
clock_gettime()
Optimización adicional con el fast-path del kernel
- El kernel ofrece un fast-path que accede directamente al hilo actual cuando en
clockid está codificado PID=0
- Si la JVM construye el
clockid directamente en vez de usar pthread_getcpuclockid() e inserta PID=0, puede omitir la búsqueda en el radix tree
- Usando un
clockid construido manualmente, el tiempo promedio bajó de 81.7ns a 70.8ns, una mejora adicional de aproximadamente 13%
- Sin embargo, como depende de detalles internos del kernel, como el tamaño de
clockid_t, existe preocupación por la pérdida de legibilidad y compatibilidad
Conclusión y lecciones
- Al eliminar 40 líneas, se cerró una brecha de rendimiento de 400x, sin nuevas funciones del kernel y solo aprovechando la estructura detallada del ABI existente
- Se resalta el valor de estudiar el código fuente del kernel: POSIX garantiza portabilidad, pero el código del kernel muestra los límites de lo posible
- También destaca la importancia de revisar supuestos antiguos: parsear
/proc tenía sentido en el pasado, pero hoy resulta ineficiente
- Este cambio se incluirá en JDK 26 (previsto para marzo de 2026), ofreciendo una mejora automática de rendimiento al llamar a
ThreadMXBean.getCurrentThreadUserTime()
5 comentarios
Impresionante.
> Si se volvió 2 veces más rápido, puede que haya sido algo inteligente; si se volvió 100 veces más rápido, probablemente solo dejaron de hacer algo tonto.
Creo que no está del todo equivocado, pero cuando está involucrado el kernel, me parece que incluso darse cuenta de que algo iba lento debió de haber sido realmente difícil.
¿Cómo se puede descubrir este tipo de cosas en un proyecto? No parece fácil darse cuenta solo por ejecutar IA...
Al ver casos como estos, pienso que yo también quiero aprender y vivir algo así algún día.
De hecho, ya es difícil lograr una mejora de 2 a 3 veces rehaciendo todo el código, así que conseguir hasta 400 veces más rendimiento simplemente cambiando unas cuantas líneas es realmente impresionante.
Comentarios de Hacker News
Descubrí que la pregunta “¿cuánto tiempo de CPU usó este hilo?” es una operación muchísimo más costosa de lo que parece
Sin una referencia del nivel de un reloj atómico, me parece difícil afirmar valores absolutos
clock_gettime()evita el cambio de contexto mediante vDSO. Por eso también se ve su rastro en el flamegraphCLOCK_VIRToCLOCK_SCHEDtodavía se necesita una llamada syscallCLOCK_THREAD_CPUTIME_IDtermina yendo al kernel, porque necesita consultar la task structComo referencia, el código fuente del kernel relacionado está en posix-cpu-timers.c,
cputime.c,
gettimeofday.c
PERF_COUNT_SW_TASK_CLOCKtambién se pueden lograr mediciones de alrededor de 8 nsLa idea es leer desde una página compartida vía
perf_event_mmap_pagey calcular el delta con una llamada ardtscEstá poco documentado y casi no hay implementaciones open source
perf_eventy los permisos que requiere, parece más adecuado para hilos de larga duraciónseqlock. Si es para evitar que ocurra un cambio de contexto entre los valores de la página yrdtscProbablemente sea una estructura donde, después de
rdtsc, se vuelve a verificar el valor de la página y, si cambió, se reintentaPor cierto,
clock_gettimetambién es una syscall virtual basada en vdsoclock_gettimeno usa syscall, sino vdsoCuando solo ves el código, puede parecer que todo está bien, pero al mirar el flamegraph muchas veces piensas: “¡¿qué es esto?!”
Encontré varios problemas, como inicialización que no era estática y una llamada de logging de una sola línea que provocaba una serialización costosa
Normalmente uso el generador HTML de async-profiler, pero esta vez usé la herramienta de Brendan para tener un solo SVG
/proc, del profiling con eBPF y de la historia de una ABI de user-space poco documentadaDejé más detalles en mi post del blog
Tuit de origen
El blog de Jaromir también estuvo realmente genial