Umfassende Suchoptimierung: So verarbeiten Sie ein Diagramm mit 10 Milliarden Status

Bild

Vor ein paar Monaten musste ich endlich zugeben, dass ich nicht klug genug war, um einige Level des Snakebird- Puzzles durchzugehen . Die einzige Möglichkeit, etwas von dem SelbstwertgefĂŒhl wiederzugewinnen, bestand darin, einen Löser zu schreiben. Ich könnte also so tun, als wĂ€re das Erstellen eines Programms zum Lösen des Puzzles fast dasselbe wie das Lösen selbst. Der Code fĂŒr das resultierende C ++ - Programm ist auf Github verfĂŒgbar. Der Hauptteil des im Artikel berĂŒcksichtigten Codes ist in search.h und compress.h implementiert. In diesem Beitrag werde ich hauptsĂ€chlich ĂŒber die Optimierung einer Breitensuche sprechen, die 50 bis 100 GB Speicher benötigt, um in 4 GB zu passen.

SpĂ€ter werde ich einen weiteren Beitrag schreiben, der die Besonderheiten des Spiels beschreibt. In diesem Beitrag mĂŒssen Sie wissen, dass ich keine guten Alternativen zu Brute Force finden konnte, da keiner der ĂŒblichen Tricks funktioniert hat. Das Spiel hat viele ZustĂ€nde, da es viele sich bewegende oder geschobene Objekte gibt und die Form einiger von ihnen wichtig ist, was sich im Laufe der Zeit Ă€ndern kann. Es gab keine geeignete konservative Heuristik fĂŒr Algorithmen wie A *, um den Suchraum einzugrenzen. Der Suchgraph war orientiert und implizit spezifiziert, daher war eine gleichzeitige Suche in VorwĂ€rts- und RĂŒckwĂ€rtsrichtung unmöglich. Der einzige Schritt könnte den Zustand in vielerlei Hinsicht verĂ€ndern, sodass nichts so nĂŒtzlich sein könnte wie das Hashing von Zobrist .

Grobe SchĂ€tzungen haben gezeigt, dass es im grĂ¶ĂŸten Puzzle nach Eliminierung aller symmetrischen Positionen etwa 10 Milliarden Staaten geben wird. Selbst nach dem Packen von Zustandsbeschreibungen mit maximaler Dichte betrug die ZustandsgrĂ¶ĂŸe 8-10 Bytes. Mit 100 GB Arbeitsspeicher wĂ€re die Aufgabe trivial, aber nicht fĂŒr meinen Heimcomputer mit 16 GB Arbeitsspeicher. Und da Chrome 12 GB davon benötigt, liegt mein realer Speicherbedarf nĂ€her bei 4 GB. Alles, was dieses Volumen ĂŒberschreitet, muss auf der Festplatte gespeichert werden (alte und rostige Festplatte).

Wie passt man 100 GB Daten in 4 GB RAM? Entweder a) die ZustĂ€nde mĂŒssen auf 1/20 ihrer ursprĂŒnglichen, bereits optimierten GrĂ¶ĂŸe komprimiert werden, oder b) der Algorithmus sollte in der Lage sein, ZustĂ€nde effektiv auf der Festplatte zu speichern und umgekehrt, oder c) eine Kombination der beiden oben genannten Methoden, oder d) ich muss mehr kaufen RAM oder mieten Sie eine leistungsstarke virtuelle Maschine fĂŒr mehrere Tage. Option D habe ich nicht in Betracht gezogen, weil es zu langweilig ist. Die Optionen A und B wurden nach dem Proof of Concept mit gzip ausgeschlossen: Ein Fragment einer Zustandsbeschreibung von 50 MB wurde auf nur 35 MB komprimiert. Dies sind ungefĂ€hr 7 Bytes pro Zustand, und mein Speicher ist ungefĂ€hr 0,4 Bytes pro Zustand. Das heißt, Option B blieb bestehen, obwohl die Breitensuche fĂŒr die Speicherung auf sekundĂ€ren Laufwerken eher unpraktisch schien.

Inhalt


Dies ist ein ziemlich langer Beitrag, daher hier ein kurzer Überblick ĂŒber die folgenden Abschnitte:

  • Breitensuche Breitensuche - Wie lautet der ĂŒbliche Wortlaut der Breitensuche (BFS) und warum eignet er sich nicht zum Speichern von Teilen eines Status auf der Festplatte?
  • BFS mit Sortieren und ZusammenfĂŒhren - eine Änderung des Algorithmus zur effizienten Stapelentsorgung redundanter Daten.
  • Komprimierung - Reduziert den Speicherbedarf aufgrund der Kombination aus Standard- und nativer Komprimierung um das Hundertfache.
  • Oh-oh, ich habe geschummelt! - In den ersten Abschnitten habe ich ĂŒber etwas geschwiegen: Es reicht nicht aus, nur zu wissen, wo die Lösung liegt, aber wir mĂŒssen genau verstehen, wie wir sie erreichen können. In diesem Abschnitt aktualisieren wir den Basisalgorithmus so, dass genĂŒgend Daten ĂŒbertragen werden, um die Lösung aus dem letzten Status neu zu erstellen.
  • Sortieren + ZusammenfĂŒhren mit mehreren Ausgaben - Durch das Speichern weiterer Status werden die Vorteile der Komprimierung vollstĂ€ndig zunichte gemacht. Der Sortier- und ZusammenfĂŒhrungsalgorithmus muss geĂ€ndert werden, damit zwei SĂ€tze von Ausgabedaten gespeichert werden: Einer, gut komprimiert, wird wĂ€hrend der Suche verwendet, und der andere wird nur verwendet, um die Lösung nach dem Finden des ersten neu zu erstellen.
  • Swap - Swap unter Linux ist viel schlimmer als ich dachte.
  • Komprimierung neuer ZustĂ€nde vor dem ZusammenfĂŒhren - Bisher funktionierten Speicheroptimierungen nur mit vielen besuchten ZustĂ€nden. Es stellte sich jedoch heraus, dass die Liste der neu generierten ZustĂ€nde viel grĂ¶ĂŸer ist, als Sie vielleicht denken. Dieser Abschnitt zeigt ein Diagramm zur effizienteren Beschreibung neuer ZustĂ€nde.
  • Platz sparen in ĂŒbergeordneten ZustĂ€nden - Untersuchen Sie die Kompromisse zwischen der Verwendung von CPU / Speicher, um die Lösung am Ende neu zu erstellen.
  • Was nicht funktionierte oder vielleicht nicht funktionierte - einige Ideen schienen vielversprechend, aber als Ergebnis mussten sie zurĂŒckgesetzt werden, wĂ€hrend andere, die Forscher sein sollten, in diesem Fall intuitiv unangemessen erscheinen.

Breite Suche "nach Lehrbuch"


Wie sieht die Breitensuche aus und warum sollten Sie keine Festplatte darin verwenden? Vor diesem kleinen Projekt habe ich nur Optionen fĂŒr die Formulierung „aus LehrbĂŒchern“ in Betracht gezogen, zum Beispiel:

def bfs(graph, start, end): visited = {start} todo = [start] while todo: node = todo.pop_first() if node == end: return True for kid in adjacent(node): if kid not in visited: visited.add(kid) todo.push_back(kid) return False 

Beim Erstellen neuer Kandidatenknoten durch das Programm wird jeder Knoten mit einer Hash-Tabelle bereits besuchter Knoten ĂŒberprĂŒft. Befindet es sich bereits in der Hash-Tabelle, wird der Knoten ignoriert. Andernfalls wird es der Warteschlange und der Hash-Tabelle hinzugefĂŒgt. Manchmal werden in Implementierungen die "besuchten" Informationen in die Knoten und nicht in eine fremde Tabelle eingegeben. Dies ist jedoch eine riskante Optimierung und es ist völlig unmöglich, wenn der Graph implizit angegeben wird.

Warum ist die Verwendung einer Hash-Tabelle problematisch? Weil Hash-Tabellen dazu neigen, ein völlig zufĂ€lliges Speicherzugriffsmuster zu erstellen. Wenn dies nicht der Fall ist, ist dies eine schlechte Hash-Funktion, und die Hash-Tabelle weist aufgrund von Kollisionen höchstwahrscheinlich eine schlechte Leistung auf. Dieses Direktzugriffsmuster kann zu Leistungsproblemen fĂŒhren, selbst wenn die Daten in den Speicher passen: Der Zugriff auf eine große Hash-Tabelle fĂŒhrt wahrscheinlich zu Cache-Fehlern und TLBs (Assoziative Translation Buffer). Was aber, wenn sich ein erheblicher Teil der Daten auf der Festplatte und nicht im Speicher befindet? Die Folgen werden katastrophal sein: etwas in der GrĂ¶ĂŸenordnung von 10 ms pro Suchvorgang.

Bei 10 Milliarden eindeutigen Status benötigen wir nur vier Monate, um auf die DatentrĂ€ger-E / A zu warten, wenn wir nur auf die Hash-Tabelle zugreifen. Das passt nicht zu uns; Die Aufgabe muss unbedingt konvertiert werden, damit das Programm große Datenpakete in einem einzigen Durchgang verarbeiten kann.

BFS mit Sortieren und ZusammenfĂŒhren


Wenn wir DatenzugriffsvorgĂ€nge so weit wie möglich in Pakete integrieren möchten, was wĂ€re dann die maximal erreichbare AnnĂ€herung? Da das Programm nicht weiß, welche Knoten in einer Schicht der Tiefe N + 1 verarbeitet werden sollen, bis die Schicht N vollstĂ€ndig verarbeitet ist, scheint es offensichtlich, dass es notwendig ist, ZustĂ€nde mindestens einmal pro Tiefe zu deduplizieren.

Wenn wir gleichzeitig mit der gesamten Ebene arbeiten, können wir Hash-Tabellen aufgeben und die Menge der besuchten und neuen ZustĂ€nde als sortierte Streams beschreiben (z. B. als Dateistreams, Arrays, Listen). Wir können die neue besuchte Menge trivial finden, indem wir die Mengen von FlĂŒssen kombinieren, und es ist ebenso trivial, die Menge zu finden, die anhand der Differenz der Mengen zu tun ist.

Zwei Operationen mit SĂ€tzen können kombiniert werden, sodass sie mit beiden Threads in einem Durchgang funktionieren. TatsĂ€chlich untersuchen wir beide Streams, verarbeiten das kleinere Element und bewegen uns dann entlang des Streams, aus dem das Element entnommen wurde (oder entlang beider Flows, wenn die Elemente am Anfang gleich sind). In beiden FĂ€llen fĂŒgen wir den Artikel dem neu besuchten Satz hinzu. Dann bewegen wir uns entlang des Stroms neuer ZustĂ€nde vorwĂ€rts und fĂŒgen der neuen Aufgabenmenge ein Element hinzu:

 def bfs(graph, start, end): visited = Stream() todo = Stream() visited.add(start) todo.add(start) while True: new = [] for node in todo: if node == end: return True for kid in adjacent(node): new.push_back(kid) new_stream = Stream() for node in new.sorted().uniq(): new_stream.add(node) todo, visited = merge_sorted_streams(new_stream, visited) return False # Merges sorted streams new and visited. Return a sorted stream of # elements that were just present in new, and another sorted # stream containing the elements that were present in either or # both of new and visited. def merge_sorted_streams(new, visited): out_todo, out_visited = Stream(), Stream() while visited or new: if visited and new: if visited.peek() == new.peek(): out_visited.add(visited.pop()) new.pop() elif visited.peek() < new.peek(): out_visited.add(visited.pop()) elif visited.peek() > new.peek(): out_todo.add(new.peek()) out_visited.add(new.pop()) elif visited: out_visited.add(visited.pop()) elif new: out_todo.add(new.peek()) out_visited.add(new.pop()) return out_todo, out_visited 

Das Datenzugriffsmuster ist jetzt vollstĂ€ndig linear und vorhersehbar, es gibt keine willkĂŒrlichen Zugriffe wĂ€hrend der Fusion. Daher wird die Verzögerung des Festplattenbetriebs fĂŒr uns nicht wichtig, und das einzige, was wichtig bleibt, ist die Bandbreite.

Wie wird die theoretische Leistung bei einer vereinfachten Verteilung von Daten ĂŒber 100 Tiefenebenen aussehen, von denen jede 100 Millionen ZustĂ€nde hat? Der gemittelte Zustand wird 50 Mal gelesen und geschrieben. Dies ergibt 10 Bytes / Zustand * 5 Milliarden ZustĂ€nde * 50 = 2,5 TB. Meine Festplatte kann angeblich mit einer durchschnittlichen Geschwindigkeit von 100 MB / s lesen und schreiben, dh durchschnittlich dauert die E / A (2 * 2,5 TB) / (100 MB / s) = ~ 50 k / s = ~ 13 Stunden . Dies sind ein paar Bestellungen weniger als das vorherige Ergebnis (vier Monate)!

Es ist auch erwĂ€hnenswert, dass dieses vereinfachte Modell die GrĂ¶ĂŸe der neu erzeugten ZustĂ€nde nicht berĂŒcksichtigt. Vor dem ZusammenfĂŒhrungsschritt mĂŒssen sie zum Sortieren + Deduplizieren im Speicher gespeichert werden. Wir werden dies in den folgenden Abschnitten behandeln.

Komprimierung


In der Einleitung sagte ich, dass in den ersten Experimenten die Zustandskomprimierung nicht vielversprechend aussah und das KomprimierungsverhĂ€ltnis nur 30% betrug. Nachdem jedoch Änderungen am Algorithmus vorgenommen wurden, wurden die ZustĂ€nde optimiert. Sie sollten viel einfacher zu komprimieren sein.

Um diese Theorie zu testen, habe ich zstd mit einem Puzzle von 14,6 Millionen ZustĂ€nden verwendet, von denen jeder 8 Bytes groß war. Nach dem Sortieren wurden sie durchschnittlich auf 1,4 Bytes pro Status komprimiert. Es scheint ein ernsthafter Schritt nach vorne zu sein. Es reicht nicht aus, das gesamte Programm im Speicher auszufĂŒhren, aber es kann die E / A-Zeit der Festplatte auf nur einige Stunden reduzieren.

Ist es möglich, das Ergebnis des modernen Allzweckkomprimierungsalgorithmus irgendwie zu verbessern, wenn wir etwas ĂŒber die Datenstruktur wissen? Sie können sich fast sicher sein. Ein gutes Beispiel hierfĂŒr ist das PNG-Format. Theoretisch ist die Komprimierung nur ein Standard-Deflate-Durchgang. Anstatt Rohdaten zu komprimieren, wird das Bild zunĂ€chst mit PNG-Filtern konvertiert. Das PNG-Filter ist im Wesentlichen eine Formel zum Vorhersagen des Werts eines Bytes von Rohdaten basierend auf dem Wert des gleichen Bytes in der vorherigen Zeile und / oder des gleichen Bytes des vorherigen Pixels. Beispielsweise konvertiert der Filter "up" jedes Byte, indem er beim Komprimieren die Werte der vorherigen Zeile davon subtrahiert und beim Entpacken die entgegengesetzte Operation ausfĂŒhrt. Angesichts der Bildtypen, fĂŒr die PNG verwendet wird, besteht das Ergebnis fast immer aus Nullen oder Zahlen nahe Null. Deflate kann solche Daten viel besser komprimieren als Rohdaten.

Kann dieses Prinzip auf BFS-StatusdatensĂ€tze angewendet werden? Es scheint, dass dies möglich sein sollte. Wie bei PNG haben wir eine konstante LiniengrĂ¶ĂŸe und können erwarten, dass die benachbarten Linien sehr Ă€hnlich sind. Die ersten Proben mit dem Subtraktions- / Additionsfilter, gefolgt von zstd, fĂŒhrten zu einer Verbesserung des KompressionsverhĂ€ltnisses um weitere 40%: 0,87 Bytes pro Zustand. FiltervorgĂ€nge sind trivial, daher sind sie aus Sicht des CPU-Verbrauchs praktisch „kostenlos“.

Mir war nicht klar, ob weitere Verbesserungen vorgenommen werden konnten oder ob dies eine praktische Grenze war. In den Bilddaten können Sie logischerweise erwarten, dass die benachbarten Bytes derselben Zeile Àhnlich sind. Aber in diesen Staaten gibt es so etwas nicht. TatsÀchlich können etwas ausgefeiltere Filter die Ergebnisse noch verbessern. Am Ende bin ich zu diesem System gekommen:

Angenommen, wir haben benachbarte Zeilen R1 = [1, 2, 3, 4] und R2 = [1, 2, 6, 4]. Bei der Ausgabe von R2 vergleichen wir jedes Byte mit dem gleichen Byte der vorherigen Zeile, und 0 zeigt eine Übereinstimmung an, und 1 zeigt eine NichtĂŒbereinstimmung an: diff = [0, 0, 1, 0]. Dann ĂŒbergeben wir diese Bitmap, die als VarInt codiert ist, gefolgt von nur Bytes, die nicht mit der vorherigen Zeile ĂŒbereinstimmen. In diesem Beispiel erhalten wir zwei Bytes 0b00000100 6. Dieser Filter komprimiert die Referenzdaten fĂŒr sich genommen auf 2,2 Bytes / Status. Durch die Kombination des Filters + zstd haben wir die DatengrĂ¶ĂŸe auf 0,42 Byte / Status reduziert. Anders ausgedrĂŒckt, dies sind 3,36 Bit pro Status, was nur ein bisschen mehr ist als unsere ungefĂ€hren berechneten Indikatoren, die erforderlich sind, um sicherzustellen, dass alle Daten in den RAM passen.

In der Praxis verbessern sich die KompressionsverhĂ€ltnisse, weil sortierte SĂ€tze dichter werden. Wenn die Suche den Punkt erreicht, an dem der Speicher Probleme verursacht, können die Komprimierungsraten viel besser werden. Das grĂ¶ĂŸte Problem ist, dass wir am Ende 4,6 Milliarden besuchte Staaten bekommen. Nach dem Sortieren belegen diese ZustĂ€nde 405 MB und werden gemĂ€ĂŸ dem oben dargestellten Schema komprimiert. Dies ergibt 0,7 Bits pro Zustand . Am Ende nehmen Komprimierung und Dekomprimierung etwa 25% der CPU-Zeit des Programms in Anspruch. Dies ist jedoch ein hervorragender Kompromiss, um den Speicherverbrauch um das Hundertfache zu reduzieren.

Der obige Filter scheint aufgrund des VarInt-Headers in jeder Zeile etwas kostspielig zu sein. Es scheint einfach zu sein, ein Upgrade auf Kosten niedriger CPU-Kosten oder einer leichten Erhöhung der KomplexitĂ€t durchzufĂŒhren. Ich habe verschiedene Optionen ausprobiert, Daten nach Spalten sortiert oder Bitmasken in grĂ¶ĂŸeren Blöcken usw. geschrieben. Diese Optionen allein ergaben viel höhere KomprimierungsverhĂ€ltnisse, zeigten jedoch keine gute Leistung, wenn die Filterausgabe durch zstd komprimiert wurde. Und es war kein zstd-Fehler, die Ergebnisse mit gzip und bzip2 waren Ă€hnlich. Ich habe keine besonders genialen Theorien darĂŒber, warum sich herausgestellt hat, dass diese bestimmte Art der Codierung bei der Komprimierung viel besser ist als andere Optionen.

Ein weiteres RĂ€tsel: Die Komprimierungsrate erwies sich als viel besser, wenn die Daten eher nach Little-Endian als nach Big-Endian sortiert wurden. Anfangs dachte ich, dass es passiert ist, weil es bei der Little-Endian-Sortierung mehr fĂŒhrende Nullen mit der von VarInt codierten Bitmaske gibt. Dieser Unterschied bleibt jedoch auch bei Filtern bestehen, die keine solchen AbhĂ€ngigkeiten aufweisen.

(Es gibt viele Untersuchungen zum Komprimieren sortierter SĂ€tze von Ganzzahlen, da diese die Grundbausteine ​​von Suchmaschinen sind. Ich fand jedoch nicht viele Informationen zum Komprimieren sortierter DatensĂ€tze konstanter LĂ€nge und wollte nicht raten, die Daten als Ganzzahlwerte mit willkĂŒrlicher Genauigkeit darzustellen.)

Oh-oh, ich habe geschummelt!


Möglicherweise haben Sie bemerkt, dass die obigen BFS-Implementierungen im Pseudocode nur Boolesche Werte zurĂŒckgeben - die Lösung wurde gefunden / nicht gefunden. Dies ist nicht besonders nĂŒtzlich. In den meisten FĂ€llen mĂŒssen wir eine Liste der genauen Schritte der Lösung erstellen und nicht nur die VerfĂŒgbarkeit der Lösung melden.

Auf den ersten Blick scheint dieses Problem leicht zu lösen zu sein. Anstatt SĂ€tze von ZustĂ€nden zu erfassen, mĂŒssen Sie Statusbeziehungen zu ĂŒbergeordneten ZustĂ€nden erfassen. Nachdem Sie die Lösung gefunden haben, können Sie einfach von Ende zu Anfang von der Liste der elterlichen Lösungen zurĂŒckkehren. FĂŒr eine auf Hash-Tabellen basierende Lösung wĂŒrde dies ungefĂ€hr so ​​aussehen:

 def bfs(graph, start, end): visited = {start: None} todo = [start] while todo: node = todo.pop_first() if node == end: return trace_solution(node, visited) for kid in adjacent(node): if kid not in visited: visited[kid] = node todo.push_back(kid) return None def trace_solution(state, visited): if state is None: return [] return trace_solution(start, visited[state]) + [state] 

Leider werden dadurch alle im vorherigen Abschnitt erzielten Komprimierungsvorteile zerstört. Sie basieren auf der Annahme, dass benachbarte Linien sehr Ă€hnlich sind. Wenn wir nur die Staaten selbst betrachten, ist dies wahr. Es gibt jedoch keinen Grund zu der Annahme, dass dies fĂŒr Elternstaaten gilt. TatsĂ€chlich handelt es sich um zufĂ€llige Daten. Zweitens sollte eine Sort + Merge-Lösung alle ZustĂ€nde lesen und schreiben, die bei jeder Iteration angezeigt werden. Um die VerknĂŒpfung des Status / ĂŒbergeordneten Status zu speichern, mĂŒssen wir bei jeder Iteration all diese schlecht komprimierten Daten lesen und auf die Festplatte schreiben.

Sortieren + ZusammenfĂŒhren mit mehreren Ausgaben


Ganz am Ende, wenn Sie zur Lösung zurĂŒckkehren, benötigt das Programm nur BĂŒndel von ZustĂ€nden / ĂŒbergeordneten ZustĂ€nden. Daher können wir zwei Datenstrukturen parallel speichern. Besucht werden weiterhin die besuchten Staaten, wie sie zuvor wĂ€hrend der ZusammenfĂŒhrung neu berechnet wurden. Eltern ist mindestens eine sortierte Liste von Status / Eltern-Statuspaaren, die nicht ĂŒberschrieben werden. Nach jedem ZusammenfĂŒhrungsvorgang wird das Paar "Status + ĂŒbergeordneter Status" zu den ĂŒbergeordneten Elementen hinzugefĂŒgt.

 def bfs(graph, start, end): parents = Stream() visited = Stream() todo = Stream() parents.add((start, None)) visited.add(start) todo.add(start) while True: new = [] for node in todo: if node == end: return trace_solution(node, parents) for kid in adjacent(node): new.push_back(kid) new_stream = Stream() for node in new.sorted().uniq(): new_stream.add(node) todo, visited = merge_sorted_streams(new_stream, visited, parents) return None # Merges sorted streams new and visited. New contains pairs of # key + value (just the keys are compared), visited contains just # keys. # # Returns a sorted stream of keys that were just present in new, # another sorted stream containing the keys that were present in either or # both of new and visited. Also adds the keys + values to the parents # stream for keys that were only present in new. def merge_sorted_streams(new, visited, parents): out_todo, out_visited = Stream(), Stream() while visited or new: if visited and new: visited_head = visited.peek() new_head = new.peek()[0] if visited_head == new_head: out_visited.add(visited.pop()) new.pop() elif visited_head < new_head: out_visited.add(visited.pop()) elif visited_head > new_head: out_todo.add(new_head) out_visited.add(new_head) out_parents.add(new.pop()) elif visited: out_visited.add(visited.pop()) elif new: out_todo.add(new.peek()[0]) out_visited.add(new.peek()[0]) out_parents.add(new.pop()) return out_todo, out_visited 

Dies ermöglicht es uns, beide AnsĂ€tze in Bezug auf Laufzeit und ArbeitssĂ€tze zu nutzen, erfordert jedoch mehr sekundĂ€ren Speicherplatz. DarĂŒber hinaus stellt sich heraus, dass in Zukunft aus anderen GrĂŒnden eine separate Kopie der besuchten Staaten nĂŒtzlich sein wird, gruppiert nach Tiefe.

Tauschen


Ein weiteres Detail wird im Pseudocode ignoriert: Es gibt keinen expliziten Code fĂŒr die Festplatten-E / A, sondern nur die abstrakte Stream-Schnittstelle. Stream kann ein Dateistream oder ein Array im Speicher sein, aber wir haben dieses Implementierungsdetail ignoriert. Stattdessen erstellt der Pseudocode ein Speicherzugriffsmuster, das eine optimale Nutzung der Festplatte ermöglicht. In einer idealen Welt wĂŒrde dies ausreichen, und der Rest könnte vom virtuellen Speichersubsystem des Betriebssystems ĂŒbernommen werden.

Dies ist jedoch zumindest unter Linux nicht der Fall. Irgendwann (bevor der Arbeitsdatensatz auf SpeichergrĂ¶ĂŸe komprimiert werden konnte) wurde das Programm in etwa 11 Stunden ausgefĂŒhrt, und die Daten wurden hauptsĂ€chlich auf der Festplatte gespeichert. Dann ließ ich das Programm anonyme Seiten verwenden, anstatt sie in Dateien zu speichern, und wĂ€hlte eine Auslagerungsdatei von ausreichender GrĂ¶ĂŸe auf demselben Laufwerk aus. Drei Tage spĂ€ter ging das Programm jedoch nur ein Viertel des Weges und wurde im Laufe der Zeit immer langsamer. Nach meinen optimistischen SchĂ€tzungen sollte sie den Job in 20 Tagen beenden.

Ich werde es klarstellen - es war der gleiche Code und genau das gleiche Zugriffsmuster . Das einzige, was sich geĂ€ndert hat, ist, dass der Speicher nicht als explizite Festplattendatei, sondern als Swap gespeichert wurde. Es sind fast keine Beweise dafĂŒr erforderlich, dass das Austauschen die Linux-Leistung vollstĂ€ndig zerstört, wĂ€hrend dies bei regulĂ€ren Datei-E / A nicht der Fall ist. Ich habe immer angenommen, dass dies auf die Tatsache zurĂŒckzufĂŒhren ist, dass Programme RAM eher als Arbeitsspeicher betrachten. Dies ist jedoch nicht der Fall.

Es stellt sich heraus, dass Dateispeicherseiten und anonyme Seiten vom Subsystem der virtuellen Maschine unterschiedlich behandelt werden. Sie werden in separaten LRU-Caches mit unterschiedlichen Ablaufrichtlinien gespeichert. DarĂŒber hinaus scheinen sie unterschiedliche Read / Load-Read-Ahead-Eigenschaften zu haben.

Jetzt weiß ich: Das Tauschen unter Linux wird höchstwahrscheinlich auch unter optimalen Bedingungen nicht gut funktionieren. Wenn Teile des Adressraums wahrscheinlich fĂŒr einige Zeit auf die Festplatte entladen werden, ist es besser, sie manuell in Dateien zu speichern, als dem Swap zu vertrauen. Ich habe dies erreicht, indem ich meine eigene Klasse von Vektoren implementiert habe, die anfĂ€nglich nur im Speicher funktioniert. Nach Überschreiten eines bestimmten GrĂ¶ĂŸenschwellenwerts wechselt sie in einer temporĂ€ren separaten Datei zu mmap.

Komprimierung neuer ZustĂ€nde vor dem ZusammenfĂŒhren


In einem vereinfachten Leistungsmodell gingen wir davon aus, dass in jeder Tiefe 100 Millionen neue Bedingungen auftreten wĂŒrden. Es stellte sich heraus, dass dies nicht sehr weit von der RealitĂ€t entfernt ist (im komplexesten Puzzle maximal mehr als 150 Millionen einzigartige neue ZustĂ€nde auf einer Tiefenschicht). Dies ist aber nicht zu messen; Der Arbeitssatz vor dem ZusammenfĂŒhren ist nicht nur eindeutigen ZustĂ€nden zugeordnet, sondern auch allen ZustĂ€nden, die fĂŒr diese Iteration abgeleitet wurden. Diese Zahl erreicht 880 Millionen AusgangszustĂ€nde pro Tiefenschicht. Diese 880 Millionen ZustĂ€nde mĂŒssen a) mit einem Direktzugriffsmuster zum Sortieren verarbeitet werden, b) können aufgrund fehlender Sortierung nicht effektiv komprimiert werden, c) mĂŒssen zusammen mit dem ĂŒbergeordneten Zustand gespeichert werden. Dieser Arbeitssatz ist ungefĂ€hr 16 GB groß.

Die offensichtliche Lösung: Verwenden Sie eine externe Sortierung. Schreiben Sie einfach alle Status auf die Festplatte, fĂŒhren Sie eine externe Sortierung durch, deduplizieren Sie sie und fĂŒhren Sie sie dann wie gewohnt zusammen. Zuerst habe ich diese Lösung verwendet, und obwohl sie das Problem A höchstens beseitigte, konnte ich B und C nicht bewĂ€ltigen.

Am Ende habe ich einen alternativen Ansatz gewĂ€hlt: Ich habe die ZustĂ€nde in einem Array im Speicher gesammelt. Wenn das Array zu groß wird (z. B. mehr als 100 Millionen Elemente), wird es sortiert, dedupliziert und komprimiert. Dies gibt uns ein Paket sortierter StatuslĂ€ufe, und es gibt keine Duplikate in jedem Lauf, aber sie sind zwischen den LĂ€ufen möglich. GrundsĂ€tzlich bleibt der Code zum ZusammenfĂŒhren neuer und besuchter Staaten derselbe; es basiert immer noch auf einem allmĂ€hlichen Durchgang durch die BĂ€che. Der einzige Unterschied besteht darin, dass anstatt nur zwei Streams zu durchlaufen, fĂŒr jeden sortierten Lauf neuer ZustĂ€nde ein separater Stream vorhanden ist.

NatĂŒrlich sind die Komprimierungsraten dieser LĂ€ufe von 100 Millionen ZustĂ€nden nicht so gut wie die Komprimierung der Menge aller besuchten ZustĂ€nde. Aber selbst mit solchen Indikatoren wird das Volumen sowohl des Arbeitssatzes als auch die Anforderungen an die Festplatten-E / A erheblich reduziert. Sie benötigen etwas mehr CPU-Ressourcen, um die PrioritĂ€tswarteschlange von Threads zu verarbeiten, aber es ist immer noch ein großer Kompromiss.

Platz sparen in ĂŒbergeordneten ZustĂ€nden


Zu diesem Zeitpunkt wird der grĂ¶ĂŸte Teil des vom Programm belegten Speicherplatzes fĂŒr die Speicherung der ĂŒbergeordneten ZustĂ€nde aufgewendet, sodass wir nach dem Finden der Lösung den Prozess neu erstellen können. Höchstwahrscheinlich können sie kaum gut zusammengedrĂŒckt werden, aber vielleicht gibt es einen Kompromiss zwischen CPU und Speicher?

Wir mĂŒssen den Zustand S 'in der Tiefe D + 1 mit seinem Elternzustand S in der Tiefe D verbinden. Wenn wir alle möglichen ElternzustĂ€nde S' durchlaufen können, können wir prĂŒfen, ob einer von ihnen in der Tiefe D in der besuchten Menge erscheint . (Wir haben bereits viele Besucher erstellt, gruppiert nach Tiefe als praktisches Nebenprodukt der Ableitung von staatlichen / elterlichen StaatsbĂŒndeln wĂ€hrend der ZusammenfĂŒhrung). Leider funktioniert dieser Ansatz fĂŒr diese Aufgabe nicht. es ist einfach zu schwierig fĂŒr uns, alle möglichen ZustĂ€nde von S fĂŒr ein gegebenes S 'zu erzeugen. FĂŒr viele andere Suchaufgaben könnte eine solche Lösung jedoch funktionieren.

Wenn wir nur ÜbergĂ€nge zwischen ZustĂ€nden vorwĂ€rts, aber nicht rĂŒckwĂ€rts erzeugen können, warum dann nicht einfach? Lassen Sie uns iterativ alle ZustĂ€nde in Tiefe D umgehen und sehen, welche Art von AusgabezustĂ€nden sie erhalten. Wenn ein Zustand am Ausgang S 'ergibt, haben wir ein geeignetes S gefunden. Das Problem bei diesem Plan ist, dass er den Gesamt-CPU-Verbrauch des Programms um 50% erhöht. (Nicht 100%, da wir S im Durchschnitt finden, wenn wir die HĂ€lfte der ZustĂ€nde in der Tiefe D betrachten).

Daher mag ich keinen der GrenzfĂ€lle, aber hier ist zumindest ein Kompromiss zwischen CPU / Speicher möglich. Gibt es irgendwo dazwischen eine akzeptablere Lösung? Am Ende habe ich beschlossen, nicht das Paar (S ', S) zu speichern, sondern das Paar (S', H (S)), wobei H eine 8-Bit-Hash-Funktion ist. Um S fĂŒr ein gegebenes S 'zu finden, gehen wir erneut iterativ alle ZustĂ€nde in Tiefe D durch. Bevor wir jedoch etwas anderes tun, berechnen wir denselben Hash. Wenn die Ausgabe nicht mit H (S) ĂŒbereinstimmt, ist dies nicht der gesuchte Zustand, und wir können ihn einfach ĂŒberspringen. Diese Optimierung bedeutet, dass kostspielige Neuberechnungen nur fĂŒr 1/256 ZustĂ€nde durchgefĂŒhrt werden mĂŒssen, was eine leichte Erhöhung der CPU-Auslastung darstellt, und gleichzeitig die Speichermenge zum Speichern von ElternzustĂ€nden von 8-10 Bytes auf 1 Byte reduziert.

Was hat nicht funktioniert oder kann nicht funktionieren


In den vorherigen Abschnitten haben wir uns die Reihenfolge der funktionierenden Optimierungen auf hoher Ebene angesehen. Ich habe andere Dinge ausprobiert, die nicht funktionierten oder die ich in der Literatur gefunden habe, aber entschieden, dass sie in diesem speziellen Fall nicht funktionieren wĂŒrden. Hier ist eine unvollstĂ€ndige Liste.

Zu diesem Zeitpunkt berechne ich nicht den gesamten Satz neu, der bei jeder Iteration besucht wurde. Stattdessen wurden so viele sortierte LĂ€ufe gespeichert, und diese LĂ€ufe wurden von Zeit zu Zeit komprimiert. Der Vorteil dieses Ansatzes besteht darin, dass weniger FestplattenschreibvorgĂ€nge und CPU-Ressourcen fĂŒr die Komprimierung verwendet werden. Der Nachteil ist eine erhöhte CodekomplexitĂ€t und eine verringerte Komprimierungsrate. Anfangs dachte ich, dass ein solches Schema sinnvoll ist, weil in meinem Fall SchreibvorgĂ€nge teurer sind als Lesen. Am Ende stellte sich jedoch heraus, dass das KompressionsverhĂ€ltnis doppelt so hoch war. Die Vorteile eines solchen Kompromisses liegen nicht auf der Hand, weshalb ich zu einer einfacheren Form zurĂŒckgekehrt bin.

Es wurden bereits wenige Untersuchungen zur volumetrischen Breitensuche nach implizit definierten Diagrammen im SekundĂ€rspeicher durchgefĂŒhrt. Sie können dieses Thema untersuchenaus diesem Artikel von 2008 . Wie Sie vielleicht erraten haben, ist die Idee, Deduplizierung zusammen mit Sortieren + ZusammenfĂŒhren im SekundĂ€rspeicher durchzufĂŒhren, nicht neu. Das Überraschende daran ist, dass es erst 1993 eröffnet wurde. Ziemlich spĂ€t! Es gibt spĂ€ter VorschlĂ€ge fĂŒr die Breitensuche im SekundĂ€rspeicher, fĂŒr die kein Sortierschritt erforderlich ist.

Eine davon bestand darin, ZustĂ€nde an ganze Zahlen zu binden und eine Bitmap der besuchten ZustĂ€nde im Speicher zu speichern. In meinem Fall ist dies völlig nutzlos, da die GrĂ¶ĂŸen des codierten Zustands im Vergleich zu den wirklich erreichbaren ZustandsrĂ€umen sehr unterschiedlich sind. Und ich bezweifle sehr, dass es interessante Probleme gibt, bei denen ein solcher Ansatz funktionieren wĂŒrde.

Eine weitere ernsthafte Alternative basiert auf temporĂ€ren Hash-Tabellen. BesuchszustĂ€nde werden ohne Sortierung in einer Datei gespeichert. Wir speichern die Ausgabe aus Tiefe D in der Hash-Tabelle. Gehen Sie dann iterativ durch die besuchten ZustĂ€nde und suchen Sie sie in der Hash-Tabelle. Wenn das Element in der Hash-Tabelle gefunden wird, löschen Sie es. Nach dem iterativen Durchlaufen der gesamten Datei verbleiben nur nicht doppelte Elemente darin. Sie werden dann zur Datei hinzugefĂŒgt und zum Initialisieren der Aufgabenliste fĂŒr die nĂ€chste Iteration verwendet. Wenn die Ausgabemenge so groß ist, dass die Hash-Tabelle nicht in den Speicher passt, können sowohl Dateien als auch Hash-Tabellen nach denselben Kriterien (z. B. den oberen Statusbits) in Teile unterteilt werden, und jeder Teil sollte separat verarbeitet werden.

Obwohl es Benchmarks gibtDies zeigt, dass der Hash-basierte Ansatz etwa 30% schneller ist als das Sortieren + ZusammenfĂŒhren, aber es scheint, dass sie die Komprimierung nicht berĂŒcksichtigen. Ich habe einfach nicht gesehen, wie sich die Ablehnung der Vorteile der Komprimierung rechtfertigen kann, deshalb habe ich nicht einmal mit solchen AnsĂ€tzen experimentiert.

Ein weiterer Forschungsbereich, der Beachtung verdient, war die Optimierung von Datenbankabfragen. Es sieht aus wie. dass die Deduplizierungsaufgabe stark mit dem Datenbank-Join zusammenhĂ€ngt, der genau das gleiche Dilemma zwischen Sortierung und Hashing aufweist. Offensichtlich können einige dieser Studien auf das Suchproblem angewendet werden. Der Unterschied kann darin bestehen, dass die Ausgabe der Join-Datenbank temporĂ€r ist, wĂ€hrend die Ausgabe der BFS-Deduplizierung bis zum Ende der Berechnung gespeichert wird. Dies scheint das Gleichgewicht der Kompromisse zu verĂ€ndern: Jetzt geht es nicht nur um die effizienteste Verarbeitung einer Iteration, sondern auch um die Erstellung des optimalsten Ausgabedatenformats fĂŒr die nĂ€chste Iteration.

Fazit


Damit ist mein Bericht ĂŒber das abgeschlossen, was ich aus einem Projekt gelernt habe, das allgemein mit brutaler Gewalt auf andere Suchaufgaben anwendbar ist. Die Kombination dieser Tricks ermöglichte es, das Lösungsvolumen fĂŒr die komplexesten RĂ€tsel des Spiels von 50 bis 100 GB auf 500 MB zu reduzieren und die Kosten reibungslos zu erhöhen, wenn die Aufgabe den verfĂŒgbaren Speicher ĂŒberschreitet und auf die Festplatte geschrieben wird. Außerdem ist meine Lösung 50% schneller als eine naive Deduplizierung von ZustĂ€nden basierend auf Hash-Tabellen, selbst fĂŒr RĂ€tsel, die in den Speicher passen.

Snakebird kann bei Steam , Google Play und im App Store gekauft werden . Ich empfehle es jedem, der sich fĂŒr sehr komplexe, aber ehrliche RĂ€tsel interessiert.

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


All Articles