Los Mutex más rápidos
(justine.lol)- En situaciones de alta contención, las diferencias entre implementaciones de Mutex se vuelven muy evidentes, y
pthread_mutex_tde Cosmopolitan Libc muestra tiempos de ejecución más cortos y menor uso de CPU que las principales implementaciones de Windows y Linux - En una prueba en Windows con un Threadripper 29070WX de 24 núcleos, Cosmopolitan fue 2.75 veces más rápido que Microsoft SRWLOCK y usó 18 veces menos recursos de CPU
- En Linux con un Threadripper Pro 7995WX de 96 núcleos, es 3 veces más rápido que glibc y 11 veces más rápido que musl libc, con una brecha aún mayor en tiempo de CPU
- En MacOS con M2 Ultra, Apple Libc queda ligeramente por delante, y Cosmopolitan usa un algoritmo simple que depende de la llamada al sistema ulock de XNU en entornos ARM
- La base del rendimiento es la integración de nsync de Google, con una ruta rápida CAS, cola de espera, futex/ulock/
WaitOnAddress(), prevención de inanición y diseño de designated waker como claves
Metodología del benchmark de Mutex con contención
- La prueba crea 30 hilos, y cada hilo incrementa el mismo entero global
g_chores100,000 veces - Cada incremento se ejecuta dentro de una sección crítica muy pequeña entre
pthread_mutex_lock()ypthread_mutex_unlock() - Las mediciones están en microsegundos y distinguen tres tiempos
- wall time: el tiempo real que tarda en ejecutarse el programa, incluyendo la sobrecarga de creación de hilos y join
- user time: tiempo de CPU consumido en espacio de usuario
- system time: tiempo de CPU consumido en el kernel
- Como varios hilos se ejecutan en paralelo, la suma de user time y system time puede ser mayor que el wall time
- En escenarios sin contención, la diferencia de rendimiento entre implementaciones suele ser pequeña, pero en situaciones con contención las diferencias de diseño del Mutex se notan mucho
Windows: Cosmopolitan es más rápido que SRWLOCK
- La prueba en Windows se realizó en un Threadripper 29070WX de 24 núcleos
- MutexShootout, de Mark Waterman, evaluó SRWLOCK de Windows como la implementación más fuerte en escenarios de alta contención
- Bajo las mismas condiciones,
pthread_mutex_tde Cosmopolitan registró menor wall time y menor uso de CPU que SRWLOCK
| Implementación | wall time | user time | system time |
|---|---|---|---|
pthread_mutex_t de Cosmopolitan |
148,940µs | 328,125µs | 62,500µs |
| Microsoft SRWLOCK | 410,416µs | 5,515,625µs | 1,640,625µs |
Microsoft CRITICAL_SECTION |
949,187µs | 7,937,500µs | 5,078,125µs |
MSVC 2022 std::mutex |
991,750µs | 12,156,250µs | 4,031,250µs |
| spin lock | 1,165,435µs | 24,515,000µs | 15,000µs |
pthread_mutex_t de Cygwin |
9,780,803µs | 1,937,000µs | 6,156,000µs |
- El Mutex de Cosmopolitan es 2.75 veces más rápido que Microsoft SRWLOCK y usa 18 veces menos recursos de CPU
- Comparado con el Mutex de Cygwin, que ofrece una implementación POSIX en Windows, es 65 veces más rápido
- En este caso de uso, el Mutex de Cygwin resulta incluso más lento que un spin lock
Linux: una brecha de tiempo de CPU mayor que la de wall time
- La prueba en Linux se realizó en un Threadripper Pro 7995WX de 96 núcleos
| Implementación | wall time | user time | system time |
|---|---|---|---|
pthread_mutex_t de Cosmopolitan |
36,905µs | 44,511µs | 23,492µs |
pthread_mutex_t de glibc |
101,353µs | 150,706µs | 2,724,851µs |
| spin lock | 202,423µs | 4,694,749µs | 2,000µs |
pthread_mutex_t de Musl libc |
411,013µs | 2,167,898µs | 9,926,850µs |
- El Mutex de Cosmopolitan es 3 veces más rápido que glibc y 11 veces más rápido que musl libc
- En términos de tiempo de CPU, usa 42 veces menos que glibc y 178 veces menos que musl libc
- En cargas de trabajo donde todos los hilos deben realizar trabajo serializado, Cosmopolitan puede verse en
htopcomo si solo un núcleo estuviera activo - En la misma situación, glibc y musl libc pueden llenar mucho el uso de CPU, lo que aumenta la carga al ejecutar varios trabajos en el mismo servidor
MacOS: Apple Libc queda apenas por delante
- La prueba en MacOS se realizó en un M2 Ultra
| Implementación | wall time | user time | system time |
|---|---|---|---|
| Apple Libc | 52,263µs | 43,202µs | 911,009µs |
pthread_mutex_t de Cosmopolitan |
54,700µs | 63,055µs | 1,003,674µs |
- En MacOS M2 ARM64, Apple Libc es ligeramente más rápida que el Mutex de Cosmopolitan
- La implementación general de Mutex de Cosmopolitan no funciona bien en esta plataforma
- En MacOS ARM, Cosmopolitan usa un algoritmo más simple basado en Futexes Are Tricky, de Ulrich Drepper
- Este enfoque deja la mayor parte del trabajo pesado a la llamada al sistema ulock de XNU y, como resultado, logra un rendimiento casi igual al de la implementación de Apple
La base del rendimiento: integración con nsync
- La clave del rendimiento del Mutex de Cosmopolitan es la integración de la biblioteca nsync de Google
- nsync es una biblioteca con 371 estrellas en GitHub y fue escrita por Mike Burrows, de Google
- Durante la integración con Cosmopolitan se realizaron los siguientes trabajos
- Se encontró y corrigió un bug que llevaba mucho tiempo sin detectarse en la función de unlock del Mutex de nsync
- Se portó a operaciones atómicas C11 en AARCH64, haciendo que el Mutex de nsync con contención fuera 30% más rápido que upstream nsync
- Se reescribieron integraciones de sistema tipo futex para permitir portabilidad en runtime
- Se hizo que funcionara de forma fluida con la cancelación de hilos POSIX
Cómo funciona nsync
- nsync primero intenta de inmediato un CAS (compare and swap) optimista para obtener el lock rápidamente
- Si no consigue el lock, agrega el hilo llamador a una lista doblemente enlazada de espera
- Cada waiter tiene su propio semáforo en una línea de caché separada e independiente
- Un hilo que entra en estado de espera ya no toca el lock principal
- Esto es importante para reducir la sobrecarga de comunicación cuando varios núcleos tocan la misma línea de caché
- Como contexto relacionado, se enlaza What Every Programmer Should Know About Memory, de Ulrich Drepper
- nsync usa el futex del sistema operativo para poner hilos a dormir
- En MacOS, el futex se llama ulock
- En Windows,
WaitOnAddress()cumple el rol de futex - De los sistemas operativos que soporta Cosmo, solo NetBSD no tiene futex, e implementa semáforos POSIX en espacio de kernel y requiere un nuevo descriptor de archivo por cada semáforo
- nsync evita la inanición (starvation) con el concepto de “long wait”
- Si un waiter se despierta 30 veces pero internamente falla cada vez al adquirir el lock, se agrega un bit al lock para impedir que hilos que aún no han esperado obtengan el lock
- Cuando existe este bit, el CAS inicial de los hilos recién llegados falla hasta que la cola de espera se vacía hasta cierto punto
- Los casos de uso con contención en secciones críticas pequeñas se aceleran con el concepto de designated waker
- Cuando un hilo se despierta e intenta obtener el lock, se establece un bit en el lock principal
- En nsync, la función de unlock tiene la responsabilidad de despertar al siguiente hilo en espera
- Gracias a este bit, el hilo que está haciendo unlock no necesita despertar a un segundo waiter cuando ya hay un hilo despierto
- El código fuente relacionado está en
cosmopolitan/third_party/nsync/mu.cycosmopolitan/libc/intrin/pthread_mutex_lock.c
Servicio real y código de verificación
- Como demo en vivo que usa el Mutex de Cosmo, se puede ver el servidor http://ipv4.games/
- Este servicio se ejecuta en una VM GCE de 2 núcleos y hasta ahora ha resistido una DDoS de botnet de hasta 49,131,669 IPs
- Gracias a nsync, fue posible mover las consultas SQL a hilos en segundo plano y usar una estructura en la que los hilos se envían mensajes entre sí
- Los indicadores de estado se pueden consultar en /statusz
- El código del benchmark mide el wall time con
gettimeofday()y el user time y system time congetrusage() - Al final, verifica que
g_chores == THREADS * ITERATIONSpara comprobar que se realizaron todos los incrementos
Precauciones al mirar spin locks
- En escenarios sin contención, las diferencias entre implementaciones de Mutex son pequeñas, y un spin lock de unas pocas líneas puede ser mejor
- Pero un spin lock solo debería usarse cuando realmente no hay otra opción
- Es útil en lugares donde las restricciones de muy bajo nivel, como en el kernel, dificultan usar enfoques más complejos
- También puede usarse un spin lock como detalle interno de implementación dentro de un lock de nsync
- Si se evalúa el rendimiento de un lock solo por wall time, un spin lock puede parecer bueno, por lo que también hay que revisar el tiempo de CPU con
getrusage()
1 comentarios
Opiniones de Hacker News
Las nuevas implementaciones de mutex y sus comparaciones siempre son interesantes, pero no me gusta este enfoque de benchmark. Parece casi un microbenchmark.
Quienes despliegan locks rápidos en la práctica suelen usar programas multihilo muy grandes como principal medio de prueba de rendimiento. En cargas de trabajo complejas, donde varían la duración de la sección crítica, la cantidad de hilos en competencia y el grado de contención, parece que cambian los factores que hacen que un mutex sea rápido o lento.
Como referencia, escribí el lock rápido de WebKit, inventé la abstracción ParkingLot para implementar locks (también se usa en Rust y Unreal Engine), y hace tiempo hice investigación y publiqué un artículo sobre locks rápidos para Java.
Como programador de audio en tiempo real, me importa más el costo de tomar un mutex que todavía no está bloqueado. En nuestra app, esa situación es por mucho la más común. Del mismo modo, también quisiera saber el costo de una operación
try-lockque va a fallar, no cuando N hilos compiten.Como Cosmopolitan es open source, podría medirlo yo mismo, pero aun así se echa de menos.
Al igual que con los hash maps, rara vez un único hash map es mejor para todas las cargas de trabajo posibles.
Un mutex que, al fallar al adquirir el lock, duerme un tiempo fijo (por ejemplo, 100 µs) casi siempre agrupará el trabajo, se acercará a ese comportamiento y podrá “ganar” en el benchmark. Pero en una aplicación real, con incluso un poco de contención, ese mutex sería terrible.
No digo que este mutex sea malo ni que el mutex pthread sea bueno; digo que ese microbenchmark no mide algo que permita predecir el rendimiento en aplicaciones reales.
En la parte que dice que “Cosmopolitan Mutex es bueno porque usó una biblioteca llamada nsync”, nunca había oído hablar de nsync, pero Mike Burrows también escribió la implementación de mutex de producción de Google: https://github.com/abseil/abseil-cpp/blob/master/absl/synchr...
Por eso me pregunto por qué esa implementación de mutex quedó fuera del benchmark. Y si en macOS delega a
__ulock, parece que se podría lograr de forma más simple usando solo las funciones miembrowait()ynotify_one()de la biblioteca atomic de libc++.Hace tiempo también hubo un hilo grande relacionado con la mejora de la implementación de mutex en Rust: https://github.com/rust-lang/rust/issues/93740#issuecomment-.... Lo interesante es que allí se discute en detalle el funcionamiento interno de casi todas las implementaciones populares de mutex.
Puede que sea cierto, pero no puedo confirmarlo directamente. Era un ingeniero extremadamente inteligente y enfocado en la eficiencia. Aunque no solíamos permanecer mucho tiempo en un mismo servidor.
La implementación actual del mutex de Rust entró a principios de este año y, aunque quizá no sea muy distinta en Linux, entiendo que en Windows y Mac sí es trabajo nuevo.
Aun así, la explicación de Mara sobre los interiores de otras implementaciones sigue siendo interesante, pero conviene comprobar si esa información está desactualizada para tu caso.
https://awards.acm.org/award-recipients/burrows_9434147
La frase “Todavía es una biblioteca C nueva y tiene partes ásperas, pero está mejorando tan rápido que no usarla en producción empieza a parecer una irresponsabilidad profesional” suena bastante rara. Valoro mucho el proyecto Cosmopolitan, pero este tipo de afirmaciones exageradas de superioridad suelen ser una señal de alerta bastante mala.
Entiendo que a algunas personas les pueda parecer brusco. Antes también hubo un drama de ese tipo en llamacpp.
Lo más prioritario en producción no es que “mejore rapidísimo”, sino la estabilidad, previsibilidad y confiabilidad. Claro que el rendimiento también importa. Un código más rápido puede reducir infraestructura, lo cual es bueno en costos y en impacto ambiental. Pero la velocidad va al final de la lista.
Por ejemplo, APE me parece un hack muy impresionante, pero también se puede criticar diciendo: “¿Entonces ahora, en vez de ser inseguro en una sola plataforma, puede ser inseguro en varias plataformas al mismo tiempo?”.
Cuanto más tiempo paso en tecnología, más me doy cuenta de que los beneficios mutuos perfectos son rarísimos, y que la mayoría de las cosas son trade-offs, con ganancias y pérdidas al mismo tiempo.
Es totalmente tangencial, pero como desarrollador de juegos terminé apreciando los mutex lentos que hacen mucho trabajo de depuración en todas las builds de desarrollo. Por ejemplo, que tengan un nombre/ID de depuración, rastreen al dueño, reporten al profiler el tiempo consumido en contención y también reporten los cambios de propiedad.
Los juegos tienden a estructurar la concurrencia de otra manera, y también han desarrollado patrones para evitar locks. Pero esos patrones son difíciles de usar y obligan a los programadores a cambiar la estructura. La mayor parte del código empieza con “pongamos un lock aquí por ahora y pasemos el milestone”.
Incluso los locks rápidos pueden volverse impredeciblemente lentos y romper cualquier garantía de tiempo real que hubiera. Pueden ser rápidos en promedio, pero la latencia de cola no desaparece. No quiero ser la persona que vuelve a investigar “nuestro juego se traba”, pero normalmente termino siendo esa persona.
Así que prefiero usar locks lentos. Esos que aparecen enormes y en rojo en el profiler. Si ves que te están pegando, los refactorizas y los eliminas.
Sé que es una exigencia difícil. En una producción AAA, la gente que sabe usar un profiler se puede contar con los dedos. Lo he visto en varias producciones y siempre fue así.
Perdón por la queja, pero espero que continúe la investigación en primitivas y algoritmos de concurrencia rápidos.
En juegos, si es posible, nunca quieres contención de locks, y en muchos casos puedes demostrar que tomar un lock es innecesario. Por ejemplo, cada frame se divide en etapas, y el acceso mutable a cierto recurso compartido solo hace falta en una etapa específica. Casos como
update()antes derender(), o hot reload de assets.Con scoped threads y las reglas de préstamo de Rust, puedes estructurarlo de modo que no haga falta ningún mutex, y puedes tener la certeza de que, si más adelante el código cambia y pasa a ser necesario, el compilador dará un error de forma estricta.
Siempre que se pueda, es mejor recibir un error de compilación que un pico en el profiler.
Por un lado, la familia Cosmo/APE/redbean parece realmente impresionante, y los comentarios en los artículos relacionados en general son positivos; tampoco hay mucho que refute el concepto en sí. Pero, por otro lado, casi no he oído que otra gente lo esté usando
No todo el mundo comparte mucho su trabajo, pero si ya pasaron varios años, esperaría haber visto al menos algunos posts de retrospectiva de proyectos. Todas las menciones a Cosmo/APE/redbean que vi salieron del sitio de Justine
Por eso me da curiosidad. ¿Hay alguna trampa oculta? ¿Es una herramienta que hace algo malo para obtener esos resultados? ¿Es una broma o troleo al estilo tom7 que no entiendo porque no conozco a fondo compiladores o runtimes? ¿O de verdad son herramientas ingeniosas que todavía no se han difundido mucho?
La mayoría de quienes crean software multiplataforma no quieren un único ejecutable que corra en todas las plataformas, sino una única base de código que funcione correctamente en cada plataforma soportada
Desde esa perspectiva, un lenguaje como Go, que permite compilar cruzado a todos los targets si evitas CGO, es una delicia. Pero la magia de APE para ejecutarse de tres formas, por muy ingeniosa que sea, no inspira confianza en que vaya a funcionar para siempre, y para la mayoría tampoco ofrece mucho beneficio práctico
Cada plataforma tiene sus propios requisitos de empaquetado y firma, así que es mejor compilar por separado para cada target de plataforma
Por ejemplo, si ya puedes compilar cruzado un proyecto para otros sistemas operativos y plataformas, o si ya tienes esa infraestructura de build, no hay motivo para buscar una solución que produzca un único binario que funcione en todas partes
Además, APE usa hacks ingeniosos para ejecutarse en varios sistemas operativos. ¿Qué pasa si, a medida que evolucionan los formatos de ejecutables, ese hack se rompe algún día? ¿Y si nadie tiene tiempo de arreglar APE para adaptarlo a ese cambio?
En cambio, las herramientas aburridas como gcc, clang, go y rust se seguirán actualizando y seguirán funcionando en sistemas operativos que evolucionan. Por eso simplemente me quedo con lo aburrido. No me preocupo por lo ingenioso porque lo aburrido, para mí, simplemente funciona bien
También se puede ejecutar sin pesos integrados y hacer que lea los pesos desde el sistema de archivos. Puede ser la forma más fácil de “descargar y ejecutar al instante” un LLM local
Pero para usarlo como tecnología base, como
libc, parece más útil sobre todo como juguete divertido o para pequeños proyectos personalesEn ese contexto, cuando se lo presenta como una alternativa seria a cosas como
glibc,muslomsvcrt, se siente un poco raro. Es un hack muy simpático, pero si lo encontrara en algo de lo que dependo en serio, me preocuparía bastanteTambién suben regularmente a Hugging Face modelos populares reempaquetados en ese formato: https://huggingface.co/models?search=llamafile
Dicho eso, si tiene utilidad práctica más allá de probar rápidamente modelos pequeños es otra cuestión
Si es tan bueno, me pregunto por qué no todas las bibliotecas de C adoptaron el mismo truco.
Mi suposición es que esos trucos probablemente solo sean consistentemente rápidos en ciertas arquitecturas, ciertos modelos de CPU, ciertas cargas de trabajo o patrones de acceso. Si se hicieran benchmarks adecuados con diversas cargas de trabajo en todo el hardware soportado, quizá no se obtendría la misma ventaja.
O tal vez la semántica de la API pthread que Cosmopolitan intenta implementar sea sutilmente distinta, y esta implementación no cumpla estrictamente con la especificación.
Me cuesta imaginar que varios autores de libc no estén al día con la investigación más reciente sobre primitivas del sistema operativo.
El malloc de glibc es más o menos usable, pero en velocidad general y escalabilidad queda fácilmente por detrás de alternativas más modernas. Sufre mucha fragmentación, empeora con el tiempo y tiene muchos ajustes como
MALLOC_ARENA_MAXque impactan bastante en cargas de trabajo reales. El malloc de musl es terrible en rendimiento a todos los niveles. Usar el asignador de musl en programas multihilo arruinaba tanto el rendimiento que casi podría llamarse negligencia.musl tampoco tiene cosas como rutinas de comparación de cadenas optimizadas con SIMD. Te sorprendería saber cuántos ciclos de CPU se gastan en estas operaciones en programas no triviales; aparece claramente en perfiles reales, y mejorarlo beneficia de forma casi universal a casi todos los programas. Las rutinas optimizadas de glibc son buenas, pero aun así parece que podrían ser más rápidas.
Estas no son “optimizaciones especializadas para una sola arquitectura que no se generalizan”. En particular, estas dos áreas están bien exploradas y entendidas, y reducen el tiempo de reloj de pared entre 2 y 5 veces en casi cualquier carga de trabajo, además de mejorar mucho el uso del conjunto de trabajo a largo plazo. Entonces, ¿por qué no se adoptaron? Como siempre, probablemente porque había otras cosas que hacer, o porque existían prioridades en conflicto, como en musl, que prioriza la simplicidad sobre el máximo rendimiento.
No estoy culpando a esos proyectos. Nadie dice “mi programa es horriblemente lento, está diseñado para no hacer nada bien y estoy orgulloso de eso”. Pero la idea de que quienes trabajan en esos proyectos solo eligieron diseños en una frontera de Pareto perfecta no es nada realista y no refleja cómo funcionan realmente la mayoría de los proyectos.
Cambiar algo en glibc o en el equivalente del lado de C++ tarda una eternidad.
Hay varios tipos de primitivas de sincronización, y pthreads solo soporta algunas. Si te limitas a eso, por lo general estás renunciando a rendimiento a cambio de portabilidad.
No sé en el caso de los mantenedores de libc, pero como alguien que mantiene algunas cosas, no intento implementar la investigación más reciente. Intento mantener la estabilidad y asegurarme de que el rendimiento sea aceptable. Las implementaciones de investigación quedan fuera de mi presupuesto de “mantenimiento”.
Un hombre y un estadístico caminan por la calle y ven un billete de 50 euros. El estadístico sigue caminando, y el hombre se detiene y dice: “Mira, hay dinero en el suelo”. Entonces el estadístico dice: “Debe de ser falso. Si fuera real, alguien ya lo habría recogido”, y sigue caminando. El otro hombre recoge el dinero.
Los hilos y los mutex son de las cosas que más complejidad agregan en ciencias de la computación. Siempre miro con escepticismo una implementación nueva hasta que se haya usado a gran escala durante años.
Los bugs en mecanismos de threading como estos muchas veces se escapan incluso a las revisiones más intensas. Cuando Java apareció a mediados de los 90, expuso todo tipo de bugs de hilos y mutex en Solaris.
No necesitamos la implementación de mutex más rápida, sino una implementación confiable.
Este código no hace benchmark del rendimiento del bloqueo de mutex, sino de la contención de mutex. Si estás usando locks de esta forma, deberías reevaluar tu código.
Cada hilo bloquea y desbloquea el mutex cada vez que incrementa
g_chores. Esto genera el overhead de adquirir y liberar el mutex con frecuencia, repetido 100,000 veces por hilo.Ese overhead oculta las diferencias reales de rendimiento entre los mecanismos de lock, porque el benchmark está dominado por la contención del lock y no por trabajo real. Este tipo de benchmark no sirve.
Soy fan de Justine y de su trabajo, pero probablemente este sea de los casos de prueba menos interesantes para un benchmark de mutex. Para empezar, debería evitarse una situación en la que varios hilos estén golpeando constantemente el mismo mutex.
Por eso no me parece muy interesante qué implementación de mutex maneja mejor este caso.
Se omitió algo importante: bajo contención, un lock con mal rendimiento puede tener efectos sistémicos muy negativos, como crear hotspots en la red de memoria, y eso también se revelaría aquí.
Se me ocurren varios casos en los que varios hilos se concentran en el mismo mutex. Un ejemplo simple es llenar simultáneamente una estructura de datos como una lista o un diccionario.
También podría hacerse con paso de mensajes, pero puede usar más memoria y ser más lento que esperar para escribir en una ubicación compartida.
Producción no se trata de velocidad, eficiencia ni de hacks obviamente “ingeniosos”.
Si tengo que sacrificar el 50% de eficiencia para garantizar que no me llamen a las 3 de la mañana de un domingo a arreglar un sistema roto, elegiré eso cada vez.
Producción se trata de confiabilidad, y escribir código confiable es 10 veces más difícil que escribir código “rápido”.