C++ Speicherverwaltung in einer Game-Engine: Wer frisst den Frame?
Ein Aufruf von new innerhalb des Frames erzeugte 13 ms-Spitzen im Profiler; nach einer Frame‑Arena und Objekt‑Pooling sank der Peak auf 7,9 ms. So ging es.
Ein dreimillisekunden‑Spike im Profiler
Ende letzten Winters profilierte ich die Kampfszene in unserer eigenen Engine. Die Frame‑Zeit lag im Durchschnitt bei etwa 9 ms, aber alle paar Sekunden tauchte ein 13‑ms‑Frame auf. Auf dem Bildschirm war kein sichtbares Stottern zu sehen, doch bei einem Ziel von 60 FPS waren diese Spitzen bemerkbar. Wir schauten zuerst auf das Rendering, weil dort zuerst nachgesehen wird, und die Renderzeit stellte sich als fast konstant von Frame zu Frame heraus.
Der Schuldige war eine einzelne Zeile in DamageNumberSystem: ein new DamageLabel() für jeden Treffer. Während einer Welle mit 400 Treffern pro Sekunde bedeutete das sechs bis sieben Heap‑Allokationen pro Frame. Die meisten dauerten 60 ns, aber gelegentlich überschritt eine 40 µs und ruinierte den gesamten Frame. Ein Durchschnitts‑Diagramm zeigt das nie, weil 399 von 400 Allokationen billig sind.
Nicht jeder Spike entsteht durch Allokation, daher war der erste Schritt, die Ursachen zu trennen. Wir legten einen Zähler über jeden operator new-Aufruf im Frame, und die Frames, in denen dieser Zähler peakte, fielen exakt mit den Frames zusammen, in denen die Frame‑Zeit peakte. Danach blieb nichts mehr zu diskutieren.
Genau deshalb ist C++ Speicherverwaltung in einer Game‑Engine ein eigenständiges Thema: Der durchschnittliche Aufwand ist irrelevant, der Worst‑Case zählt. Innerhalb eines Budgets von 16,6 ms verpasst ein einzelner Tail‑Latency‑Spike den Frame, und der Spieler spürt es, bevor man es in den Zahlen sieht.
Warum new/delete im Frame gefährlich ist
Ein Allzweck‑Allocator ist nicht deterministisch. malloc durchläuft Freilisten, sucht einen passenden Block, nimmt ein Lock und fragt manchmal das Betriebssystem nach frischen Pages. Im letzten Fall springt der Aufwand von Nanosekunden zu Mikrosekunden, und kein Code‑Reading sagt, in welchem Frame das passiert. Unter Windows fielen die meisten von uns gemessenen Spitzen exakt mit Page‑Faults zusammen.
Das zweite Problem ist Fragmentierung. Über eine lange Session erzeugen tausende kurzlebige Allokationen unterschiedlicher Größe Löcher im Adressraum, und nach einer Weile kostet eine Anfrage gleicher Größe mehr als zuvor. In einer 40‑Minuten‑Testsession dauerte derselbe Code‑Pfad am Ende doppelt so lange wie in der ersten Spielminute. Auf Konsolen ist es schlimmer, weil dort nicht derselbe virtuelle Speicher‑Puffer vorhanden ist.
Drittens ist in einer multithreaded Engine jeder Allokationsort ein versteckter Synchronisationspunkt. Wenn vier WorkerThread-Instanzen gleichzeitig allokieren, serialisiert das interne Lock des Allocators sie. Man sieht das nicht als einen klaren Stall‑Block im Profiler; man sieht kleine Verzögerungen überall verteilt, weshalb es so spät bemerkt wird.
Der vierte Punkt ist die Messung selbst. Die Kosten eines Allzweck‑Allocators sammeln sich nie an einer Stelle, sie verteilen sich über Hunderte von Aufrufstellen, und keine davon wirkt allein groß genug, um Aufmerksamkeit zu erregen. Deshalb tauchen Speicherverwaltungs‑Probleme erst auf, wenn man das Gesamte misst, nicht wenn man ein einzelnes System betrachtet.
Frame‑Arena: ein Reset pro Frame
Ein Arena‑Allocator, auch linearer Allocator genannt, ist eine einfache Idee: Bei Startup einen großen Block reservieren, bei jeder Allokation einen Cursor vorwärts schieben und nie einzeln freigeben. Am Ende des Frames setzt man den Cursor zurück auf Null und ist fertig. Unser FrameArena startet mit 8 MB, und eine Allokation kostet einen Alignment‑Schritt plus eine Addition. Wir dimensionierten ihn auf das Doppelte der gemessenen Spitzen‑Nutzung und schreiben die genutzte Byte‑Anzahl am Ende jedes Frames in die Telemetrie.
Die Regel lautet, dass nur Dinge, die nicht über den Frame hinausleben, in die Arena kommen: die Sichtbarkeits‑Liste, das temporäre DrawCommand-Array, die Paar‑Liste aus der Physik‑Broadphase, die Text‑Geometrie, die die UI für diesen Frame baut. Kein Zeiger, der eine Frame‑Grenze überschreitet, darf aus der Arena stammen, sonst wird er im nächsten Frame überschrieben, und dieser Bug ist still.
Reset() führt keine Destruktoren aus. Deshalb legen wir nur trivial destruktible Typen in die Arena. Alles andere benötigt eine separate Destruktor‑Liste, was einen Teil des Geschwindigkeitsvorteils wieder auffrisst; in der Praxis haben wir diesen Weg nie gegangen. Ein static_assert an der Allokationsstelle erzwingt die Regel zur Compile‑Zeit.
Jeder Worker‑Thread bekam seine eigene Arena. Das entfernte sogar die letzte atomare Operation aus dem Allokationspfad, und die versteckte Kontention zwischen Threads verschwand komplett. Eine Arena, die nie geteilt wird, braucht ebenfalls kein Lock. Vier Arenen ergeben 32 MB, ein trivialer Preis gegenüber dem, was wir an Frame‑Zeit gewonnen haben.
Object Pooling für Objekte fester Größe
Die Arena ist nutzlos für Objekte, die länger als ein Frame leben. Projektile, Audio-Quellen und Partikel-Emitter existieren Sekunden und sterben in beliebiger Reihenfolge. Für diese verwenden wir einen Pool‑Allocator: ein Array gleichgroßer Blöcke plus eine Freiliste, die auf die leeren zeigt. Da die Blockgröße fest ist, ist Fragmentierung überhaupt kein Problem.
Der Vorteil eines Memory‑Pools ist, dass die Freiliste in den Blöcken selbst leben kann. Ein freier Block wird nicht benutzt, also schreibt man die Adresse des nächsten freien Blocks in seine ersten acht Bytes; keine separate Datenstruktur, keine separate Allocation. Das macht Acquire() und Release() konstantzeitig und fast verzweigungsfrei.
Wir geben niemals rohe Zeiger auf gepoolte Objekte heraus. Jedes Projektil bekommt einen 32‑Bit‑Index plus einen Generation‑Counter; wenn das Projektil stirbt, wird die Generation erhöht und jeder veraltete Handle, der woanders gehalten wird, wird dadurch automatisch ungültig. Das wandelt Use‑After‑Free von einem Crash in einen stillen Fehler um, den man vor dem Schaden asserten kann.
Für ProjectilePool haben wir eine Kapazität von 4096 gewählt; die Spitzenlast in der dichtesten Welle betrug 2.870. Wenn der Pool voll ist, wachsen wir nicht, sondern recyceln das älteste Projektil. Ein Wachstum mitten im Frame würde den Grund, warum wir Arena und Pool eingeführt haben, zunichte machen. Die Kapazität prüfen wir einmal pro Release und passen sie anhand der Telemetrie‑Spitzenwerte an.
Cache‑Lokalität, Alignment und False Sharing
Der eigentliche Gewinn beim Wechsel des Allocators liegt nicht in der Allocationszeit, sondern darin, dass die Objekte nebeneinander im Speicher liegen. Die 2.870 Projektile aus dem Pool sind zusammenhängend, die Update‑Schleife liest 64‑Byte‑Cache‑Lines sequenziell, und der Hardware‑Prefetcher greift. Als wir dieselbe Anzahl Projektile mit new verstreut haben, hat sich die Cache‑Miss‑Rate verdreifacht.
Die andere Hälfte ist, die heißen Daten klein zu halten. Das Schrumpfen der Projectile-Struktur von 96 Byte auf 48 Byte ermöglichte zwei Projektile pro Cache‑Line und reduzierte die Update‑Schleife von 0,9 ms auf 0,6 ms. Das Verschieben kalter Felder wie dem Mesh‑Pointer und der Sound‑ID in ein paralleles Array reichte aus. Das Einzige, das sich in dieser Messung änderte, war das Feld‑Layout; die Mathematik blieb identisch.
False Sharing ist hingegen unsichtbar, bis man es misst. Jeder unserer vier Worker‑Threads inkrementierte seinen eigenen Counter, aber die Counter lagen auf derselben Cache‑Line, sodass jeder Schreibvorgang die Line auf den anderen Kernen invalidierte. Das Trennen mit alignas(64) sparte 1,2 ms auf diesem System und war eine Ein‑Zeilen‑Änderung.
Auf der SIMD‑Seite ist Alignment nicht optional. Die Allocate-Funktion der Arena nimmt einen Alignment‑Parameter; wir übergeben 16 für einen Buffer mit __m128-Werten und 32 im AVX‑Pfad. Selbst auf Plattformen, wo unaligned Access nicht crasht, haben wir gemessen, dass es merklich langsamer läuft. Da die Arena bereits ausgerichtete Blöcke zurückgibt, befindet sich diese Prüfung an genau einer Stelle.
Ist std::pmr genug, oder ein eigener Allocator?
Die Mess‑Zusammenfassung: In der Kampfszene sank die durchschnittliche Frame‑Zeit von 9,2 ms auf 7,1 ms, aber der eigentliche Unterschied liegt beim 99. Perzentil. Der Peak fiel von 13,4 ms auf 7,9 ms und die periodischen Spikes im Profiler verschwanden komplett. Wir wiederholten die Messung auf zwei verschiedenen Maschinen und das Verhältnis hielt. Der gesamte Aufwand dauerte drei Wochen und passt in zwei Header‑Dateien.
Den größten Teil davon könntest du mit std::pmr erledigen. std::pmr::monotonic_buffer_resource ist bereits eine Arena, und unsynchronized_pool_resource ist bereits ein Pool. Wenn du mit Standard‑Containern arbeitest, misst du zuerst und steckst dann eine Memory‑Resource darunter – das deckt das meiste ab, bei nahezu null Wartungskosten. Außerdem hilft es, dass es eine standardisierte Schnittstelle ist, die jedem im Team bereits bekannt ist.
Die Fälle, in denen du einen eigenen Allocator schreiben musst, sind eng: heiße Loops, in denen du nicht einmal einen virtuellen Aufruf willst, Systeme, in denen du 32‑Bit‑Indizes statt Zeigern speicherst, spezielle Speicherregionen auf Konsolen‑Hardware und deine eigene Telemetrie, die das Frame‑Budget meldet. Wenn keiner dieser vier Punkte auf dich zutrifft, bleib bei pmr.
Wenn du nur eine Sache tun willst, fang damit an, die Allocations pro Frame zu zählen. Einen Counter in die Engine einzubauen und auszugeben, wie viele Allocations jeder Frame macht, dauert etwa eine halbe Stunde, und die Zahl, die dabei herauskommt, ist meist eine, die niemand im Team erwartet hat. Je näher diese Zahl an Null kommt, desto einfacher wird alles andere.