Wie und warum haben wir den Algorithmus zum Bereinigen von SLAB-Caches im Linux-Kernel optimiert?

Die wachsende Beliebtheit von Containern und ihre Verwendung in Verbindung mit Kontrollgruppen ergab ein ernstes Skalierbarkeitsproblem, das bei großen Maschinen zu einem erheblichen Leistungsabfall führt. Das Problem ist, dass die Umgehungszeit von SLAB-Caches quadratisch von der Anzahl der Container abhängt und der aktive Verbrauch großer Speichermengen in kurzer Zeit dazu führen kann, dass das System in eine Besetztschleife gerät und 100% der Prozessorzeit verbraucht. Heute möchte ich Ihnen sagen, wie wir dieses Problem gelöst haben, indem wir den Abrechnungsalgorithmus für die Verwendung der memcg-Kontrollgruppe zur Verwendung von SLAB-Cache-Objekten geändert und die Funktion shrink_slab () optimiert haben.

Speicherreinigung

Warum stellte sich die Frage nach der Optimierung von Prozessen im Kernel? Alles begann damit, dass einer unserer Kunden, der aktiv Container und Speichersteuerungsgruppen (memcg) verwendete, auf die merkwürdigen Spitzen des Prozessorressourcenverbrauchs aufmerksam machte, die von Zeit zu Zeit auftreten. Die normale Systemlast betrug etwa 50%, und zu Spitzenzeiten wurden 100% der Prozessorzeit beansprucht, und fast alles davon wurde vom Kernel verbraucht (Systemzeit).
Der Knoten selbst war ein Mehrbenutzer, auf dem ungefähr 200 OpenVZ-Container gestartet wurden. Die Analyse ergab, dass eine große Anzahl von Benutzern verschachtelte Docker-Container und mehrstufige Hierarchien von Speichersteuerungsgruppen erstellt hat. Jeder Container der obersten Ebene auf Benutzerebene enthielt ungefähr 20 Einhängepunkte und 20 von systemd erstellte Kontrollspeichergruppen (memcg). Darüber hinaus wurden vom oben genannten Docker Einhängepunkte und Kontrollgruppen erstellt. Einfach ausgedrückt, der Knoten war stark ausgelastet und die Belastung war viel stärker als der Durchschnitt aller unserer anderen Kunden. Wir waren daran interessiert, den Grund für das Auftreten dieser Peaks zu finden, da das gleiche Problem auf weniger ausgelasteten Maschinen auftreten kann, auf denen es kaum wahrnehmbar ist (geben Sie beispielsweise Peaks mit einer Systemzeit von + 5% an, die die Leistung beeinträchtigen).

Durch die Manipulation von Perf gelang es mir, den Gipfel zu erreichen und die Spur zu entfernen. Es stellte sich heraus, dass der größte Teil der Prozessorzeit für das Löschen von SLAB-Caches aufgewendet wird, nämlich für Superblock-Caches:

- 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 


Hier lohnt es sich, eine Erklärung abzugeben und näher auf dieses Thema einzugehen. Jeder weiß, dass der Kernel nicht verwendete Daten für eine Weile zwischenspeichert, bevor schließlich Speicher freigegeben wird. Der Kernel nutzt dieses Prinzip in großem Umfang. Beispielsweise enthält der Seitencache Seiten mit Daten, die sich auf die Datei beziehen, was den wiederholten Zugriff beim Lesen erheblich beschleunigt (da Sie nicht erneut auf die Festplatte zugreifen müssen). In unserem Fall trat das Problem mit dem Superblock-Metadaten-Cache auf, der in zwei LRU-Listen enthalten ist: s_dentry_lru und s_inode_lru.

LRU (am wenigsten kürzlich verwendet)

struct lru_list zeigt auf ein Array verknüpfter Listen, und jedes aktive Memcg entspricht einem Element (list_lru_one) in diesem Array. Wenn ein bestimmtes SLAB-Objekt vom Kernel nicht mehr verwendet wird, fügt der Kernel es einer der verknüpften Listen des Arrays hinzu (abhängig davon, zu welchem ​​Memcg das Objekt gehört oder grob gesagt, zu welchem ​​Memcg der Prozess, der beim Erstellen dieses Objekts verwendet wurde). Das Array selbst wird wie folgt beschrieben (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] gibt eine Liste von Objekten an, die sich auf memcg mit der ID 0 beziehen;
lru [1] gibt eine Liste von Objekten an, die sich auf memcg mit der ID 1 beziehen;
...
lru [n] gibt eine Liste von Objekten an, die sich auf memcg mit der ID n beziehen;

LRU-Listen s_dentry_lru und s_inode_lru erscheinen in unserem Problem und enthalten, wie der Name schon sagt, nicht verwendete Dentry- und Inode-Dateisystemobjekte.
Wenn in Zukunft nicht genügend Speicher im System oder in einem bestimmten Memcg vorhanden ist, werden einige der Listenelemente endgültig freigegeben, und ein spezieller Mechanismus namens Shrinker führt dies aus.

Schrumpfer

Wenn der Kernel Speicherseiten zuweisen muss, aber auf dem NUMA-Knoten oder im System kein freier Speicher vorhanden ist, wird der Mechanismus zum Bereinigen gestartet. Er versucht, eine bestimmte Menge an Datenträger zu werfen oder zu verwerfen: 1) Seiten mit dem Inhalt von Dateien aus dem Seiten-Cache; 2) Seiten, die sich auf anonymen Speicher in einem Swap beziehen, und 3) zwischengespeicherte SLAB-Objekte (das Problem, auf das wir gestoßen sind, hängt mit ihnen zusammen).

Das Verwerfen eines Teils der zwischengespeicherten SLAB-Objekte wirkt sich nicht direkt auf die Freigabe von Seiten aus: Ihre Größe ist in der Regel erheblich kleiner als die Seitengröße, und eine Seite enthält Hunderte von Objekten. Wenn ein Teil der Objekte freigegeben wird, werden auf den SLAB-Seiten freie Speicherlücken angezeigt, mit denen andere SLAB-Objekte erstellt werden können. Dieser Algorithmus wird absichtlich im Kernel akzeptiert: Er ist einfach und sehr effizient. Ein interessierter Leser kann die Formel zum Auswählen eines Teils der zu bereinigenden Objekte in der Funktion do_shrink_slab () sehen.

Diese Funktion führt die eigentliche Reinigung eines Teils der Objekte durch, basierend auf der Beschreibung, die im Strukturschrumpfer an sie übergeben wurde:

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

In Bezug auf den Schrumpfer-Superblock werden diese Funktionen wie folgt implementiert. Jeder Superblock verwaltet seine eigenen s_dentry_lru- und s_inode_lru-Listen nicht verwendeter Objekte, die damit zusammenhängen:

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


Die Methode .count_objects gibt die Anzahl der Objekte zurück:

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


Die .scan_objects-Methode gibt tatsächlich Objekte frei:

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

Die Anzahl der freizugebenden Objekte wird im Parameter sc übergeben. Dort ist auch memcg angegeben, dessen Objekte aus der LRU geworfen werden sollen:

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

Daher wählt prune_dcache_sb () eine verknüpfte Liste aus dem Array struct list_lru_memcg :: lru [] aus und arbeitet damit. Prune_icache_sb () macht dasselbe.

Alter Schrumpfer-Bypass-Algorithmus

Beim Standardansatz werden Objekte aus dem SLAB „ausgeworfen“, wenn nicht genügend Speicher vorhanden ist
sc-> target_mem_cgroup geschieht wie folgt:

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

Wir gehen alle untergeordneten Memcg durch und rufen für jeden von ihnen shrink_slab () auf. Als nächstes gehen wir in der Funktion shrink_slab () alle Shrinker durch und rufen für jeden von ihnen do_shrink_slab () auf:

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

Denken Sie daran, dass für jeden Superblock ein eigener Shrinker zu dieser Liste hinzugefügt wird. Zählen wir, wie oft do_shrink_slab () für den Fall mit 200 Containern mit jeweils 20 Memcg und 20 Mountpunkten aufgerufen wird. Insgesamt haben wir 200 * 20 Einhängepunkte und 200 * 20 Kontrollgruppen. Wenn im obersten Memcg nicht genügend Speicher vorhanden ist, müssen wir alle untergeordneten Memcg (d. H. Im Allgemeinen alles) umgehen und für jeden von ihnen jeden Shrinker aus der shrinker_list aufrufen. Daher ruft der Kernel die Funktion do_shrink_slab () mit 200 * 20 * 200 * 20 = 16000000 auf.

Darüber hinaus ist die überwältigende Anzahl von Aufrufen dieser Funktion nutzlos: Container sind normalerweise untereinander isoliert, und die Wahrscheinlichkeit, dass CT1 den in CT2 erstellten super_block2 verwendet, ist im Allgemeinen gering. Oder was auch immer, wenn memcg1 eine Kontrollgruppe von CT1 ist, dann ist das entsprechende Element des Arrays super_block2-> s_dentry_lru-> node-> memcg_lrus-> lru [memcg1_id] eine leere Liste, und es macht keinen Sinn, do_shrink_slab () dafür aufzurufen.

Dieses Problem kann mit einem einfachen Bash-Skript modelliert werden (hier werden Daten aus dem Patchset verwendet, das anschließend an den Kernel übergeben wurde):
 $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 

Mal sehen, was passiert, wenn Sie die Cache-Reset-Prozedur fünfmal hintereinander aufrufen:
 $time echo 3 > /proc/sys/vm/drop_caches 

Die erste Iteration dauert 14 Sekunden, da sich die zwischengespeicherten Objekte tatsächlich im Speicher befinden: 0,00 Benutzer 13,78 System 0: 13,78 99% CPU verstrichen .
Die zweite Iteration dauert 5 Sekunden, obwohl keine Objekte mehr vorhanden sind: 0.00user 5.59system 0: 05.60elapsed 99% CPU.
Die dritte Iteration dauert 5 Sekunden: 0.00user 5.48system 0: 05.48elapsed 99% CPU
Die vierte Iteration dauert 8 Sekunden: 0.00user 8.35system 0: 08.35elapsed 99% CPU
Die fünfte Iteration dauert 8 Sekunden: 0.00user 8.34system 0: 08.35elapsed 99% CPU

Es wurde deutlich, dass der vom Vanillekern verwendete Shrinker-Bypass-Algorithmus nicht optimal ist, und wir müssen ihn hinsichtlich der Skalierbarkeit zum Besseren ändern.

Neuer Shrinker-Bypass-Algorithmus

Mit dem neuen Algorithmus wollte ich Folgendes erreichen:

  1. befreie ihn von den Fehlern der alten und
  2. Fügen Sie keine neuen Sperren hinzu. Rufen Sie do_shrink_slab () nur dann auf, wenn dies sinnvoll ist (dh die entsprechende verknüpfte Liste aus dem Array s_dentry_lru oder aus dem Array s_inode_lru ist nicht leer), greifen Sie jedoch nicht direkt auf den Speicher der verknüpften Liste zu.

Es war klar, dass dies nur durch eine neue Datenstruktur über heterogenen Shrinkern bereitgestellt werden kann (es gibt Shrinker nicht nur des Superblocks, sondern auch anderer Datenobjekte, die in diesem Artikel nicht beschrieben werden. Der Leser kann sich mit ihnen vertraut machen, indem er nach dem Schlüsselwort prealloc_shrinker () sucht. im Kernel-Code). Die neue Datenstruktur sollte die Codierung von zwei Zuständen ermöglichen: "Es ist sinnvoll, do_shrink_slab () aufzurufen" und "Es macht keinen Sinn, do_shrink_slab () aufzurufen".

Datenstrukturen vom Typ IDA wurden abgelehnt, weil Sie benutzen Schlösser in sich. Die Datenstruktur des Bitfelds ist für diese Rolle voll geeignet: Sie ermöglicht die atomare Modifikation einzelner Bits und ermöglicht in Kombination mit Speicherbarrieren die Erstellung eines effizienten Algorithmus ohne Verwendung von Sperren.

Jeder Shrinker erhält eine eigene eindeutige ID (shrinker :: id), und jedes Memcg erhält eine Bitmap, die die größte ID der aktuell registrierten IDs enthalten kann. Wenn das erste Element zur Liste s_dentry_lru-> node-> memcg_lrus-> lru [memcg_id] hinzugefügt wird, wird die entsprechende memcg-Bitmap mit der Nummer shrinker-> id auf 1 Bit gesetzt. Gleiches gilt für s_inode_id.

Jetzt kann die Schleife in shrink_slab () so optimiert werden, dass nur die erforderlichen Shrinker umgangen werden:

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

(Die Bitbereinigung wird auch implementiert, wenn der Shrinker in den Status "Es macht keinen Sinn, do_shrink_slab () aufzurufen. Weitere Informationen finden Sie im Github- Commit ."

Wenn Sie den Cache-Reset-Test wiederholen, werden mit dem neuen Algorithmus deutlich bessere Ergebnisse angezeigt:
 $time echo 3 > /proc/sys/vm/drop_caches 

Erste Iteration: 0.00user 1.10system 0: 01.10elapsed 99% CPU
Zweite Iteration: 0.00user 0.00system 0: 00.01elapsed 64% CPU
Dritte Iteration: 0.00user 0.01system 0: 00.01elapsed 82% CPU
Vierte Iteration: 0.00user 0.00system 0: 00.01elapsed 64% CPU
Fünfte Iteration: 0.00user 0.01system 0: 00.01elapsed 82% CPU
Die Dauer der zweiten bis fünften Iteration beträgt 0,01 Sekunden, 548-mal schneller als zuvor.

Da ähnliche Aktionen zum Zurücksetzen der Caches bei jedem Speichermangel auf dem Computer ausgeführt werden, verbessert diese Optimierung den Betrieb von Computern mit einer großen Anzahl von Containern und Speichersteuerungsgruppen erheblich. Eine Reihe von Patches (17 Stück) wurde in den Vanillekern aufgenommen und ist dort ab Version 4.19 zu finden.

Bei der Überprüfung der Patches wurde ein Google-Mitarbeiter angezeigt, und es stellte sich heraus, dass er das gleiche Problem hatte. Daher wurden Patches auf einer anderen Art von Last weiter getestet.
Infolgedessen wurde das Patchset aus der 9. Iteration übernommen. und sein Eintritt in den Vanillekern dauerte ungefähr 4 Monate. Auch heute ist das Patchset in unserem eigenen Virtuozzo 7-Kernel enthalten, beginnend mit Version vz7.71.9

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


All Articles