1 puntos por GN⁺ 2025-03-19 | 1 comentarios | Compartir por WhatsApp
  • Al reconstruir el mismo código fuente de jq y cambiar el asignador, el tiempo para procesar un GeoJSON de 500 MB bajó de 4.606 s a 2.428 s, haciéndolo 1.90 veces más rápido que el binario de Ubuntu
  • El benchmark se realizó en un Ryzen 9 9950X usando el parcel map del Alameda County Assessor, extrayendo SitusCity con la condición TotalNetValue < 193000
  • Solo con una reconstrucción simple ya hubo una mejora de 2 a 4%, y la combinación de clang-18, -O3, -flto y -DNDEBUG logró 1.20 veces el rendimiento del paquete de Ubuntu
  • En el perfil apareció con fuerza el costo de asignación de memoria, por lo que se compararon TCMalloc, jemalloc y mimalloc; en los experimentos con LD_PRELOAD, mimalloc fue el más rápido
  • La compilación final enlazada con mimalloc también registró 0.755 s frente a 1.424 s en otro caso de procesamiento de JSON de 2.2 GB, mostrando que, según la carga de trabajo, puede haber una gran diferencia frente a la compilación predeterminada de la distribución

Carga de trabajo base y método de medición

  • El objetivo de la prueba es la herramienta de procesamiento JSON jq, y los datos de entrada son un archivo GeoJSON de 500 MB con el parcel map del Alameda County Assessor
  • La consulta ejecutada imprime SitusCity de los elementos de la lista de parcelas que cumplen la condición TotalNetValue < 193000
    • .features[] | select(.properties.TotalNetValue < 193000) | .properties.SitusCity
  • El /usr/bin/jq predeterminado de Ubuntu tardó alrededor de 5 segundos con el archivo en caché, y el benchmark detallado se midió repetidamente con hyperfine
  • Para reducir la variación durante la ejecución, se fijó al CPU lógico 2 con taskset -c 2
    • Es una configuración para evitar el impacto de las interrupciones del sistema que corren en el CPU 0 y de la migración de CPU

Reconstrucción simple del mismo código fuente

  • Se tomó el código fuente de jq que usa Ubuntu y se ejecutaron configure y build sin flags adicionales
  • Solo con esta reconstrucción simple ya fue aproximadamente 2 a 4% más rápido que el paquete binario de Ubuntu
    • Binario reconstruido: promedio de 4.517 s
    • /usr/bin/jq de Ubuntu: promedio de 4.641 s
    • En consecuencia, el rendimiento fue de alrededor de 1.03 veces

Aplicación de clang y flags de optimización

  • En el siguiente paso se aplicaron juntos clang-18, un nivel de optimización más alto, LTO y flags relacionados con depuración y profiling
  • Los flags clave que afectaron el rendimiento fueron -O3, -flto y -DNDEBUG
    • -O3 usa un nivel de optimización más alto que -O2
    • -flto habilita la optimización en tiempo de enlace
    • -DNDEBUG reduce el costo de las aserciones, que aparecía grande en el perfil
  • Un ejemplo de configure aplicado es el siguiente
    • CC=clang-18
    • LDFLAGS="-flto -g -Wl,--emit-relocs -Wl,-z,now -Wl,--gc-sections -fuse-ld=lld"
    • CFLAGS="-flto -DNDEBUG -fno-omit-frame-pointer -gmlt -march=native -O3 -mno-omit-leaf-frame-pointer -ffunction-sections -fdata-sections"
  • Esta compilación fue 1.20 veces más rápida que el binario de Ubuntu
    • Reconstrucción optimizada: promedio de 3.853 s
    • /usr/bin/jq de Ubuntu: promedio de 4.631 s

Experimentos cambiando el asignador

  • jq es un programa complejo en C, y en el perfil la asignación de memoria apareció como el mayor costo
  • Primero se reconstruyó enlazando TCMalloc, provisto como paquete de Ubuntu
    • Se agregó -L/usr/lib/x86_64-linux-gnu -ltcmalloc_minimal a LDFLAGS
    • Binario reconstruido: promedio de 3.253 s
    • /usr/bin/jq de Ubuntu: promedio de 4.611 s
    • El resultado fue 1.42 veces más rápido que el binario de Ubuntu
  • Incluso cambiando solo el asignador mediante LD_PRELOAD en el binario predeterminado de Ubuntu se puede lograr cierta mejora
    • Predeterminado: promedio de 4.601 s
    • TCMalloc precargado: promedio de 4.082 s
    • 1.13 veces más rápido que el predeterminado

Precarga dinámica y configuración de THP

  • Se compararon mediante LD_PRELOAD jemalloc, mimalloc y TCMalloc provistos por Ubuntu
  • Esta comparación se obtuvo después de configurar las siguientes variables de entorno
    • MIMALLOC_LARGE_OS_PAGES=1
    • MALLOC_CONF="thp:always,metadata_thp:always"
    • GLIBC_TUNABLES=glibc.malloc.hugetlb=1
  • Como resultado, mimalloc fue el más rápido
    • glibc predeterminado: promedio de 4.123 s
    • TCMalloc precargado: promedio de 4.130 s
    • jemalloc precargado: promedio de 3.510 s
    • mimalloc precargado: promedio de 3.154 s
  • Activar THP beneficia tanto al asignador de glibc como a jemalloc y mimalloc
  • THP + mimalloc es 31% más rápido que THP + glibc y 48% más rápido que el valor predeterminado de glibc

Resultado final de la compilación enlazada con mimalloc

  • Al considerar que la precarga dinámica no es ideal en términos de rendimiento, en la etapa final se reconstruyó jq enlazándolo con mimalloc
  • La compilación final fue 1.90 veces más rápida que el paquete binario de Ubuntu
    • Reconstrucción con mimalloc: promedio de 2.428 s
    • /usr/bin/jq de Ubuntu: promedio de 4.606 s
    • Cada benchmark se basa en 10 ejecuciones
  • La misma compilación también se usó en otra aplicación
    • Procesó 2.2 GB de JSON en 13,000 archivos
    • Para la paralelización se usó rush
    • jq reconstruido con mimalloc: 0.755 s
    • jq del paquete de Ubuntu: 1.424 s
  • En este caso separado, la mejora de velocidad también se acerca a casi 2 veces

1 comentarios

 
GN⁺ 2025-03-19
Opiniones de Hacker News
  • Clickbait como “recompilar un paquete de Ubuntu y cambiar el asignador de memoria para hacerlo 90% más rápido” dan ganas de dar un golpe a través de TCP/IP. Era exactamente un paquete, y algunas mejoras ni siquiera se debían a la recompilación
    Aun así, alguna vez metí jemalloc en un programa con LD_PRELOAD para cambiar la implementación de malloc, y los resultados fueron bastante buenos. No medí el rendimiento, pero el uso de memoria de esa aplicación se estabilizó y también se resolvió un problema que parecía una fuga de memoria. En realidad, es muy probable que no fuera un problema de la app en sí, sino fragmentación de memoria del malloc estándar

    • Investigué el asignador de memoria de glibc, y esto no era fragmentación de memoria, sino cachés por hilo que nunca se devuelven al kernel. Aunque se llame a free(), salvo en situaciones excepcionales la memoria no se libera realmente hacia afuera
      Cuantos más hilos y núcleos de CPU haya, peor se vuelve este problema. Una solución sencilla es establecer la variable de entorno “mágica” MALLOC_ARENA_MAX=2 para limitar la cantidad de cachés. Otra opción es que la aplicación llame periódicamente a malloc_trim() para vaciar la caché, pero eso requiere modificar el código fuente
      https://www.joyfulbikeshedding.com/blog/2019-03-14-what-caus...
    • Sí, yo también casi me lo creí por un momento. Pero también es fácil culpar a Ubuntu por la causa del error. Personalmente, creo que Ubuntu ensambla bastante bien los paquetes y, de hecho, también compila con las opciones de protección de pila activadas
      En cambio, me alegra que no compile con el potencialmente problemático -O3. Puede ser bueno para algunas partes donde el rendimiento es importante, pero no quiero que todo el sistema se compile con -O3
    • Que una mejora global así pueda lograrse con solo la explicación de un artículo parece claramente exagerado. 90% más rápido es una cifra de microbenchmark
    • Me pregunto cuántas distribuciones de binarios preempaquetados se compilan con las opciones más seguras para el sistema operativo y el hardware, sin alcanzar el máximo rendimiento posible. Sinceramente, creo que la mayoría
      Hace mucho empecé a compilar Mozilla y mi kernel de Linux a mi gusto, y por lo general obtenía una mejora de rendimiento decente. Todo el propósito de la distribución Gentoo Linux, por ejemplo, es la mejora de rendimiento que se puede obtener al compilar todo desde el código fuente con optimizaciones
    • El título es clickbait, pero está bien animar a los desarrolladores de apps a probar recompilar. Especialmente cuando la CPU es el cuello de botella en algunas utilidades comunes como jq, grep, ffmpeg u ocrmypdf, y estas utilidades generales de Unix muchas veces se crean como builds de propósito general, no para una aplicación específica
  • La ingeniería es negociación. En el texto, la mayor parte de las ganancias viene de especializar el asignador de memoria. Hay que recordar que algunos proyectos son multihilo, y pueden asignar en un hilo, escribir datos en otro y liberar en un tercer hilo
    El asignador tiene que manejar eso, así que una mejora de velocidad en un proyecto puede convertirse en un crash en otro. La estrategia de reasignación también es un problema. Algunos programas preasignan y nunca vuelven a tocar malloc, pero otros liberan y vuelven a obtener memoria constantemente. También importa qué tan bien maneja la fragmentación y si el tiempo de ejecución es de 10 segundos o de 10 años. A veces, la elección del asignador es la diferencia entre estabilidad a largo plazo y velocidad a corto plazo
    Al probar video 4K y crear un editor de video que cachea frames, experimenté con varios asignadores. Con 32 MB por frame y 60 fps, es casi 2 GB por segundo por cada pista. Enseguida te topas con los límites del asignador, y te das cuenta de que, al menos, el asignador predeterminado de glibc es el mejor para la estabilidad a largo plazo. Pero en benchmarks cortos es el más lento

    • Mimalloc es un asignador de propósito general, como JEMalloc / TCMalloc. glibc tiene fama de ser un asignador bastante malo, y MIMalloc o el TCMalloc moderno, es decir, no la versión incluida por defecto en Ubuntu, están muy por delante de glibc
      Claro que la magnitud de la mejora puede variar, pero los benchmarks en general muestran mejoras generales. Si eso tiene sentido para una aplicación específica es un asunto totalmente distinto. En cuanto a los crashes, todos estos son asignadores multihilo de propósito general, así que no se comportan distinto de glibc, y los bugs podrían existir igual en glibc
    • Yo también trabajo con frames de video 8K grandes [1]. Si hablamos de los frames en sí, asignar 60 veces por segundo no es nada. La razón por la que glibc es lento es una sola: cada asignación supera DEFAULT_MMAP_THRESHOLD_MAX, que en plataformas de 64 bits es 32 MiB, así que no puedes convencer a glibc de cachearlo como está documentado en el manual de mallopt
      Cada vez pide memoria directamente al kernel con mmap y la devuelve con munmap. Estas llamadas al sistema son algo lentas y, en mi caso, el costo de generar un fallo de página en cada página de memoria en el primer acceso es lo bastante lento como para no cumplir mis objetivos de rendimiento. La solución es realmente simple: usar una lista libre propia encima de un asignador de propósito general o de mmap solo para los frames de video. Funciona bien porque se repiten de forma muy estable asignaciones de exactamente el mismo tamaño
      [1] En formato UYVY es un poco menos de 64 MiB, y en formato I420 es un poco menos de 48 MiB
    • Me cuesta un poco entender este comentario. No conozco bien C ni los compiladores de C, pero leí todo el gist, aprendí mucho y sentí que tenía valor
      Pero al leer este comentario superior me preocupa haber malinterpretado por completo el texto. Por el tono, suena como si dijera que lo que propone el gist no debe hacerse jamás, y que es una sugerencia terrible que pasa por alto toda esta complejidad. ¿Podrías ayudarme a entender si el gist original es un buen texto, si tiene puntos válidos o si no tiene ningún valor? Hasta ver este comentario pensaba que sí lo tenía, pero me di cuenta de que no soy lo bastante listo para distinguirlo
    • Por eso usar un solo asignador para todo en el mundo es una mala idea. Es terrible que las aplicaciones de un solo hilo, e incluso las aplicaciones multihilo que gestionan los recursos de forma estricta, tengan que pagar el costo de la seguridad de hilos
    • Es un sufrimiento común. Si usas el lenguaje adecuado para el proyecto, la gente dice que por tal o cual razón deberías haber usado otro lenguaje
      Si usas el algoritmo de compresión adecuado para los datos, la gente dice por qué eres tonto y deberías haber usado otro algoritmo. Hace poco tuve que comprimir una cadena JSON larga específica para meterla en Dynamo, y tras probar exhaustivamente todos los algoritmos populares, Brotli quedó muy por delante. Aun así, eso no impidió que cada persona que pasaba dijera que zlib era mejor. A veces cansa bastante
  • Gentoo Linux es, en la práctica, una distribución hecha para este tipo de personas, para que puedan optimizar su equipo Linux según sus propios usos
    Después de la configuración inicial, es bastante simple y fácil de usar. Recuerdo haber hecho muchos amigos en el canal de Gentoo Linux en Matrix; fueron tiempos divertidos
    https://www.gentoo.org/
    Como dato curioso, las primeras versiones de ChromeOS eran básicamente una instalación personalizada de Gentoo Linux. No sé si todavía usan Gentoo Linux internamente

    • Es cierto, pero vale la pena señalar que aquí optimización no necesariamente significa rendimiento
      He usado Gentoo durante 20 años, pero nunca lo usé por rendimiento. Gentoo es excelente cuando sabes cómo quieres que se comporte el sistema y te ayuda a llegar ahí
    • Es la primera vez que veo el meme “install gentoo” al estilo HN. Definitivamente es más refinado
      El objetivo de Gentoo es tener un sistema operativo que compile todos los programas desde el código fuente, en lugar de usar paquetes binarios precompilados. Esto permite mejoras avanzadas de velocidad y personalización, pero también significa que incluso los componentes más básicos, como el kernel, deben compilarse desde el código fuente. En la comunidad Linux es conocido como un sistema operativo muy complejo por su proceso de instalación exigente. Una instalación básica de Gentoo arranca directamente en una línea de comandos, y el usuario debe particionar el disco manualmente, descargar y descomprimir un paquete llamado “Stage 3 tarball”, instalar paquetes a mano y construir el sistema. Los usuarios nuevos o con poca experiencia muchas veces no saben qué hacer cuando entran al instalador y no hay interfaz gráfica. Los miembros de /g/ a menudo exageran el valor de Gentoo para engañar a usuarios nuevos y hacer que intenten instalarlo
    • Usé Gentoo continuamente desde 2003, hasta que hace muy poco, a fines de 2024, probé Void Linux y me cambié. En Void, que el usuario final pueda compilar desde el código fuente no es un objetivo declarado ni una función de arquitectura, pero hay bastantes probabilidades de lograr que funcione en la práctica
      Puede haber uno o dos tropiezos, pero si tienes suficiente experiencia general con Linux, es probable que puedas meterte en la receta de compilación, arreglarla, hacer que funcione como necesitas y contribuir los cambios upstream. Esto es resultado de un enfoque obsesivo en el minimalismo y de evitar cualquier tipo de sobreingeniería. También es algo que extrañé durante todo el tiempo que usé Gentoo. En Gentoo, siempre terminaba tocando flags USE y máscaras de paquetes de formas que no eran muy útiles para otros usuarios. El sistema de compilación era tan complejo que, durante muchos años, fue muy difícil dominarlo bien, solucionar problemas a nivel de causa raíz y contribuirlos upstream. Void también puede ser una base ideal si no quieres compilar todo el sistema desde el código fuente, pero sí quieres mezclar binarios provistos por la distribución con paquetes compilados directamente desde el código fuente
    • Usé Gentoo por un tiempo, pero por la tentación de estar ajustando todo sin parar terminaba rompiendo el sistema. No fue culpa de Gentoo, fue mía
      Después me pasé a ArchLinux, y en general me fue bien. Si usas un procesador bastante estándar, no creo que Gentoo te dé una ventaja tan grande
    • Según tengo entendido, ChromeOS basado en Gentoo está siendo reemplazado por Android
  • Si haces esto, te quedas fuera no solo de jq, sino también de las actualizaciones de seguridad de onigurama, la dependencia de parsing de expresiones regulares. Ya hubo una actualización de seguridad de onigurama antes, y si vuelve a pasar algo así podrías quedar vulnerable. jq se usa con frecuencia para parsear JSON no confiable
    Decía “Actualización de seguridad: corrige varias desreferencias de punteros inválidos, corrupción de memoria por escritura fuera de límites y desbordamiento de búfer en la pila”, y ese caso correspondía a CVE-2017-9224, CVE-2017-9226, CVE-2017-9227, CVE-2017-9228 y CVE-2017-9229

    • Si usas un gestor de paquetes en espacio de usuario como Gentoo Prefix, puedes instalar esta compilación personalizada y aun así seguir recibiendo actualizaciones de seguridad
    • Aun así, ¿no hay aquí una idea semilla para un sistema de gestión de paquetes que decida inteligentemente si compilar según la plataforma? El rendimiento parece demasiado grande como para dejarlo pasar
    • En general eso es cierto, pero en este caso no. La compilación descrita en el gist sigue enlazando dinámicamente con onigurama. onigurama está en otro paquete llamado libonig5, y se actualizará normalmente
    • Me pregunto qué tan aplicables son realmente estos CVE en general. Suena como decir que si usas una puerta interior en tu casa estás perdiendo la seguridad que ofrece la puerta de una bóveda. No es falso, pero hay una razón por la que no todas las puertas de un banco son puertas de bóveda
      No quiero menospreciar el valor del sistema CVE, pero también es difícil negar que hay grandes diferencias de impacto real entre los hallazgos
    • Totalmente cierto. Además, si cambias el asignador y modificas las flags del compilador, quizá por accidente te vuelvas inmune a ataques que dependen de una disposición de memoria específica
  • Hace tiempo que no trabajo con este tipo de cosas, pero según recuerdo, en cuanto pasas de las flags que usan los desarrolladores upstream, te ganas bugs raros y una enorme indiferencia cuando aparecen. Me refiero a los desarrolladores upstream, no a los empaquetadores de la distribución
    Nunca he usado un malloc que no sea el de libc, pero imagino que aplica el mismo principio

    • Dos cosas opuestas pueden ser ciertas al mismo tiempo. Si una persona intenta no desviarse ni un poco, está en el mismo camino que la mayor cantidad de gente y, a corto plazo, tiene la mayor probabilidad de éxito
      Pero si todos hacen eso, se convierte en un monocultivo, y los monocultivos son frágiles y malos. El código solo se vuelve un poco más robusto porque se compila en otros contextos: otras plataformas, compiladores, opciones, bibliotecas, etc. Un bug que la mayoría no tocó porque una plataforma o unas flags de compilación pasaron por casualidad justo al lado de la trampa sigue siendo un bug, y encontrarlo y corregirlo es mejor para el código. Como individuos, todos nos beneficiamos cuando el código en general se vuelve más robusto en lugar de frágil
    • Llevo mucho tiempo compilando mi propio emacs, y todavía no me he encontrado con bugs raros. Pensaba que bastaba con evitar optimizaciones inseguras
      Claro que también pensaba que -march=native era la mejora principal que veía, pero este artículo muestra que no necesariamente es así. También parece posible que las aplicaciones que usan coma flotante tengan más asperezas
    • Por el contrario, si una optimización ayuda de forma consistente en varias plataformas, puedes convencer al desarrollador upstream de implementarla directamente. No tiene que ser en todas las plataformas; si la mejora de rendimiento es lo bastante grande en una sola arquitectura, puede ser motivo suficiente para ajustar la configuración de esa compilación
  • Aunque se parece más a haberlo recompilado con otro asignador que da buenos benchmarks en flujos de trabajo específicos

    • Casi cualquier cosa puede ser mejor que malloc de glibc. Que las distribuciones sigan usando malloc de glibc en lugar de mimalloc o jemalloc es prácticamente una negligencia profesional
    • ¿Se sabe siquiera para qué cargas de trabajo es bueno malloc de glibc?
  • Me da curiosidad cómo se compara el rendimiento con este clon de jq basado en Rust
    cargo install --locked jaq
    Para activar optimizaciones específicas para una familia de CPU, también se puede agregar RUSTFLAGS="-C target-cpu=native". cargo install es una función subestimada de Rust que encaja perfecto con el tipo de uso descrito en el artículo. Como compila la herramienta desde el código fuente, se pueden elegir funciones o instrucciones específicas de la plataforma que normalmente no se incluirían en los binarios por compatibilidad con CPUs antiguas. No hace falta clonar el repositorio ni averiguar cómo compilarlo: viene incluido
    jaq[1] y yq[2] son mis opciones cada vez que estoy usando jq y necesito una mejora de rendimiento rápida y sencilla
    [1] https://github.com/01mf02/jaq
    [2] https://github.com/mikefarah/yq

    • De vez en cuando comparo jaq con jq y gojq, usando como prueba mi solución en jq para AoC 2022 day 13
      https://gist.github.com/oguz-ismail/8d0957dfeecc4f816ffee79d...
      Todavía queda por detrás de ambos
    • Como extra que quizá la gente no conozca, cuando quieres usar directamente un repositorio, cargo install también tiene la bandera --git, que permite especificar la URL del repositorio. Sirve cuando no hay un paquete público o cuando quieres un commit reciente que todavía no se ha lanzado
      La he usado varias veces antes, sobre todo como una forma sencilla de instalar rápidamente herramientas personales hechas a las apuradas después de subirlas a un repositorio, sin tener que armar un proceso de release ni copiar binarios manualmente a mis equipos personales y rastrear el commit exacto usado para compilar
  • Si de verdad vas a hacer esto, basta con hacer que Ubuntu descargue el paquete fuente exacto que quiere. En este caso, se puede usar apt-get source jq
    Luego entras al paquete y recompilas todo lo que quieras. También puedes volver a empaquetarlo para distribuirlo o archivarlo. Así obtendrás un resultado mucho más cercano al Ubuntu upstream, en lugar de un montón de errores raros e inconsistencias

  • El título induce a error. Se refiere al 90% del tiempo que se volvió más rápido; en realidad es aproximadamente 45% más rápido
    Es algo interesante si te importa cómo usamos el lenguaje. También podríamos decir que hace 90% más trabajo en el mismo tiempo, lo cual coincide con otras unidades de velocidad que usamos comúnmente, como millas por hora, palabras por minuto o bits por segundo. Pero en rendimiento de computadoras existe la convención de medir el tiempo que tarda una cantidad fija de trabajo. Supongo que se debe a que, por lo general, la cantidad de trabajo es fija y lo que cambia es el tiempo de espera. En el caso de este post, también es exactamente así, por eso el tiempo queda en el numerador. El artículo en sí es muy interesante y está bien escrito, pero no es 90% más rápido

    • Lo que induce más a error es el matiz de que se pueden hacer 90% más rápidos todos los paquetes. Esto es un paquete específico
    • Aquí parece tener más sentido usar “90% faster” como una unidad de throughput, no como una unidad de tiempo
      Además, si se usara una unidad de tiempo, no usaría la palabra “faster”. “45% less time” y “45% faster” son afirmaciones muy distintas, y ambas tienen sentido dentro y fuera de la programación
    • Artículo relacionado: https://randomascii.wordpress.com/2018/02/04/what-we-talk-ab...
    • Pensándolo mejor, creo que entiendo por qué induce a error. Es porque se expresa el cambio de un valor mayor como porcentaje de un valor menor
      Cuando decimos “reduje algo en N%”, normalmente asumimos que ese N% es del propio objeto reducido, no de otro valor
    • Creo que es correcto. Se puede decir que el paquete, es decir el código, es 45% más rápido, o que aumenta el throughput de parsing en 90%. Pero mezclar ambas cosas genera confusión
  • Al leer que un cambio tan simple puede lograr una gran mejora de velocidad, lo primero que se me ocurrió fue que habría que avisarles a los autores de jq. Puede haber trampas a tener en cuenta, o quizá podrían probarlo y hacerlo más rápido para todos
    Sea cual sea el resultado, parece útil avisarles brevemente. Pero el artículo ni siquiera parece considerar esa opción, y tampoco la veo en estos comentarios. ¿Me estoy perdiendo algo?

    • Me pregunto si Intel Clear Linux obtiene beneficios similares usando opcodes más recientes del conjunto de instrucciones
      No sé si allí el asignador de glibc también es el estándar
      https://en.m.wikipedia.org/wiki/Clear_Linux_OS