Cómo y por qué optimizamos el algoritmo para limpiar cachés SLAB en el kernel de Linux

La creciente popularidad de los contenedores y su uso en conjunto con los grupos de control revelaron un serio problema de escalabilidad, que conduce a una caída significativa en el rendimiento en máquinas grandes. El problema es que el tiempo de derivación de las memorias caché SLAB depende de forma cuadrática de la cantidad de contenedores, y el consumo activo de grandes cantidades de memoria en un período corto puede hacer que el sistema entre en un bucle ocupado, consumiendo el 100% del tiempo del procesador. Hoy me gustaría contarles cómo resolvimos este problema cambiando el algoritmo de contabilidad para usar el grupo de control memcg para usar objetos de caché SLAB y optimizar la función shrink_slab ().

Limpieza de la memoria

¿Por qué surgió la cuestión de la optimización de procesos en el núcleo? Todo comenzó con el hecho de que uno de nuestros clientes, utilizando activamente contenedores y grupos de control de memoria (memcg), llamó la atención sobre los picos extraños del consumo de recursos del procesador que ocurren de vez en cuando. La carga normal del sistema fue de aproximadamente el 50%, y en las horas pico se tomó el 100% del tiempo del procesador, y el núcleo consumió casi todo (tiempo sys).
El nodo en sí era multiusuario, y se lanzaron alrededor de 200 contenedores OpenVZ. El análisis mostró que un gran número de usuarios creó contenedores Docker anidados y jerarquías de niveles múltiples de grupos de control de memoria. Cada contenedor de nivel superior de nivel de usuario contenía aproximadamente 20 puntos de montaje y 20 grupos de memoria de control (memcg) creados por systemd. Además, había puntos de montaje y grupos de control creados por el mencionado Docker. En pocas palabras, el nodo estaba muy cargado y la carga en él era mucho más fuerte que el promedio de todos nuestros otros clientes. Estábamos interesados ​​en encontrar la razón de la aparición de estos picos, ya que el mismo problema podría aparecer en máquinas menos ocupadas, donde apenas era notable (por ejemplo, dar picos a + 5% del tiempo del sistema, lo que degrada el rendimiento).

Al manipular el rendimiento, logré alcanzar el pico y eliminar el rastro. Resultó que la mayor parte del tiempo del procesador se dedica a borrar cachés SLAB, a saber, cachés de superbloque:

- 100,00% 0,00% kswapd0 [kernel.vmlinux] [k] kthread - 99,31% balance_pgdat - 82,11% shrink_zone - 61,69% shrink_slab - 58,29% super_cache_count + 54,56% list_lru_count_one 


Aquí vale la pena hacer una explicación y reflexionar sobre este tema con más detalle. Todos saben que el kernel almacena en caché los datos no utilizados durante un tiempo antes de finalmente liberar memoria. El núcleo hace un amplio uso de este principio. Por ejemplo, la memoria caché de la página contiene páginas de datos relacionados con el archivo, lo que acelera en gran medida el acceso repetido a ellas durante la lectura (porque no es necesario volver a acceder al disco). En nuestro caso, el problema surgió con el caché de metadatos de superbloque contenido en dos listas de LRU: s_dentry_lru y s_inode_lru.

LRU (menos utilizado recientemente)

struct lru_list apunta a una matriz de listas vinculadas, y cada memcg activo corresponde a un elemento (list_lru_one) en esta matriz. Cuando el kernel ya no usa un determinado objeto SLAB, el kernel lo agrega a una de las listas vinculadas de la matriz (dependiendo de a qué miembro pertenece el objeto o, más o menos, a qué memcg utilizó el proceso cuando creó este objeto). La matriz en sí se describe de la siguiente manera (lru_list :: node :: memcg_lrus):

 struct list_lru_memcg { struct rcu_head rcu; /* array of per cgroup lists, indexed by memcg_cache_id */ struct list_lru_one *lru[0]; /*    */ }; struct list_lru_one { struct list_head list; /*    */ /* may become negative during memcg reparenting */ long nr_items; /*     */ }; 

lru [0] indica una lista de objetos relacionados con memcg con ID 0;
lru [1] indica una lista de objetos relacionados con memcg con ID 1;
...
lru [n] indica una lista de objetos relacionados con memcg con ID n;

Las listas de LRU s_dentry_lru y s_inode_lru aparecen en nuestro problema y, como su nombre lo indica, contienen objetos de sistema de archivos de inodos y dentry no utilizados.
En el futuro, si no hay suficiente memoria en el sistema o una memoria específica, algunos de los elementos de la lista finalmente se liberan, y un mecanismo especial llamado reductor lo hace.

Encogedor

Cuando el núcleo necesita asignar páginas de memoria, pero no hay memoria libre en el nodo NUMA o en el sistema, se inicia el mecanismo para limpiarlo. Está tratando de tirar o descartar una cierta cantidad de disco: 1) páginas del contenido de los archivos del caché de la página; 2) páginas relacionadas con memoria anónima en un intercambio, y 3) objetos SLAB en caché (el problema que encontramos está relacionado con ellos).

Tirar parte de los objetos SLAB en caché no afecta directamente la liberación de las páginas: su tamaño, como regla, es significativamente menor que el tamaño de la página, y una página contiene cientos de objetos. Cuando se libera parte de los objetos, aparecen espacios de memoria libre en las páginas SLAB, que se pueden usar para crear otros objetos SLAB. Este algoritmo es aceptado en el núcleo intencionalmente: es simple y bastante eficiente. Un lector interesado puede ver la fórmula para seleccionar una parte de los objetos para limpiar en la función do_shrink_slab ().

Esta función realiza la limpieza real de parte de los objetos, guiada por la descripción que se le pasa en el reductor de estructura:

 static unsigned long do_shrink_slab(struct shrink_control *shrinkctl, struct shrinker *shrinker, int priority) { … /*   */ freeable = shrinker->count_objects(shrinker, shrinkctl); if (freeable == 0) return 0; total_scan = _(freeable); while (total_scan >= batch_size) { /*   */ ret = shrinker->scan_objects(shrinker, shrinkctl); total_scan -= shrinkctl->nr_scanned; } ... } 

En relación con el superbloque retráctil, estas funciones se implementan de la siguiente manera. Cada superbloque mantiene sus propias listas s_dentry_lru y s_inode_lru de objetos no utilizados relacionados con él:

 struct super_block { ... struct shrinker s_shrink; /* per-sb shrinker handle */ ... struct list_lru s_dentry_lru; struct list_lru s_inode_lru; … }; 


El método .count_objects devuelve el número de objetos:

 static unsigned long super_cache_count(struct shrinker *shrink, struct shrink_control *sc) { total_objects += list_lru_shrink_count(&sb->s_dentry_lru, sc); total_objects += list_lru_shrink_count(&sb->s_inode_lru, sc); /*     ) */ total_objects = vfs_pressure_ratio(total_objects); return total_objects; } 


El método .scan_objects en realidad libera objetos:

 static unsigned long super_cache_scan(struct shrinker *shrink, struct shrink_control *sc) { /*     s_dentry_lru */ prune_dcache_sb(sb, sc); /*     s_inode_lru */ prune_icache_sb(sb, sc); } 

El número de objetos a liberar se pasa en el parámetro sc. Además, memcg se indica allí, cuyos objetos deben arrojarse fuera de la LRU:

 struct shrink_control { int nid; /* ID NUMA  */ unsigned long nr_to_scan; /*   */ struct mem_cgroup *memcg; /* memcg */ }; 

Por lo tanto, prune_dcache_sb () selecciona una lista vinculada de la matriz struct list_lru_memcg :: lru [] y trabaja con ella. Prune_icache_sb () hace lo mismo.

Antiguo algoritmo de derivación de contracción

Con el enfoque estándar, "expulsar" objetos de SLAB sin memoria en
sc-> target_mem_cgroup ocurre de la siguiente manera:

 shrink_node() { … struct mem_cgroup *root = sc->target_mem_cgroup; /*      sc->target_mem_cgroup  */ memcg = mem_cgroup_iter(root, NULL, &reclaim); do { … shrink_slab(memcg, ...); … } while ((memcg = mem_cgroup_iter(root, memcg, &reclaim))); ... } 

Revisamos todos los elementos secundarios y llamamos a shrink_slab () para cada uno de ellos. A continuación, en la función shrink_slab (), revisamos todos los encogedores y para cada uno de ellos llamamos a do_shrink_slab ():

 static unsigned long shrink_slab(gfp_t gfp_mask, int nid, struct mem_cgroup *memcg, int priority) { list_for_each_entry(shrinker, &shrinker_list, list) { struct shrink_control sc = { .nid = nid, .memcg = memcg, }; ret = do_shrink_slab(&sc, shrinker, ...); } } 

Recuerde que para cada superbloque, se agrega su propio reductor a esta lista. Vamos a contar cuántas veces se llamará a do_shrink_slab () para el caso con 200 contenedores de 20 memcg y 20 puntos de montaje en cada uno. En total, tenemos 200 * 20 puntos de montaje y 200 * 20 grupos de control. Si no hay suficiente memoria en la memoria superior, nos veremos obligados a omitir todas sus memorias secundarias (es decir, generalmente todo), y para cada una de ellas, llame a cada uno de los encogedores de la lista shrinker_list. Por lo tanto, el núcleo realizará 200 * 20 * 200 * 20 = 16000000 llamadas a la función do_shrink_slab ().

Al mismo tiempo, la abrumadora cantidad de llamadas a esta función será inútil: los contenedores generalmente están aislados entre sí, y la probabilidad de que CT1 use super_block2 creado en CT2 es generalmente baja. O, lo que es lo mismo, si memcg1 es un grupo de control de CT1, entonces el elemento correspondiente de la matriz super_block2-> s_dentry_lru-> node-> memcg_lrus-> lru [memcg1_id] será una lista vacía, y no tiene sentido llamar a do_shrink_slab () para ello.

Este problema se puede modelar usando un script bash simple (aquí se usan los datos del conjunto de parches, que posteriormente se pasaron al kernel):
 $echo 1 > /sys/fs/cgroup/memory/memory.use_hierarchy $mkdir /sys/fs/cgroup/memory/ct $echo 4000M > /sys/fs/cgroup/memory/ct/memory.kmem.limit_in_bytes $for i in `seq 0 4000`; do mkdir /sys/fs/cgroup/memory/ct/$i; echo $$ > /sys/fs/cgroup/memory/ct/$i/cgroup.procs; mkdir -ps/$i; mount -t tmpfs $is/$i; touch s/$i/file; done 

Veamos qué sucede si llama al procedimiento de reinicio de caché 5 veces seguidas:
 $time echo 3 > /proc/sys/vm/drop_caches 

La primera iteración dura 14 segundos, porque los objetos en caché realmente están en la memoria: 0.00 usuario 13.78 sistema 0: 13.78 transcurrió 99% CPU.
La segunda iteración lleva 5 segundos, aunque no hay más objetos: 0.00usuario 5.59sistema 0: 05.60epsed 99% CPU.
La tercera iteración tarda 5 segundos: 0.00usuario 5.48sistema 0: 05.48elapsado 99% CPU
La cuarta iteración tarda 8 segundos: 0.00usuario 8.35sistema 0: 08.35elapsado 99% CPU
La quinta iteración tarda 8 segundos: 0.00usuario 8.34sistema 0: 08.35epsado 99% CPU

Se hizo evidente que el algoritmo de derivación de contracción utilizado por el núcleo de vainilla no es óptimo, y debemos cambiarlo para mejorarlo en términos de escalabilidad.

Nuevo algoritmo de derivación de contracción

Desde el nuevo algoritmo quería lograr lo siguiente:

  1. liberarlo de los defectos de lo viejo y
  2. No agregue nuevas cerraduras. Llame a do_shrink_slab () solo cuando tenga sentido (es decir, la lista vinculada correspondiente de la matriz s_dentry_lru o de la matriz s_inode_lru no está vacía), pero no acceda directamente a la memoria de la lista vinculada.

Estaba claro que esto solo podía proporcionarse mediante una nueva estructura de datos además de los reductores heterogéneos (hay reductores no solo del superbloque, sino también de otros objetos de datos no descritos en este artículo. El lector puede familiarizarse con ellos buscando la palabra clave prealloc_shrinker () en el código del núcleo). La nueva estructura de datos debería permitir la codificación de dos estados: "tiene sentido llamar a do_shrink_slab ()" y "no tiene sentido llamar a do_shrink_slab ()".

Las estructuras de datos de tipo IDA fueron rechazadas porque usan cerraduras dentro de ellos mismos. La estructura de datos del campo de bits es totalmente adecuada para este rol: permite la modificación atómica de bits individuales y, en combinación con las barreras de memoria, le permite construir un algoritmo eficiente sin el uso de bloqueos.

Cada reductor obtiene su propia identificación única (shrinker :: id), y cada memcg obtiene un mapa de bits capaz de contener la identificación más grande de las registradas actualmente. Cuando el primer elemento se agrega a la lista s_dentry_lru-> node-> memcg_lrus-> lru [memcg_id], el mapa de bits de memcg correspondiente se establece en 1 bit con el encogedor de números-> id. Lo mismo con s_inode_id.

Ahora el bucle en shrink_slab () se puede optimizar para omitir solo los encogedores necesarios:

 unsigned long shrink_slab() { … for_each_set_bit(i, map, shrinker_nr_max) { … shrinker = idr_find(&shrinker_idr, i); … do_shrink_slab(&sc, shrinker, priority); … } } 

(La limpieza de bits también se implementa cuando el reductor entra en el estado "no tiene sentido llamar a do_shrink_slab (). Consulte el compromiso de Github para obtener más detalles".

Si repite la prueba de restablecimiento de la memoria caché, entonces, utilizando el nuevo algoritmo, muestra resultados significativamente mejores:
 $time echo 3 > /proc/sys/vm/drop_caches 

Primera iteración: 0.00user 1.10system 0: 01.10elapsed 99% CPU
Segunda iteración: 0.00usuario 0.00sistema 0: 00.01 CPU 64% transcurrida
Tercera iteración: 0.00usuario 0.01 sistema 0: 00.01 transcurrido 82% CPU
Cuarta iteración: 0.00usuario 0.00sistema 0: 00.01epidió 64% CPU
Quinta iteración: 0.00usuario 0.01 sistema 0: 00.01 transcurrido 82% CPU
La duración de la segunda a la quinta iteración es 0.01 segundos, 548 veces más rápido que antes.

Dado que se producen acciones similares para restablecer los cachés con cada falta de memoria en la máquina, esta optimización mejora significativamente el funcionamiento de las máquinas con una gran cantidad de contenedores y grupos de control de memoria. Se ha aceptado un conjunto de parches (17 piezas) en el núcleo de vainilla, y puede encontrarlo a partir de la versión 4.19.

En el proceso de revisión de los parches, apareció un empleado de Google, y resultó que tenían el mismo problema. Por lo tanto, los parches se probaron en un tipo diferente de carga.
Como resultado, el conjunto de parches se adoptó a partir de la novena iteración; y su entrada en el núcleo de vainilla tomó alrededor de 4 meses. También hoy, el parche está incluido en nuestro propio núcleo Virtuozzo 7, comenzando con la versión vz7.71.9

Source: https://habr.com/ru/post/es435694/


All Articles