Jedes Byte zählt: Warum die Datenanordnung über die Geschwindigkeit entscheidet

Deine Reaktion:

Du stehst an der Gepäckausgabe am Flughafen. Dein Koffer kommt auf dem Band, du greifst zu – aber zwischen deinem Koffer und dem nächsten liegen immer zwei andere Koffer. Du wartest jedes Mal, bis die anderen vorbeigezogen sind. Wären alle Koffer eines Typs direkt hintereinander, könntest du sie viel schneller einsammeln. Genauso arbeitet der Speicher deines Computers – und viele Entwickler ignorieren das, bis die Performance plötzlich einbricht.

Ein erfahrener Java-Entwickler hat sich dieses Phänomen genauer angesehen. Er berichtet von seiner Arbeit mit großen Klassen und vielen Attributen. Normalerweise denkt man bei Performance an Algorithmen und deren Komplexitätsklassen – O(n), O(log n) und so weiter. Doch selbst bei einem einfachen linearen Durchlauf (O(n)) können die Laufzeiten dramatisch variieren. Der Grund liegt tiefer: im Zusammenspiel zwischen CPU-Caches und der Anordnung der Daten im Arbeitsspeicher.

Wie der Computer seine Daten organisiert

Die CPU hat mehrere Cache-Ebenen: L1, L2 und L3. L1 ist klein und extrem schnell, L3 größer und langsamer. Das Betriebssystem teilt den Arbeitsspeicher in Seiten (Pages) ein, und die Hardware lädt Daten in sogenannten Cache-Lines. Eine Cache-Line ist typischerweise 64 Bytes groß. Wenn du auch nur ein einziges Byte aus dem Speicher anforderst, lädt die CPU gleich die gesamte Umgebung – 64 Bytes – in den Cache. Der Gedanke dahinter: Daten, die zeitlich oder räumlich nahe beieinander liegen, werden oft zusammen benötigt.

Der Entwickler hat auf seinem System die konkreten Werte ermittelt: L1d-Cache pro CPU-Kern etwa 35 KiB (nach Teilung durch die Anzahl der Instanzen), was 560 Cache-Lines entspricht. Diese Zahlen sind nicht nur Theorie – sie entscheiden darüber, wie schnell dein Programm läuft.

Array of Structs vs. Struct of Arrays – ein Beispiel

Angenommen, du modellierst ein Spiel mit Monstern. Jedes Monster hat einen Namen, eine Position, Lebenspunkte und einen booleschen Wert is_alive. In Java würdest du vermutlich eine Klasse Monster schreiben mit allen Feldern und dann ein Array von Monster-Objekten anlegen. Das nennt man Array of Structs (AoS). Jedes Objekt belegt im Speicher eine bestimmte Größe – sagen wir 64 Bytes. Wenn du jetzt alle Monster durchgehst und nur prüfen willst, ob sie noch leben, holst du trotzdem jedes Mal das gesamte Monster-Objekt in den Cache. Die Cache-Line wird mit den 64 Bytes eines Monsters gefüllt – und du nutzt nur ein einziges Byte daraus. Verschwendung.

Die Alternative: Lege die Daten spaltenweise an. Statt eines Arrays von Monstern hast du separate Arrays für jeden Feldtyp: ein boolean-Array für is_alive, ein float-Array für die x-Position, ein int-Array für Lebenspunkte. Das nennt sich Struct of Arrays (SoA). Nun liegen alle booleschen Werte hintereinander im Speicher. Eine Cache-Line fasst 64 boolesche Werte (1 Byte pro boolean in Java, wenn nicht optimiert). Du kannst also 64 Monster auf einmal auf ihren Lebensstatus prüfen, ohne unnötige Daten zu laden.

Der Entwickler hat einen Benchmark durchgeführt. Bei einer Monster-Struktur von 1 KiB Größe zeigte sich ein bis zu 30-facher Geschwindigkeitsvorteil für SoA. Bei kleineren Strukturen (z. B. 64 Bytes) ist der Unterschied geringer, weil mehrere Monster-Objekte noch in eine Cache-Line passen. Aber sobald die Struktur größer wird, explodiert der Performance-Unterschied. Die CPU kann die Daten viel effizienter vorausladen, wenn sie sequenziell angeordnet sind. Der sogenannte Prefetcher erkennt das Muster und holt die nächste Cache-Line, bevor du sie brauchst. Du wartest praktisch nie auf den Speicher.

Wenn der Zugriff zufällig wird

Nicht alle Programme durchlaufen Daten schön linear. Hash-Maps, Bäume, Graphen und viele andere Datenstrukturen springen wild im Speicher umher. Der CPU-Prefetcher kann dann nicht mehr vorhersagen, wo die nächste Anfrage hingeht. Jeder Zugriff wird zu einem Cache-Miss – die CPU muss auf den langsameren Hauptspeicher warten. Hier wird die Größe deiner gesamten Datenmenge zum entscheidenden Faktor: Passt sie in den L1-Cache, sind die Zugriffe blitzschnell. Für L2 dauert es länger, für L3 noch länger, und wenn du den Hauptspeicher brauchst, wird es richtig teuer.

Der Entwickler hat eine Pointer-Chasing-Benchmark gebaut: N Monster-ähnliche Knoten werden zufällig verlinkt, und dann wird der Kette gefolgt. Jeder Sprung trifft eine unvorhersehbare Adresse. Das Ergebnis: eine Treppenkurve, die zeigt, wie die Zugriffszeit in Stufen ansteigt, sobald die Datenmenge die nächste Cache-Grenze überschreitet. Die Tabelle im Originalartikel verdeutlicht das: Bei 512 Monstern mit je 64 Bytes (Arbeitsmenge 32 KiB) liegt die Latenz bei ~3 ns – alles in L1. Verdoppelst du die Strukturgröße auf 128 Bytes (64 KiB Arbeitsmenge), steigt die Latenz auf ~11 ns, weil die Daten bereits in L2 ausweichen. Bei großen Datenmengen (131.072 Monster, 8 MiB vs. 16 MiB) liegen die Latenzen bei über 160 ns. Das ist der Unterschied zwischen einem Wimpernschlag und einem spürbaren Warten.

Besonders bemerkenswert: Die Kurven für verschiedene Strukturgrößen zeigen das gleiche Treppenmuster, nur verschoben. Größere Strukturen treffen die Stufen früher. Das heißt: Wenn du die Kontrolle über die Gesamtgröße deines Working Sets behältst, kannst du die Performance massiv beeinflussen.

Was heißt das für dich?

Als Entwickler denkst du vielleicht zuerst an Algorithmen-Komplexität. Das ist wichtig, aber nicht alles. Der Weg zum Speicher ist oft der Flaschenhals. Wenn du eine Datenstruktur entwirfst, frage dich: Welche Felder werden gemeinsam genutzt? Wie groß ist mein Working Set? Passt es in den L1-Cache? Kann ich die Daten so anordnen, dass sie sequenziell gelesen werden? Besonders bei Java mit seinen Objekt-Overheads (Header, Padding) ist der Unterschied zwischen AoS und SoA eklatant. Moderne JVMs optimieren zwar, aber sie zaubern nicht – sie können keine Cache-Misses vermeiden, wenn die Daten falsch liegen.

Auch für KI- und Datenverarbeitungs-Pipelines ist das relevant. Viele Matrix-Operationen, Embedding-Lookups oder Graph-Traversals profitieren von cache-freundlichen Layouts. Das Wissen um Cache-Lines und Seiten ist keine Spielerei für Assembler-Programmierer – es ist ein Werkzeug für alle, die ernsthaft performante Software schreiben wollen.

Der Artikel des Entwicklers erinnert uns daran: Jedes Byte zählt. Nicht nur auf der Festplatte, sondern auch im Arbeitsspeicher. Die Hardware ist kein undurchschaubarer Kasten – sie folgt klaren Regeln. Wer sie versteht, kann aus demselben Algorithmus das Doppelte oder Dreifache an Leistung herausholen. Und manchmal reicht schon eine kleine Umstellung – von Array of Structs zu Struct of Arrays – um aus einer lahme Endlosschleife einen rasanten Durchlauf zu machen.

Quelle: fzakaria.com

Deine Reaktion:
Artikel teilen:
Sebastian Krötzsch
Autor

Sebastian Krötzsch

Sebastian Krötzsch schreibt auf sebask.de über Künstliche Intelligenz, Automatisierung, digitale Systeme und die Frage, was davon im Alltag wirklich nützlich ist. Ohne Buzzword-Nebel, dafür mit klarem Blick auf Praxis, Tools und echte Wirkung.