Azure Native Qumulo ahora disponible en la UE, el Reino Unido y Canadá: Más información

Mejora del rendimiento de IOPS y eficiencia de bloqueo

Escrito por:

Hoy me gustaría hablar sobre el trabajo de un día en particular que me pareció interesante.

Nuestro equipo estuvo recientemente en medio de la investigación. rendimiento de lectura para solicitudes pequeñas pero numerosas, que habían demostrado estar vinculadas a la CPU. Aproximadamente la mitad de nuestro tiempo de CPU se gastó luchando en bloqueos, y tuvimos algo de éxito en la lista de los mejores contendientes en un esfuerzo por descubrir más IOPS. Dado que ya habíamos trabajado en la reducción de la contención de bloqueos en varias áreas, comencé a preguntarme qué impacto tendrían nuestros bloqueos en el rendimiento.

Dediqué algo de tiempo a analizar nuestra implementación de bloqueo más utilizada. Mi idea era ver si había alguna manera de que pudiéramos hacer que todos nuestros candados disputados fueran un poco más rápidos. Descubrí dos implementaciones. Un "spinlock" era un envoltorio para pthread_mutex, y el otro que se usaba con menos frecuencia era un envoltorio para futex. Un pthread_mutex es una estructura masiva de 40 bytes proporcionada por la biblioteca pthreads, mientras que un futex tiene 4 bytes. Decidí obtener algunos datos sobre su rendimiento, pero ya me estaba empezando a gustar la idea de optimizar la implementación basada en futex y hacerla más estándar.

Antes de salir corriendo y hacer cambios más grandes, decidí comparar ambos bloqueos. Desde mi experiencia, la mayoría de los bloqueos que causan problemas de rendimiento tienden a ser fuertemente enfrentados. Escribí un microbenchmark que hace girar cuatro hilos donde cada hilo adquiere un bloqueo, incrementa un entero y libera el bloqueo. También reconozco que cualquier sobrecarga en la velocidad de adquisición no controlada probablemente sería bastante difícil de detectar en el producto, por lo que también escribí una variante de un solo hilo. Dejé que cada uno de ellos corriera durante 30 segundos antes de verificar el contador. Aquí están los resultados iniciales:

Incontenido Contendido (Hilos 4)
Pthread Mutex ~ 47 ma / s * ~ 13 ma / s *
Futex ~ 53 ma / s * ~ 8.4 ma / s *

* millones de adquisiciones / segundo

Validé estos resultados con una prueba rápida en el producto real, donde cambié la implementación de Pthreads por la implementación de Futex. Efectivamente, la implementación de Futex retrocedió el rendimiento. Este es un resultado frustrante, ya que sé que Pthread Mutex está implementado en la parte superior de Futex para esta plataforma. Eché un vistazo más de cerca a la implementación de Futex. Aquí están las partes importantes en el pseudo-código. Estoy simplificando intencionalmente los detalles de cómo funcionan los futexes porque es en gran medida irrelevante para esta historia.

bool trylock (futex_lock * self) {return! atomic_bool_exchange (& self-> is_locked, true); } void lock (futex_lock * self) {for (int i = 0; i <SOME_NUMBER; ++ i) {if (trylock (self)) return; pause () // insinúa a la CPU que estamos en un bucle de spinlock} while (verdadero) {futex_sleep (& self-> is_locked); if (trylock (self)) return; }} Desbloqueo vacío (futex_lock * self) {atomic_bool_set (& self-> is_locked, false); futex_wake_all (& self-> is_locked); }

Lo más obvio para cambiar aquí para mí fue ALGUIEN NÚMERO, seguido quizás por una pausa. Después de algunas iteraciones de probar diferentes números y algunas ideas diferentes sobre cómo hacer una pausa, descubrí que lo más eficiente era no girar en absoluto. La nueva cerradura era algo así.

void lock (futex_lock * self) {while (true) {if (trylock (self)) return; futex_sleep (& self-> is_locked); }}
Tema 1 Hilos 4
Pthread Mutex ~ 47 ma / s * ~ 13 ma / s *
Futex ~ 53 ma / s * ~ 8.4 ma / s *
Futex (sin hilar) ~ 53 ma / s * ~ 17 ma / s *

* millones de adquisiciones / segundo

Esto puede ser sorprendente dado que dormir en un futex es una visita única, y por lo tanto es bastante caro. Algo más caro debe estar sucediendo mientras giramos. En las CPU de Intel, hay tres estados para las líneas de caché. "I" significa que este núcleo tiene acceso exclusivo (seguro para escribir). "S" significa que la línea del caché está limpia, pero también puede estar en el caché de otro núcleo (seguro para leer). "A" significa que otro núcleo puede tener acceso exclusivo a la línea de caché (inseguro sin preguntar). Cuando varios núcleos están haciendo acceso atómico a los datos en la misma línea de caché, intercambian qué núcleo obtiene acceso exclusivo y los ping-pongs de datos entre los estados I y A. Esta es una operación costosa que potencialmente implica la transmisión de mensajes entre los sockets de la CPU.

Con estos antecedentes, nuestra mejora de rendimiento tiene mucho sentido. También abre más vías para la optimización. Futex_wake_all parece ser un problema particularmente importante aquí. En el caso de que haya más de un camarero, solo uno logrará conseguir el bloqueo, mientras que todos lucharán por el acceso exclusivo a la línea de caché. Después de algunos problemas, se me ocurrió algo como lo siguiente.

bool trylock (futex_lock * self) {auto old_state = atomic_uint_compare_exchange (& self-> estado, LOCK_FREE, LOCK_HELD); return old_state == LOCK_FREE; } bloqueo vacío (futex_lock * self) {auto old_state = atomic_uint_compare_exchange (& self-> estado, LOCK_FREE, LOCK_HELD); if (old_state == LOCK_FREE) return if (old_state == LOCK_HELD) old_state = atomic_uint_exchange (& self-> state, LOCK_CONTENDED) while (old_state! = spinlock_free) {futex_wait_if_value_equals (& self-> estado_de_estadio_de_examen_todo, LOCK_CONTENDED) estado, LOCK_CONTENDED)}} desbloqueo vacío (futex_lock * self) {auto old_state = atomic_uint_exchange (& self-> estado, LOCK_FREE); if (old_state == LOCK_CONTENDED) futex_wake_one (& self-> state)}

Este nuevo diseño tiene algunas ventajas bastante grandes para un rendimiento competitivo. Al agregar un nuevo estado en conflicto, podemos hacer que resulte seguro despertar solo uno a la vez. Si bien aún es posible llamar innecesariamente a despertar, deberíamos haber eliminado el origen de la contención de la línea de caché extra del despertar adicional. Esto condujo a algunos aumentos adicionales en el rendimiento de microbenchmark.

Tema 1 Hilos 4
Pthread Mutex ~ 47 ma / s * ~ 13 ma / s *
Futex ~ 53 ma / s * ~ 8.4 ma / s *
Futex (sin hilar) ~ 53 ma / s * ~ 17 ma / s *
Futex (estado 3) ~ 53 ma / s * ~ 20 ma / s *

* millones de adquisiciones / segundo

Si bien todavía parece haber algunas áreas menores de mejora, esto parece estar lo suficientemente por delante del Pthread Mutex que deberíamos ver algunas ganancias en el producto real si lo cambiamos. Al ejecutar nuestro punto de referencia de IOPS de lectura aleatoria, esto nos dio un aumento de ~ 4 por ciento. No es mucho, pero el cambio tuvo un impacto muy amplio en muchas cargas de trabajo de bloqueo para el producto. La reducción de tamaño de 40-> 4 bytes también redujo nuestro consumo total de RAM en algunos escenarios hasta en un gigabyte.

Artículos Relacionados

Ir al Inicio