Warum hohe CPU-Auslastung oft bedeutet, dass Ihre Datenbank auf Speicher wartet
Ein tiefgehender Blick darauf, wie Speicherlatenz CPUs bei Datenbank-Aggregationen blockiert und wie Radix-Partitionierung die Cache-Nutzung verbessert, um die Leistung zu steigern.
Automatisch aus dem englischen Original übersetzt.
Wenn ein Datenbankserver eine CPU-Auslastung von 100 % meldet, rechnet er nicht unbedingt mit voller Geschwindigkeit. Oft ist der Prozessor im Leerlauf und wartet auf Daten aus dem Hauptspeicher. Eine aktuelle technische Analyse des Pivot-Teams zeigt, dass in vielen gängigen Datenbankoperationen nicht die Rechenkomplexität, sondern die Speicherlatenz der primäre Engpass ist.
Was passiert ist
Das Engineering-Team von Pivot untersuchte, warum ihre Datenbank-Engine während Aggregationsabfragen zwar vollständig ausgelastet wirkte, aber langsamer performte als erwartet. Sie konzentrierten sich auf eine Standard-GROUP BY-Operation unter Verwendung des ClickBench-Datensatzes, der 99 Millionen Zeilen enthält. Die spezifische Abfrage zählte das Vorkommen eindeutiger UserIDs, was zu etwa 17 Millionen verschiedenen Gruppen führte. Um die Leistungsmerkmale isoliert zu betrachten, führten sie den Test auf einem Bare-Metal-Server mit einem AMD EPYC 9124 Genoa-Prozessor durch und banden den Prozess an einen einzelnen Kern, um Störungen durch parallele Aufgaben zu vermeiden.
Die ursprüngliche Implementierung nutzte einen naiven Ansatz: eine einzelne wachsende HashTable, indiziert nach UserID. Für jede Zeile prüfte die Engine, ob der Schlüssel existierte, und aktualisierte den Zähler. Diese Methode dauerte beim ersten Durchlauf 3,9 Sekunden und bei nachfolgenden Läufen, bei denen die Daten bereits im Page-Cache des Betriebssystems lagen, 3,6 Sekunden. Obwohl diese Zeiten akzeptabel erscheinen mögen, zeigten interne Leistungszähler ein anderes Bild. Die CPU verbrachte die Hälfte ihrer Zeit damit, nichts anderes zu tun, als auf den Abschluss von Speicherzugriffen zu warten.
Um dies zu beheben, ersetzten die Ingenieure die einzelne HashTable durch einen zweiphasigen Radix-Partitionierungsalgorithmus. In der ersten Phase hashten sie jeden Schlüssel und verteilten die Zeilen basierend auf den oberen Bits des Hash-Werts auf 4.096 separate Partitionen. In der zweiten Phase verarbeiteten sie jede Partition einzeln und erstellten für jede kleine temporäre HashTables. Diese Änderung reduzierte die Abfragezeit auf gecachten Daten auf 2,7 Sekunden. Weitere Optimierungen innerhalb der Pivot-Engine brachten die Einzelkern-Zeit schließlich auf 1,8 Sekunden herunter und demonstrierten, dass strukturelle Änderungen an den Datenzugriffsmustern erhebliche Leistungssteigerungen erzielen können.
Wie es funktioniert
Moderne CPUs führen Anweisungen viel schneller aus, als sie Daten aus dem RAM abrufen können. Ein Prozessor könnte 4 bis 8 Anweisungen pro Nanosekunde abschließen, während ein einzelner Zugriff auf DRAM 50 bis 100 Nanosekunden dauert. Während dieser Wartezeit hätte die CPU Hunderte von Anweisungen ausführen können. Um dies abzumildern, verwenden CPUs hierarchische Caches (L1, L2, L3), die deutlich schneller sind, aber kleiner als der Hauptspeicher. Wenn die für eine Operation benötigten Daten nicht im Cache vorhanden sind, kommt die CPU zum Stillstand.
In der naiven GROUP BY-Implementierung wächst die HashTable so weit, dass sie Platz für Millionen eindeutiger Schlüssel bietet. Wenn die Tabelle expandiert, überschreitet sie die Kapazität der L1- und L2-Caches. Folglich führt fast jeder Lookup zu einem „Cache Miss“, der die CPU zwingt, Daten vom langsamen DRAM abzurufen. Die Leistungsmetriken bestätigten dies: 50,1 % der CPU-Zyklen wurden aufgrund von Speicherengpässen angehalten, und nur 16,1 % der Zyklen wurden für tatsächliche Arbeit genutzt. Das System lud 4,5 GB Daten für die Abfrage aus dem DRAM.
Radix-Partitionierung löst dieses Problem, indem sie das Problem in kleinere Blöcke aufteilt, die in den Cache passen. Indem die 17 Millionen Schlüssel auf 4.096 Partitionen verteilt werden, bleibt die HashTable jeder einzelnen Partition klein genug, um während der Verarbeitung im schnellen L1- oder L2-Cache zu bleiben. Obwohl dieser Ansatz mehr Gesamtanweisungen erfordert – doppeltes Hashing und Verwaltung mehrerer Vektoren – reduziert er die Anzahl der langsamen DRAM-Zugriffe drastisch. Der optimierte Lauf zeigte, dass die Speicherstillstände auf 23,5 % fielen und die nützliche Arbeit auf 32,2 % stieg. Der DRAM-Datenverkehr sank auf 1,5 GB, was beweist, dass der Tausch zusätzlicher CPU-Anweisungen gegen bessere Cache-Lokalität eine gewinnbringende Strategie ist.
Wichtige Details
- Der Speicherzugriff auf DRAM ist 50- bis 100-mal langsamer als der L1-Cache-Zugriff, was zu erheblichen CPU-Stillständen führt.
- Der naive HashTable-Ansatz führte dazu, dass 50,1 % der CPU-Zyklen für Wartezeiten auf den Speicher verschwendet wurden.
- Radix-Partitionierung teilt Daten in 4.096 Blöcke auf, sodass die Arbeitsdatensätze in die CPU-Caches passen.
- Der optimierte partitionierte Ansatz reduzierte den DRAM-Datentransfer für dieselbe Abfrage von 4,5 GB auf 1,5 GB.
- Trotz der Ausführung mehrerer Gesamtanweisungen reduzierte die cache-freundliche Methode die Abfragezeit um über 25 %.
- Die endgültige optimierte Einzelkern-Leistung erreichte 1,8 Sekunden, wobei die parallele Ausführung auf 16 Kernen 171 ms erreichte.
Warum das wichtig ist
Für Software-Ingenieure und Datenbankentwickler hebt diese Analyse hervor, dass Metriken zur CPU-Auslastung irreführend sein können. Ein Server, der 100 % CPU-Auslastung anzeigt, leidet möglicherweise eher an Datenmangel als an mangelnder Rechenleistung. Das Verständnis des Unterschieds zwischen compute-bound (rechenintensiven) und memory-bound (speicherintensiven) Workloads ist entscheidend für effektives Performance-Tuning. Die Optimierung der Cache-Lokalität bringt oft größere Verbesserungen als das bloße Hinzufügen weiterer Kerne oder das Erhöhen der Taktfrequenzen.
Diese Erkenntnis lässt sich breit auf jedes System anwenden, das große Datensätze mit häufigen Lookups oder Aggregationen verarbeitet. Entwickler, die Hochleistungs-Backends bauen, sollten Datenlayout und Zugriffsmuster frühzeitig im Designprozess berücksichtigen. Algorithmen, die auf dem Papier rechenintensiver erscheinen, können in der Praxis besser performen, wenn sie die physischen Einschränkungen der Speichhierarchie respektieren. Das Ignorieren dieser Hardware-Realitäten kann zu Systemen führen, die trotz reichlich vorhandener Rechenressourcen schlecht skalieren.
Was Sie tun können
- Verwenden Sie Tools wie
perf stat, um Speicherstillstandszyklen und Cache-Fehlerraten zu messen, nicht nur die CPU-Zeit. - Analysieren Sie Ihre Datenzugriffsmuster, um Operationen zu identifizieren, die große, zufällige Speicherbereiche berühren.
- Erwägen Sie die Aufteilung großer Datensätze in kleinere Blöcke, die in die L2- oder L3-Cache-Größen passen.
- Bewerten Sie Algorithmen basierend auf Cache-Lokalität, nicht nur auf theoretischer Zeitkomplexität.
- Binden Sie kritische Prozesse während des Benchmarkings an bestimmte CPU-Kerne, um Rauschen durch Kontextwechsel zu reduzieren.
- Überwachen Sie die DRAM-Bandbreitennutzung als wichtigen Indikator für speichergebundene Engpässe in Ihrer Anwendung.



