Jump to content
Unity Insider Forum

Speicheroperationen in C#


Zer0Cool

Recommended Posts

Ich habe heute ein paar Tests zum Kopieren von Speicher innerhalb von C# gemacht. Das Problem welches ich aktuell habe ist (im Zuge meines Gras-Renderers), ich habe mehrere Chunks an Daten (Array / Struct) und wollte diese Chunks in eine zusammengesetzten Speicherstruktur überführen. Daher habe ich getestet, wie schnell Speicheroperationen in C# sind. Meine aktuellen Ergebnisse weisen allerdings darauf hin, das ein Verschieben (oder Umsortieren) von Speicherblöcken je Frame leider keine gute Idee ist. Obwohl ich bereits vermutlich die schnellste Speicherkopierroutine verwendet habe, ist mir der Zeitaufwand pro Frame immer noch zu hoch (2 ms für 14.4 Mio Bytes). Ich werde vermutlich nun keine "Speicherchunks" erstellen und die Chunks des "Terrains" lieber über den Computeshader aufteilen. Damit übernimmt der Computeshader eine Aufteilung hinsichtlich (wird gerendert / wird nicht gerendert). Ich wollte ursprünglich die Menge an Instanzen die der CS bearbeiten muss reduzieren, aber da Speicheroperationen scheinbar so viel Zeit kosten ... 

Hier die Ergebnisse:

Die Ausgangslage

  • Einheiten pro Array 100000
  • Structgröße 144 Bytes ( = Bytes je Einheit)
  • Bytes insgesamt 14.4 Mio (gesamter Speicherblock)

Der Test:
Für die Messung der Performance wurde eine Klasse verwendet, die den Zeitverbrauch pro Frame misst und über 600 Frames hinweg aufsummiert (wenn jemand Bedarf hat, kann ich diese Klasse online stellen).
Jede Methode musste den gesamten Speicherblock in ein neues Array kopieren.


Die Performance:

  • MemCpy(DLL) needed 1277ms for 600 runs (2.128333ms / frame)
  • MemMove(DLL) needed 1196ms for 600 runs (1.993333ms / frame)
  • RtlCopyMem(DLL) needed 1173ms for 600 runs (1.955ms / frame)
  • Array.Copy(C#) needed 1167ms for 600 runs (1.945ms / frame)
  • CopyLong(C#) needed 10898ms for 600 runs (18.16333ms / frame)


Zu meiner Überraschung ist Array.Copy der Sieger, ist aber dennoch zu langsam.

*MemCpy und MemMove sind der DLL ""msvcrt.dll"" entnommen und damit direkte DLL-Zugriffe
* RtlCopyMem sind der DLL "kernel32.dll" entnommen 
* Array.Copy ist die C#-Routine
*"CopyLong" war eine selbstimplementierte Kopierroutine
*Ich möchte noch nicht unerwähnt lassen, daß die Methode Buffer.BlockCopy für einen Struct nicht verwendet werden kann, was sie praktisch unbrauchbar macht.

PS:
Der Computeshader verbraucht übrigens 7ms für die Verarbeitung von 1 Mio Einheiten (nur als Vergleich wie ineffektiv die oberen Speichervorgänge sind)


Verwendete Methoden (Auszug):
"RtlCopyMem"

   [DllImport("kernel32.dll", EntryPoint = "RtlCopyMemory")]
   public static extern void CopyKernel(IntPtr Destination, IntPtr Source, uint Length);

    unsafe void RtlCopyMem()
    {
        fixed (instanceData* d = &terrainGlobalData[0])
        fixed (instanceData* s = &terrainSegmentData[0])

        CopyKernel(new IntPtr(d), new IntPtr(s), (uint)(terrainSegmentData.Length * Marshal.SizeOf(typeof(instanceData))));
    }

"CopyLong"

  unsafe void CopyLong()
    {
        System.Runtime.InteropServices.GCHandle handleS = System.Runtime.InteropServices.GCHandle.Alloc(terrainSegmentData, System.Runtime.InteropServices.GCHandleType.Pinned);
        System.Runtime.InteropServices.GCHandle handleD = System.Runtime.InteropServices.GCHandle.Alloc(terrainGlobalData, System.Runtime.InteropServices.GCHandleType.Pinned);
        IntPtr addressS = handleS.AddrOfPinnedObject();
        IntPtr addressD = handleD.AddrOfPinnedObject();
        byte* sptr = (byte*)addressS.ToPointer();
        byte* dptr = (byte*)addressD.ToPointer();

        FastCopy(sptr, dptr, terrainSegmentData.Length * Marshal.SizeOf(typeof(instanceData)));
    }

    // The unsafe keyword allows pointers to be used within
    // the following method:
    static unsafe void FastCopy(byte* src, byte* dst, int count)
    {
            int size = 8;
            byte* ps = src;
            byte* pd = dst;

            // Loop over the count in blocks of 8 bytes, copying an
            // long (8 bytes) at a time:
            for (int n = 0; n < count / size; n++)
            {
                *((long*)pd) = *((long*)ps);
                pd += size;
                ps += size;
            }

        // Complete the copy by moving any bytes that weren't
        // moved in blocks of 8:
        for (int n = 0; n < count % size; n++)
            {
                *pd = *ps;
                pd++;
                ps++;
            }
    }

 

Link zu diesem Kommentar
Auf anderen Seiten teilen

Deine Ergebnisse sind tatsächlich nicht sonderlich überraschend, wenn man sich mal mit Speicherperformance auseinandersetzt.

Obwohl in den vergangenen Jahren CPUs / GPUs deutlich schneller geworden sind, ist die Performance von Speicher im Vergleich bloß minimal angestiegen. (Siehe angehängte Grafik)

Nichts davon kann man übrigens wirklich C# Speicheroperationen nennen, denn C# implementiert überhaupt keine Funktionen um Speicher zu kopieren. Das macht in den meisten Fällen der GC (in unserem Fall leider der veraltete Boehm GC, zumindest glaub ich, dass Unity da noch nicht aktualisiert hat)
Array.Copy und RtlCopyMem hingegen machen interop, schieben deine Pointer also auf die native Seite und kopieren da (Siehe Kernel32.dll CopyKernel)

CopyLong macht da nicht so viel Sinn, da du halt weiter den GC zum Speicherallokieren benutzt. Da macht es auch keinen großen Unterschied, dass du dein Zeug direkt zu Pointer umwandelst. (was intern sowieso passieren würde, würdest du es nicht tun) Du könntest da durchaus einen kleinen Bonus kriegen, aber der ist vernachlässigbar.

 

Ich weiß nicht genau was du da treibt, aber 14mb jeden Frame hin- und her zu schieben ist allgemein nicht so praktisch. Idealerweise willst du soviel Speicher wie möglich zusammenfassen und erst beim Rendern wieder auseinander pflücken.

cpu_vs_memory.png

Link zu diesem Kommentar
Auf anderen Seiten teilen

Unity 2017.1 /2 scheint noch .Net 3.5 zu verwenden. In Unity 2017.3/2018 kann man aber schon auf .NET 4.6 gehen denke ich mal. Hatte gesehen, auch die Methode "Buffer.BlockCopy" hat sich deutlich verändert von 3.5 nach 4.6. Den Sourcecode für 4.6 kann man sich bequem ansehen, der für 3.5 war nicht mehr greifbar.

Die Methode CopyLong habe ich mit aufgeführt, da sie bei kleinen Array effektiver ist als MemCpy oder MemMove, aber eben nur bei size byte, int, long.
Aus diesem Grund ist in der Methode "Buffer.BlockCopy" eine riesige Case-Switch-Anweisung die die Size des Kopiervorgangs prüft und erst ab > size 512 an die interne Kopiermethode (DLL) übergibt. Ich fand es einfach interessant solche eine Methode mal nachzustellen. Hat bei mir nur keinen Sinn gemacht, da ich enorme Menge kopieren wollte ...
https://referencesource.microsoft.com/mscorlib/system/buffer.cs.html#https://referencesource.microsoft.com/mscorlib/system/buffer.cs.html,https://referencesource.microsoft.com/mscorlib/system/buffer.cs.html,0d691e54a9fdddc3

Das Ganze war halt ein Test wie schnell eine mögliche (Memory-)Lösung sein könnte ...

Hintergrund ist folgender (ich arbeite immer noch am Gras-Shader-System):
- ein Terrain (oder Mesh spielt keine Rolle) wird in Quadranten aufgeteilt (nennen wir sie mal Chunks)
- sagen wir ein 2000x2000 großes Gebiet hat Chunks mit einer Größe von 100, das macht 20x20 Chunks = 400 Chunks für das ganze Gebiet
- jeder Chunk kann dabei maximal bis zu 500 Grasdetails besitzen (viel mehr macht keinen Sinn, da eine feinere Auflösung keinen Mehrwert bringt)

Diese Chunks werden nun geprüft, ob sie geculled werden müssen oder nicht. Sagen wir mal von den 400 Chunks sind 150 Chunks sichtbar.

Das macht:
- 150 * 500 = 75000 sichtbare Einheiten die gerendert werden müssen
- damit werden ca. 1.5 ms benötigt um diese 150 Chunks in einem Speicherblock zusammenzufügen

Dieser verbundene Speicherblock wird für den "Rendercall" benötigt, alle Grasdetails werden damit in einem Drawcall gerendert.

Wo kommen die vielen Daten her (ich rechne her natürlich schon oft mit Maximalmengen)?
Jedes Grasdetail hat aktuell:
- 1 Matrix WorldtoObjekt (4x4 Floats = 4x4x4 Bytes)
- 1 Matrix ObjekttoWorld (4x4 Floats = 4x4x4 Bytes)
- 1 Color (4 Floats = 4 Bytes)

Diese Daten werden vom Shader 1:1 benötigt... Der Vorteil dabei, der Shader muss nichts mehr berechnen und kann die Matrizen sofort verwenden, sonst müsste er aus der Position (4 Floats) beide Matrizen berechnen für jedes einzelne Grasdetail (können bis zu 200k werden)

Ich werde es aber wohl nun so machen und spare mir damit die voraussichtlich im Schnitt 1.5 -3.0 ms pro Frame (stark vereinfacht):
- das Terrain wird in Chunks aufgeteilt
- jeder Chunk bekommt eine ID verpasst
- jedem Chunk werden Grasdetails mit ID zugeordnet
- die Culling Api von Unity ermittelt mir welche Chunks aktuell sichtbar sind 
- 150 von 400 sind beispielsweise sichtbar
- nun schicke ich zusätzlich ein Array an den Computeshader mit ca. 500 Slots (nennen wird es mal Cullingarray) (diese Methode ist quasi wie ein vereinfachtes Hashset)
- in jedem Slot steht 0 für einen nicht aktiven und eine 1 für einen aktiven Chunk
- der Computeshader bekommt alle Grasdetails (können bis zu 1 Mio Details sein oder mehr) als Input
- der Computeshader schnappt sich Grasdetail XY und prüft die ID gegen das Cullingarray
- wenn die ID des Grasdetails im Cullingarray = 1 hat, dann wird es in die Ausgabe des Computeshader (sichtbare Grasdetails) geschrieben
- die Ausgabe des Computeshaders wird gerendert

Link zu diesem Kommentar
Auf anderen Seiten teilen

Wenn du riesige Mengen an Gras rendern willst, dann musst du dich vermutlich von dem Gedanken entfernen, jedes einzelne Grasdetail im Speicher zu kennen und einzelnt zu rendern.
Ich glaube es ist für Gras ziemlich üblich density maps zu generieren und die dann zum Rendern auszulesen.

Das Paper hier sieht ziemlich vielversprechend aus (ich hab's selbst bloß überflogen)
http://www.kevinboulanger.net/grass.html

und das hier klingt auch ganz interessant
http://developer.amd.com/wordpress/media/2012/10/i3dGrassFINAL.pdf

Ansonsten, versuch so viel wie möglich zusammen zu fassen.
Wenn z.B. 1000 Gras details die selbe Farbe haben, ist es verschwenderisch die Farbe 1000 mal im Speicher zu haben :)

Link zu diesem Kommentar
Auf anderen Seiten teilen

Naja der Instanced-Indirect-Shader wird leider über ein Array bestückt, da kann man leider keine Textur verwenden. Ansonsten hättest du natürlich Recht:
https://docs.unity3d.com/ScriptReference/Graphics.DrawMeshInstancedIndirect.html

Wenn man diese Schnittstelle bedienen will, dann muss man alle Instanzinformationen (die der Shader braucht) im Speicher halten und dieser Methode (pro Frame) übergeben. Ich werde aber deinen Vorschlag mal im Hinterkopf behalten, da ich noch eine 2. Farbpalette für den Boden der Grashalme übergeben möchte. Hier wäre es eine gute Idee eine Textur zu baken und diese dem Shader zu übergeben, muss dann nur schauen wie ich die UVs berechne, da ich ja den Terrainmesh nicht im Zugriff habe...
Ansonsten stellt die reine Speichermenge "eigentlich" kein Problem dar, man darf eben wohl nur nicht, den Speicher jedes Frame umstrukturieren (was ich oben ausprobiert hatte). Ich werde nun aber wie oben beschrieben das Speicherarray (und diesem die Chunks zuteilen) nur 1x erzeugen und dann unverändert im Speicher lassen und damit gibt es hier keinen Performanceverlust. Die Verwendung einer Densitymap macht aber ggf. trotzdem Sinn vor allem wenn ich später einmal Instanzinformationen unabhängig vom Unityterrain speichern möchte. Hier könnte man eine Densitymap der Grasdaten erzeugen und speichern, vielleicht schiebe ich aber auch die Instanzinformationen erst einmal einfach in ein Skriptable Object. Aber hier muss ich auch noch einmal Tests laufen lassen (hängt davon ab, wie schnell und wie viel Unity  hier in ein Asset speichern kann)

Ansonsten ist das System aber auch schon recht weit fortgeschritten und ich habe bereits 1 Mio Grasdetails erfolgreich rendern können (was schon eine Art Maximalbelastung darstellt / 140 FPS). Der Umbau zu einem Chunksystem wird aber noch einmal einiges an Arbeit kosten, aber die Methodik steht soweit. Ein Problem werde vermutlich ich noch einmal mit der Unity Culling Api bekommen, da diese nur Spheren abbilden kann und und ich aber eigentlich Boxes brauche ...

Link zu diesem Kommentar
Auf anderen Seiten teilen

Archiviert

Dieses Thema ist jetzt archiviert und für weitere Antworten gesperrt.

×
×
  • Neu erstellen...