How C# Language Features Affect Gas Cost In Stratis Smart Contracts

If you've ever browsed the Stratis smart contract samples you've come across lots of C# code like,

Assert(seats.Length <= MAX_SEATS, $"Cannot handle more than {MAX_SEATS} seats");

This statement uses a C# feature called string interpolation which allows you to insert the value of C# expressions (in this case the constant MAX_SEATS) directly into a double-quoted string literal. String interpolation was introduced in C# version 6 along with several major features like expression-bodied functions (the => syntax), auto-property initializers, the nameof operator, and the null conditional operator ??. Before the introduction of string interpolation in C#, you probably would have said something like this,

Assert(seats.Length <= MAX_SEATS, string.Format("Cannot handle more than {0} seats", MAX_SEATS);

which uses formattable strings to concatenate C# expressions and string literals. Another common usage would be simple concatenation:

Assert(seats.Length <= MAX_SEATS, "Cannot handle more than " + MAX_SEATS + " seats", MAX_SEATS);

There are lots of reasons to prefer the newer syntax over the older ones. The newer syntax is shorter and easier to read. More importantly, the interpolated string expression's syntax is checked at compile time instead of runtime like formattable strings. How many times have you caused a runtime exception in your program by making a typo and passing an invalid formattable string to string.Format? Formattable strings can also become a source of security vulnerabilities if at runtime the user has some control over the string that is being passed back and evaluated by string.Format for building a SQL query or looking up values in a dictionary. 

But what if in a C# smart contract one syntax actually consumes less gas than the other?

Using C# and the .NET CLR to develop and execute smart contracts is an interesting experiment and introduces several issues that may not be present in smart contract VMs like the EVM and languages like Solidity. C# is a complex multi-paradigm language that is constantly being worked on and improved and incorporating features from other languages. Many experienced devs would tell you they are stil learning about the language and are constantly being surprised when they encounter some new usage or feature they did not know about. In C# there are always multiple ways of doing things e.g. there are at least six ways to concatenate string literals and C# expressions like what we did above. 

  • The + operator
  • String interpolation
  • String.Concatenate() method
  • String.Join() method
  • String.Format() method
  • StringBuilder.Append() method 

It's hard to think of a language-level task in C# that doesn't have at least 2 distinct ways of doing it. Perhaps this is inevitable as a language grows even in smart contract languages which tend to emphasize simplicity and readability over complex language-level features. From their inception, blockchain programming languages have always been defined by the features they did not have as much as by the ones they did. Introducing a featureful language like C# for smart contracts raises the possibility that some language features result in a significantly higher gas cost than other features that are functionally the same. Some C# language features are just syntactic sugar over existing features e.g. if we disassemble the following program to native instructions using JitPad:

class Class {
    const int MAX_SEATS = 100;
    static void Assert(bool condition, string msg) {}
    static void Test1(object[] seats) {
        Assert(seats.Length <= MAX_SEATS, $ "Cannot handle more than {MAX_SEATS} seats");
    }
    static void Test2(object[] seats) {
        Assert(seats.Length <= MAX_SEATS, string.Format("Cannot handle more than {0) seats", MAX_SEATS));
    }
}

The JIT compiler generates identical code for both Test1 and Test2 functions even though one uses string interpolation and the other calls string.Format directly. 

; ================================================================================
; Class.Test2(System.Object[])
; 132 (0x84) bytes
; 34 (0x22) instructions
...
;     {
        nop
;       Assert(seats.Length <= MAX_SEATS, $"Cannot handle more than {MAX_SEATS} seats");
        mov     rcx,offset methodtable(System.Int32)
        call    CORINFO_HELP_NEWSFAST
        mov     [rbp-8],rax
        mov     rdx,[rbp+10h]
        cmp     dword ptr [rdx+8],64h
        setle   dl
        movzx   edx,dl
        mov     [rbp-0Ch],edx
        mov     rdx,[rbp-8]
        mov     dword ptr [rdx+8],64h
        mov     rdx,[rbp-8]
        mov     rcx,offset <diffable-addr>
        mov     rcx,[rcx]
        call    System.String.Format(System.String, System.Object)
        mov     [rbp-18h],rax
        mov     ecx,[rbp-0Ch]
        mov     rdx,[rbp-18h]
        call    Class.Assert(Boolean, System.String)
        nop
;     }

The Stratis platform has already defined what CIL code is acceptable in a smart contract. Determinism is the main tenet and anything that could execute differently on different nodes in a smart contract is not accepted. This already excludes a lot of C# features like LINQ. But within the boundaries of valid smart contract code there can be significant differences in gas costs depending on what language features are used.

We're going to be using the Silver tool to look at how C# features and syntax impact the gas costs of methods in a smart contract. Silver is a static analyzer and formal verifier for Stratis smart contracts and contains several tools for dissecting and analyzing C# smart contracts. We're going to be using the Silver disassembler to compare the CIL code generated by different C# syntax which functionally do the same thing. The smart contract executor calculates the gas costs each time the code in a smart contract is executed by the .NET CLR. The way gas is metered is:

  1. Split the code into basic blocks (called code segments in the Stratis code) separated by branch instructions
  2. Each instruction in a segment has a gas cost of 1.
  3. Each method call in a segment has a gas cost of 5
  4. At the start of each code segment insert code that calls the gas meter for the cost of all the instructions in the segment

Let's look at one of the Silver smart contract examples: the Ticketbooth example. We can compile the Ticketbooth contract assembly using Visual Studio or using Silver on the command line

silver compile examples\TicketBooth\Ticketbooth.Contract.csproj.

Once we have an assembly we can disassemble it using the dis command,

silver dis examples\TicketBooth\bin\Debug\netcoreapp3.1\Ticketbooth.Contract.dll

We can filter the disassembler output by the names of methods we are interested in. In this case we are only interested in methods that begin with Test

silver dis examples\TicketBooth\bin\Debug\netcoreapp3.1\Ticketbooth.Contract.dll -m Test*

The disassembler prints out the CIL code for each method that begins with Test. 

How C# language features affect gas cost in Stratis smart contracts

At the end of each method disassembly Silver prints out the number of instructions in the method and the total gas cost. The Test1 method uses string interpolation and is compiled to this code,

public void Test1(int count) {
    TicketContract.cs(246:5)-(246: 6): 
    IL_0000: Nop
    TicketContract.cs(248:9)-(248: 82): 
    IL_0001: Ldarg_0
    IL_0002: Ldarg_1[param] System.Int32 count
    IL_0003: Ldc_I4_S 65
    IL_0005: Cgt
    IL_0007: Ldc_I4_0
    IL_0008: Ceq
    IL_000a: Ldstr "Cannot handle more than {0} seats"
    IL_000f: Ldc_I4_S 65
    IL_0011: Box System.Int32
    IL_0016: Call[method] System.String System.String.Format(System.String, System.Object)
    IL_001b: Call[method] System.Void Stratis.SmartContracts.SmartContract.Assert(System.Boolean, System.String)
    IL_0020: Ret
    Total instructions in method: 13
    Total gas cost: 21
}

String interpolated expressions get turned into calls to string.Format by the C# compiler. If we replace the interpolated expression with calls to string.Format

public void Test2(int count) {
    Assert(count <= MAX_SEATS, string.Format("Cannot handle more than {0} seats.", MAX_SEATS));
}

The CIL code is identical. On the other hand using string concatenation like in Test3 is more expensive.

public void Test3(int count) {
    TicketContract.cs(258:5)-(258:6): 
    IL_0000: Nop
    TicketContract.cs(260:9)-(260:87): 
    IL_0001: Ldarg_0
    IL_0002: Ldarg_1[param] System.Int32 count
    IL_0003: Ldc_I4_S 65
    IL_0005: Cgt
    IL_0007: Ldc_I4_0
    IL_0008: Ceq
    IL_000a: Ldstr "Cannot handle more than "
    IL_000f: Ldc_I4_S 65
    IL_0011: Stloc_0
    IL_0012: Ldloca_S
    IL_0014: Call[method] System.String System.Int32.ToString()
    IL_0019: Ldstr "seats."
    IL_001e: Call[method] System.String System.String.Concat(System.String, System.String, System.String)
    IL_0023: Call[method] System.Void Stratis.SmartContracts.SmartContract.Assert(System.Boolean, System.String)
    IL_0028: Ret
    Total instructions in method: 16
    Total gas cost: 28
}

More CIL instructions are used and another method call -- the ToString() method for coverting the integer parameter to a string-- is required. The number of instructions and consequently the total gas cost for the method has increased by 25%. The newer string interpolation syntax or its equivalent string.Format representation is cheaper than other ways of concatenating strings with other .NET expressions.

For object initializers, another more recent C# feature, the story is a bit different. If we consider the following 3 methods:

public void Test4(string name) {
    var v = new TestStruct {
        Name = name, Time = 1000
    };
}
public void Test5(string name) {
    var v = new TestStruct(name, 1000);
}
public void Test6(string name) {
    TestStruct v;
    v.Name = name;
    v.Time = 1000;
}

The disassembly of these methods is,

public void Test4(string name) {
    TicketContract.cs(264:5)-(264:6): 
    IL_0000: Nop
    TicketContract.cs(265: 9) - (265: 61): 
    IL_0001: Ldloca_S[var] TicketContract.TestStruct local_1
    IL_0003: Initobj TicketContract.TestStruct
    IL_0009: Ldloca_S[var] TicketContract.TestStruct local_1
    IL_000b: Ldarg_1[param] System.String name
    IL_000c: Stfld[field] System.String TicketContract.TestStruct.Name
    IL_0011: Ldloca_S[var] TicketContract.TestStruct local_1
    IL_0013: Ldc_I4 1000
    IL_0018: Conv_I8
    IL_0019: Stfld[field] System.UInt64 TicketContract.TestStruct.Time
    IL_001e: Ldloc_0[var] TicketContract.TestStruct local_1
    IL_001f: Stloc_1[var] TicketContract.TestStruct v
    IL_0020: Ret
    Total instructions in method: 13
    Total gas cost: 13
}
public void Test5(string name) {
    TicketContract.cs(269:5)-(269:6): 
    IL_0000: Nop
    TicketContract.cs(270:9)-(270:45): 
    IL_0001: Ldloca_S[var] TicketContract.TestStruct v
    IL_0003: Ldarg_1[param] System.String name
    IL_0004: Ldc_I4 1000
    IL_0009: Conv_I8
    IL_000a: Call[method] System.Void TicketContract.TestStruct..ctor(System.String, System.UInt64)
    IL_000f: Ret
    Total instructions in method: 7
    Total gas cost: 11
}
public void Test6(string name) {
    TicketContract.cs(276:5)-(276:6): 
    IL_0000: Nop
    TicketContract.cs(278:9)-(278:23): 
    IL_0001: Ldloca_S[var] TicketContract.TestStruct v
    IL_0003: Ldarg_1[param] System.String name
    IL_0004: Stfld[field] System.String TicketContract.TestStruct.Name
    TicketContract.cs(279:9)-(279:23): 
    IL_0009: Ldloca_S[var] TicketContract.TestStruct v
    IL_000b: Ldc_I4 1000
    IL_0010: Conv_I8
    IL_0011: Stfld[field] System.UInt64 TicketContract.TestStruct.Time
    IL_0016: Ret
    Total instructions in method: 9
    Total gas cost: 9
}

The first version uses object initializers, the second uses the new keyword with the struct constructor and the third declares a variable of the struct type and then populates each field manually. Surprisingly this last way is cheaper than the other two. The first way uses 13 instructions.The 2nd way uses less instructions but results in a method call to the struct's constructor which costs an additional 5 gas. The last way uses less instructions than using an object initializer and avoids a method call and winds up being cheaper than the first 2.

Counting the instructions in a method is just a ballpark estimate for much gas will be consumed because different methods will be called with different frequency. Some methods at the core of the contract are called frequently while some only rarely. We can get an idea of how frequently a method is called by creating a control-flow graph. In Silver you can generate the graph like this:

silver cfg examples\TicketBooth\bin\Debug\netcoreapp3.1\Ticketbooth.Contract.dll Ticketbooth.dgml

 This will generate a CFG in DGML format that can be viewed and manipulated inside Visual Code

How C# language features affect gas cost in Stratis smart contracts

All of the methods in a smart contract assembly are rewritten to use the gas meter, including those that aren't directly inside a public smart contract method. Methods that are called frequently will spend the most gas and it makes sense to analyze the gas costs of these methods to see if they can be reduced.

Different C# language features that are functionally equivalent can produce different CIL code in a smart contract with different gas costs. Analyzing the CIL code in a smart contract assembly can be a fruitful way of reducing gas costs especially in methods that are called frequently.


Similar Articles