30 puntos por GN⁺ 2026-01-15 | 5 comentarios | Compartir por WhatsApp
  • 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

 
crawler 2026-01-15

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.

 
[Este comentario fue ocultado.]
 
princox 2026-01-19

¿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.

 
aobamisaki 2026-01-15

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.

 
GN⁺ 2026-01-15
Comentarios de Hacker News
  • Soy el autor. Después de mi publicación anterior sobre el bug del kernel, revisé cómo la JVM reporta por sí sola la actividad de los hilos
    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
    • Para hablar de mediciones en nanosegundos hay que entender muy bien la estabilidad y precisión del reloj
      Sin una referencia del nivel de un reloj atómico, me parece difícil afirmar valores absolutos
    • Me pregunto si analizaron por qué la distribución se extiende por varios órdenes de magnitud. Eso en sí mismo es un fenómeno interesante
    • De verdad agradecí el resumen TL;DR corto. Ese tipo de resumen baja la barrera de entrada del artículo y motiva a leerlo
    • Dejó una reacción de “no sorprende (Quelle Surprise)”
  • clock_gettime() evita el cambio de contexto mediante vDSO. Por eso también se ve su rastro en el flamegraph
    • Pero eso solo aplica a algunos clocks. En casos como CLOCK_VIRT o CLOCK_SCHED todavía se necesita una llamada syscall
    • Si miras debajo del frame de vDSO, sigue habiendo una syscall. Parece que no hay implementada una ruta rápida (fast path) para ciertos clock id
    • CLOCK_THREAD_CPUTIME_ID termina yendo al kernel, porque necesita consultar la task struct
      Como referencia, el código fuente del kernel relacionado está en posix-cpu-timers.c,
      cputime.c,
      gettimeofday.c
  • Usando PERF_COUNT_SW_TASK_CLOCK también se pueden lograr mediciones de alrededor de 8 ns
    La idea es leer desde una página compartida vía perf_event_mmap_page y calcular el delta con una llamada a rdtsc
    Está poco documentado y casi no hay implementaciones open source
    • Es un truco realmente genial. Eso sí, por la configuración de perf_event y los permisos que requiere, parece más adecuado para hilos de larga duración
    • Preguntan por qué hace falta seqlock. Si es para evitar que ocurra un cambio de contexto entre los valores de la página y rdtsc
      Probablemente sea una estructura donde, después de rdtsc, se vuelve a verificar el valor de la página y, si cambió, se reintenta
      Por cierto, clock_gettime también es una syscall virtual basada en vdso
    • clock_gettime no usa syscall, sino vdso
  • El flamegraph es una herramienta realmente excelente
    Cuando 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
    • A mí también me gustan los icicle graph. Se acumulan en la dirección opuesta al flamegraph, así que es más fácil ver cuellos de botella cuando varias rutas llaman a una biblioteca común
    • Si abres este ejemplo SVG en una pestaña nueva, puedes hacer zoom interactivo
    • Los experimentos de profiling y optimización de rendimiento son una de las partes más divertidas del desarrollo. Hay muchas sorpresas de “¿por qué esto es tan lento?”
    • También hubo quien opinó que la combinación de parsing de strings y memoization sonaba extraña. En realidad, el problema venía de no cachear el parsing de patrones regex costosos
    • Para quien quiera probar flamegraph por primera vez, preguntan por los conceptos básicos y por dónde empezar
  • Me sorprendió que “abrir imagen en una pestaña nueva” realmente ofreciera interacción SVG
  • Soy el autor del parche para OpenJDK. Hablé del overhead de memoria al leer /proc, del profiling con eBPF y de la historia de una ABI de user-space poco documentada
    Dejé más detalles en mi post del blog
    • Me preguntaron por qué la implementación original estaba hecha así. Hacer IO de archivos y parsing de strings en cada llamada es ineficiente, pero imagino que en su momento hubo una razón
    • Jaromir vio mi post y dijo “yo también escribí un borrador en la misma época”, así que enlazamos nuestros artículos. Me alegró que dijera que el mío era más riguroso
  • Que algo esté escrito en un lenguaje de sistemas como C o C++ no significa que siempre vaya a ser rápido. La velocidad depende muchísimo de qué se está haciendo
  • Leer vía vDSO es muchísimo más rápido porque evita la transición al kernel, la serialización del buffer y el parsing
  • Comparten la cita: “si lo hiciste 2 veces más rápido, quizá fue algo inteligente; si lo hiciste 100 veces más rápido, probablemente solo dejaste de hacer algo tonto
    Tuit de origen
  • El equipo de QuestDB está en la élite absoluta en este campo. Tanto la gente como el software son excelentes
    El blog de Jaromir también estuvo realmente genial