SmallBuffer: A Stack-Based Array
Sometimes you just want a small array without the heap allocations and GC. Existing solutions like stackalloc
require unsafe
code, don’t allow for dynamic growth, and don’t support foreach
loops. So today we’ll design and build a code generator that puts a new tool in your toolbox!
Goals
The goal today is to create a code generator that outputs a C# struct
type that behaves like an array. The output types are generally called SmallBuffer
as they’re meant for small numbers of elements that conveniently fit onto the stack. It can be stored on the heap, such as by being a field of a class
, but it’s primarily used as a local variable on the stack.
The following table summarizes its characteristics in comparison to other common array-like collection types:
Collection | Allocation | Location | Managed Elements | Bounds Checks | Foreach | Dynamic | GC |
---|---|---|---|---|---|---|---|
SmallBuffer | Stack, Heap | Local, Field | Yes | Exceptions, Assertions, None | If unmanaged | Optional (capped) | No |
Managed array | Heap | Local, Field | Yes | Exceptions, None | Yes | No | Yes |
List |
Heap | Local, Field | Yes | Exceptions | Yes | Yes (uncapped) | Yes |
stackalloc | Stack | Local | No | None | No | No | No |
fixed buffer | Stack, Heap | Local,Field | No | None | No | No | No |
NativeArray |
Heap | Local, Field | No | Exceptions, None | Yes | No | No |
NativeList |
Heap | Local, Field | No | Exceptions or none | Yes | Yes | No |
This type has many nice advantages: allocation on the stack or the heap, use as a local or a field, support for managed elements, flexible bounds checks, and no triggering of the GC. On the other hand, foreach
support is only available with unmanaged elements and dynamic resizing is limited to a fixed capacity. Additionally, SmallBuffer
types can use Unity features such as the Burst compiler, but can also drop the Unity dependency when those features aren’t needed. They can take advantage of C# 7 features, but also support older versions of C# such as in Unity 2017.4.
As for the code generator for SmallBuffer
types, we’ll write it in pure C# so that it can be used in Unity editor scripts and as a command line tool. The code generator should be free of dependencies on Unity or C# 7, execute quickly, and easily fit into any build pipeline or workflow.
Design
Now let’s talk about how to achieve these goals. At its simplest, we’re looking for a type like this:
[StructLayout(LayoutKind.Sequential)] public struct SmallBufferInt4 { // Elements of the "array" private int m_Element0; private int m_Element1; private int m_Element2; private int m_Element3; // Index into the array public int this[int index] { get { switch (index) { case 0: return m_Element0; case 1: return m_Element1; case 2: return m_Element2; case 3: return m_Element3; } } set { switch (index) { case 0: m_Element0 = value; break; case 1: m_Element1 = value; break; case 2: m_Element2 = value; break; case 3: m_Element3 = value; break; } } } // The length is constant public const int Length = 4; }
While these switch
statements are necessary for managed types like string
, we can simply index into the sequential fields using a little unsafe
code:
[StructLayout(LayoutKind.Sequential)] public unsafe struct SmallBufferInt4 { private int m_Element0; private int m_Element1; private int m_Element2; private int m_Element3; public int this[int index] { get { fixed (int* elements = &m_Element0) { return elements[index]; } } set { fixed (int* elements = &m_Element0) { return elements[index] = value; } } } public const int Length = 4; }
If C# 7 support is enabled, we can omit the set
and use a ref
return:
[StructLayout(LayoutKind.Sequential)] public unsafe struct SmallBufferInt4 { private int m_Element0; private int m_Element1; private int m_Element2; private int m_Element3; public ref int this[int index] { get { fixed (int* elements = &m_Element0) { return ref elements[index]; } } } public const int Length = 4; }
Error-checking should go into [BurstDiscard]
functions with contents depending on the specified error-handling strategy. These can then be called when necessary, such as when the index
parameter to the indexer is out of bounds.
// Exceptions [Unity.Burst.BurstDiscard] public void RequireIndexInBounds(int index) { if (index < 0 || index >= 4) { throw new System.InvalidOperationException( "Index out of bounds: " + index); } } // Unity assertions [Unity.Burst.BurstDiscard] public void RequireIndexInBounds(int index) { UnityEngine.Assertions.Assert.IsTrue( index >= 0 && index < 4, "Index out of bounds: " + index); } // None [Unity.Burst.BurstDiscard] public void RequireIndexInBounds(int index) { }
To support dynamic resizing, up to the fixed capacity of course, we add length and version fields as well as functions like Add
, Insert
, RemoveAt
, RemoveRange
, and Clear
as well as helper functions like GetElement
and SetElement
that skip the error-checking. This also converts the Length
constant into a Count
property and adds a Capacity
constant to match the API of List<T>
.
private int m_Length; private int m_Version; public int Count { get { return m_Length; } } public const int Capacity = 4; public void RemoveAt(int index) { RequireIndexInBounds(index); for (int i = index; i < m_Length - 1; ++i) { SetElement(i, GetElement(i + 1)); } m_Length--; m_Version++; } private int GetElement(int index) { fixed (int* elements = &m_Element0) { return elements[index]; } } private void SetElement(int index, int value) { fixed (int* elements = &m_Element0) { elements[index] = value; } }
Then we can add support for foreach
loops by including a GetEnumerator
method that returns a nested Enumerator
type with a Current
property and MoveNext
method. This is safe in C# 7 because the Enumerator
type is a ref struct
. This allows us to store pointer fields in it without worrying that its lifetime will extend beyond the lifetime of the SmallBuffer
it points into.
public ref struct Enumerator { private readonly int* m_Elements; private int m_Index; private readonly int m_OriginalVersion; private readonly int* m_Version; private readonly int m_Length; public Enumerator(int* elements, int* version, int length) { m_Elements = elements; m_Index = -1; m_OriginalVersion = *version; m_Version = version; m_Length = length; } public bool MoveNext() { RequireVersionMatch(); m_Index++; return m_Index < m_Length; } public ref int Current { get { RequireVersionMatch(); RequireIndexInBounds(); return ref m_Elements[m_Index]; } } [Unity.Burst.BurstDiscard] public void RequireVersionMatch() { if (m_OriginalVersion != *m_Version) { throw new System.InvalidOperationException( "Buffer modified during enumeration"); } } [Unity.Burst.BurstDiscard] public void RequireIndexInBounds() { if (m_Index < 0 || m_Index >= m_Length) { throw new System.InvalidOperationException( "Index out of bounds: " + m_Index); } } } public Enumerator GetEnumerator() { // Safe because Enumerator is a 'ref struct' fixed (int* elements = &m_Element0) { return new Enumerator(elements); } }
Implementation
The code generator will exist in a single C# file containing a simple API made up of a single Generate
function and a couple of enum
types to specify the configuration. Its input consists of the following parameters:
- string namespaceName: Namespace to put the generated type in
- string typeName: Name of the generated type
- int capacity: Capacity of the buffer to generate in number of elements
- bool isFixedLength: If the generated buffer has a fixed length
- ErrorHandlingStrategy boundsCheckStrategy: Error handling strategy the generated type should use to handle out-of-bounds errors.
None
,UnityAssertions
, orExceptions
are supported. - ErrorHandlingStrategy versionCheckStrategy: Error handling strategy the generated type should use to handle version check errors (i.e. if the buffer is changed during enumeration).
- string elementTypeName: Name of the type of elements stored in the buffer
- ElementType elementType: Type of the element.
Managed
,Unmanaged
,UnmanagedWithUnityBurstSupport
,UnmanagedWithCsharp7Support
, andUnmanagedWithCsharp7SupportAndUnityBurstSupport
are supported.
Given those inputs, SmallBufferGenerator.Generate
returns a string
of the C# source code containing the SmallBuffer
type. It should be saved to file and built into a project. Alternatively, it can easily be copied to the clipboard or transferred over a network for extra flexibility.
Now that we’ve thought through the design of the code generator, its implementation is extremely straightforward. A StringBuilder
is used to build up the source code string through a series of Append
and AppendLine
calls. The code generator is mostly a linear series of steps to write out the source. It’s verbose, but easy to understand imperative-style code. Have a read, or simply copy this into a C# project:
using System.Text; /// <summary> /// Code generator for structs representing a small buffer /// </summary> /// /// <author> /// Jackson Dunstan, https://JacksonDunstan.com/articles/5051 /// </author> /// /// <license> /// MIT /// </license> public static class SmallBufferGenerator { /// <summary> /// Types of error handling /// </summary> public enum ErrorHandlingStrategy { /// <summary> /// Perform no error checking at all /// </summary> None, /// <summary> /// Check errors using Unity assertions /// </summary> UnityAssertions, /// <summary> /// Check errors using exceptions /// </summary> Exceptions, } /// <summary> /// Types of buffer elements /// </summary> public enum ElementType { /// <summary> /// Managed types /// </summary> Managed, /// <summary> /// Unmanaged types /// </summary> Unmanaged, /// <summary> /// Unmanaged types with support for usage in a Unity Burst-compiled job /// </summary> UnmanagedWithUnityBurstSupport, /// <summary> /// Unmanaged types with support for usage in a C# 7 application /// </summary> UnmanagedWithCsharp7Support, /// <summary> /// Unmanaged types with support for usage in a C# 7 application and /// in a Unity Burst-compiled job /// </summary> UnmanagedWithCsharp7SupportAndUnityBurstSupport, } /// <summary> /// One level of indentation /// </summary> private const char OneIndent = '\t'; /// <summary> /// Generate a C# source file for a small buffer type /// </summary> /// /// <returns> /// The generated C# source file /// </returns> /// /// <param name="namespaceName"> /// Namespace to put the generated type in /// </param> /// /// <param name="typeName"> /// Name of the generated type /// </param> /// /// <param name="capacity"> /// Capacity of the buffer to generate in number of elements /// </param> /// /// <param name="isFixedLength"> /// If the generated buffer has a fixed length /// </param> /// /// <param name="boundsCheckStrategy"> /// Error handling strategy the generated type should use to handle /// out-of-bounds errors /// </param> /// /// <param name="versionCheckStrategy"> /// Error handling strategy the generated type should use to handle /// version check errors (i.e. if the buffer is changed during enumeration) /// </param> /// /// <param name="elementTypeName"> /// Name of the type of elements stored in the buffer. Should be usable /// without any 'using' statements. Pass null to make the type generic. /// </param> /// /// <param name="elementType"> /// Type of the element /// </param> public static string Generate( string namespaceName, string typeName, int capacity, bool isFixedLength, ErrorHandlingStrategy boundsCheckStrategy, ErrorHandlingStrategy versionCheckStrategy, string elementTypeName, ElementType elementType) { // Replace type name with 'T' to make it generic bool isGeneric; if (elementTypeName == null) { isGeneric = true; elementTypeName = "T"; } else { isGeneric = false; } // Decide if Unity Burst is enabled or not bool enableBurst; switch (elementType) { case ElementType.UnmanagedWithUnityBurstSupport: case ElementType.UnmanagedWithCsharp7SupportAndUnityBurstSupport: enableBurst = true; break; default: enableBurst = false; break; } // Decide if C# 7 is enabled or not bool enableCsharp7; switch (elementType) { case ElementType.UnmanagedWithCsharp7Support: case ElementType.UnmanagedWithCsharp7SupportAndUnityBurstSupport: enableCsharp7 = true; break; default: enableCsharp7 = false; break; } int indentLevel = 0; // File header StringBuilder output = new StringBuilder(1024 * 64); output.AppendLine("////////////////////////////////////////////////////////////////////////////////"); output.AppendLine("// Warning: This file was automatically generated by SmallBufferGenerator."); output.AppendLine("// If you edit this by hand, the next run of SmallBufferGenerator"); output.AppendLine("// will overwrite your edits."); output.AppendLine("////////////////////////////////////////////////////////////////////////////////"); output.AppendLine(); // Begin namespace output.Append("namespace "); output.AppendLine(namespaceName); output.AppendLine("{"); indentLevel++; // Begin struct output.Append(OneIndent, indentLevel); output.AppendLine("[System.Runtime.InteropServices.StructLayout("); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("System.Runtime.InteropServices.LayoutKind.Sequential)]"); indentLevel--; output.Append(OneIndent, indentLevel); output.Append("public "); if (elementType != ElementType.Managed) { output.Append("unsafe "); } output.Append("struct "); output.Append(typeName); if (isGeneric) { output.Append('<'); output.Append(elementTypeName); output.AppendLine(">"); indentLevel++; output.Append(OneIndent, indentLevel); output.Append("where T : unmanaged"); indentLevel--; } output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; if (elementType != ElementType.Managed && enableCsharp7) { // Begin enumerator struct output.Append(OneIndent, indentLevel); output.AppendLine("public ref struct Enumerator"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; // Enumerator buffer field output.Append(OneIndent, indentLevel); output.Append("private readonly "); output.Append(elementTypeName); output.AppendLine("* m_Elements;"); // Enumerator index field output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("private int m_Index;"); // Enumerator version field if (!isFixedLength) { output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("private readonly int m_OriginalVersion;"); output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("private readonly int* m_Version;"); output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("private readonly int m_Length;"); } // Enumerator constructor output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("public Enumerator("); output.Append(elementTypeName); output.Append("* elements"); if (!isFixedLength) { output.Append(", int* version, int length"); } output.AppendLine(")"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("m_Elements = elements;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Index = -1;"); if (!isFixedLength) { output.Append(OneIndent, indentLevel); output.AppendLine("m_OriginalVersion = *version;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Version = version;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Length = length;"); } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine(); // Enumerator MoveNext method output.Append(OneIndent, indentLevel); output.AppendLine("public bool MoveNext()"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; if (!isFixedLength) { output.Append(OneIndent, indentLevel); output.AppendLine("RequireVersionMatch();"); } output.Append(OneIndent, indentLevel); output.AppendLine("m_Index++;"); output.Append(OneIndent, indentLevel); output.Append("return m_Index < "); if (isFixedLength) { output.Append(capacity); } else { output.Append("m_Length"); } output.AppendLine(";"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine(); // Enumerator Current property output.Append(OneIndent, indentLevel); output.Append("public ref "); output.Append(elementTypeName); output.AppendLine(" Current"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("get"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; if (!isFixedLength) { output.Append(OneIndent, indentLevel); output.AppendLine("RequireVersionMatch();"); } output.Append(OneIndent, indentLevel); output.AppendLine("RequireIndexInBounds();"); output.Append(OneIndent, indentLevel); output.AppendLine("return ref m_Elements[m_Index];"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); if (!isFixedLength) { // RequireVersionMatch method output.Append(OneIndent, indentLevel); output.AppendLine(); if (enableBurst) { output.Append(OneIndent, indentLevel); output.AppendLine("[Unity.Burst.BurstDiscard]"); } output.Append(OneIndent, indentLevel); output.AppendLine("public void RequireVersionMatch()"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; switch (versionCheckStrategy) { case ErrorHandlingStrategy.UnityAssertions: output.Append(OneIndent, indentLevel); output.AppendLine( "UnityEngine.Assertions.Assert.IsTrue("); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("m_OriginalVersion == *m_Version,"); output.Append(OneIndent, indentLevel); output.AppendLine( "\"Buffer modified during enumeration\");"); indentLevel--; break; case ErrorHandlingStrategy.Exceptions: output.Append(OneIndent, indentLevel); output.AppendLine( "if (m_OriginalVersion != *m_Version)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine( "throw new System.InvalidOperationException("); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine( "\"Buffer modified during enumeration\");"); indentLevel--; indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); break; } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } // RequireIndexInBounds method output.Append(OneIndent, indentLevel); output.AppendLine(); if (enableBurst) { output.Append(OneIndent, indentLevel); output.AppendLine("[Unity.Burst.BurstDiscard]"); } output.Append(OneIndent, indentLevel); output.AppendLine("public void RequireIndexInBounds()"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; AppendBoundsCheck( output, "m_Index", indentLevel, isFixedLength, capacity, boundsCheckStrategy); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // End enumerator struct indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } // Element fields for (int i = 0; i < capacity; ++i) { output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("private "); if (elementType != ElementType.Managed && enableCsharp7) { output.Append("readonly "); } output.Append(elementTypeName); output.Append(" m_Element"); output.Append(i); output.AppendLine(";"); } if (!isFixedLength) { // Version field output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("private int m_Version;"); // Length field output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("private int m_Length;"); } // Indexer output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("public "); if (elementType != ElementType.Managed && enableCsharp7) { output.Append("ref "); } output.Append(elementTypeName); output.AppendLine(" this[int index]"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("get"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("RequireIndexInBounds(index);"); output.Append(OneIndent, indentLevel); output.Append("return "); if (elementType != ElementType.Managed && enableCsharp7) { output.Append("ref "); } output.AppendLine("GetElement(index);"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); if (elementType == ElementType.Managed || !enableCsharp7) { output.Append(OneIndent, indentLevel); output.AppendLine("set"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("RequireIndexInBounds(index);"); output.Append(OneIndent, indentLevel); output.AppendLine("SetElement(index, value);"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // GetElement output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("private "); if (elementType != ElementType.Managed && enableCsharp7) { output.Append("ref "); } output.Append(elementTypeName); output.AppendLine(" GetElement(int index)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; if (elementType == ElementType.Managed) { output.Append(OneIndent, indentLevel); output.AppendLine("switch (index)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; for (int i = 0; i < capacity; ++i) { output.Append(OneIndent, indentLevel); output.Append("case "); output.Append(i); output.Append(": return "); if (enableCsharp7) { output.Append("ref "); } output.Append("m_Element"); output.Append(i); output.AppendLine(";"); } output.Append(OneIndent, indentLevel); output.Append("default: return default("); output.Append(elementTypeName); output.AppendLine(");"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } else { output.Append(OneIndent, indentLevel); output.Append("fixed ("); output.Append(elementTypeName); output.AppendLine("* elements = &m_Element0)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.Append("return "); if (enableCsharp7) { output.Append("ref "); } output.AppendLine("elements[index];"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // SetElement output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("private void SetElement(int index, "); output.Append(elementTypeName); output.AppendLine(" value)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; if (elementType == ElementType.Managed) { output.Append(OneIndent, indentLevel); output.AppendLine("switch (index)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; for (int i = 0; i < capacity; ++i) { output.Append(OneIndent, indentLevel); output.Append("case "); output.Append(i); output.Append(": m_Element"); output.Append(i); output.AppendLine(" = value; break;"); } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } else { output.Append(OneIndent, indentLevel); output.Append("fixed ("); output.Append(elementTypeName); output.AppendLine("* elements = &m_Element0)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("elements[index] = value;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // Length constant or property output.Append(OneIndent, indentLevel); output.AppendLine(); if (isFixedLength) { output.Append(OneIndent, indentLevel); output.Append("public const int Length = "); output.Append(capacity); output.AppendLine(";"); } else { output.Append(OneIndent, indentLevel); output.AppendLine("public int Count"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("get"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("return m_Length;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } // Capacity constant if (!isFixedLength) { output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("public const int Capacity = "); output.Append(capacity); output.AppendLine(";"); } // GetEnumerator method if (elementType != ElementType.Managed && enableCsharp7) { output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("public Enumerator GetEnumerator()"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("// Safe because Enumerator is a 'ref struct'"); output.Append(OneIndent, indentLevel); output.Append("fixed ("); output.Append(elementTypeName); output.AppendLine("* elements = &m_Element0)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; if (isFixedLength) { output.Append(OneIndent, indentLevel); output.AppendLine("return new Enumerator(elements);"); } else { output.Append(OneIndent, indentLevel); output.AppendLine("fixed (int* version = &m_Version)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine( "return new Enumerator(elements, version, m_Length);"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } if (!isFixedLength) { // Add method output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("public void Add("); output.Append(elementTypeName); output.AppendLine(" item)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("RequireNotFull();"); output.Append(OneIndent, indentLevel); output.AppendLine("SetElement(m_Length, item);"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Length++;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Version++;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // Clear method output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("public void Clear()"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("for (int i = 0; i < m_Length; ++i)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.Append("SetElement(i, default("); output.Append(elementTypeName); output.AppendLine("));"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Length = 0;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Version++;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // Insert method output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.Append("public void Insert(int index, "); output.Append(elementTypeName); output.AppendLine(" value)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("RequireNotFull();"); output.Append(OneIndent, indentLevel); output.AppendLine("RequireIndexInBounds(index);"); output.Append(OneIndent, indentLevel); output.AppendLine("for (int i = m_Length; i > index; --i)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("SetElement(i, GetElement(i - 1));"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine("SetElement(index, value);"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Length++;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Version++;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // RemoveAt method output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("public void RemoveAt(int index)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("RequireIndexInBounds(index);"); output.Append(OneIndent, indentLevel); output.AppendLine("for (int i = index; i < m_Length - 1; ++i)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("SetElement(i, GetElement(i + 1));"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Length--;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Version++;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // RemoveRange method output.Append(OneIndent, indentLevel); output.AppendLine(); output.Append(OneIndent, indentLevel); output.AppendLine("public void RemoveRange(int index, int count)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("RequireIndexInBounds(index);"); switch (boundsCheckStrategy) { case ErrorHandlingStrategy.UnityAssertions: output.Append(OneIndent, indentLevel); output.AppendLine("UnityEngine.Assertions.Assert.IsTrue("); indentLevel++; output.Append(OneIndent, indentLevel); output.Append("count >= 0"); output.AppendLine(","); output.Append(OneIndent, indentLevel); output.AppendLine("\"Count must be positive: \" + count);"); indentLevel--; break; case ErrorHandlingStrategy.Exceptions: output.Append(OneIndent, indentLevel); output.AppendLine("if (count < 0)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("throw new System.ArgumentOutOfRangeException("); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("\"count\","); output.Append(OneIndent, indentLevel); output.AppendLine("\"Count must be positive: \" + count);"); indentLevel--; indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); break; } output.Append(OneIndent, indentLevel); output.AppendLine("RequireIndexInBounds(index + count - 1);"); output.Append(OneIndent, indentLevel); output.AppendLine("int indexAfter = index + count;"); output.Append(OneIndent, indentLevel); output.AppendLine("int indexEndCopy = indexAfter + count;"); output.Append(OneIndent, indentLevel); output.AppendLine("if (indexEndCopy >= m_Length)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("indexEndCopy = m_Length;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine("int numCopies = indexEndCopy - indexAfter;"); output.Append(OneIndent, indentLevel); output.AppendLine("for (int i = 0; i < numCopies; ++i)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("SetElement(index + i, GetElement(index + count + i));"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine("for (int i = indexAfter; i < m_Length - 1; ++i)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("SetElement(i, GetElement(i + 1));"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Length -= count;"); output.Append(OneIndent, indentLevel); output.AppendLine("m_Version++;"); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // RequireNotFull method output.Append(OneIndent, indentLevel); output.AppendLine(); if (enableBurst) { output.Append(OneIndent, indentLevel); output.AppendLine("[Unity.Burst.BurstDiscard]"); } output.Append(OneIndent, indentLevel); output.AppendLine("public void RequireNotFull()"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; switch (boundsCheckStrategy) { case ErrorHandlingStrategy.UnityAssertions: output.Append(OneIndent, indentLevel); output.Append( "UnityEngine.Assertions.Assert.IsTrue(m_Length != "); output.Append(capacity); output.AppendLine(", \"Buffer overflow\");"); break; case ErrorHandlingStrategy.Exceptions: output.Append(OneIndent, indentLevel); output.Append("if (m_Length == "); output.Append(capacity); output.AppendLine(")"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine( "throw new System.InvalidOperationException("); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("\"Buffer overflow\");"); indentLevel--; indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); break; } indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); } // RequireIndexInBounds method output.Append(OneIndent, indentLevel); output.AppendLine(); if (enableBurst) { output.Append(OneIndent, indentLevel); output.AppendLine("[Unity.Burst.BurstDiscard]"); } output.Append(OneIndent, indentLevel); output.AppendLine("public void RequireIndexInBounds(int index)"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; AppendBoundsCheck( output, "index", indentLevel, isFixedLength, capacity, boundsCheckStrategy); indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // End of struct indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); // End of namespace output.AppendLine("}"); return output.ToString(); } private static void AppendBoundsCheck( StringBuilder output, string indexName, int indentLevel, bool isFixedLength, int capacity, ErrorHandlingStrategy boundsCheckStrategy) { switch (boundsCheckStrategy) { case ErrorHandlingStrategy.UnityAssertions: output.Append(OneIndent, indentLevel); output.AppendLine("UnityEngine.Assertions.Assert.IsTrue("); indentLevel++; output.Append(OneIndent, indentLevel); output.Append(indexName); output.Append(" >= 0 && "); output.Append(indexName); output.Append(" < "); if (isFixedLength) { output.Append(capacity); } else { output.Append("m_Length"); } output.AppendLine(","); output.Append(OneIndent, indentLevel); output.Append("\"Index out of bounds: \" + "); output.Append(indexName); output.AppendLine(");"); indentLevel--; break; case ErrorHandlingStrategy.Exceptions: output.Append(OneIndent, indentLevel); output.Append("if ("); output.Append(indexName); output.Append(" < 0 || "); output.Append(indexName); output.Append(" >= "); if (isFixedLength) { output.Append(capacity); } else { output.Append("m_Length"); } output.AppendLine(")"); output.Append(OneIndent, indentLevel); output.AppendLine("{"); indentLevel++; output.Append(OneIndent, indentLevel); output.AppendLine("throw new System.InvalidOperationException("); indentLevel++; output.Append(OneIndent, indentLevel); output.Append("\"Index out of bounds: \" + "); output.Append(indexName); output.AppendLine(");"); indentLevel--; indentLevel--; output.Append(OneIndent, indentLevel); output.AppendLine("}"); break; } } }
Usage
To use the code generator, follow these steps:
- Build the above source code into a Unity or non-Unity project
- Call
SmallBufferGenerator.Generate
- Save the C# output to a C# file in a Unity or non-Unity project
- Build the project and use the generated type
Examples
Now let’s look at some examples of using a generated SmallBuffer
type. First, here’s a fixed-size array:
int GetWinningTeamIndex(Player[] players) { // Assume there are four teams // This array has four int elements SmallBufferInt4 pointTotals = default; // Total the points for each team foreach (Player player in players) { pointTotals[player.TeamIndex] += player.NumPoints; } // Find the highest point total int maxPoints = pointTotals[0]; int winningTeamIndex = 0; for (int i = 1; i < pointTotals.Length; ++i) { int points = pointTotals[i]; if (points > maxPoints) { maxPoints = points; winningTeamIndex = i; } } return winningTeamIndex; }
Consider how this would have looked without SmallBuffer
:
int GetWinningTeamIndex(Player[] players) { // Assume there are four teams int pointTotals0 = 0; int pointTotals1 = 0; int pointTotals2 = 0; int pointTotals3 = 0; // Total the points for each team foreach (Player player in players) { switch (player.TeamIndex) { case 0: pointTotals0 += player.NumPoints; break; case 1: pointTotals1 += player.NumPoints; break; case 2: pointTotals2 += player.NumPoints; break; case 3: pointTotals3 += player.NumPoints; break; } } // Find the highest point total int maxPoints = pointTotals0; int winningTeamIndex = 0; if (pointTotals1 > maxPoints) { maxPoints = pointTotals1; winningTeamIndex = 1; } if (pointTotals2 > maxPoints) { maxPoints = pointTotals2; winningTeamIndex = 2; } if (pointTotals3 > maxPoints) { maxPoints = pointTotals3; winningTeamIndex = 3; } return winningTeamIndex; }
This version includes duplication in the local variables, the foreach
loop’s switch
, and the calculation at the end. It’s also slower because there are more branches than with a SmallBuffer
that can use indexing.
Now let’s look at an example of a dynamically-resizing SmallBuffer
:
// Get the locations and average location of the players on a team Vector3 GetPlayerLocations( Player[] players, int teamIndex, ref SmallBufferDynamic20Vector3 locations) // up to 20 Vector3 elements { // Get the locations foreach (Player player in players) { if (player.TeamIndex == teamIndex) { locations.Add(player.Location); } } // Find the average location Vector3 average = new Vector3(0, 0, 0); foreach (ref Vector3 location in locations) { average += location; } return average / locations.Count; }
In this function, we take a SmallBuffer
containing 20 Vector3
elements as an ref
parameter so it’s passed by reference. We simply loop over the players calling Add
on the SmallBuffer
as we find members of the team. This assumes there are up to 20 players on a team and we’ll get an assertion or exception if we’re wrong. Then we use a foreach
loop with a ref
variable to enumerate the found locations and sum them up.
The calling code might look like this:
SmallBufferDynamic20Vector3 locations = default; Vector3 averageLocation = GetPlayerLocations(m_Players, m_Team, ref locations); foreach (ref Vector3 location in locations) { // ... use 'location' }
Now let’s look at how we’d do this without SmallBuffer
:
// Get the locations and average location of the players on a team Vector3 GetPlayerLocations( Player[] players, int teamIndex, NativeList<Vector3> locations) { // Get the locations foreach (Player player in players) { if (player.TeamIndex == teamIndex) { locations.Add(player.Location); } } // Find the average location Vector3 average = new Vector3(0, 0, 0); foreach (ref Vector3 location in locations) { average += location; } return average / locations.Count; } NativeList<Vector3> locations = new NativeList<Vector3>(20, Allocator.Temp); Vector3 averageLocation = GetPlayerLocations(m_Players, m_Team, locations); foreach (ref Vector3 location in locations) { // ... use 'location' } locations.Dispose();
The code is similar, but with two key differences. First, NativeList<Vector3>
is allocating its memory on the heap rather than the stack. Second, we must remember to call locations.Dispose()
after we’re done with it. This presents a resource-management issue where we might accidentally use it after calling Dispose
or forget to call Dispose
altogether. No such management is necessary with a SmallBuffer
.
Conclusion
The SmallBuffer
code generator and the struct
types it produces provide a new tool in our toolbox. It’s useful when we need a small-sized array and want to avoid heap allocation, unsafe code, exceptions, and the GC. By no means does it replace other collection types such as managed arrays and NativeArray<T>
, but it may come in handy in your projects.
#1 by Yilmaz Kiymaz on February 12th, 2019 ·
I think there’s a small mistake in one of the code blocks:
“case 1: pointTotals0 += player.NumPoints; break;”
should be
“case 1: pointTotals1 += player.NumPoints; break;”
And same for the other switch cases in that block.
#2 by jackson on February 12th, 2019 ·
Thanks for pointing this out! I’ve updated the article with the fix.
#3 by confused on February 14th, 2019 ·
I’m impressed. And bewildered.
Would this be a good way to shift particles during a teleportation?
Apparently I can get and set the particles.
https://docs.unity3d.com/ScriptReference/ParticleSystem.GetParticles.html
But teleportation I’d like to be SMOOTH, seamless, even with ~700 particles, and happen in well less than a 60th of a second on iOS devices.
Is something like this approach going to be good for that?
#4 by jackson on February 15th, 2019 ·
GetParticles
andSetParticles
take managed arrays—Particle[]
—so you’ll have to use one of those. You can, however, disable the null checks and array bounds checks on those managed arrays for improved performance.#5 by confused on March 5th, 2019 ·
sorry for slow response. Got bogged in bugs in other aspects of what I’m doing in Unity. Returning to this problem now… THANK YOU!
Will try to figure out how to disable null checks and array bounds. And see how it goes. I’m a VFX and animations type of guy, in UIs. Your blogs have been (mostly) a million miles above my understanding of anything. Some are merely 100,000 miles above. They’re all amazingly thorough and full of what must be hard earned wisdom.
#6 by MechEthan on February 21st, 2019 ·
What you probably really want is Unity’s “C# Job System” support for the particle system…
Experimental support for that is coming in 2019.1 it seems: https://unity3d.com/unity/roadmap
#7 by confused on March 5th, 2019 ·
Thanks MechEthan,
I have been busily avoiding looking at Jobs. It’s so… what’s the word, byzantine?
I dunno. I like the concept. However, the execution, setup and everything about how I, a mere user, use it… just too much effort is required. So far.
I was hoping ECS and Jobs would be about making an elegant interface to DOD. It seems more like the creation of the most bewildering interface to programming’s simplest and most elegant idea. A veritable mecca of busy work.
#8 by Chris Ochs on March 23rd, 2019 ·
Very useful in certain situations and a better implementation then what many of us throw together on the fly at times. I just replaced some of our less desirable implementations with this that we use in Unity jobs.
#9 by pk on September 11th, 2021 ·
Thanks a ton for sharing this.
Would you please clarify what you mean by
” This is safe in C# 7 because the Enumerator type is a ref struct. This allows us to store pointer fields in it without worrying that its lifetime will extend beyond the lifetime of the SmallBuffer it points into.”
I can see that it the enumerator pointer lifetime will not extend beyond the lifetime of the SmallBuffer, but what about the actual memory inside the SmallBuffer? I’m not sure how “fixed” works, but it only looks like the memory pointer inside the enumerator will be fixed during its creation.
So I don’t see why it couldn’t theoretically happen that during the foreach loop, the memory of the SmallBuffer could move (if allocated on heap).
But I guess maybe the fixed pointer exists as long as necessary. I’m not sure how it works – I assumed it only lasts while inside the {} block following fixed.
#10 by jackson on September 11th, 2021 ·
SmallBuffer
is just astruct
with someint
fields, so it works like other structs such asVector3
. If you declare one as a local variable or function parameter, it’ll be allocated on the stack. If you create an enumerator, such as with aforeach
loop, then you have a pointer to theSmallBuffer
which is on the stack. Thefixed
is required in C# to tell the memory manager that it’s not allowed to move the memory being pointed at until thefixed
block is done. This means we can safely access theSmallBuffer
memory within thefixed
block.If you allocate a
SmallBuffer
on the heap, such as by creating aNativeArray<SmallBuffer>
, then its lifetime is dictated by theNativeArray
’s allocation strategy. No matter which one you choose, you won’t be able to get aref struct
like the enumerator to outlive theSmallBuffer
since all those allocation strategies have lifetimes longer than the stack lifetime. The key here is that thefixed
pointer does not escape (e.g. by return value) theref struct
so there’s no possibility that it’s lifetime will extend beyond theSmallBuffer
it points to.#11 by Ludger Weidner on March 11th, 2022 ·
Hey Mate! You saved me ! What a nice tool you have build up :)
I have an urgend question: is it possible that I somehow, can use your type, in a way, that it will allocate X items on the stack, by a constructor parameter for instance, I would like to use ur type sort of dynamically, when the user(developer) says, “new MyType(sizeOfStackArray: 64)”?
If that is somehow also included and was obvious, sry for the dumb question then, but this would be sooo good!
I would love to hear from u!
#12 by Ludger Weidner on March 11th, 2022 ·
Hey, me again:
what I concretly want is this:
and then use it outside:
Idk, would this be possible?
#13 by jackson on March 12th, 2022 ·
It’s important to remember that using
SmallBuffer
requires you to run a code generator which outputs C#. Then you can write your code that uses the generated C# and compile again. It’s not possible to run the code generator and use the generated source all in one compilation step, like in your second example.C# generics are also not powerful enough to implement your first example where you specify the length as an argument. This is why we have to resort to a code generator that we control the capabilities of. In C++, we’d simply use non-type template parameters like the Standard Library does with std::array so we can write code like
std::array<int, 64> buf;
.