00:00:00 Einführung in den Vortrag über die Auslagerung auf die Festplatte
00:00:34 Datenverarbeitung im Einzelhandel und Speicherbegrenzungen
00:02:13 Lösung für dauerhaften Speicher und Kostenvergleich
00:04:07 Geschwindigkeitsvergleich von Festplatte und Speicher
00:05:10 Begrenzungen von Partitionierungs- und Streaming-Techniken
00:06:16 Bedeutung von geordneten Daten und optimaler Lesegröße
00:07:40 Schlimmstes Szenario für Datenlesen
00:08:57 Auswirkungen des Arbeitsspeichers des Computers auf den Programmablauf
00:10:49 Techniken zur Auslagerung auf die Festplatte und Speichernutzung
00:12:59 Erklärung des Code-Abschnitts und .NET-Implementierung
00:15:06 Kontrolle über Speicherzuweisung und Konsequenzen
00:16:18 Speichergemappte Seite und Speichermapping-Dateien
00:18:24 Lese-Schreib-Speicherkarten und Systemleistungstools
00:20:04 Verwendung von virtuellem Speicher und speichergemappten Seiten
00:22:08 Umgang mit großen Dateien und 64-Bit-Zeigern
00:24:00 Verwendung von Span zum Laden aus speichergemapptem Speicher
00:26:03 Datenkopieren und Verwendung von Struktur zum Lesen von Ganzzahlen
00:28:06 Erstellen eines Span aus einem Zeiger und Speichermanager
00:30:27 Erstellen einer Instanz eines Speichermanagers
00:31:05 Implementierung des Programms zur Auslagerung auf die Festplatte und Speichermapping
00:33:34 Speichergemappte Version bevorzugt für die Leistung
00:35:22 Strategie zur Pufferung von Dateistreams und Einschränkungen
00:37:03 Strategie zur Kartierung einer großen Datei
00:39:30 Aufteilung des Speichers über mehrere große Dateien
00:40:21 Schlussfolgerung und Einladung zu Fragen

Zusammenfassung

Um mehr Daten zu verarbeiten, als in den Speicher passen, können Programme einige dieser Daten auf einen langsameren, aber größeren Speicher auslagern, wie z.B. NVMe-Laufwerke. Durch eine Kombination von zwei eher obskuren .NET-Funktionen (speichergemappte Dateien und Speichermanager) kann dies in C# mit wenig bis gar keinem Leistungsverlust erfolgen. Dieser Vortrag, gehalten auf den Warsaw IT Days 2023, geht tief in die Details, wie dies funktioniert, und diskutiert, wie das Open-Source NuGet-Paket Lokad.ScratchSpace die meisten dieser Details vor Entwicklern verbirgt.

Erweiterte Zusammenfassung

In einem umfassenden Vortrag geht Victor Nicolet, der CTO von Lokad, auf die Feinheiten der Auslagerung auf die Festplatte in .NET ein, eine Technik, die die Verarbeitung großer Datensätze ermöglicht, die die Speicherkapazität eines typischen Computers übersteigen. Nicolet schöpft aus seiner umfangreichen Erfahrung im Umgang mit komplexen Datensätzen im Bereich der supply chain Optimierung bei Lokad und liefert ein praktisches Beispiel für einen Einzelhändler mit hunderttausend Produkten an 100 Standorten. Dies führt zu einem Datensatz von 10 Milliarden Einträgen, wenn man tägliche Datenpunkte über drei Jahre betrachtet, was 37 Gigabyte Speicher erfordern würde, um einen Gleitkommawert für jeden Eintrag zu speichern, weit über die Kapazität eines typischen Desktop-Computers hinaus.

Nicolet schlägt die Verwendung von persistentem Speicher, wie z.B. NVMe SSD-Speicher, als kosteneffektive Alternative zum Speicher vor. Er vergleicht die Kosten von Speicher und SSD-Speicher und stellt fest, dass man für den Preis von 18 Gigabyte Speicher eine Terabyte SSD-Speicher kaufen könnte. Er diskutiert auch den Leistungskompromiss und stellt fest, dass das Lesen von der Festplatte sechsmal langsamer ist als das Lesen aus dem Speicher.

Er führt Partitionierung und Streaming als Techniken ein, um Festplattenspeicher als Alternative zum Speicher zu nutzen. Partitionierung ermöglicht die Verarbeitung von Datensätzen in kleineren Teilen, die in den Speicher passen, erlaubt aber keine Kommunikation zwischen den Partitionen. Streaming hingegen ermöglicht es, einen Zustand zwischen der Verarbeitung verschiedener Teile beizubehalten, erfordert aber, dass die Daten auf der Festplatte ordnungsgemäß geordnet oder ausgerichtet sind, um eine optimale Leistung zu erzielen.

Nicolet führt dann Spill-to-Disk-Techniken als Lösung für die Einschränkungen des Fits-in-Memory-Ansatzes ein. Diese Techniken verteilen die Daten dynamisch zwischen Speicher und persistentem Speicher, verwenden mehr Speicher, wenn verfügbar, um schneller zu laufen, und verlangsamen sich, um weniger Speicher zu verwenden, wenn weniger verfügbar ist. Er erklärt, dass Spill-to-Disk-Techniken so viel Speicher wie möglich verwenden und erst dann Daten auf die Festplatte auslagern, wenn sie keinen Speicher mehr haben. Dies macht sie besser darin, auf mehr oder weniger Speicher zu reagieren, als ursprünglich erwartet.

Er erklärt weiter, dass Spill-to-Disk-Techniken den Datensatz in zwei Abschnitte unterteilen: den Hot-Bereich, der immer im Speicher ist, und den Cold-Bereich, der Teile seines Inhalts jederzeit auf den persistenten Speicher auslagern kann. Das Programm verwendet Hot-Cold-Transfers, die in der Regel große Chargen umfassen, um die Nutzung der NVMe-Bandbreite zu maximieren. Der Cold-Bereich ermöglicht es diesen Algorithmen, so viel Speicher wie möglich zu verwenden.

Nicolet diskutiert dann, wie man dies in .NET implementiert. Für den Hot-Bereich werden normale .NET-Objekte verwendet, während für den Cold-Bereich eine Referenzklasse verwendet wird. Diese Klasse hält eine Referenz auf den Wert, der in den Cold-Speicher gelegt wird, und dieser Wert kann auf null gesetzt werden, wenn er nicht mehr im Speicher ist. Ein zentrales System im Programm behält den Überblick über alle Cold-Referenzen und bestimmt, wann immer eine neue Cold-Referenz erstellt wird, ob sie den Speicher überläuft und ruft die Spill-Funktion einer oder mehrerer bereits im System vorhandener Cold-Referenzen auf, um innerhalb des für den Cold-Speicher verfügbaren Speicherbudgets zu bleiben.

Er führt dann das Konzept des virtuellen Speichers ein, bei dem das Programm keinen direkten Zugriff auf physische Speicherseiten hat, sondern Zugriff auf virtuelle Speicherseiten hat. Es ist möglich, eine speichergemappte Seite zu erstellen, was eine gängige Methode zur Implementierung der Kommunikation zwischen Programmen und Speichermapping-Dateien ist. Der Hauptzweck des Speichermappings besteht darin, zu verhindern, dass jedes Programm seine eigene Kopie der DLL im Speicher hat, da alle diese Kopien identisch sind.

Nicolet diskutiert dann das System Performance Tool, das die aktuelle Nutzung des physischen Speichers anzeigt. In Grün ist der Speicher, der direkt einem Prozess zugewiesen wurde, in Blau ist der Seiten-Cache und die modifizierten Seiten in der Mitte sind diejenigen, die eine genaue Kopie der Festplatte sein sollten, aber Änderungen im Speicher enthalten.

Er diskutiert dann den zweiten Versuch mit virtuellem Speicher, bei dem der Cold-Bereich vollständig aus speichergemappten Seiten besteht. Wenn das Betriebssystem plötzlich Speicher benötigt, weiß es, welche Seiten speichergemappt sind und sicher verworfen werden können.

Nicolet erklärt dann die grundlegenden Schritte zur Erstellung einer speichergemappten Datei in .NET, die zunächst darin bestehen, eine speichergemappte Datei von einer Datei auf der Festplatte zu erstellen und dann einen View-Accessor zu erstellen. Die beiden werden getrennt gehalten, weil .NET den Fall eines 32-Bit-Prozesses behandeln muss. Im Falle eines 64-Bit-Prozesses kann ein View-Accessor erstellt werden, der die gesamte Datei lädt.

Nicolet diskutiert dann die Einführung von Speicher und Spanne vor fünf Jahren, die Typen sind, die verwendet werden, um einen Bereich von Speicher auf eine Weise darzustellen, die sicherer ist als nur Zeiger. Die allgemeine Idee hinter Spanne und Speicher ist, dass bei gegebenem Zeiger und einer Anzahl von Bytes eine neue Spanne erstellt werden kann, die diesen Speicherbereich darstellt. Sobald eine Spanne erstellt ist, kann sie sicher innerhalb der Spanne gelesen werden, wobei bekannt ist, dass, wenn versucht wird, über die Grenzen hinaus zu lesen, die Laufzeit dies abfängt und eine Ausnahme ausgelöst wird, anstatt dass der Prozess einfach abgeschlossen wird.

Nicolet diskutiert dann, wie man Spanne verwendet, um aus speichergemapptem Speicher in .NET verwalteten Speicher zu laden. Zum Beispiel, wenn es eine Zeichenkette gibt, die gelesen werden muss, können viele APIs, die sich um Spannen drehen, verwendet werden. Nicolet erklärt die Verwendung von APIs, die sich um Spannen drehen, wie MemoryMarshal.Read, das eine Ganzzahl vom Anfang der Spanne lesen kann. Er erwähnt auch die Funktion Encoding.GetString, die aus einer Spanne von Bytes in eine Zeichenkette laden kann.

Er erklärt weiter, dass diese Operationen auf Spannen durchgeführt werden, die einen Abschnitt von Daten darstellen, die auf der Festplatte statt im Speicher sein könnten. Das Betriebssystem kümmert sich um das Laden der Daten in den Speicher, wenn sie zum ersten Mal zugegriffen werden. Nicolet gibt ein Beispiel für eine Sequenz von Gleitkommawerten, die in ein Float-Array geladen werden müssen. Er erklärt die Verwendung von MemoryMarshal.Read zum Lesen der Größe, die Zuweisung eines Arrays von Gleitkommawerten dieser Größe und die Verwendung von MemoryMarshal.Cast, um die Spanne von Bytes in eine Spanne von Gleitkommawerten umzuwandeln.

Er diskutiert auch die Verwendung der CopyTo-Funktion von Spannen, die eine Hochleistungskopie der Daten aus der speichergemappten Datei in das Array durchführt. Er merkt an, dass dieser Prozess etwas verschwenderisch sein kann, da er die Erstellung einer völlig neuen Kopie beinhaltet. Nicolet schlägt vor, eine Struktur zu erstellen, die den Header mit zwei Ganzzahlwerten darin darstellt, die von MemoryMarshal gelesen werden können. Er diskutiert auch die Verwendung einer Kompressionsbibliothek zum Entpacken der Daten.

Nicolet diskutiert die Verwendung eines anderen Typs, Memory, um einen längerlebigen Datenbereich darzustellen. Er erwähnt den Mangel an Dokumentation darüber, wie man ein Memory aus einem Zeiger erstellt und empfiehlt ein Gist auf GitHub als beste verfügbare Ressource. Er erklärt die Notwendigkeit, einen MemoryManager zu erstellen, der intern von einem Memory verwendet wird, wann immer es etwas komplexer als nur das Zeigen auf einen Abschnitt eines Arrays tun muss.

Nicolet diskutiert die Verwendung von Speichermapping gegenüber FileStream und merkt an, dass FileStream die offensichtliche Wahl ist, wenn auf Daten zugegriffen wird, die auf der Festplatte sind und deren Verwendung gut dokumentiert ist. Er merkt an, dass der FileStream-Ansatz nicht threadsicher ist und ein Lock um die Operation erfordert, was das Lesen von mehreren Stellen gleichzeitig verhindert. Nicolet erwähnt auch, dass der FileStream-Ansatz einige Overhead einführt, die bei der speichergemappten Version nicht vorhanden ist.

Er erklärt, dass stattdessen die speichergemappte Version verwendet werden sollte, da sie in der Lage ist, so viel Speicher wie möglich zu verwenden und, wenn der Speicher ausgeht, Teile der Datensätze wieder auf die Festplatte auslagert. Nicolet stellt die Frage, wie viele Dateien zugeteilt werden sollen, wie groß sie sein sollten und wie man durch diese Dateien zirkuliert, wenn Speicher zugeteilt und freigegeben wird.

Er schlägt vor, den Speicher auf mehrere große Dateien aufzuteilen, niemals zweimal in den gleichen Speicher zu schreiben und Dateien so schnell wie möglich zu löschen. Nicolet schließt mit der Mitteilung, dass sie in der Produktion bei Lokad den Lokad-Kratzraum mit spezifischen Einstellungen verwenden: Die Dateien haben jeweils 16 Gigabyte, es gibt 100 Dateien auf jeder Festplatte und jede L32VM hat vier Festplatten, was etwas mehr als 6 Terabyte Auslagerungsplatz für jede VM darstellt.

Vollständiges Transkript

Folie 1

Victor Nicolet: Hallo und willkommen zu diesem Vortrag über das Auslagern auf die Festplatte in .NET.

Das Auslagern auf die Festplatte ist eine Technik zur Verarbeitung von Datensätzen, die nicht in den Speicher passen, indem Teile des Datensatzes, die nicht in Gebrauch sind, stattdessen auf einem persistenten Speicher gehalten werden.

Dieser Vortrag basiert auf meiner Erfahrung bei Lokad. Wir machen quantitative supply chain optimization.

Der quantitative Teil bedeutet, dass wir mit großen Datensätzen arbeiten und der “supply chain” Teil, nun, er ist Teil der realen Welt, so dass sie unordentlich, überraschend und voller Randfälle innerhalb von Randfällen sind.

Also, wir machen eine Menge ziemlich komplexer Verarbeitung.

Folie 4

Schauen wir uns ein typisches Beispiel an. Ein Einzelhändler hätte in der Größenordnung von hunderttausend Produkten.

Diese Produkte sind in bis zu 100 Standorten vorhanden. Das können Geschäfte sein, das können Lager sein, das können sogar Abschnitte von Lagern sein, die dem E-Commerce gewidmet sind.

Und wenn wir irgendeine Art von realer Analyse darüber machen wollen, müssen wir uns das vergangene Verhalten ansehen, was mit diesen Produkten und diesen Standorten passiert.

Angenommen, wir behalten nur einen Datenpunkt für jeden Tag und wir schauen nur drei Jahre in die Vergangenheit, das bedeutet etwa 1000 Tage. Multiplizieren Sie all diese zusammen und unser Datensatz wird 10 Milliarden Einträge haben.

Wenn wir nur einen Gleitkommawert für jeden Eintrag behalten, nimmt der Datensatz bereits 37 Gigabyte Speicherplatz ein. Das ist mehr als ein typischer Desktop-Computer hätte.

Folie 10

Und ein Gleitkommawert ist bei weitem nicht genug, um irgendeine Art von Analyse durchzuführen.

Eine bessere Zahl wäre 20 und selbst dann machen wir sehr gute Anstrengungen, um den Speicherplatz klein zu halten. Selbst dann sprechen wir von etwa 745 Gigabyte Speicherverbrauch.

Das passt in Cloud-Maschinen, wenn sie groß genug sind, etwa siebentausend Dollar pro Monat. Also, es ist irgendwie erschwinglich, aber es ist auch irgendwie verschwenderisch.

Folie 11

Wie Sie vielleicht aus dem Titel dieses Vortrags erraten haben, ist die Lösung, stattdessen einen persistenten Speicher zu verwenden, der langsamer, aber billiger als der Speicher ist.

Heutzutage können Sie NVMe SSD-Speicher für etwa 5 Cent pro Gigabyte kaufen. Eine NVMe SSD ist etwa die schnellste Art von persistentem Speicher, den Sie heutzutage leicht bekommen können.

Im Vergleich kostet ein Gigabyte RAM 275 Dollar. Das ist etwa ein 55-facher Unterschied.

Folie 14

Eine andere Möglichkeit, dies zu betrachten, ist, dass Sie für das Budget, das es braucht, um 18 Gigabyte Speicher zu kaufen, genug hätten, um ein Terabyte SSD-Speicher zu bezahlen.

Folie 15

Was ist mit Cloud-Angeboten? Nun, nehmen wir als Beispiel die Microsoft Cloud, auf der linken Seite ist die L32s, Teil einer Reihe von virtuellen Maschinen, die für den Speicher optimiert sind.

Für etwa zweitausend Dollar pro Monat erhalten Sie fast 8 Terabyte persistenten Speicher.

Auf der rechten Seite ist die M32ms, Teil einer Reihe, die für den Speicher und für mehr als das Zweieinhalbfache des Preises optimiert ist, erhalten Sie nur 875 Gigabyte RAM.

Wenn mein Programm auf der Maschine links läuft und doppelt so lange braucht, um fertig zu werden, gewinne ich immer noch bei den Kosten.

Folie 16

Was ist mit der Leistung? Nun, das Lesen aus dem Speicher läuft mit etwa 21 Gigabyte pro Sekunde. Das Lesen von einer NVMe SSD läuft mit etwa 3,5 Gigabyte pro Sekunde.

Dies ist kein tatsächlicher Benchmark. Ich habe einfach eine virtuelle Maschine erstellt und diese beiden Befehle ausgeführt und es gibt viele Möglichkeiten, diese Zahlen sowohl zu erhöhen als auch zu verringern.

Der wichtige Punkt hier ist nur die Größenordnung des Unterschieds zwischen den beiden. Das Lesen von der Festplatte ist sechsmal langsamer als das Lesen aus dem Speicher.

Die Festplatte ist also sowohl enttäuschend langsam, man möchte nicht die ganze Zeit mit zufälligen Zugriffsmustern von der Festplatte lesen. Aber andererseits ist sie auch überraschend schnell. Wenn Ihre Verarbeitung hauptsächlich CPU-gebunden ist, bemerken Sie vielleicht nicht einmal, dass Sie von der Festplatte statt aus dem Speicher lesen.

Folie 19

Eine ziemlich bekannte Technik, um Festplattenspeicher als Alternative zum Speicher zu nutzen, ist die Partitionierung.

Die Idee hinter der Partitionierung besteht darin, eine der Dimensionen des Datensatzes auszuwählen und den Datensatz in kleinere Stücke zu schneiden. Jedes Stück sollte klein genug sein, um in den Speicher zu passen.

Die Verarbeitung lädt dann jedes Stück für sich, führt seine Verarbeitung durch und speichert dieses Stück zurück auf die Festplatte, bevor das nächste Stück geladen wird.

In unserem Beispiel, wenn wir die Datensätze entlang der Standorte schneiden und die Standorte einzeln verarbeiten würden, dann würde jeder Standort nur 7,5 Gigabyte Speicher benötigen. Das ist gut im Rahmen dessen, was ein Desktop-Computer leisten kann.

Folie 21

Bei der Partitionierung gibt es jedoch keine Kommunikation zwischen den Partitionen. Wenn wir also Daten über Standorte hinweg verarbeiten müssen, können wir diese Technik nicht mehr verwenden.

Eine andere Technik ist das Streaming. Streaming ähnelt der Partitionierung insofern, als wir zu jedem Zeitpunkt nur kleine Datenmengen in den Speicher laden.

Im Gegensatz zur Partitionierung dürfen wir beim Verarbeiten verschiedener Teile einen gewissen Zustand beibehalten. Während wir also den ersten Standort verarbeiten, würden wir den Anfangszustand einrichten, und dann, wenn wir den zweiten Standort verarbeiten, dürfen wir verwenden, was zu diesem Zeitpunkt im Zustand vorhanden war, um am Ende der Verarbeitung des zweiten Standorts einen neuen Zustand zu schaffen.

Im Gegensatz zur Partitionierung eignet sich Streaming nicht für parallele Ausführung. Aber es löst das Problem, etwas über alle Daten im Datensatz zu berechnen, anstatt in jedes Stück separat eingeteilt zu sein.

Streaming hat jedoch seine eigene Einschränkung. Damit es leistungsfähig ist, sollten die Daten auf der Festplatte ordnungsgemäß geordnet oder ausgerichtet sein.

Folie 26

Um diese Anforderungen zu verstehen, müssen Sie wissen, dass NVMe Daten in Sektoren von jeweils einem halben Kilobyte liest und schreibt und die früheren Leistungswerte, wie 3,5 Gigabyte pro Sekunde, davon ausgehen, dass Sektoren vollständig gelesen und genutzt werden.

Wenn wir nur einen Teil des Sektors verwenden, aber der gesamte Sektor gelesen werden muss, dann verschwenden wir Bandbreite und teilen unsere Leistung durch einen großen Faktor.

Folie 28

Und so ist es optimal, wenn die Daten, die wir lesen, ein Vielfaches von einem halben Kilobyte sind und an den Grenzen der Sektoren ausgerichtet sind.

Wir verwenden jetzt keine rotierenden Festplatten mehr, daher wird das Überspringen und Nichtlesen des Sektors ohne Kosten durchgeführt.

Folie 30

Wenn es nicht möglich ist, die Daten an den Sektorrändern auszurichten, gibt es jedoch eine andere Möglichkeit, sie in sequenzieller Reihenfolge zu laden.

Denn sobald ein Sektor im Speicher geladen wurde, erfordert das Lesen des zweiten Teils des Sektors keine weitere Ladung von der Festplatte. Stattdessen wird das Betriebssystem in der Lage sein, Ihnen die verbleibenden Bytes zu geben, die noch nicht verwendet wurden.

Und so, wenn Daten nacheinander geladen werden, gibt es keine verschwendete Bandbreite und Sie erhalten immer noch die volle Leistung.

Folie 31

Der schlimmste Fall ist, wenn Sie nur ein oder wenige Bytes aus jedem Sektor lesen. Zum Beispiel, wenn Sie einen Gleitkommawert aus jedem Sektor lesen, teilen Sie Ihre Leistung durch 128.

Folie 32

Was noch schlimmer ist, ist, dass es eine weitere Einheit der Datengruppierung über den Sektoren gibt, die als Betriebssystemseite bezeichnet wird, und das Betriebssystem lädt normalerweise ganze Seiten von etwa 4 Kilobyte in ihrer Gesamtheit.

Wenn Sie also jetzt einen Gleitkommawert von jeder Seite lesen, haben Sie Ihre Leistung durch 1024 geteilt.

Aus diesem Grund ist es wirklich wichtig, sicherzustellen, dass Daten, die aus dem persistenten Speicher gelesen werden, in großen aufeinanderfolgenden Chargen gelesen werden.

Folie 33

Mit diesen Techniken ist es möglich, das Programm in eine kleinere Menge an Speicher zu passen. Nun behandeln diese Techniken Speicher und Festplatte als zwei separate Speicherbereiche, unabhängig voneinander.

Und so wird die Verteilung des Datensatzes zwischen Speicher und Festplatte vollständig durch das, was der Algorithmus ist und was die Struktur des Datensatzes ist, bestimmt.

Wenn wir also das Programm auf einer Maschine ausführen, die genau die richtige Menge an Speicher hat, passt das Programm eng und es wird in der Lage sein zu laufen.

Wenn wir eine Maschine bereitstellen, die weniger als die benötigte Menge an Speicher hat, wird das Programm nicht in den Speicher passen und es wird nicht in der Lage sein zu laufen.

Schließlich, wenn wir eine Maschine bereitstellen, die mehr als die notwendige Menge an Speicher hat, wird das Programm tun, was Programme normalerweise tun, es wird den zusätzlichen Speicher nicht nutzen und es wird immer noch mit der gleichen Geschwindigkeit laufen.

Folie 38

Wenn wir ein Diagramm der Ausführungszeit basierend auf dem verfügbaren Speicher zeichnen würden, würde es so aussehen. Unterhalb des Speicher-Footprints gibt es keine Ausführung, also gibt es keine Verarbeitungszeit. Über dem Footprint ist die Verarbeitungszeit konstant, weil das Programm nicht in der Lage ist, den zusätzlichen Speicher zu nutzen, um schneller zu laufen.

Folie 39

Und was passiert, wenn der Datensatz wächst? Nun, je nach Dimension, wenn der Datensatz auf eine Weise wächst, die die Anzahl der Partitionen erhöht, dann bleibt der Speicher-Footprint gleich, es gibt nur mehr Partitionen.

Folie 41

Andererseits, wenn die einzelnen Partitionen wachsen, dann wird auch der Speicher-Footprint wachsen, was die minimale Menge an Speicher erhöht, die das Programm benötigt, um zu laufen.

Folie 42

Mit anderen Worten, wenn ich einen größeren Datensatz habe, den ich verarbeiten muss, wird es nicht nur länger dauern, sondern es wird auch einen größeren Footprint haben.

Dies schafft eine unangenehme Situation, in der ich mehr Speicher hinzufügen muss, um große Datensätze verarbeiten zu können, wenn sie auftauchen, aber das Hinzufügen von mehr Speicher verbessert nichts an den kleineren Datensätzen.

Folie 43

Dies ist eine Einschränkung des Ansatzes “passt in den Speicher”, bei dem die Verteilung des Datensatzes zwischen Speicher und persistentem Speicher vollständig durch die Struktur des Datensatzes und den Algorithmus selbst bestimmt wird.

Es berücksichtigt nicht die tatsächliche Menge an verfügbarem Speicher. Was die Techniken des Auslagerung auf die Festplatte tun, ist, dass sie diese Verteilung dynamisch vornehmen. Also, wenn mehr Speicher verfügbar ist, werden sie mehr Speicher nutzen, um schneller zu laufen.

Folie 46

Im Gegensatz dazu, wenn weniger Speicher verfügbar ist, dann werden sie bis zu einem gewissen Punkt in der Lage sein, langsamer zu werden, um weniger Speicher zu nutzen. Die Kurven sehen in diesem Fall viel besser aus. Der minimale Footprint ist kleiner und ist für beide Datensätze gleich.

Folie 47

Die Leistung steigt in allen Fällen, wenn mehr Speicher hinzugefügt wird. Techniken, die auf den Speicher passen, werden vorbeugend einige Daten auf die Festplatte auslagern, um den Speicher-Footprint zu reduzieren. Im Gegensatz dazu werden Techniken, die auf die Festplatte auslagern, so viel Speicher wie möglich nutzen und erst dann, wenn sie keinen Speicher mehr haben, beginnen, einige Daten auf die Festplatte auszulagern, um Platz zu schaffen.

Dies macht sie viel besser darin, auf mehr oder weniger Speicher zu reagieren, als ursprünglich erwartet. Techniken, die auf die Festplatte auslagern, teilen den Datensatz in zwei Abschnitte. Der heiße Abschnitt wird immer im Speicher vermutet und daher ist es immer sicher in Bezug auf die Leistung, auf ihn mit zufälligen Zugriffsmustern zuzugreifen. Er wird natürlich ein maximales Budget haben, vielleicht so etwas wie 8 Gigabyte pro CPU auf einer typischen Cloud-Maschine.

Auf der anderen Seite darf der kalte Abschnitt zu jedem Zeitpunkt Teile seines Inhalts auf den persistenten Speicher auslagern. Es gibt kein maximales Budget außer dem, was verfügbar ist. Und natürlich ist es in Bezug auf die Leistung nicht sicher möglich, aus dem kalten Abschnitt zu lesen.

Also wird das Programm heiße-kalte Transfers nutzen. Diese werden in der Regel große Mengen beinhalten, um die Nutzung der NVMe-Bandbreite zu maximieren. Und da die Mengen ziemlich groß sind, werden sie auch mit einer ziemlich niedrigen Frequenz durchgeführt. Und so ist es der kalte Abschnitt, der es diesen Algorithmen ermöglicht, so viel Speicher wie möglich zu nutzen.

Folie 50

Denn der kalte Abschnitt wird so viel RAM füllen, wie verfügbar ist, und den Rest auf den persistenten Speicher auslagern. Also, wie können wir das in .NET zum Laufen bringen? Da ich dies den ersten Versuch nenne, können Sie erraten, dass es nicht funktionieren wird. Also, versuchen Sie im Voraus herauszufinden, was das Problem sein wird.

Folie 51

Für den heißen Abschnitt werde ich normale .NET-Objekte verwenden und das Problem, das wir bei einem normalen .NET-Programm betrachten werden. Für den kalten Abschnitt werde ich diese sogenannte Referenzklasse verwenden. Diese Klasse hält eine Referenz auf den Wert, der in den kalten Speicher gelegt wird, und dieser Wert kann auf null gesetzt werden, wenn er nicht mehr im Speicher ist. Sie hat eine Auslagerungsfunktion, die den Wert aus dem Speicher nimmt und auf den Speicher schreibt und dann die Referenz auf null setzt, was dem .NET-Müllsammler erlaubt, diesen Speicher zurückzugewinnen, wenn er Druck verspürt.

Und schließlich hat sie eine Wert-Eigenschaft. Diese Eigenschaft, wenn sie aufgerufen wird, gibt den Wert aus dem Speicher zurück, wenn er vorhanden ist, und wenn nicht, laden wir ihn zurück von der Festplatte in den Speicher, bevor wir ihn zurückgeben. Jetzt, wenn ich in meinem Programm ein zentrales System einrichte, das alle kalten Referenzen verfolgt, dann kann ich, wann immer eine neue kalte Referenz erstellt wird, feststellen, ob sie den Speicher überläuft und die Auslagerungsfunktion einer oder mehrerer der bereits im System befindlichen kalten Referenzen aufrufen, um innerhalb des für den kalten Speicher verfügbaren Speicherbudgets zu bleiben.

Folie 53

Also, was wird das Problem sein? Nun, wenn ich mir den Inhalt des Speichers einer Maschine ansehe, die unser Programm ausführt, wird er im Idealfall so aussehen. Zuerst auf der linken Seite ist der Speicher des Betriebssystems, den es für seine eigenen Zwecke verwendet. Dann gibt es den internen Speicher, den .NET für Dinge wie geladene Assemblies oder Overhead des Garbage Collectors und so weiter verwendet. Dann ist der Speicher aus dem heißen Abschnitt und schließlich nimmt alles andere der dem kalten Abschnitt zugewiesene Speicher ein.

Mit einigem Aufwand können wir alles kontrollieren, was rechts ist, denn das ist das, was wir zuweisen und zur Sammlung durch den Garbage Collector freigeben. Was jedoch links ist, liegt außerhalb unserer Kontrolle. Und was passiert, wenn plötzlich das Betriebssystem zusätzlichen Speicher benötigt und feststellt, dass alles von dem, was der .NET-Prozess erstellt hat, belegt ist?

Folie 56

Nun, die typische Reaktion des Linux-Kernels in diesem Fall wäre, das Programm zu beenden, das den meisten Speicher verwendet, und es gibt keine Möglichkeit, schnell genug zu reagieren, um etwas Speicher an den Kernel zurückzugeben, damit er uns nicht tötet. Also, was ist die Lösung?

Folie 57

Moderne Betriebssysteme haben das Konzept des virtuellen Speichers. Das Programm hat keinen direkten Zugriff auf physische Speicherseiten. Stattdessen hat es Zugriff auf virtuelle Speicherseiten und es gibt eine Zuordnung zwischen diesen Seiten und den tatsächlichen Seiten im physischen Speicher. Wenn ein anderes Programm auf demselben Computer läuft, kann es nicht auf eigene Faust auf die Seiten des ersten Programms zugreifen. Es gibt jedoch Möglichkeiten zu teilen.

Es ist möglich, eine Speicherseite zu erstellen. In diesem Fall wird alles, was das erste Programm auf die gemeinsame Seite schreibt, sofort von der anderen Seite sichtbar sein. Dies ist eine gängige Methode zur Implementierung der Kommunikation zwischen Programmen, aber ihr Hauptzweck ist die Speicherabbildung von Dateien. Hier weiß das Betriebssystem, dass diese Seite eine genaue Kopie einer Seite auf dem persistenten Speicher ist, normalerweise Teile einer gemeinsam genutzten Bibliotheksdatei.

Der Hauptzweck hier ist es, zu verhindern, dass jedes Programm seine eigene Kopie der DLL im Speicher hat, denn all diese Kopien sind identisch, so dass es keinen Grund gibt, Speicher für die Speicherung dieser Kopien zu verschwenden. Hier haben wir zum Beispiel zwei Programme, die insgesamt vier Speicherseiten belegen, wenn im physischen Speicher nur Platz für drei ist. Was passiert nun, wenn wir im ersten Programm eine weitere Seite zuweisen wollen? Es ist kein Platz verfügbar, aber das Kernel-Betriebssystem weiß, dass die Speicherseite vorübergehend fallen gelassen werden kann und bei Bedarf identisch aus dem persistenten Speicher nachgeladen werden kann.

Folie 63

Also wird es genau das tun. Die beiden gemeinsamen Seiten zeigen jetzt auf die Festplatte anstatt auf den Speicher. Der Speicher wird vom Betriebssystem gelöscht, auf Null gesetzt und dann dem ersten Programm zur Verwendung für seine dritte logische Seite gegeben. Jetzt ist der Speicher vollständig gefüllt und wenn eines der Programme versucht, auf die gemeinsame Seite zuzugreifen, wird kein Platz für sie sein, um wieder in den Speicher geladen zu werden, weil Seiten, die Programmen gegeben werden, nicht vom Betriebssystem zurückgefordert werden können.

Folie 66

Was also hier passieren wird, ist ein Speicherfehler. Eines der Programme wird sterben, der Speicher wird freigegeben und dann umfunktioniert, um die Speicherabbildungsdatei wieder in den Speicher zu laden. Auch wenn die meisten Speicherabbildungen nur lesbar sind, ist es auch möglich, einige zu erstellen, die lesbar und beschreibbar sind.

Folie 70

Wenn ein Programm eine Änderung im Speicher auf der abgebildeten Seite vornimmt, wird das Betriebssystem irgendwann in der Zukunft den Inhalt dieser Seite wieder auf die Festplatte speichern. Und natürlich ist es möglich, zu einem bestimmten Zeitpunkt darum zu bitten, dass dies geschieht, indem man Funktionen wie Flush unter Windows verwendet. Das System Performance Tool hat dieses schöne Fenster, das die aktuelle Nutzung des physischen Speichers zeigt.

In Grün ist der Speicher, der direkt einem Prozess zugewiesen wurde. Er kann nicht zurückgefordert werden, ohne den Prozess zu beenden. In Blau ist der Seiten-Cache. Das sind Seiten, die bekanntermaßen identische Kopien einer Seite auf der Festplatte sind und so, wann immer ein Prozess eine Seite lesen muss, die bereits im Cache ist, dann wird kein Festplattenlesen stattfinden und der Wert wird direkt aus dem Speicher zurückgegeben.

Folie 71

Schließlich sind die modifizierten Seiten in der Mitte diejenigen, die eine genaue Kopie der Festplatte sein sollten, aber Änderungen im Speicher enthalten. Diese Änderungen wurden noch nicht auf die Festplatte zurückgespielt, aber sie werden in recht kurzer Zeit sein. Unter Linux zeigt das h-stop-Tool ein ähnliches Diagramm an. Auf der linken Seite sind die Seiten, die direkt den Prozessen zugewiesen wurden und nicht zurückgefordert werden können, ohne sie zu töten und auf der rechten Seite in Gelb ist der Seiten-Cache.

Folie 73

Wenn Sie interessiert sind, gibt es eine ausgezeichnete Ressource von Vyacheslav Biryukov über das, was im Linux-Seitencache vor sich geht. Mit virtuellem Speicher machen wir unseren zweiten Versuch. Wird es diesmal funktionieren? Jetzt entscheiden wir, dass der kalte Abschnitt vollständig aus Speicherabbildungsseiten bestehen wird. Also werden alle erwartet, zuerst auf der Festplatte vorhanden zu sein.

Das Programm hat keine Kontrolle mehr darüber, welche Seiten im Speicher sein werden und welche nur auf der Festplatte vorhanden sein werden. Das Betriebssystem macht das transparent. Also, wenn das Programm versucht, sagen wir, die dritte Seite im kalten Abschnitt zuzugreifen, wird das Betriebssystem feststellen, dass sie nicht im Speicher vorhanden ist, wird eine der vorhandenen Seiten entladen, sagen wir die zweite, und dann wird es die dritte Seite in den Speicher laden.

Folie 76

Aus der Sicht des Prozesses selbst war es völlig transparent. Die Wartezeit für das Lesen aus dem Speicher war nur ein wenig länger als üblich. Und was passiert, wenn das Betriebssystem plötzlich Speicher benötigt, um seine eigenen Dinge zu tun? Nun, es weiß, welche Seiten Speicherabbildungen sind und sicher verworfen werden können. Also wird es einfach eine der Seiten fallen lassen, sie für seine eigenen Zwecke verwenden und dann zurückgeben, wenn es fertig ist.

Folie 77

All diese Techniken gelten für .NET und sind im Lokad Scratch Space Open Source Projekt vorhanden. Und der größte Teil des folgenden Codes basiert darauf, wie dieses NuGet-Paket die Dinge macht.

Folie 78

Wie würden wir zuerst eine Speicherabbildungsdatei in .NET erstellen? Speicherabbildung existiert seit .NET Framework 4, also seit etwa 13 Jahren. Sie ist im Internet ziemlich gut dokumentiert und der Quellcode ist vollständig auf GitHub verfügbar.

Folie 80

Die grundlegenden Schritte sind zunächst die Erstellung einer Speicherabbildungsdatei aus einer Datei auf der Festplatte und dann die Erstellung eines Ansichtszugriffs. Diese beiden Typen werden getrennt gehalten, weil sie getrennte Bedeutungen haben. Die Speicherabbildungsdatei teilt dem Betriebssystem lediglich mit, dass von dieser Datei einige Abschnitte auf den Speicher des Prozesses abgebildet werden. Der Ansichtszugriff selbst repräsentiert diese Abbildungen.

Die beiden werden getrennt gehalten, weil .NET den Fall eines 32-Bit-Prozesses behandeln muss. Eine sehr große Datei, eine, die größer als vier Gigabyte ist, kann nicht auf den Speicherplatz eines 32-Bit-Prozesses abgebildet werden. Sie ist zu groß. Nun ist der Punkt nicht groß genug, um ihn darzustellen. Stattdessen ist es möglich, nur kleine Abschnitte der Datei einzeln auf eine Weise abzubilden, die sie passend macht.

In unserem Fall werden wir mit 64-Bit-Zeigern arbeiten. Also können wir einfach einen Ansichtszugriff erstellen, der die gesamte Datei lädt. Und jetzt benutze ich AcquirePointer, um den Zeiger auf die ersten Bytes dieses Speicherabbildungsbereichs des Speichers zu bekommen. Wenn ich mit dem Zeiger fertig bin, kann ich ihn einfach freigeben. Mit Zeigern in .NET zu arbeiten ist unsicher. Es erfordert das Hinzufügen des unsicheren Schlüsselworts überall und es kann explodieren, wenn Sie versuchen, Speicher über die Enden des erlaubten Bereichs hinaus zuzugreifen.

Folie 81

Glücklicherweise gibt es eine Möglichkeit, das zu umgehen. Vor fünf Jahren führte .NET Speicher und Spanne ein. Dies sind Typen, die verwendet werden, um einen Speicherbereich auf eine sicherere Weise als nur Zeiger darzustellen. Es ist ziemlich gut dokumentiert und der größte Teil des Codes kann an dieser Stelle auf GitHub gefunden werden.

Folie 83

Die allgemeine Idee hinter Spanne und Speicher ist, dass man anhand eines Zeigers und einer Anzahl von Bytes eine neue Spanne erstellen kann, die diesen Speicherbereich repräsentiert.

Sobald Sie diese Spanne haben, können Sie sicher innerhalb der Spanne lesen, wissend, dass, wenn Sie versuchen, über die Grenzen hinaus zu lesen, die Laufzeit es für Sie abfangen wird und Sie eine Ausnahme anstelle des einfachen Prozessablaufs erhalten.

Folie 84

Lassen Sie uns sehen, wie wir Spanne verwenden können, um aus dem Speicherabbildungsspeicher in den .NET verwalteten Speicher zu laden. Denken Sie daran, wir wollen aus Leistungsgründen nicht direkt auf den kalten Abschnitt zugreifen. Stattdessen wollen wir kalte zu heiße Transfers durchführen, die gleichzeitig eine Menge Daten laden.

Zum Beispiel, sagen wir, wir haben eine Zeichenkette, die wir lesen wollen. Sie wird in der Speicherabbildungsdatei als Größe gefolgt von einer UTF-8 codierten Nutzlast von Bytes angelegt, und wir wollen daraus eine .NET-Zeichenkette laden.

Folie 86

Nun, es gibt viele APIs, die um Spannen herum zentriert sind, die wir verwenden können. Zum Beispiel kann MemoryMarshal.Read eine Ganzzahl vom Anfang der Spanne lesen. Dann kann ich mit dieser Größe die Funktion Encoding.GetString bitten, aus einer Spanne von Bytes in eine Zeichenkette zu laden.

Alle diese Operationen arbeiten mit Spannen und obwohl die Spanne einen Abschnitt von Daten repräsentiert, der möglicherweise auf der Festplatte anstelle von im Speicher vorhanden ist, kümmert sich das Betriebssystem um das transparente Laden der Daten in den Speicher, wenn sie zum ersten Mal zugegriffen werden.

Folie 87

Ein weiteres Beispiel wäre eine Sequenz von Gleitkommawerten, die wir in ein Float-Array laden wollen.

Folie 88

Wieder verwenden wir MemoryMarshal.Read, um die Größe zu lesen. Wir weisen ein Array von Gleitkommawerten dieser Größe zu und dann verwenden wir MemoryMarshal.Cast, um die Spanne von Bytes in eine Spanne von Gleitkommawerten umzuwandeln. Dies interpretiert die in der Spanne vorhandenen Daten wirklich nur als Gleitkommawerte anstelle von einfachen Bytes.

Schließlich verwenden wir die CopyTo-Funktion von Spannen, die eine Hochleistungskopie der Daten aus der Speicherabbildungsdatei in das Array selbst durchführt. Dies ist in gewisser Weise ein wenig verschwenderisch, wir machen eine völlig neue Kopie.

Folie 89

Vielleicht könnten wir das vermeiden. Nun, normalerweise werden wir auf der Festplatte nicht die rohen Gleitkommawerte speichern. Stattdessen speichern wir eine komprimierte Version davon. Hier speichern wir die komprimierte Größe, die uns sagt, wie viele Bytes wir lesen müssen. Wir speichern die Zielgröße oder die dekomprimierte Größe. Dies sagt uns, wie viele Gleitkommawerte wir im verwalteten Speicher zuweisen müssen. Und schließlich speichern wir die komprimierte Nutzlast selbst.

Folie 90

Um dies zu laden, wäre es besser, wenn wir anstelle von zwei Ganzzahlen eine Struktur erstellen, die diesen Header mit zwei Ganzzahlwerten darstellt.

Folie 91

MemoryMarshal wird in der Lage sein, eine Instanz dieser Struktur zu lesen und die beiden Felder gleichzeitig zu laden. Wir weisen ein Array von Gleitkommawerten zu und dann hat unsere Kompressionsbibliothek fast sicher eine Art von Dekompressionsfunktion, die eine schreibgeschützte Spanne von Bytes als Eingabe und eine Spanne von Bytes als Ausgabe nimmt. Wir können wieder MemoryMarshal.Cast verwenden, diesmal um das Array von Gleitkommawerten in eine Spanne von Bytes umzuwandeln, die als Ziel verwendet wird.

Jetzt ist keine Kopie beteiligt. Stattdessen liest der Kompressionsalgorithmus direkt von der Festplatte, normalerweise über den Seitencache, in das Zielarray von Floats.

Folie 92

Spanne hat eine große Einschränkung, sie kann nicht als Klassenmitglied verwendet werden und kann daher auch nicht als lokale Variable in einer asynchronen Methode verwendet werden.

Glücklicherweise gibt es einen anderen Typ, Memory, der verwendet werden sollte, um einen längerlebigen Datenbereich darzustellen.

Folie 94

Leider gibt es enttäuschend wenig Dokumentation darüber, wie man dies macht. Eine Spanne aus einem Zeiger zu erstellen ist einfach, ein Memory aus einem Zeiger zu erstellen ist nicht dokumentiert, so dass die beste verfügbare Dokumentation ein Gist auf GitHub ist, den ich wirklich empfehle, dass Sie lesen.

Folie 95

Kurz gesagt, wir müssen einen MemoryManager erstellen. Der MemoryManager wird intern von einem Memory verwendet, wenn er etwas komplexeres tun muss, als nur auf einen Abschnitt eines Arrays zu zeigen.

In unserem Fall müssen wir auf den Speicherabbild-View-Zugriffsgeber verweisen, in den wir schauen. Wir müssen die Länge kennen, die wir betrachten dürfen, und schließlich benötigen wir einen Offset. Dies liegt daran, dass ein Memory von Bytes nicht mehr als zwei Gigabyte darstellen kann, und die Datei selbst wird wahrscheinlich länger als zwei Gigabyte sein. Der Offset gibt uns also den Ort an, an dem der Speicher innerhalb des breiteren View-Zugriffsgebers beginnt.

Folie 97

Der Konstruktor der Klasse ist ziemlich einfach.

Folie 98

Wir müssen nur eine Referenz zu dem sicheren Handle hinzufügen, das den Speicherbereich darstellt, und diese Referenz wird in der Dispose-Funktion freigegeben.

Folie 99

Als nächstes haben wir eine Adress-Eigenschaft, die nicht eine weitere Fahrt ist, es ist nur etwas, das für uns nützlich ist. Wir verwenden DangerousGetHandle, um einen Zeiger zu erhalten, und wir addieren den Offset, so dass die Adresse auf die ersten Bytes in der Region zeigt, die unser Speicher darstellen soll.

Folie 100

Wir überschreiben die GetSpan-Funktion, die all die Magie macht. Sie erstellt einfach eine Spanne mit der Adresse und der Länge.

Folie 101

Es gibt zwei weitere Methoden, die auf dem MemoryManager implementiert werden müssen. Eine davon ist Pin. Sie wird von der Laufzeit in einem Fall verwendet, in dem der Speicher für eine kurze Dauer an derselben Stelle gehalten werden muss. Wir fügen eine Referenz hinzu und geben einen MemoryHandle zurück, der auf den richtigen Ort zeigt und auch das aktuelle Objekt als das anheftbare referenziert.

Folie 102

Dies wird der Laufzeit mitteilen, dass, wenn der Speicher entpinnt wird, dann wird sie die Unpin-Methode dieses Objekts aufrufen, was die Freigabe des sicheren Handles wieder verursacht.

Folie 103

Sobald diese Klasse erstellt wurde, reicht es aus, eine Instanz davon zu erstellen und auf ihre Memory-Eigenschaft zuzugreifen, die ein Memory von Bytes zurückgibt, das intern den gerade erstellten MemoryManager referenziert. Und da haben Sie es, jetzt haben Sie ein Stück Speicher. Wenn Sie darauf schreiben, wird es automatisch auf die Festplatte entladen, wenn Platz benötigt wird, und bei Zugriff wird es transparent von der Festplatte zurückgeladen, wann immer Sie es benötigen.

Folie 104

Das reicht aus, um unser Spill-to-Disk-Programm zu implementieren. Es gibt eine andere Frage, warum verwenden wir Speichermapping, wenn wir stattdessen FileStream verwenden könnten? Schließlich ist FileStream die offensichtliche Wahl, wenn man auf Daten zugreift, die auf der Festplatte sind, und seine Verwendung ist ziemlich gut dokumentiert. Um beispielsweise ein Array von Gleitkommawerten zu lesen, benötigen Sie einen FileStream und einen BinaryReader, der um den FileStream herum gewickelt ist. Sie setzen die Position auf den Offset, wo die Daten vorhanden sind, Sie lesen ein Int32 mit dem Reader, weisen das Gleitkomma-Array zu und dann MemoryMarshal.Cast es zu einer Spanne von Bytes.

Folie 106

FileStream.Read hat jetzt eine Überladung, die eine Spanne von Bytes als Ziel nimmt. Dies verwendet tatsächlich auch den Seitencache. Anstatt diese Seiten in Ihren Prozessadressraum zu mappen, behält das Betriebssystem sie einfach und um die Werte zu lesen, lädt es sie einfach von der Festplatte in den Speicher und kopiert dann von dieser Seite in die von Ihnen bereitgestellte Zielspanne. Dies ist also in Bezug auf Leistung und Verhalten äquivalent zu dem, was in der speichergemappten Version passiert ist.

Es gibt jedoch zwei wesentliche Unterschiede. Erstens ist dies nicht threadsicher. Sie setzen die Position in einer Zeile und dann verlassen Sie sich in einer anderen Aussage darauf, dass diese Position immer noch dieselbe ist. Das bedeutet, dass Sie ein Lock um diese Operation benötigen und daher können Sie nicht von mehreren Stellen gleichzeitig lesen, obwohl dies mit Speichermapping-Dateien möglich ist.

Ein weiteres Problem ist, dass Sie, abhängig von der Strategie, die von FileStream verwendet wird, zwei Lesevorgänge durchführen, einen für das Int32 und einen für das Lesen zur Spanne. Eine Möglichkeit besteht darin, dass jeder von ihnen einen Systemaufruf durchführt. Es wird das Betriebssystem aufrufen und das Betriebssystem wird einige Daten aus seinem eigenen Speicher in den Prozessspeicher kopieren. Das hat einige Overhead. Die andere Möglichkeit besteht darin, dass der Stream gepuffert ist. In diesem Fall wird das Lesen von vier Bytes zunächst eine Kopie einer Seite erstellen, wahrscheinlich. Und diese Kopie geschieht zusätzlich zu der tatsächlichen Kopie, die später von der Lese-Funktion durchgeführt wird. Es führt also zu einigem Overhead, der in der speichergemappten Version einfach nicht vorhanden ist.

Aus diesem Grund ist die Verwendung der speichergemappten Version in Bezug auf die Leistung vorzuziehen. Schließlich ist FileStream die offensichtliche Wahl, um auf Daten zuzugreifen, die auf der Festplatte vorhanden sind, und seine Verwendung ist sehr gut dokumentiert. Zum Beispiel, um ein Array von Gleitkommawerten zu lesen, benötigen Sie einen FileStream, einen BinaryReader. Sie setzen die Position des FileStream auf den Offset, wo die Daten in der Datei vorhanden sind, Sie lesen ein Int32, um die Größe zu bekommen, weisen das Gleitkomma-Array zu, verwandeln es in eine Spanne von Bytes mit MemoryMarshal.Cast und geben es an die FileStream.Read-Überladung, die eine Spanne von Bytes als ihr Ziel zum Lesen möchte. Und dies verwendet auch den Seitencache. Anstatt dass die Seiten mit dem Prozess verknüpft sind, werden sie vom Betriebssystem selbst aufbewahrt und es wird einfach von der Festplatte in den Seitencache geladen und von dort in den Prozessspeicher kopiert, genau wie wir es mit der speichergemappten Version gemacht haben.

Der FileStream-Ansatz hat jedoch zwei wesentliche Nachteile. Das erste ist, dass dieser Code nicht sicher für den Einsatz in mehreren Threads ist. Schließlich wird die Position in einer Aussage gesetzt und dann in den folgenden Aussagen verwendet. Daher benötigen wir ein Lock um diese Leseoperationen. Die speichergemappte Version benötigt keine Locks und kann tatsächlich von mehreren Stellen auf der Festplatte gleichzeitig laden. Für SSDs erhöht dies die Warteschlangentiefe, was die Leistung erhöht und daher in der Regel wünschenswert ist. Das andere Problem ist, dass FileStream zwei Lesevorgänge durchführen muss.

Je nach der intern verwendeten Strategie des Streams kann dies zu zwei Systemaufrufen führen, die das Betriebssystem aufwecken müssen. Es kopiert einige Daten aus seinem eigenen Speicher in den Prozessspeicher und muss dann alles löschen und die Kontrolle an den Prozess zurückgeben. Dies hat einige Overhead-Kosten. Die andere mögliche Strategie besteht darin, dass der FileStream gepuffert wird. In diesem Fall würde nur ein Systemaufruf durchgeführt, aber es würde eine Kopie vom Betriebssystemspeicher in den internen Puffer des FileStreams involvieren und dann müsste die Leseanweisung erneut aus dem internen Puffer des FileStreams in das Gleitkomma-Array kopieren. Dies erzeugt eine verschwenderische Kopie, die bei der speichergemappten Version nicht vorhanden ist.

Der FileStream, obwohl etwas einfacher zu bedienen, hat einige Einschränkungen. Stattdessen sollte die speichergemappte Version verwendet werden. So haben wir nun ein System, das so viel Speicher wie möglich nutzen kann und, wenn der Speicher ausgeht, Teile der Datensätze wieder auf die Festplatte auslagert. Dieser Prozess ist völlig transparent und arbeitet mit dem Betriebssystem zusammen. Es läuft mit maximaler Leistung, da die Teile des Datensatzes, die häufig zugegriffen werden, immer im Speicher gehalten werden.

Es gibt jedoch eine letzte Frage, die wir beantworten müssen. Schließlich mappen Sie beim Speichermapping nicht die Festplatte, sondern Dateien auf der Festplatte. Nun ist die Frage, wie viele Dateien wir zuweisen werden? Wie groß werden sie sein? Und wie werden wir durch diese Dateien zirkulieren, wenn wir Speicher zuweisen und freigeben?

Die offensichtliche Wahl ist, einfach eine große Datei zu mappen, dies beim Programmstart zu tun und einfach darüber zu laufen. Wenn ein Teil nicht mehr verwendet wird, schreiben Sie einfach darüber. Dies ist offensichtlich und daher ist es falsch.

Slide 111

Das erste Problem mit diesem Ansatz ist, dass das Überschreiben einer Speicherseite einen diskreten Algorithmus erfordert.

Der Algorithmus lautet wie folgt: Zuerst laden Sie die Seite sofort in den Speicher. Dann ändern Sie den Inhalt der Seite im Speicher. Das Betriebssystem hat keine Möglichkeit zu wissen, dass Sie in Schritt zwei alles löschen und ersetzen werden, daher muss es die Seite immer noch laden, damit die Teile, die Sie nicht ändern, gleich bleiben. Schließlich planen Sie, die Seite irgendwann in der Zukunft wieder auf die Festplatte zu schreiben.

Nun, das erste Mal, dass Sie auf eine bestimmte Seite in einer brandneuen Datei schreiben, gibt es keine Daten zum Laden. Das Betriebssystem weiß, dass alle Seiten null sind, also ist das Laden kostenlos. Es nimmt einfach eine Nullseite und verwendet sie. Aber wenn die Seite bereits geändert wurde und nicht mehr im Speicher ist, muss das Betriebssystem sie von der Festplatte neu laden.

Slide 113

Ein zweites Problem besteht darin, dass die Seiten aus dem Seitencache auf einer am wenigsten kürzlich verwendeten Basis verdrängt werden, und das Betriebssystem ist sich nicht bewusst, dass ein toter Abschnitt Ihres Speichers, der nie wieder verwendet wird, fallen gelassen werden muss. Daher könnte es einige Teile des Datensatzes im Speicher behalten, die nicht benötigt werden, und einige Teile, die benötigt werden, verdrängen. Es gibt keine Möglichkeit, dem Betriebssystem zu sagen, dass es die toten Abschnitte einfach ignorieren soll.

Slide 114

Ein drittes Problem ist ebenfalls verwandt, nämlich dass das Schreiben der Daten auf die Festplatte immer hinter dem Schreiben der Daten in den Speicher zurückbleibt. Und wenn Sie wissen, dass eine Seite nicht mehr notwendig ist und noch nicht auf die Festplatte geschrieben wurde, nun, das Betriebssystem weiß das nicht. Daher verbringt es immer noch Zeit damit, diese Bytes, die nie wieder verwendet werden, auf die Festplatte zu schreiben und alles zu verlangsamen.

Slide 115

Stattdessen sollten wir den Speicher auf mehrere große Dateien aufteilen. Wir schreiben nie zweimal in denselben Speicher. Dies stellt sicher, dass jeder Schreibvorgang eine Seite trifft, von der das Betriebssystem weiß, dass sie komplett null ist und kein Laden von der Festplatte erfordert. Und wir löschen Dateien so schnell wie möglich. Dies teilt dem Betriebssystem mit, dass dies nicht mehr benötigt wird, es kann aus dem Seitencache gelöscht werden, es muss nicht auf die Festplatte geschrieben werden, wenn es noch nicht geschehen ist.

Slide 116

In der Produktion bei Lokad verwenden wir auf einer typischen Produktions-VM den Lokad-Kratzraum mit den folgenden Einstellungen: Die Dateien haben jeweils 16 Gigabyte, es gibt 100 Dateien auf jeder Festplatte und jede L32VM hat vier Festplatten. Insgesamt entspricht dies etwas mehr als 6 Terabyte Spill-Raum für jede VM.

Slide 117

Das ist alles für heute. Bitte melden Sie sich, wenn Sie Fragen oder Kommentare haben, und danke fürs Zuschauen.