Saturday, January 12, 2013

Benchmarking C# Struct and Object Sizes

Getting the codez:
In the previous post, I described a class you can use to do casual execution time benchmarking of C# code. That Bench class also contains a couple methods that can be used to obtain memory size information about instances of classes or structs.

These methods measure the total memory consumed by an object or struct, including all the objects created as part of its creation. The methods work by taking a parameter that is a Func<T>, which is expected to create and return an object or struct instance for measurement. The technique used to measure the memory consumption is pretty simple; it just asks the garbage collector what the total memory consumed is both before and after the object creation, and notes the difference between the two. This may not give accurate results in a busy system, but during development, it seems to work pretty reliably. It works in either 32 or 64 bits, the results of which are slightly different from each other (identical objects or structs will often consume a little bit more memory in 64 bit builds).

There are two methods for obtaining memory consumption data. ByteSize returns a long value of the memory consumed by the object created by the specified Func. ByteSizeDescription returns a string describing the total memory consumed, as well as further information about the breakdown of that memory.
ByteSize and ByteSizeDescription will work for both objects and value types (like structs). Value types are handled by boxing them as objects so they consume heap memory, and then backing out the memory associated with the boxing overhead. Note that just as it is when measuring objects, for structs containing references to objects, the size returned by Bench’s methods will include not only the size of the reference in the struct, but also the size of the object referred to, if those objects are created by the Func method passed in.

There is a little fuzziness about the value type sizes, because .Net likes things to align on 4 byte boundaries in 32 bit builds, and 8 byte boundaries in 64 bit builds. It's also not entirely straightforward how much of an object's size is overhead and how much is content. Technically, there are 8 bytes of overhead for objects in 32 bit builds. However, an object will never take less then 12 bytes of size. For an empty object, 4 bytes of that are unused. An object with a single int member variable will also occupy 12 bytes - 8 bytes of overhead, plus 4 bytes for the int. As an object has more member variables, the size will increase, but in jumps of 4 bytes at a time. For 64 bit builds, there are 16 bytes of overhead, and a minimum of 8 bytes of content, whether it's used or not. To read more detail about this topic, I recommend these blog posts and articles:
Of Memory And Strings
How much much memory does a C# string take up?
and for the real nitty gritty detail (although a bit old)
Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects

The ByteSize function will return the aligned size of the struct returned by the passed in Func method. So, for instance, a struct containing a single byte field in a 32 bit build will return a size of 4 bytes. The ByteSizeDescription function will give additional information. Besides the deep aligned size of the struct returned by the Func method, it reports the deep packed size, and also the aligned size and packed size of a default instance of the struct. The reason for displaying aligned and packed versions can be seen when you have an array of structs. Using the example of a struct with a single byte field, an array of 1 element will have a size of 16 bytes (12 bytes of object overhead of the array object, plus 4 bytes for the single struct element). However, an array of 2, 3, or 4 elements will have this same size of 16 bytes. An array of 5 elements jumps the memory to 20 bytes. So the actual memory consumed by a struct may depend on how the struct is being used.

The following snippet of code shows some examples of using the ByteSize functions.
struct S1 { byte b; }
struct S2 { int i; }
struct S3 { string s; public S3(string s) { this.s = s;} }

public class Program {
    public static void Main(string[] args) {
        var bench = new Bench();
        Console.WriteLine(bench.ByteSize(() => { return new S1(); }));
        Console.WriteLine(bench.ByteSize(() => { return new S2(); }));
        Console.WriteLine(bench.ByteSize(() => { return new string('a', 16); }));
        Console.WriteLine(bench.ByteSize(() => { return new S3(new string('a', 16)); }));
        Console.WriteLine(bench.ByteSize(() => { return new int[16]; }));
        Console.WriteLine(bench.ByteSize(() => { 
            var d = new Dictionary<int,int>(10000);
            for (int i = 0; i < 10000; i++) d.Add(i,i);;
            return d;
         }));

        Console.WriteLine();
        Console.WriteLine(bench.ByteSizeDescription(() => { return new S1(); }) + "\n");
        Console.WriteLine(bench.ByteSizeDescription(() => { return new S2(); }) + "\n");
        Console.WriteLine(bench.ByteSizeDescription(() => { return new string('a', 16); }) + "\n");
        Console.WriteLine(bench.ByteSizeDescription(() => { return new S3(new string('a', 16)); }) + "\n");
        Console.WriteLine(bench.ByteSizeDescription(() => { return new int[16]; }) + "\n");
        Console.WriteLine(bench.ByteSizeDescription(() => { 
            var d = new Dictionary<int,int>(10000);
            for (int i = 0; i < 10000; i++) d.Add(i,i);;
            return d;
         }));

        Console.Write("done");
        Console.ReadKey();
    }
}
This produces the following output in 32 bit builds,

4
4
4
48
52
76
202136

S1 (struct): deep aligned size= 4 bytes, deep packed size= 1 bytes
    aligned default size= 4 bytes, packed default size= 1 bytes

S2 (struct): deep aligned size= 4 bytes, deep packed size= 4 bytes
    aligned default size= 4 bytes, packed default size= 4 bytes

String: size= 48 bytes, objOverhead= 8 bytes, content= 40 bytes

S3 (struct): deep aligned size= 52 bytes, deep packed size= 52 bytes
    aligned default size= 4 bytes, packed default size= 4 bytes

Int32[]: size= 76 bytes, objOverhead= 8 bytes, content= 68 bytes
    count= 16, avg/item= 4 bytes

Dictionary`2: size= 202136 bytes, objOverhead= 8 bytes, content= 202128 bytes
    count= 10000, avg/item= 20 bytes

and the following in 64 bit builds.

8
8
8
64
72
88
202192

S1 (struct): deep aligned size= 8 bytes, deep packed size= 1 bytes
    aligned default size= 8 bytes, packed default size= 1 bytes

S2 (struct): deep aligned size= 8 bytes, deep packed size= 4 bytes
    aligned default size= 8 bytes, packed default size= 4 bytes

String: size= 64 bytes, objOverhead= 16 bytes, content= 48 bytes

S3 (struct): deep aligned size= 72 bytes, deep packed size= 72 bytes
    aligned default size= 8 bytes, packed default size= 8 bytes

Int32[]: size= 88 bytes, objOverhead= 16 bytes, content= 72 bytes
    count= 16, avg/item= 4 bytes

Dictionary`2: size= 202192 bytes, objOverhead= 16 bytes, content= 202176 bytes
    count= 10000, avg/item= 20 bytes

The source code for the two ByteSize methods is included below. It’s also in the the full Bench class that can be downloaded from the link at the top of this post.
static private long overheadSize = ComputeOverheadSize();

/// <summary>
/// Determines the size of the object or struct created and returned by the
///    specified method. </summary>
/// <remarks>Should not be used in production! This is meant for use during
/// development, not as a general purpose sizeof function.</remarks>
/// <param name="maker">The method that creates and returns the object or 
/// struct whose size will be determined.</param>
/// <returns>The size in bytes of the object created by the method.</returns>
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
public long ByteSize<T>(Func<T> maker) {
    if (maker == null) throw new ArgumentNullException("maker");

    long size = long.MinValue;
    long prevSize;
    int count = 0;
    // because we're using an unreliable method of obtaining size, repeat until
    // we get a confirmed result to help eliminate reporting spurious results.
    const int maxAttempts = 10;
    long dummy;
    do {
        prevSize = size;
        count++;
        if (typeof(T).IsValueType) {
            long objSize = ByteSize(() => { return (object) 1L; });
            long boxedSize = ByteSize(() => { return (object)maker(); });
            // size is the boxed size less the overhead of boxing
            size = (boxedSize - objSize) + sizeof(long);
        } else {
            object obj = null;
            long startSize = GC.GetTotalMemory(true);
            obj = maker(); 
            long endSize = GC.GetTotalMemory(true);
            size = endSize - startSize;
            // ensure object stays alive through measurement
            dummy = obj.GetHashCode();
        }
    } while ((count < maxAttempts) && ((size != prevSize) || (size <= 0)));
    return size;
}

/// <summary>
/// Returns a string describing details about the size of the object or struct
/// created by the specified method.
/// </summary>
/// <remarks>Should not be used in production! This is meant for use during
/// development, not as a general purpose sizeof function.</remarks>
/// <param name="maker">The method that creates and returns the object or struct
/// whose size will be determined.</param>
/// <returns>String describing details about the size of an object.</returns>
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
public string ByteSizeDescription<T>(Func<T> maker) {
    if (maker == null) throw new ArgumentNullException("maker");
                
    // get size of target
    long byteSize = ByteSize(() => { return maker(); });
    string s = typeof(T).Name;
    if (typeof(T).IsValueType) {
        // special handling of value types (i.e. structs)
        long emptyArray = ByteSize(() => { return new T[0]; });
        long deepPacked = (ByteSize(() => {
            var x = new T[16];
            for (int i = 0; i < x.Length; i++) x[i] = maker();
            return x;
        }) - emptyArray) / 16;
        long alignedSize = ByteSize(() => { return new T[1]; }) - emptyArray;
        long packedSize = (ByteSize(() => { return new T[16]; }) - emptyArray) / 16;
        s += " (struct): deep aligned size= " + byteSize + " bytes, deep packed size= "
            + deepPacked + " bytes"
            + Environment.NewLine + "    aligned default size= " + alignedSize
            + " bytes, packed default size= " + packedSize + " bytes";
    } else {
        // handling of objects
        s += ": size= " + byteSize + " bytes"
            + ", objOverhead= " + overheadSize 
            + " bytes, content= " + (byteSize - overheadSize) + " bytes";
        if (typeof(System.Collections.ICollection).IsAssignableFrom(typeof(T))) {
            // special handling of classes implementing ICollection
            var coll = maker() as System.Collections.ICollection;
            int count = coll.Count;
            s += Environment.NewLine + "    count= " + count;
            if (count > 0) s += ", avg/item= " + ((byteSize - overheadSize) / count) + " bytes";
        }
    }
    return s;
}

static private long ComputeOverheadSize() {
    var bench = new Bench();
    long simpleSize = bench.ByteSize(() => { return new SimpleObject(); });
    return simpleSize - SimpleObject.InternalSize;
}


private class SimpleObject {
    public const int InternalSize = sizeof(int) * 2;
    int one;
    int two;
}

No comments:

Post a Comment