Das Geheimnis der 240 Elemente: Warum wird Ihr Rust-Code plötzlich langsamer?
\n\nStellen Sie sich vor: Sie schreiben hochperformanten Code in Rust, alles läuft blitzschnell. Sie testen einen Algorithmus mit einem Array von 200 Elementen – hervorragend. Sie fügen 40 weitere Elemente hinzu (insgesamt 240) – und die Performance bricht um das Zwei- bis Dreifache ein. Klingt nach Magie? Nein, das ist Physik. Genauer gesagt: die Architektur moderner Prozessoren.
\n\nAuf Stack Overflow hat die Frage "Why is there a large performance impact when looping over an array with 240 or more elements?" tausende Aufrufe gesammelt und ist ein Klassiker. Im Jahr 2025, mit neuen Mikroarchitekturen und einer komplexeren Cache-Hierarchie, ist das Verständnis dieses Problems für jeden Rust-Entwickler entscheidend.
\n\nIn diesem Artikel wiederholen wir nicht nur die Antwort. Wir zeigen, wie sich das Problem in Rust äußert, wie Sie es diagnostizieren und – vor allem – wie Sie es mit modernen Praktiken von 2025 vermeiden können.
\n\nDie Ursache des Übels: Cache-Misses
\n\nEin Prozessor arbeitet nicht direkt mit dem Arbeitsspeicher (RAM). Er nutzt den Cache – einen ultraschnellen Speicher auf dem Chip. Daten werden in Blöcken, sogenannten Cache Lines (üblicherweise 64 Bytes), in den Cache geladen.
\n\nDie Magie der Zahl 240 hängt mit der Größe des L1-Caches (normalerweise 32 KB für Daten) und der Elementgröße zusammen. Wenn das Array aus i32 (4 Bytes) besteht, belegen 240 Elemente 960 Bytes. Das würde scheinbar in den L1-Cache passen. Das Problem ist jedoch, dass der Algorithmus gleichzeitig mit zwei oder drei Arrays arbeiten kann oder der Compiler den Code nicht effizient vektorisieren kann, was zu Cache-Misses führt.
In der ursprünglichen C++-Frage lag die Schwelle genau bei 240 Elementen, weil sie an die Grenzen des Prefetchings und der L1/L2-Cache-Größe stieß.
\n\nWie sieht das in Rust aus?
\n\nLassen Sie uns die Situation simulieren. Wir summieren die Elemente eines Arrays, aber mit einer "Schrittweite", die den Cache zerstört.
\n\nuse std::time::Instant;\n\nfn main() {\n // Array mit 240 Elementen\n let size = 240;\n let data: Vec<i32> = (0..size).collect();\n \n // Kalter Start\n let start = Instant::now();\n let sum: i32 = data.iter().sum();\n let duration = start.elapsed();\n println!("240 Elemente (sequentiell): {:?}", duration);\n \n // Jetzt machen wir einen "schlechten" Zugriff mit Schrittweite 64 (Cache-Line-Größe)\n let mut sum2 = 0i32;\n let start = Instant::now();\n for i in (0..size).step_by(64) {\n sum2 += data[i];\n }\n let duration2 = start.elapsed();\n println!("240 Elemente (Schrittweite 64): {:?}", duration2);\n}\n\n\nSie werden überrascht sein, aber der Unterschied kann das Zehnfache betragen! Die erste Schleife verwendet einen sequentiellen Zugriff – der Prozessor sagt Cache Lines voraus und lädt sie vor. Die zweite ist ein wahlfreier Zugriff mit großer Schrittweite, der den Cache sprengt.
\n\nWarum genau 240? Der tiefere Grund
\n\nIm Jahr 2025 haben Prozessoren einen L1-Datencache von 32 KB bis 64 KB. Aber das Problem liegt nicht nur in der Größe. Es geht um die Cache-Assoziativität (set-associative cache).
\n\nStellen Sie sich den Cache wie eine Bibliothek mit 64 Regalen (Sets) vor. Auf jedem Regal können 8 Bücher (Ways) liegen. Wenn der Prozessor auf eine Speicheradresse zugreift, berechnet er die "Regalnummer" anhand der Adresse. Wenn auf dem Regal bereits 8 Bücher liegen, muss eines entfernt werden.
\n\nBei Arrays, deren Größe ein Vielfaches einer Zweierpotenz ist (z. B. 256), können alle Elemente auf dasselbe "Regal" fallen, was zu Cache-Conflict-Misses führt. 240 ist 256 minus 16, was diese Vielfachheit bricht und... paradoxerweise die Situation aufgrund der Eigenheiten des Prefetchings noch verschlimmert.
\n\nPraktischer Test in Rust
\n\nuse std::time::Instant;\n\nfn bench_access(size: usize) {\n let data: Vec<i32> = (0..size as i32).collect();\n \n // "Schlechtes" Muster: Zugriff auf jedes 16. Element\n let mut sum = 0i32;\n let start = Instant::now();\n for i in (0..size).step_by(16)