Getting the Size of a Struct at Compile Time
I continue to learn a lot by reading the C++ code that IL2CPP outputs. Like reading decompiled code, it gives some insight into what what Unity’s build process is doing with the C# we give it. This week I learned that sizeof(MyStruct)
isn’t a compile-time constant like it is in C++. Because of that, IL2CPP generates some less-than-ideal C++ code every time you use it. Today’s article shows the process I went through to work around that issue and ends up with some code you can drop into your project to avoid the problem.
Being able to take sizeof(MyStruct)
is fundamental to using them with unmanaged memory. It’s the reason we jump through hoops so we can do things like allocate an array:
static Vector3* AllocOneThousandVectors() { return (Vector3*)Marshal.AllocHGlobal(sizeof(Vector3) * 1000); }
Since Vector3
contains just three float
fields, it’d be reasonable to assume that its size is known at compile time. After all, sizeof(float)
is a compile-time constant. We know this because we can use the const
keyword with it:
const int sizeofFloat = sizeof(float);
But if we try to use the const
keyword with Vector3
we get a compiler error:
const int sizeofVector3 = sizeof(Vector3);
error CS0133: The expression being assigned to `sizeofVector3' must be constant
This is, presumably, so the scripting environment that runs the C# code can keep its options open. It might choose to align the fields of Vector3
such that they’re not tightly packed in memory.
So what does IL2CPP output instead of just replacing sizeof(Vector3)
with 12
? Just do an iOS build and search for “AllocOneThousandVectors” and you’ll see this: (annotated with comments and formatting by me)
extern "C" Vector3_t2243707580 * TestScript_AllocOneThousandVectors_m2221219875 (Il2CppObject * __this /* static, unused */, const MethodInfo* method) { // Static bool that's checked every call // C++11 requires this to be thread-safe static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { // il2cpp_codegen_initialize_method looks like this: // il2cpp::vm::MetadataCache::InitializeMethodMetadata(index); // There's no source for InitializeMethodMetadata il2cpp_codegen_initialize_method (TestScript_AllocOneThousandVectors_m2221219875_MetadataUsageId); s_Il2CppMethodInitialized = true; } { // See below for what this looks like... uint32_t L_0 = il2cpp_codegen_sizeof(Vector3_t2243707580_il2cpp_TypeInfo_var); // See below for what this looks like... IL2CPP_RUNTIME_CLASS_INIT(Marshal_t785896760_il2cpp_TypeInfo_var); // Actual work IntPtr_t L_1 = Marshal_AllocHGlobal_m4258042074(NULL /*static, unused*/, ((int32_t)((int32_t)L_0*(int32_t)((int32_t)1000))), /*hidden argument*/NULL); void* L_2 = IntPtr_op_Explicit_m1073656736(NULL /*static, unused*/, L_1, /*hidden argument*/NULL); return (Vector3_t2243707580 *)(L_2); } } inline uint32_t il2cpp_codegen_sizeof(Il2CppClass* klass) { // It's a struct, which is a value type. // This still gets checked every time anyhow if (!klass->valuetype) { return sizeof(void*); } // There's no source for GetInstanceSize return il2cpp::vm::Class::GetInstanceSize(klass) - sizeof(Il2CppObject); } #define IL2CPP_RUNTIME_CLASS_INIT(klass) \ // This do-while trick is (sadly) normal in C/C++ macros. // Don't worry- it doesn't really generate a loop. do { \ // Another "if" to check some flags about whether the class // has a static constructor and it's done if((klass)->has_cctor && !(klass)->cctor_finished) \ // There's no source for ClassInit il2cpp::vm::Runtime::ClassInit ((klass)); \ } while (0)
So that’s quite a lot of overhead in place of what should have been just a 12
! There’s a static variable, several if
statements, and several function calls. This is fine when doing some one-time work like allocating a bunch of vectors, but if you’re using sizeof
for routine work then the overhead may well exceed the actual work you’re trying to do. Keep in mind that sizeof
is implicitly used when indexing into an unmanaged array of structs like the Vector3*
array here.
We know that our game is going to run on either an x86 or an ARM processor and the x
, y
, and z
fields will be sequential. Each float
field is four bytes, so the size of the vector is 12 bytes.
This leads to the workaround- just use a constant:
const int sizeofVector3 = 12;
Unfortunately, it’s not always this easy. For one, there are some structs that we can’t know the size of at compile time. There are two reasons for this.
The first reason is that the struct contains a non-value type, such as a string
. The struct should be converted to use an object handle instead, but until that time we simply can’t know its size at all. If we try, we’ll get an error:
error CS0208: Cannot take the address of, get the size of, or declare a pointer to a managed type `SomeStruct'
The second reason is that the struct contains a pointer. Pointers have different sizes depending on whether the CPU is 32-bit or 64-bit. Many games need to support both, especially on mobile. Unfortunately, our C# code compiles to a DLL that’s used on both 32-bit and 64-bit processors. There’s simply no constant that works for both. In this case we’re forced to use sizeof
and take on the overhead.
This workaround is fine for something that never changes like Vector3
, but our own structs change their fields pretty frequently. Manually recomputing the size, taking into account some complex alignment rules, is both error-prone and time-consuming. So why not leverage a bit of code generation to make these constants for us?
To demonstrate, I’ve made a simple editor script that generates a file called TypeSizes.cs
. It contains a static class with static fields indicating the sizes of all the structs in the game that don’t have any object
fields. When there are no pointer fields, it uses const
. When there are pointer fields, it uses static readonly
and sizeof
. Here’s what an example generated file looks like:
//////////////////////////////////////// // Autogenerated code! Do not modify! // //////////////////////////////////////// unsafe public static class TypeSizes { public const uint MyVector3 = 12; public static readonly uint Player = (uint)sizeof(Player); #if UNITY_EDITOR static TypeSizes() { if ( Entity != sizeof(Entity) || EntityEntry != sizeof(EntityEntry) || PositionComponent != sizeof(PositionComponent) || VelocityComponent != sizeof(VelocityComponent)) { UnityEngine.Debug.LogError("TypeSizes is stale. Re-run code generator."); UnityEditor.EditorApplication.isPlaying = false; } } #endif }
In addition to the actual sizes, a static constructor is included. When run in the editor, it checks to make sure that all of the const
sizes are correct. So if you change the fields of a struct, you’ll get an error message to warn you to re-run the code generator. You’ll also get booted out of play mode because it’s not safe to continue.
The editor script that generates this is quite small: about 150 lines including comments.
using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Runtime.InteropServices; using UnityEditor; using UnityEngine; /// <summary> /// Editor script to generate a C# file containing a static class with the sizes of the value types /// in the runtime project. /// </summary> /// <author>JacksonDunstan.com/articles/3921</author> /// <license>MIT</license> public class GenerateTypeSizes { [MenuItem("Code Generators/Type Sizes")] public static void Generate() { // Find the assembly with the runtime code Assembly runtimeAssembly = null; foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { if (assembly.FullName.StartsWith("Assembly-CSharp,")) { runtimeAssembly = assembly; break; } } if (runtimeAssembly == null) { Debug.LogError("Couldn't find runtime assembly. No generating code."); return; } // Overwrite the output file const string outputClassName = "TypeSizes"; string path = Path.Combine(Application.dataPath, outputClassName) + ".cs"; using (FileStream stream = File.OpenWrite(path)) { // Clear the existing file stream.SetLength(0); stream.Flush(); // Write the sizes // Also make a list of constant types List<Type> constTypes = new List<Type>(1024); StreamWriter writer = new StreamWriter(stream); writer.Write("////////////////////////////////////////\n"); writer.Write("// Autogenerated code! Do not modify! //\n"); writer.Write("////////////////////////////////////////\n"); writer.Write('\n'); writer.Write("unsafe public static class "); writer.Write(outputClassName); writer.Write('\n'); writer.Write("{\n"); foreach (Type type in runtimeAssembly.GetTypes()) { // Only output value types // Skip the output type itself // Skip enums if (type.IsValueType && !type.IsEnum && type.Name != outputClassName) { // Make sure it doesn't have any non-value field types except pointers bool hasPointers = false; foreach (FieldInfo field in type.GetFields()) { bool isPointer = field.FieldType.IsPointer; if (isPointer) { hasPointers = true; } if (!field.FieldType.IsValueType && !isPointer) { Debug.LogWarningFormat( "{0}.{1} is a {2}, which is not a value type. Not outputting size.", type.Name, field.Name, field.FieldType); goto continueOuterLoop; } } writer.Write("\tpublic "); // The size of a pointer differs by CPU architecture (e.g. 32-bit vs. 64-bit) // We're forced to use sizeof(MyStruct) to determine it at runtime if (hasPointers) { writer.Write("static readonly uint "); writer.Write(type.Name); writer.Write(" = (uint)sizeof("); writer.Write(type.Name); writer.Write(')'); } // The size of structs with no pointers and only value types can be // determined by Marshal.SizeOf. else { writer.Write("const uint "); writer.Write(type.Name); writer.Write(" = "); writer.Write(Marshal.SizeOf(type)); constTypes.Add(type); } writer.Write(";\n"); } continueOuterLoop:; } // Output static constructor to check that constants are valid when run in editor writer.Write("\t\n"); writer.Write("#if UNITY_EDITOR\n"); writer.Write("\tstatic "); writer.Write(outputClassName); writer.Write("()\n"); writer.Write("\t{\n"); writer.Write("\t\tif (\n"); for (int i = 0, len = constTypes.Count; i < len; ++i) { Type type = constTypes[i]; writer.Write("\t\t\t"); writer.Write(type.Name); writer.Write(" != sizeof("); writer.Write(type.Name); writer.Write(")"); if (i != len - 1) { writer.Write(" ||\n"); } else { writer.Write(")\n"); } } writer.Write("\t\t{\n"); writer.Write( "\t\t\tUnityEngine.Debug.LogError(\"TypeSizes is stale. Re-run code generator.\");\n"); writer.Write("\t\t\tUnityEditor.EditorApplication.isPlaying = false;\n"); writer.Write("\t\t}\n"); writer.Write("\t}\n"); writer.Write("#endif\n"); // End the file writer.Write("}\n"); writer.Flush(); } // Done AssetDatabase.Refresh(); Debug.LogFormat("Successfully generated {0}.cs", outputClassName); } }
Just drop this into an Editor
folder in your project and click the Code Generators -> Type Sizes
menu item. It runs extremely quickly, so it’s really easy to regenerate the type sizes.
I hope you find this workaround and this application of code generation useful. It can certainly help to improve performance by cutting out some of the IL2CPP overhead.