In game development we often want to reduce the amount of memory that is allocated on the heap. One technique is to use value types like structs that can be stored on the stack instead of the heap. But surprisingly the simple act of referencing a struct by an interface can lead to memory allocations and poor performance.
Normally we can create a struct as a local variable without allocating memory. You can see this by running the following test. (nUnit 3.6 with .NET 4.5)
private struct DummyStruct { } [SetUp] public void SetUp() { GC.Collect(); } [TearDown] public void TearDown() { GC.Collect(); } [Test] public void CreateLocalStruct_DoesNotAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { new DummyStruct(); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.EqualTo(0)); }
The same is true if we return a struct from another method. The following test will also not allocate memory on the heap.
private static DummyStruct CreateDummyStruct() { return new DummyStruct(); } [Test] public void ReturnStruct_DoesNotAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { CreateDummyStruct(); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.EqualTo(0)); }
But what if we refer to the struct by an interface? That seems like a harmless change, but it makes a big difference to the compiler. Let’s redefine DummyStruct to inherit from an interface, and then return it from a method via its interface.
private interface IDummy { } private struct DummyStruct : IDummy { } private static IDummy CreateDummy() { return new DummyStruct(); } [Test] public void ReturnInterface_DoesAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { CreateDummy(); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.GreaterThan(0)); }
Surprisingly this small change forces the compiler to allocate the struct on the heap instead of the stack! This is because it doesn’t know the concrete return type of the CreateDummy() method until run time. Notice that this happens even if we don’t use the return value from the method. The sheer fact that the method returns an interface causes the returned value to be put onto the heap for garbage collection.
We can see the same thing happens if we pass a struct as a method parameter using an interface. The compiler can only avoid the heap allocation if the parameter type is of a known size at compile time. If the parameter is an interface type, then the parameter will be put onto the heap even if it is a fixed size object.
[Test] public void InjectStruct_DoesNotAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { var dummy = new DummyStruct(); ConsumeDummyStruct(dummy); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.EqualTo(0)); } [Test] public void InjectInterface_DoesAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { var dummy = new DummyStruct(); ConsumeDummy(dummy); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.GreaterThan(0)); }
The point here is that stack allocation is a very specific compiler optimization. We can’t assume anything will be stack allocated unless all the requirements are met.
Here is the complete test fixture.
namespace Experiments.Tests.MemoryAllocation { using System; using NUnit.Framework; [TestFixture] public class StructTests { [SetUp] public void SetUp() { GC.Collect(); } [TearDown] public void TearDown() { GC.Collect(); } [Test] public void CreateLocalStruct_DoesNotAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { new DummyStruct(); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.EqualTo(0)); } [Test] public void ReturnStruct_DoesNotAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { CreateDummyStruct(); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.EqualTo(0)); } [Test] public void ReturnInterface_DoesAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { CreateDummy(); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.GreaterThan(0)); } [Test] public void InjectStruct_DoesNotAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { var dummy = new DummyStruct(); ConsumeDummyStruct(dummy); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.EqualTo(0)); } [Test] public void InjectInterface_DoesAllocateMemory() { // Arrange long initialMemory = GC.GetTotalMemory(false); // Act for (int i = 0; i < 100000; i++) { var dummy = new DummyStruct(); ConsumeDummy(dummy); } // Assert long changeInMemory = GC.GetTotalMemory(false) - initialMemory; Assert.That(changeInMemory, Is.GreaterThan(0)); } private static DummyStruct CreateDummyStruct() { return new DummyStruct(); } private static IDummy CreateDummy() { return new DummyStruct(); } private static void ConsumeDummyStruct(DummyStruct dummy) { } private static void ConsumeDummy(IDummy dummy) { } private interface IDummy { } private struct DummyStruct : IDummy { } } }