C# Stack Optimization

Example 1:

SomeStruct var;
for (int i=0; i<1000000; i++)
     var=something;

Example 2:

for (int i=0; i<1000000; i++)
    SomeStruct var=something;

My understanding is that in the first example var will be created on stack once. In the second example it will be created on stack million times, wasting a ton of cycles. Correct?

Dont worry about it until you’ve seen something wrong in the profiler. Other than that, look at the manual: Learn game development w/ Unity | Courses & tutorials in game design, VR, AR, & Real-time 3D | Unity Learn

I don’t believe this is correct. The only difference between the two is Example 1 var has the scope of the entire method, while in Example 2 var’s scope is only in that for loop. If you try to use var outside that for loop it will give a compiler error.

Odds are that the compiler will convert both of those to the same thing. Try it out and view the resulting IL. You’ll likely see that they are the same.

I have the same understanding as op. I however also follow the profiler approach, I.e. Only worry if there is a “problem”.

You could measure the time to see the effect + check in the profiler for gc.

Local variable storage and lifetime is an implementation detail. They may be put on the stack, or the heap for certain scopes such as lambda expressions, or they may exist entirely in a register. As others have said, profile and fix bottlenecks. Don’t spend time optimizing something that isn’t causing an issue, and your examples almost certainly have such a small difference in run time that it’s pointless to worry about it.

1 Like

Stack optimisation is normally not a thing you need to worry about. In both cases the variable will be written a million times, but it’s likely to be written to the same place. Both examples are probably identical in terms of performance.

Variables on the stack don’t get allocated or deallocated the same way as heap variables. So there is no real inherent cost of creating a new stack variable versus reusing an old one.

A for loop does not have a separate stack frame from the method in which it exists.

If you take similar code like this:

        private static void FooA()
        {
            StructA v;
            int result = 0;

            for(int i = 0; i < 1000000; i++)
            {
                v = new StructA()
                {
                    A = i,
                    B = i * 2
                };

                result += v.A + v.B;
            }

            Console.WriteLine(result);
        }

        private static void FooB()
        {
            int result = 0;

            for (int i = 0; i < 1000000; i++)
            {
                StructA v = new StructA()
                {
                    A = i,
                    B = i * 2
                };

                result += v.A + v.B;
            }

            Console.WriteLine(result);
        }

And look at it’s IL, you’ll see something like this for the first part of each method (note, I’m using Visual Studio 2015 for this, but as you’ll see, the version doesn’t particularly matter here because of the way the stack works on a fundamental level with mono/.net):

FooA

  .method private hidebysig static void 
    FooA() cil managed 
  {
    .maxstack 3
    .locals init (
      [0] valuetype Console01.StructA v,
      [1] int32 result,
      [2] int32 i,
      [3] valuetype Console01.StructA V_3,
      [4] bool V_4
    )

FooB

  .method private hidebysig static void 
    FooB() cil managed 
  {
    .maxstack 3
    .locals init (
      [0] int32 result,
      [1] int32 i,
      [2] valuetype Console01.StructA v,
      [3] valuetype Console01.StructA V_3,
      [4] bool V_4
    )

Note how the IL for both starts off relatively the same.

1. it defines the max stack size, this is the maximum number of pushes onto the stack that may exist at any time for the life of this stack frame (the methods life time). In this case because my code every really has 3 substintive variables that have coincidental usage (they have meaningful value at the same time)… that means we only need a stack frame of that size (3) to work with.

To help with what I mean, if I had say:

        private static int Bar(int i)
        {
            if(i < 0)
            {
                int j = i;
                j += 7;
                i = j + i;
            }
            else
            {
                float k = (float)i;
                k -= 6.9f;
                i = (int)(k * i);
            }

            return i;
        }

This has 3 variables: i,j,k; of varying types. BUT the IL reads a max stack size of 2:

  .method private hidebysig static int32 
    Bar(
      int32 i
    ) cil managed 
  {
    .maxstack 2
    .locals init (
      [0] bool V_0,
      [1] int32 j,
      [2] float32 k,
      [3] int32 V_3
    )

This is because only 2 variables are needed at any given moment. i is needed for the life, j is only needed if i is negative, and k is only needed if i is zero/positive (despite difference in type). So only 2 variables have any coincidence.

Moving on…

2. the IL calls ‘init local’.

This is declaring names of variables in this method. You may also notice a handful of variables that aren’t in my code… these are implicit variables that would have to exist due to the structure of my code. Such as the boolean used in the resolving the for loop.

As you can see though… THIS is when the variables are actually initialized. And it’s always this way. It’s the first thing done in the method… regardless of where the variable is used in the function.

If you have ever written any older languages, you may have tripped over languages that have this built into their syntax. Where all variables in a method had to be declared first and foremost… forcing the programmer to syntactically do this because the compiler was too simple to look through the method, determine what variables exist, rewind, so on so forth.

And of course… this goes for IL as well… IL is intermediate code. The JIT compiler is what ends up compiling this IL into machine code… and to speed things along, the IL puts this up front to speed of compilation by the JIT. BUT the C#->IL compiler can do this for us, foregoing the programmer having to do it.

The only big difference with the 2 is that ORDER of the variables in the init local… because they show up in our method in different orders.

…

If we continue on with the code, you’ll notice they’re roughly identical, the only differences really being the index of the variable in the IL because the order in ‘init local’ is different:

FooA

  .method private hidebysig static void 
    FooA() cil managed 
  {
    .maxstack 3
    .locals init (
      [0] valuetype Console01.StructA v,
      [1] int32 result,
      [2] int32 i,
      [3] valuetype Console01.StructA V_3,
      [4] bool V_4
    )

    // [21 9 - 21 10]
    IL_0000: nop          

    // [23 13 - 23 28]
    IL_0001: ldc.i4.0    
    IL_0002: stloc.1      // result

    // [25 17 - 25 26]
    IL_0003: ldc.i4.0    
    IL_0004: stloc.2      // i

    IL_0005: br.s         IL_0039
    // start of loop, entry point: IL_0039

      // [26 13 - 26 14]
      IL_0007: nop          

      // [27 17 - 31 19]
      IL_0008: ldloca.s     V_3
      IL_000a: initobj      Console01.StructA
      IL_0010: ldloca.s     V_3
      IL_0012: ldloc.2      // i
      IL_0013: stfld        int32 Console01.StructA::A
      IL_0018: ldloca.s     V_3
      IL_001a: ldloc.2      // i
      IL_001b: ldc.i4.2    
      IL_001c: mul          
      IL_001d: stfld        int32 Console01.StructA::B
      IL_0022: ldloc.3      // V_3
      IL_0023: stloc.0      // v

      // [33 17 - 33 37]
      IL_0024: ldloc.1      // result
      IL_0025: ldloc.0      // v
      IL_0026: ldfld        int32 Console01.StructA::A
      IL_002b: ldloc.0      // v
      IL_002c: ldfld        int32 Console01.StructA::B
      IL_0031: add          
      IL_0032: add          
      IL_0033: stloc.1      // result

      // [34 13 - 34 14]
      IL_0034: nop          

      // [25 41 - 25 44]
      IL_0035: ldloc.2      // i
      IL_0036: ldc.i4.1    
      IL_0037: add          
      IL_0038: stloc.2      // i

      // [25 28 - 25 39]
      IL_0039: ldloc.2      // i
      IL_003a: ldc.i4       1000000 // 0x000f4240
      IL_003f: clt          
      IL_0041: stloc.s      V_4

      IL_0043: ldloc.s      V_4
      IL_0045: brtrue.s     IL_0007
    // end of loop

    // [36 13 - 36 39]
    IL_0047: ldloc.1      // result
    IL_0048: call         void [mscorlib]System.Console::WriteLine(int32)
    IL_004d: nop          

    // [37 9 - 37 10]
    IL_004e: ret          

  } // end of method Program::FooA

FooB

  .method private hidebysig static void 
    FooB() cil managed 
  {
    .maxstack 3
    .locals init (
      [0] int32 result,
      [1] int32 i,
      [2] valuetype Console01.StructA v,
      [3] valuetype Console01.StructA V_3,
      [4] bool V_4
    )

    // [40 9 - 40 10]
    IL_0000: nop          

    // [41 13 - 41 28]
    IL_0001: ldc.i4.0    
    IL_0002: stloc.0      // result

    // [43 18 - 43 27]
    IL_0003: ldc.i4.0    
    IL_0004: stloc.1      // i

    IL_0005: br.s         IL_0039
    // start of loop, entry point: IL_0039

      // [44 13 - 44 14]
      IL_0007: nop          

      // [45 17 - 49 19]
      IL_0008: ldloca.s     V_3
      IL_000a: initobj      Console01.StructA
      IL_0010: ldloca.s     V_3
      IL_0012: ldloc.1      // i
      IL_0013: stfld        int32 Console01.StructA::A
      IL_0018: ldloca.s     V_3
      IL_001a: ldloc.1      // i
      IL_001b: ldc.i4.2    
      IL_001c: mul          
      IL_001d: stfld        int32 Console01.StructA::B
      IL_0022: ldloc.3      // V_3
      IL_0023: stloc.2      // v

      // [51 17 - 51 37]
      IL_0024: ldloc.0      // result
      IL_0025: ldloc.2      // v
      IL_0026: ldfld        int32 Console01.StructA::A
      IL_002b: ldloc.2      // v
      IL_002c: ldfld        int32 Console01.StructA::B
      IL_0031: add          
      IL_0032: add          
      IL_0033: stloc.0      // result

      // [52 13 - 52 14]
      IL_0034: nop          

      // [43 42 - 43 45]
      IL_0035: ldloc.1      // i
      IL_0036: ldc.i4.1    
      IL_0037: add          
      IL_0038: stloc.1      // i

      // [43 29 - 43 40]
      IL_0039: ldloc.1      // i
      IL_003a: ldc.i4       1000000 // 0x000f4240
      IL_003f: clt          
      IL_0041: stloc.s      V_4

      IL_0043: ldloc.s      V_4
      IL_0045: brtrue.s     IL_0007
    // end of loop

    // [54 13 - 54 39]
    IL_0047: ldloc.0      // result
    IL_0048: call         void [mscorlib]System.Console::WriteLine(int32)
    IL_004d: nop          

    // [55 9 - 55 10]
    IL_004e: ret          

  } // end of method Program::FooB

SO… no where you define the variable doesn’t matter so much with the initializing of the variable. It’s initialized up front no matter what.

BUT this comes with a different implication.

Lets take this code. This is a modified version of the ‘Bar’ earlier in this post. This time though I named the variable j in both scopes of the if statement. And typed them both ‘int’.

        private static int BarB(int i)
        {
            if (i < 0)
            {
                int j = i;
                j += 7;
                i = j + i;
            }
            else
            {
                int j = i;
                j -= 7;
                i = j * i;
            }

            return i;
        }

You’d think that the the compiler would consider this 1 variable really… but nope:

  .method private hidebysig static int32 
    BarB(
      int32 i
    ) cil managed 
  {
    .maxstack 2
    .locals init (
      [0] bool V_0,
      [1] int32 j,
      [2] int32 j,
      [3] int32 V_3
    )

Because really, IL doesn’t care about the ‘name’. It’s just an index. And because there is 2 variables in 2 different scopes, we end up with 2 distinct variables.

So… hopefully that clears up some things for ya.

6 Likes

I was about to write an “if you really care about the technicalities…” post but lordofduct just stole my spotlight. :wink:

1 Like

Probably. My understanding is that the specification doesn’t require that they’re allocated first. It most likely is always that way, but it wouldn’t have to be. My link above gives examples when using lambda expressions where the local variables may be allocated on the heap instead of the stack. And the jitter is another layer that could act differently on PC vs. Mac for example. And we shouldn’t care, and certainly shouldn’t rely on how .NET chooses to organize things under the covers.

This is the way lambdas work.

A lambda is a function object. It’s its own object.

Objects don’t allocate on stack, and instead allocate in their own memory on the heap. Just like the fields of a class (may they be structs) allocate in the heap memory allocated for the object instance of that class.

So it is for lambdas.

My post isn’t talking about how heap memory is used with regards to objects. My post is about how stack frames work in relation to pure methods.

case in point, take this simple example:

namespace Console01
{
    class Program
    {
        static void Main(string[] args)
        {
            int i = 5;

            Func<int> f = () => 5 * i;

            int j = f();

            Console.ReadLine();
        }
    
    }

}

It compiles into this IL:

// Type: Console01.Program
// Assembly: Console01, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
// MVID: 7A29CEB8-3047-4E05-940C-17F2B4D45C56
// Location: C:\Users\lordo\Documents\Visual Studio 2015\Projects\Console01\Console01\bin\Debug\Console01.exe
// Sequence point data from C:\Users\lordo\Documents\Visual Studio 2015\Projects\Console01\Console01\bin\Debug\Console01.pdb

.class private auto ansi beforefieldinit
  Console01.Program
    extends [mscorlib]System.Object
{

  .class nested private sealed auto ansi beforefieldinit
    '<>c__DisplayClass0_0'
      extends [mscorlib]System.Object
  {
    .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
      = (01 00 00 00 )

    .field public int32 i

    .method public hidebysig specialname rtspecialname instance void
      .ctor() cil managed
    {
      .maxstack 8

      IL_0000: ldarg.0      // this
      IL_0001: call         instance void [mscorlib]System.Object::.ctor()
      IL_0006: nop       
      IL_0007: ret       

    } // end of method '<>c__DisplayClass0_0'::.ctor

    .method assembly hidebysig instance int32
      '<Main>b__0'() cil managed
    {
      .maxstack 8

      // [15 33 - 15 38]
      IL_0000: ldc.i4.5 
      IL_0001: ldarg.0      // this
      IL_0002: ldfld        int32 Console01.Program/'<>c__DisplayClass0_0'::i
      IL_0007: mul       
      IL_0008: ret       

    } // end of method '<>c__DisplayClass0_0'::'<Main>b__0'
  } // end of class '<>c__DisplayClass0_0'

  .method private hidebysig static void
    Main(
      string[] args
    ) cil managed
  {
    .entrypoint
    .maxstack 2
    .locals init (
      [0] class Console01.Program/'<>c__DisplayClass0_0' 'CS$<>8__locals0',
      [1] class [mscorlib]System.Func`1<int32> f,
      [2] int32 j
    )

    IL_0000: newobj       instance void Console01.Program/'<>c__DisplayClass0_0'::.ctor()
    IL_0005: stloc.0      // 'CS$<>8__locals0'

    // [12 9 - 12 10]
    IL_0006: nop       

    // [13 13 - 13 23]
    IL_0007: ldloc.0      // 'CS$<>8__locals0'
    IL_0008: ldc.i4.5 
    IL_0009: stfld        int32 Console01.Program/'<>c__DisplayClass0_0'::i

    // [15 13 - 15 39]
    IL_000e: ldloc.0      // 'CS$<>8__locals0'
    IL_000f: ldftn        instance int32 Console01.Program/'<>c__DisplayClass0_0'::'<Main>b__0'()
    IL_0015: newobj       instance void class [mscorlib]System.Func`1<int32>::.ctor(object, native int)
    IL_001a: stloc.1      // f

    // [17 13 - 17 25]
    IL_001b: ldloc.1      // f
    IL_001c: callvirt     instance !0/*int32*/ class [mscorlib]System.Func`1<int32>::Invoke()
    IL_0021: stloc.2      // j

    // [19 13 - 19 32]
    IL_0022: call         string [mscorlib]System.Console::ReadLine()
    IL_0027: pop       

    // [20 9 - 20 10]
    IL_0028: ret       

  } // end of method Program::Main

  .method public hidebysig specialname rtspecialname instance void
    .ctor() cil managed
  {
    .maxstack 8

    IL_0000: ldarg.0      // this
    IL_0001: call         instance void [mscorlib]System.Object::.ctor()
    IL_0006: nop       
    IL_0007: ret       

  } // end of method Program::.ctor
} // end of class Console01.Program

Note the very first thing it does after decalring the ‘Console01’ class is to declare this weird ‘<>c__DisplayClass0_0’:

  .class nested private sealed auto ansi beforefieldinit
    '<>c__DisplayClass0_0'
      extends [mscorlib]System.Object
  {
    .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
      = (01 00 00 00 )

    .field public int32 i

    .method public hidebysig specialname rtspecialname instance void
      .ctor() cil managed
    {
      .maxstack 8

      IL_0000: ldarg.0      // this
      IL_0001: call         instance void [mscorlib]System.Object::.ctor()
      IL_0006: nop       
      IL_0007: ret       

    } // end of method '<>c__DisplayClass0_0'::.ctor

    .method assembly hidebysig instance int32
      '<Main>b__0'() cil managed
    {
      .maxstack 8

      // [15 33 - 15 38]
      IL_0000: ldc.i4.5 
      IL_0001: ldarg.0      // this
      IL_0002: ldfld        int32 Console01.Program/'<>c__DisplayClass0_0'::i
      IL_0007: mul       
      IL_0008: ret       

    } // end of method '<>c__DisplayClass0_0'::'<Main>b__0'
  } // end of class '<>c__DisplayClass0_0'

THIS is your lambda.

lambda’s are implied classes.

And yes, because ‘i’ was declared outside the lambda, but used in the lambda. It actually becomes a member of the class generated for the lambda.

But considering that OP was talking about for loops, and not lambdas, I didn’t bring this up.

Unless everyone here wants to read LordOfDuct’s 8,000 page manual on understanding C# and the underlying IL. I mean hell, I could… I’m known for my walls’o’text.

5 Likes

@lordofduct insightful and well explained. Like this 8000 pages would be fun :wink: