Comment et pourquoi nous avons optimisé l'algorithme de nettoyage des caches SLAB dans le noyau Linux

La popularité croissante des conteneurs et leur utilisation conjointement avec des groupes de contrôle ont révélé un grave problème d'évolutivité, ce qui entraîne une baisse significative des performances sur les grandes machines. Le problème est que le temps de contournement des caches SLAB dépend quadratique du nombre de conteneurs, et la consommation active de grandes quantités de mémoire dans une courte période peut entraîner le système à entrer dans une boucle occupée, consommant 100% du temps du processeur. Aujourd'hui, je voudrais vous dire comment nous avons résolu ce problème en modifiant l'algorithme de comptabilité pour utiliser le groupe de contrôle memcg pour utiliser les objets de cache SLAB et en optimisant la fonction shrink_slab ().

Nettoyage de la mémoire

Pourquoi la question de l'optimisation des processus dans le noyau s'est-elle posée? Tout a commencé avec le fait que l'un de nos clients, utilisant activement des conteneurs et des groupes de contrôle de la mémoire (memcg), a attiré l'attention sur les étranges pics de consommation de ressources processeur qui se produisent de temps en temps. La charge système normale était d'environ 50%, et aux heures de pointe, 100% du temps du processeur était pris, et presque tout était consommé par le noyau (temps sys).
Le nœud lui-même était multi-utilisateur et environ 200 conteneurs OpenVZ ont été lancés dessus. L'analyse a montré qu'un grand nombre d'utilisateurs ont créé des conteneurs Docker imbriqués et des hiérarchies à plusieurs niveaux de groupes de contrôle de la mémoire. Chaque conteneur de niveau supérieur de niveau utilisateur contenait environ 20 points de montage et 20 groupes de mémoire de contrôle (memcg) créés par systemd. De plus, il y avait des points de montage et des groupes de contrôle créés par le Docker susmentionné. Autrement dit, le nœud était lourdement chargé, et la charge sur celui-ci était beaucoup plus forte que la moyenne de tous nos autres clients. Nous étions intéressés à trouver la raison de l'apparition de ces pics, car le même problème pouvait apparaître sur des machines moins occupées, où il était à peine perceptible (par exemple, donner des pics à + 5% de temps sys, ce qui dégrade les performances).

En manipulant la perf, j'ai réussi à rattraper le pic et à supprimer la piste. Il s'est avéré que la majeure partie du temps du processeur est consacrée à l'effacement des caches SLAB, à savoir les caches superblocs:

- 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 


Ici, il vaut la peine de faire une explication et de s'attarder sur cette question plus en détail. Tout le monde sait que le noyau met en cache les données inutilisées pendant un certain temps avant de libérer enfin de la mémoire. Le noyau utilise largement ce principe. Par exemple, le cache de pages contient des pages de données liées au fichier, ce qui accélère considérablement leur accès répété lors de la lecture (car vous n'avez pas besoin d'accéder à nouveau au disque). Dans notre cas, le problème est survenu avec le cache de métadonnées de superbloc contenu dans deux listes LRU: s_dentry_lru et s_inode_lru.

LRU (le moins récemment utilisé)

struct lru_list pointe vers un tableau de listes liées, et chaque memcg actif correspond à un élément (list_lru_one) dans ce tableau. Lorsqu'un certain objet SLAB n'est plus utilisé par le noyau, le noyau l'ajoute à l'une des listes liées du tableau (selon le memcg auquel appartient l'objet ou, en gros, quel memcg le processus a utilisé lorsqu'il a créé cet objet). Le tableau lui-même est décrit comme suit (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] indique une liste d'objets liés à memcg avec l'ID 0;
lru [1] indique une liste d'objets liés à memcg avec l'ID 1;
...
lru [n] indique une liste d'objets liés à memcg avec l'ID n;

Les listes LRU s_dentry_lru et s_inode_lru apparaissent dans notre problème et, comme leur nom l'indique, elles contiennent des objets de système de fichiers dentry et inode inutilisés.
À l'avenir, s'il n'y a pas assez de mémoire dans le système ou un memcg spécifique, certains des éléments de la liste sont finalement libérés, et un mécanisme spécial appelé rétrécisseur le fait.

Shrinker

Lorsque le noyau doit allouer des pages de mémoire, mais qu'il n'y a pas de mémoire libre sur le nœud NUMA ou dans le système, le mécanisme de nettoyage démarre. Il essaie de jeter ou de jeter une certaine quantité de disque: 1) des pages du contenu des fichiers du cache de pages; 2) pages liées à la mémoire anonyme dans un swap, et 3) objets SLAB mis en cache (le problème que nous avons rencontré leur est lié).

L'élimination d'une partie des objets SLAB mis en cache n'affecte pas directement la publication des pages: leur taille, en règle générale, est nettement inférieure à la taille de la page, et une page contient des centaines d'objets. Lorsqu'une partie des objets est libérée, des lacunes de mémoire libres apparaissent dans les pages SLAB, qui peuvent être utilisées pour créer d'autres objets SLAB. Cet algorithme est volontairement accepté dans le noyau: il est simple et assez efficace. Un lecteur intéressé peut voir la formule de sélection d'une partie des objets à nettoyer dans la fonction do_shrink_slab ().

Cette fonction effectue le nettoyage proprement dit d'une partie des objets, guidée par la description qui lui est transmise dans le réducteur de structure:

 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; } ... } 

Par rapport au superbloc de rétrécissement, ces fonctions sont implémentées comme suit. Chaque superbloc gère ses propres listes s_dentry_lru et s_inode_lru des objets inutilisés qui lui sont associés:

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


La méthode .count_objects renvoie le nombre d'objets:

 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; } 


La méthode .scan_objects libère en fait des objets:

 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); } 

Le nombre d'objets à libérer est passé dans le paramètre sc. En outre, memcg y est indiqué, dont les objets doivent être jetés hors de la LRU:

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

Ainsi, prune_dcache_sb () sélectionne une liste liée dans le tableau struct list_lru_memcg :: lru [] et travaille avec elle. Prune_icache_sb () fait de même.

Ancien algorithme de contournement de rétrécissement

Avec l'approche standard, «éjecter» des objets de SLAB sans mémoire dans
sc-> target_mem_cgroup se produit comme suit:

 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))); ... } 

Nous passons en revue tous les enfants memcg et appelons shrink_slab () pour chacun d'eux. Ensuite, dans la fonction shrink_slab (), nous passons par tous les shrinkers et pour chacun d'eux appelons 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, ...); } } 

Rappelons que pour chaque superbloc, son propre rétrécisseur est ajouté à cette liste. Comptons combien de fois do_shrink_slab () sera appelé pour le cas avec 200 conteneurs de 20 memcg et 20 points de montage dans chacun. Au total, nous avons 200 * 20 points de montage et 200 * 20 groupes de contrôle. S'il n'y a pas assez de mémoire dans le memcg le plus haut, nous serons obligés de contourner tous ses memcg enfants (c'est-à-dire tout en général), et pour chacun d'eux, appelez chacun du rétrécisseur à partir de la liste de rétrécissement. Ainsi, le noyau fera 200 * 20 * 200 * 20 = 16000000 appels à la fonction do_shrink_slab ().

De plus, le nombre écrasant d'appels à cette fonction sera inutile: les conteneurs sont généralement isolés entre eux, et la probabilité que CT1 utilise super_block2 créé dans CT2 est généralement faible. Ou, ce qui est le même, si memcg1 est un groupe de contrôle de CT1, alors l'élément correspondant du tableau super_block2-> s_dentry_lru-> node-> memcg_lrus-> lru [memcg1_id] sera une liste vide et il n'y a aucun intérêt à appeler do_shrink_slab () pour cela.

Ce problème peut être modélisé à l'aide d'un simple script bash (les données du patchset, qui ont ensuite été transmises au noyau, sont utilisées ici):
 $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 

Voyons ce qui se passe si vous appelez la procédure de réinitialisation du cache 5 fois de suite:
 $time echo 3 > /proc/sys/vm/drop_caches 

La première itération dure 14 secondes, car les objets mis en cache sont vraiment en mémoire: 0,00 utilisateur 13,78 système 0: 13,78 écoulé 99% CPU.
La deuxième itération prend 5 secondes, bien qu'il n'y ait plus d'objets: 0.00user 5.59system 0: 05.60elapsed 99% CPU.
La troisième itération prend 5 secondes: 0.00user 5.48system 0: 05.48elapsed 99% CPU
La quatrième itération prend 8 secondes: 0.00utilisateur 8.35system 0: 08.35elapsed 99% CPU
La cinquième itération prend 8 secondes: 0,00 utilisateur 8,34 système 0: 08,35 CPU 99% écoulé

Il est devenu évident que l'algorithme de contournement de rétrécissement utilisé par le noyau vanilla n'est pas optimal, et nous devons le changer pour le mieux en termes d'évolutivité.

Nouvel algorithme de contournement de rétrécissement

À partir du nouvel algorithme, je voulais atteindre les objectifs suivants:

  1. le libérer des défauts de l'ancien et
  2. N'ajoutez pas de nouveaux verrous. Appelez do_shrink_slab () uniquement lorsque cela a du sens (c'est-à-dire que la liste liée correspondante du tableau s_dentry_lru ou du tableau s_inode_lru n'est pas vide), mais n'accédez pas directement à la mémoire de la liste liée.

Il était clair que cela ne pouvait être fourni que par une nouvelle structure de données au-dessus de rétracteurs hétérogènes (il existe non seulement des rétrécisseurs du superbloc, mais également d'autres objets de données non décrits dans cet article. Le lecteur peut se familiariser avec eux en recherchant le mot-clé prealloc_shrinker () dans le code du noyau). La nouvelle structure de données devrait permettre le codage de deux états: «il est logique d'appeler do_shrink_slab ()» et «cela n'a aucun sens d'appeler do_shrink_slab ()».

Les structures de données de type IDA ont été rejetées car ils utilisent des verrous en eux-mêmes. La structure de données du champ de bits convient parfaitement à ce rôle: elle permet la modification atomique de bits individuels et, en combinaison avec des barrières de mémoire, vous permet de créer un algorithme efficace sans utiliser de verrous.

Chaque réducteur obtient son propre identifiant unique (shrinker :: id), et chaque memcg obtient un bitmap capable de contenir le plus grand identifiant des identifiants actuellement enregistrés. Lorsque le premier élément est ajouté à la liste s_dentry_lru-> node-> memcg_lrus-> lru [memcg_id], le bitmap memcg correspondant est défini sur 1 bit avec le numéro shrinker-> id. Même chose avec s_inode_id.

Maintenant, la boucle dans shrink_slab () peut être optimisée pour contourner uniquement les rétrécisseurs nécessaires:

 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); … } } 

(Le nettoyage des bits est également implémenté lorsque le réducteur entre dans l'état «cela n'a aucun sens d'appeler do_shrink_slab (). Voir le commit Github pour plus de détails.

Si vous répétez le test de réinitialisation du cache, puis en utilisant le nouvel algorithme, il montre des résultats nettement meilleurs:
 $time echo 3 > /proc/sys/vm/drop_caches 

Première itération: 0.00user 1.10system 0: 01.10elapsed 99% CPU
Deuxième itération: 0,00 utilisateur 0,00 système 0: 00,01 CPU 64% écoulé
Troisième itération: 0,00 utilisateur 0,01 système 0: 00,01 CPU 82% écoulé
Quatrième itération: 0,00 utilisateur 0,00 système 0: 00,01 CPU 64% écoulé
Cinquième itération: 0,00 utilisateur 0,01 système 0: 00,01 CPU 82% écoulé
La durée des deuxième à cinquième itérations est de 0,01 seconde, 548 fois plus rapide qu'auparavant.

Étant donné que des actions similaires pour réinitialiser les caches se produisent avec chaque manque de mémoire sur la machine, cette optimisation améliore considérablement le fonctionnement des machines avec un grand nombre de conteneurs et de groupes de contrôle de mémoire. Un ensemble de correctifs (17 pièces) a été accepté dans le noyau vanilla, et vous pouvez le trouver à partir de la version 4.19.

Au cours de l'examen des correctifs, un employé de Google est apparu et il s'est avéré qu'ils avaient le même problème. Par conséquent, les patchs ont été testés sur un type de charge différent.
En conséquence, le patchset a été adopté à partir de la 9ème itération; et son entrée dans le noyau de vanille a pris environ 4 mois. Aujourd'hui encore, le patchset est inclus dans notre propre noyau Virtuozzo 7, à partir de la version vz7.71.9

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


All Articles