23 Februar 2020

Worklog Seeing# 2: Memory Management

In den letzten zwei Monaten habe ich mich intensiver mit Speicher-Management in .Net beschäftigt. Auslöser dafür war das Buch Pro .Net Memory Management von Konrad Kokosa [1]. Anhand der Erkenntnisse daraus habe ich verschiedene Stellen von Seeing# dahingehend angepasst, dass während der zyklischen Rendering- und Update-Logik insgesamt sorgsamer mit der Erzeugung von Objekten umgegangen wird. Grundsätzlich habe ich das Thema zwar schon immer beachtet, allerdings etwas unterschätzt – vor meinen Änderungen wurden im Kern von Seeing# pro Schleifendurchlauf locker 50-100 Objekte erzeugt, was bei ca. 60 Frames pro Sekunde 3.000 bis 6.000 Objekte bedeutet, um die sich der Garbage Collector kümmern muss. Und das auch ohne, dass der User irgendetwas macht (z. B. 3D-Kamera bewegen o. Ä.). Hier im Artikel stelle ich einige Anpassungen vor, die ich in den letzten Monaten gemacht habe.

Hauptschleife EngineMainLoop

Zunächst noch ein paar Infos, um welche Stelle in Seeing# es geht. Optimiert habe ich primär die Hauptschleife EngineMainLoop [2] und die davon in jedem Durchlauf aufgerufenen Methoden. Diese Klasse und die davon aufgerufenen Methoden bilden somit DIE kritische Stelle, da hier permanent etwas läuft – auch, wenn der Benutzer nichts macht. Sie steuert etwa das 2D-/3D-Rendering auf allen aktiven Views und auch CPU-lastigere Themen wie die Berechnungen aller Animationen, Objekt-Transformationen und Sichtbarkeiten.

Einsatz RingBuffer bei Performance-Messung

Der erste Punkte betrifft die Performance-Messung, welche bei Seeing# zunächst immer mit läuft und in der Klasse PerformanceAnalyzer durchgeführt wird [3]. Im nachfolgenden Screenshot sieht man, für was diese Klasse zuständig ist. Die Hauptschleife wird in verschiedene Bereiche zerlegt, von denen jeweils die notwendige Zeit gemessen wird. Anschließend wird über einen bestimmten Zeitraum (derzeit müsste eine Sekunde konfiguriert sein) der Durchschnitt in Millisekunden gebildet.

Die durch die Klasse PerformanceAnalyzer ermittelten Messwerte in Seeing#

Soweit, so gut. Problem an der bisherigen Implementierung war, dass für jede Einzelne Messung mehrere Objekte erzeugt und i. d. R. kurz darauf nicht mehr benötigt wurden. Nimmt man etwa den Wert bei Graphics.Global.Render (…), so wurde hier in jedem Schleifendurchlauf folgendes gemacht:

  • Messung der Laufzeit mithilfe einer Stopwatch
  • Anhängen des Ergebnisses an einer Queue (Queue<…>)
  • Das Angehängte Ergebnis war eine Tuple<DateTime, long>, die beiden Werte standen entsprechend für den Zeitstempel der Messung und die gemessene Dauer in Ticks
  • Nach Ablauf einer Sekunde wurden alle gemessenen Werte genommen (Dequeue-Methode), daraus eine Ergebniszeile erzeugt und diese wieder an eine List<…> angehängt.
  • Bei letzterer Liste wird das vorher erzeugte Messergebnis wieder entfernt (ggf. auch nicht bis zu einer gewissen Anzahl, um so auch ältere Werte behalten zu können)

Insgesamt macht das schon aus dem hier Beschriebenen ca. 2-3 Objekte pro Schleifendurchlauf pro Messwert aus, also bei 60 Durchläufen pro Sekunde und > 10 Messstellen eine ganze Menge. Lösung ist zum einen Caching – dazu komme ich später noch – und zum anderen ein sog. RingBuffer. RingBuffer ist hierbei eine Liste ähnlich der List<T>, nur dass bei Erreichen des Maximums an Einträgen wieder von 0 begonnen wird und sich der Index 0 um eine Stelle nach rechts verschiebt. Kurz: Eine Liste, bei der man ewig die Add-Methode aufrufen kann, nur dass sie nach Erreichen des Maximums die ältesten Einträge überschreibt. Index 0 zeigt immer auf den Ältesten Eintrag, der höchste Index auf den Neuesten. Die Klasse RingBuffer habe ich für Seeing# neu implementiert, ist aber grundsätzlich allgemeingültig gehalten [4].

Im nachfolgenden Beispiel sieht man eine einfache Anwendung der RingBuffer Klasse. Die Einträge werden hier auch durch einen Werte-Typ gebildet, was ermöglicht, dass beim Hinzufügen mit der AddByRef Methode ein bestehender Eintrag direkt geändert wird – ohne ein Objekt anlegen zu müssen oder dergleichen.

private RingBuffer<ActivityDurationInfo> m_lastDurationItems;

...

/// <summary>
/// Notifies the a done activity and it's duration.
/// </summary>
/// <param name="durationTicks">Total ticks the activity took.</param>
internal void NotifyActivityDuration(long durationTicks)
{
    ref var actItem = ref m_lastDurationItems.AddByRef();
    actItem.TimeStamp = DateTime.UtcNow;
    actItem.DurationTicks = durationTicks;
}

Caching

Ein weiterer Punkt klingt zunächst relativ banal: Caching. Objekte, die man regelmäßig benötigt, sollte man irgendwo zwischenspeichern. Interessant ist dabei besonders auch das Thema ObjectPool. Hierbei handelt es sich um ein Pattern bzw. in der Regel um eine Klasse, welche eine Rent- (ausleihen) und eine Return- (zurückgeben) -Methode enthält. In Seeing# hätte ich dazu die Klasse ConcurrentObjectPool [5] übernommen. Ursprünglich kommt diese Implementierung aus dem Roslyn-Projekt, über oben verwiesenes Buch von Konrad Kokosa bin ich darauf gestoßen (das Wort Concurrent hätte ich selbst noch vorangestellt). In Seeing# verwende ich die Klasse nun, um verschiedene Objekte wie etwa die Stopwatch oben bei der Zeitmessung zu cachen (nicht nur die Stopwatch, es steckt noch ein kleines bisschen mehr drin).

Ein anderer Punkt, welcher mir vorher noch gar nicht bewusst war, bezieht sich auf die string.Format Methode. Grundsätzlich ist es ein guter Stil, diese Methode oder die neuere Schreibweise (z. B. $“Test-String mit Parameter {dummyVariable}“) zu verwenden, um verschiedene Variablen-Werte in einen String mit einzubauen. Problem an der Sache ist, dass hier Boxing stattfindet, wenn Werte-Typen in den String eingebaut werden. In Seeing# gab es verschiedene Stellen im EngineMainLoop, an denen genau das gemacht wurde, z. B. bei dem Messwert „Graphics.RenderLoop.Render (Scene: 0, View: 1)“. Die Nummer der Scene und der View sind dabei jeweils Integer-Werte, per string.Format wird der ganze Name in jedem Frame wieder von neuem zusammengesetzt. Macht in Summe ebenfalls wieder eine ganze Reihe neu erzeugter Objekte pro Schleifendurchlauf, welche an sich aber völlig unnötig sind. Lösung ist hier sehr simpel, an den verschiedenen Stellen gibt es jetzt entsprechend Member-Variablen, welche die Namen dieser Messwerte cachen.

Closures ausgebaut

Als letzten Punkt in diesem Artikel möchte ich noch Closures nennen. Grundsätzlich finde ich diese eine sehr gute Sache und verwende sie auch sehr häufig. Was man aber wissen sollte, ist, dass i. d. R. ein Objekt angelegt wird, über das die notwendigen Variablen aus dem Kontext des Aufrufs mitgezogen werden. Im nachfolgenden Beispiel ist das etwa die Variable result.

/// <summary>
/// Waits for the next finished render process.
/// </summary>
public Task WaitForNextFinishedRenderAsync()
{
    var result = new TaskCompletionSource<object>();

    m_afterPresentActions.Enqueue(() =>
    {
        result.TrySetResult(null);
    });

    return result.Task;
}

Im EngineMainLoop von Seeing# habe ich selbstverständlich auch sehr viele Closures verwendet. Hier habe ich jetzt allerdings alle Stellen ausgebaut, die direkt während jedem Durchlauf der Hauptschleife durchlaufen werden – jeder einzelne der betroffenen Closures hat pro Schleifendurchlauf ein Objekt erzeugt, also bei 60 Frames/Sec auch 60 Objekte pro Sekunde. Ziel für mich ist explizit nicht, alle Closures auszubauen, sondern nur diese, die auch tatsächlich in jedem Schleifendurchlauf Last am Garbage Collector erzeugen. Somit sind die Closures, welche nur in spezifischen Situationen verwendet werden, nach wie vor und auch noch länger enthalten.

Verweise:
[1] = Konrad Kokosa, Pro .Net Memory Management, Apress, 2018
[2] = Github (Seeing# 2, EngineMainLoop.cs)
[3] = Github (Seeing# 2, PerformanceAnalyzer)
[4] = Github (Seeing# 2, RingBuffer)
[5] = Github (Seeing# 2, ConcurrentObjectPool)


Schlagwörter: , , ,
Copyright 2019. All rights reserved.

Verfasst 23. Februar 2020 von Roland in category "Seeing# 2

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.