Challenging the C# StringBuilder Performance

String Concatenation Hello World

Some of the biggest performance problems in almost any .NET application boil down to string operations. They are both very common and by nature pretty expensive. In fact, looking at an average .NET Dump you’ll find that most of the memory is usually taken by strings (I heard about 70%).

As you probably know, strings are immutable. So whenever you concatenate strings, a new string object is allocated, populated with content, and eventually garbage collected. All of that is expensive and that’s why we (well, at least me) were taught that StringBuilder will always have better performance.

I tried to do some benchmarking to see if that’s really the case and was a little surprised by the results. Let’s see some benchmarks then.

NOTE: All the benchmarks are executed in Release (optimized) without a debugger attached. Each benchmark was executed 10 times and the displayed result is the always the average result. All measurements were done with the StopWatch class.

Benchmark 1: Single Expression Concatenation

Consider this code. And before reading forward, try guessing the result of this benchmark.

The result when executed 1,000,000 times is:

Execute A: 80.2267266666667ms (regular concatenation)
Execute B: 237.698413333333ms (StringBuilder)
Execute C: 260.183193333333ms (string.Format)
Execute D: 81.4275933333333ms (Interpolation)

Note that when changing the number of concatenations to about 15, StringBuilder becomes more efficient.

So several conclusions from this:

  1. StringBuilder doesn’t offer any advantages against single expression concatenations with a small number of strings.
  2. string.Format is the least performant from all available options. It’s pretty strange since as far as I know, interpolation is implemented with string.Format under the hood. So I tend to believe the compiler does optimizations specifically for interpolation with a small number of strings.
  3. When concatenating strings in a single expression, the compiler seems to do the same optimization as with string interpolation. This means there’s no advantage in using StringBuilder. Go with whatever is more readable.
  4. An interesting finding is that when the number of chars in the A,B,C, and D is small (1 char), the StringBuilder had almost the same performance as interpolation. I believe the reason is that in the scenario with 10 char strings we had to pay for its expansion in size.

Benchmark 2: Multi Expression Concatenation

Result are:

With 4 concatenations (executed 100,000 times):
Execute A: 8.84404666666667ms
Execute B: 6.10478666666667ms (StringBuilder)

With 1000 concatenations (executed 1,000 times):
Execute A: 313.65934ms
Execute B: 5.53542666666667ms (StringBuilder)

From executions A and B, we see the compiler doesn’t do its interpolation-like optimization, and new string objects are created. However, the result is still pretty similar to StringBuilder so we’re still losing on the allocation of the StringBuilder objects and method calls to .Append.

By the way, if the number of concatenations were 2 instead of 4, then StringBuilder would actually be less efficient than regular concatenation.

As expected, when the number of concatenation grows, the results change drastically. With 1000 operations, the StringBuilder is about 60 times faster than regular concatenation. So the usual paradigm that StringBuilder is always going to be more efficient with a large number of operations holds true.

Benchmark 3: Optimizing StringBuilder

When creating a StringBuilder, we’re getting the overhead of creating a new object. That object later needs to be garbage collected, which creates additional overhead. Consider the following benchmark:

The result when executing 1,000,000 times is:

Execute A: 33.1755533333333ms (concatenation)
Execute B: 48.07472ms (new StringBuilder())
Execute C: 33.6805466666667ms (reusing StringBuilder)

As you can see, just by reusing the same instance of the StringBuilder we improved the performance by almost 50%.

Admittedly, the performance difference is far less noticeable for many concatenations (or appends), so the use case should be very specific. In particular, it can be useful when you are using few appends and in a very high frequency. A classic case is for high-frequency logging.

Summary

Don’t know about you, but benchmarking is always fun. Here are my conclusions from this session:

  • Single-expression concatenations will have the best performance with regular concatenation or the string interpolation syntax.
  • For many concatenations, StringBuilder is still king.
  • The StringBuilder can be optimized by reusing the same instance and sb.Clear(). It’s most useful for small number of Appends.

A must disclaimer in any talk on performance is this:

Optimizing performance is not always necessary. In fact, mostly it’s negligible. In case of string manipulation, you’ll probably want to optimize only for algorithms and high-frequency operations. I’m talking in the ball park of millions of operations a second. Well, maybe less than that, but you get my meaning.

Share:

Enjoy the blog? I would love you to subscribe! Performance Optimizations in C#: 10 Best Practices (exclusive article)

Want to become an expert problem fixer? Check out a chapter from my book Practical Debugging for .NET Developers

28 thoughts on “Challenging the C# StringBuilder Performance”

  1. In the first benchmark, when you “Execute B: 237.698413333333ms (StringBuilder)”, you are most likely included “new StringBuilder(A)” inside a loop, so you got that big result. The main goal of use StringBuilder is to exclude new object allocation from loops or high-frequency operations. So, the second ExecuteB() is the same. You get another result if you exclude the creation of a new object from the loop.

    1. It appears you missed the point of the first test. In a real world application where one may be spinning up several concurrent instances of a class/call (say in a web API) you would not want to be using a single instance of string builder shared between them. As such, the perf difference makes sense as with that situation, for small concat operations, stringbuilder would prove to be slower than just regular concat or interpolation. The test is valid for the use case, even if it isn’t optimal (though we see the time for optimal use later in the benchmark)

      1. This is a good point to consider. However, let’s not lose sight that this test is a performance test against StringBuilder vs other concatenation approaches, without the caveat of the test attempting to mimic any particular implementation. Therefore, the issue of using some static or non static StringBuilder depending on the implementation I believe is not a valid point for this particular test.

        This benchmark is immediately fallible on the fact that the StringBuilder being challenged is an object that must be instantiated at some point or another in the application, and in this test it’s instantiation is included in the stopwatch timing in the ExecuteB() method..yet pitted against other alternate string concatenation methods that don’t require that overhead of object instantiation. For this reason I believe this benchmark is done incorrectly, but can be easily modified to be done correctly. By correctly, I mean a fair benchmark without unfair variables stacking odds in the favour of one or more methods over one or more other methods.In this case, the overhead of StringBuilder object instantiation is the unfair variable causing odds to be stacked in the favour of other methods.

        I suggest – and this even satisfied the aforementioned Web API implementation even though implementation features aren’t part of the benchmark – that the StringBuilder be a private member of the class, and in the constructor of the class, instantiate the StringBuilder with something like “this._sb = new StringBuilder()” or whatever you want to name it. Then, in the “ExecuteB()” method, simply call _sb.Append() as needed, without adding in the overhead of instantiating it while the stopwatch is running.

        On that note, I don’t understand why Benchmark 3, which is supposed to solve this issue I speak of, has a public static StringBuilder…but in the ExecuteB() method, A NEW STRINGBUILDER IS STILL INSTANTIATED AND THE STATIC CLASS STRINGBUILDER IS NOT USED! oof!

        Please do consider adjusting the code in this blog entry and rerunning your benchmarks. Aside from some slight oversights, I really do enjoy what this blog entry is about, and am interested in edited results, because many times I think to myself “hmmm…is it worth it to do a string builder here or should I ‘cheat’?”.

        Regards,

        Justin

        1. Hi Justin,

          I have to disagree. In Benchmarks 1 and 2 the “new StringBuilder()” should be part of the benchmark. The reason is that in code you will almost always create a new instance OR do concatenation. So you have to compare between these 2 scenarios.

          As for Benchmark 3 – the point is to compare between the same instance and a new instance each time. That’s why in ExecuteB I create a new instance

          BR Michael

          1. You’re right, benchmark 3 does show what the performance of StringBuilder is like against concatentaion without the overhead of StringBuilder instantiation, shown in ExecuteC. The results are interesting no? Even with a tiny amount of concatentation, plus the overhead of even the unavoidable “Clear()” call when reusing the same StringBuilder many times in the same class, it still keeps up with concatenation!

            I think the fact that with Benchmark3:ExecuteC() is “neck and neck” with the speed of concatenation, in conjunction with the findings in Benchmark2 pointing towards StringBuilder being vastly superior in scenarios with a great amount of concatenations, shows us that StringBuilder, if implemented correctly, is nearly always the way to go if performance is very important. It even as fast as regular concatenation when building a string consisting of merely 2 concatenations, WITH the overhead of clear! And with each additional concatenation the StringBuilder has to do, the performance of StringBuilder vs regular concatenation becomes even more pronounced.

            As an important side note, let’s not forget string immutability, and that StringBuilder already has the RAM usage reduction benefits over regular concatenation. Of course, RAM usage is not part of the test, but I think it’s important to point out that if StringBuilder can “keep up” with regular concatenation with only 2 calls to “Append()”, plus overhead of call to “Clear()”, there’s almost no reason not to choose StringBuilder! At least that’s my takeaway. Great blog post, and thank you for your clarification Michael, as I’d overlooked how ExecuteC in Benchmark3 did exactly what my last comment was asking for, I just totally overlooked that.

            Regards,

            Justin

          2. Actually, I retract any points about the overhead of “Clear()” vs regular concatenation (can’t edit the post)….the regular concatenation methods also have their own overhead of clearing in the form of ‘s = “”;’ It would be unfair to say StringBuilder has unfair time against it because of Clear(), when regular concatenation also has time against it in it’s own form of “clearing”, so yea, neither of ExecuteA or ExecuteC in Benchmark 3 has any unfair overhead. Not even StringBuilder needing to call “ToString()” because, well, that’s just part of using StringBuilder, even if regular concatenation doesn’t have to undergo that ToString() call. Overall, Benchmark3 for me really shows the most important truths

      1. Sure. In benchmark 1 string.Concat is 84ms, so as fast as regular concatenation.
        In benchmark 2, with a 1000 operations string.Concat is as fast as regular concatenation. So much slower than StringBuilder.

        So it seems it acts like regular concatenation for a small number of strings.

        In fact, the compiler transforms these concatenations to string.Concat calls
        https://sharplab.io/#v2:C4LglgNgNAJiDUAfAAgBgATIIwG4CwAUMgMyYBM6AwugN6HoOanIAs6AogB4CmAxgK7BuAQQAUASkJ0CjTFgwBndAF50AIjX4ZjJfFVqAhpvo70e9QCNj2hrv29rsu+piPCAXw9A

  2. Hi Michael,

    where is “Benchmark 3: Optimizing StirngBuilder” should be “Benchmark 3: Optimizing StringBuilder”.

    Why do you use the StopWatch instead of a library like BenchmarkDotNet, that is more accurate and gives you more info like GC Gen and so on?

  3. Even if you’re not re-using the StringBuilder, it can still be significantly more efficient if you initialise it with a capacity which is appropriate for your workload, avoiding some or all of the internal buffer resizing which might otherwise occur.

    Also, an interesting thought experiment is whether ExecuteA in your second and third benchmarks is being optimised by the compiler such that there are no actual concatenations. I haven’t tested this, but I suspect this is not really a fair test.

    1. Hi Nick,
      You’re right about the capacity. I actually did test it but didn’t mention the results in the article. So in the first benchmark, when given a capacity, the StringBuilder becomes faster up to 155ms. Which means regular concatenation is twice faster instead of 3 times faster.

      As for the experiment, it seems it does create new objects on each expression https://sharplab.io/#v2:C4LglgNgNAJiDUAfAAgBgATIIwG4CwAUMgMyYBM6AwugN6HoOanIAs6AogB4CmAxgK7BuAQQAUASkJ0CjTFgwBndAF50AIjX4ZjJfFVqAhpvo70e9QCNj2hrv29rsu+piPCAXw9A

  4. Nice article. Have you tried the benchmark by setting the size of the stringbuilder in the constructor? Especially in the looks where the size of the builder grows, that can be calculated in advance.

    1. Michael, good catch. I did try it but didn’t mention the results in the article. In benchmark 1, when given a capacity it speeds up the StringBuilder (Execute B) from 237ms to about 155ms. So it’s now 2 times slower than regular concatenation instead of 3 times.

  5. I would make you notice that there is a clear reason for the whole of the results you got by benchmarking.
    String interpolation does NOT turn itself to a string.Format under the hood. It is faster because it, instead, being a part of the syntax of C#, gets compiled. Indeed, reading the byte code (using a reflector), you’ll be able to see that string interpolation turned into a standard string concatenation (with + operators).
    However, in a benchmark you did, it results that standard string concatenation took ~80 ms, whereas string interpolation took ~81 ms. Well, that’s just an imprecision of the benchmark (remember that logic is reality, empirical tests are a study of the reality, and studies are not perfect – so, it is normal to have such an error in a benchmark akin yours).
    Also, why is StringBuilder slower than the standard string concatenation when testing with only two string?
    Well, here’s another reason why you should study how the runtime works a bit more: CLR is the virtual machine natively implemented in Windows in order to run CIL byte code, the language C# turns to during the compilation process. Whereas concatenating two strings involves the allocation of both those strings and of a new string whose length will be the sum of the two strings and whose content will be a pointer which is stored with the two pointers of the strings to be concatenated (~10 machine instructions which CLR will run), StringBuilder will involve the creation of one instance of itself, the invocation of the method Append(string) twice, and the invocation of the virtual method ToString() once, plus the internal stuff of the class StringBuilder, which will access the unmanaged memory to perform pointer operations in order to concatenate the string (~30 or more machine instructions). Indeed, creating instances of classes and invoking methods are relatively complex operation other than allocating a string into memory. Therefore, the more strings will be, the more the advantage of using StringBuilder for their concatenation will be consistent, starting from a situation of a huge disvantage for StringBuilder. I estimate that advantage can be seen starting from a count of about 4 or 5 strings.

    1. Thanks for this insight Davide.
      I noticed when checking vs single expression, StringBuilder becomes more effective after 10-15 ‘+’ operation (vs 10-15 appends)
      When checking vs multiple expression, SB indeed becomes more effective after 4-5 expressions (with single’+’ operation)

  6. Carsten Schuette

    Not clearing and reusing a StringBuilder is the trick in #3, but initializing the StringBuilder with an expected capacity.

    1. It was .NET Framework. Didn’t check but I suspect it wouldn’t change anything for .NET Core, since Span stuff is more operations “within” strings (indeof, substring, .Split() and so on)

Comments are closed.