For .NET Core and Unity, I have released a library called ZString that enables the memory allocation to zero for string generation. Besides introducing ZString, this article also deeply disassembles and explains the C# string and explains the String’s complexities and pitfalls and the necessity of ZString.

https://github.com/Cysharp/ZString

I am the author of high-performance serializers such as MessagePack for C# and Utf8Json. This is one of them, for performance enthusiasts.

The table below shows the performance measurement of a simple string concatenation called “x:” + x + “ y:” + y + “ z:” + z’.

“x:” + x + ” y:” + y + ” z:” + z

ZString.Concat(“x:”, x, ” y:”, y, ” z:”, z)

string.Format(“x:{0} y:{1} z:{2}”, x, y, z)

ZString.Format(“x:{0} y:{1} z:{2}”, x, y, z)

new StringBuilder(), Append(), .ToString()

ZString.CreateStringBuilder(), Append(), .ToString()

The graph shows the respective memory allocation amount and speed for the above. In all cases, ZString does not have any allocation other than 56B strings after concatenation. Also, for easy API use, ‘StringBuilder’ or ‘String.Format’, ‘String.Concat’, and ‘String.Join’ can be replaced as is.

String type configuration and generation

The C# String type is internally a ‘UTF-16’ byte string.

As with a normal object, it has an object header, and allocated in heap memory. In the same way, string is basically only be generated by ‘new string’. ‘StringBuilder.ToString’, ‘Encoding.GetString’, etc., also finally call ‘new string’ to allocate a new string.

To be precise, some .NET internal methods directly write to the string memory allocated by an internal method called ‘String.FastAllocateString(int length)’. This method has not been public. However, .NET Standard 2.1 adds the ‘String.Create<TState>(int length, TState state, SpanAction<char, TState> action)’ method. By calling this, direct writing to a new string memory is possible.

The string generated by ‘new string’ is allocated in a different memory space if even the same string value. However, only the constant string acquires the fixed reference from the application-sharing space called the Intern pool.

If you want to acquire from the intern pool, ‘String.Intern’ method can be used. The Intern method is acquired from the Intern pool. If it does not exist, it is registered and the registered reference is returned.

Since the memory registered in the Intern pool cannot be deleted, it will probably be difficult to use it well. However, with the [MasterMemory] in-memory database being developed by our company, a string data that expanded in memory as the master-data it will be persisted in application lifetime. By using this feature, all the strings are internalized.

Also, with the .NET Core’s runtime, there is a proposal to have a function called [String Deduplication] to delete the string duplicated during GC (convert to a single reference). However, it will still take a while longer to implement it.

+ concatenation and String.Concat

The C# compiler does specialized processing and String’s ‘+’ concatenation is converted to String.Concat.

Since ‘“x:” + x + “ y:” + y + “ z:” + z’ is a 6-parameter concatenation, it is converted to ‘string.Concat(string[] values)’. (In the case of Visual Studio 2019 Version 16.4.2’s C# compiler. Details later.) In other words, the result will be as follows.

Optimizing the ‘+’ concatenation with the C# compiler may obtain a different result between the current one and past one. For example, Visual Studio 2019’s C# compiler’s result of (int x) + (string y) + (int z) will be ‘String.Concat(x.ToString(), y, z.ToString())’. However, Visual Studio 2017’s C# compiler will be ‘String.Concat((object)x, y, (object)z)’, if concatenated non-string parameter, object overload will be used. Therefore, struct boxing occurs. If Unity is used, you must note that the result will differ depending on the version of the C# compiler bundled with Unity.

As Concat’s overload, three or four parameter optimized by avoid ‘params array` allocation.

StringBuilder and SpanFormatter

‘StringBuilder’ is a class that has ‘char[]’ as a temporary buffer. Append is used to write to the buffer, and ToString generates the final string.

When if you want to concatenate multiple Strings, should avoid ‘+=’ to use because a new string is generated for each ‘+=’.

StringBuilder avoids generating this temporary, new string and instead copies it to ‘char[]’.

Here, you need to watch out for ‘sb.Append(“ Current HP:” + enemy.Hp);’ etc., being written because it will create a temporary string that was concatenated. By all means, avoid using ‘+’.

When Appending numeric type, etc., the behavior will be different between .NET Standard 2.0 (Unity, etc.) and .NET Standard 2.1 (.NET Core 3.0, etc.).

With .NET Standard 2.0, it simply adds the ToString result. In other words, although an allocation is created by the string creation, with .NET Standard 2.1, ‘ISpanFormattable.TryFormat’ writes it directly to the buffer without going through the string. ISpanFormattable itself is internal. However, by checking [ISpanFormattable.references], you can see which type is implementing this direct writing.

Even in the Unity environment, the string allocation when the numeric type is added can be avoided by ZString. In .NET Standard 2.1, ZString uses their implemented TryFormat. In .NET Standard 2.0, ZString uses ported TryFormat method.

The API itself is almost the same as StringBuilder. However, it must be enclosed by “using.”

Since CreateStringBuilder’s return value ‘Utf16ValueStringBuilder’ is a struct, allocation to the StringBuilder’s heap is avoided. Also, since the ‘char[]’ buffer used for internal writing is obtained from ArrayPool, the buffer allocation is avoided. (However, this is why it is necessary to return the buffer by “using”.)

Also, ‘ZString.Concat’ uses ‘Utf16ValueStringBuilder’ internally. And since generic overload up to 15 parameters is provided, the numeric type’s string conversion allocation can be completely avoided even in the Unity environment.

Format and ReadOnlySpan<char>

Since String interpolation is converted into String.Format, it has no overhead. However, since String.Format’s parameter can only accept objects, boxing occurs.

Also, as with StringBuilder.Append, with .NET Standard 2.0, string conversion allocation also occurs.

Like ‘ZString.Concat’, ‘ZString.Format’ has generic overload up to 15 parameters. And even in the .NET Standard 2.0 environment, by implementing direct conversion with TryFormat, Zero Allocation is achieved.

‘String.Format’ supports [Composite Formatting] . The composite-formatted string expressed by ‘{ index[,alignment][:formatString]}’ can be used to format the date or set the number of digits for numeric values.

ZString.Format supports formatString, but not alignment.

In the end, this composite-formatted string is sampled as ‘.##’ and given to TryFormat. Let’s again look at the definition of ISpanFormattable.

Note that instead of being ‘string format’, it is ‘ReadOnlySpan<char> format’. It is because the format string obtained when the string is analyzed is a partial slice. If you take the string in the above example, it is divided into [x:], [.##], [, y:], and [0000.#] slices. The [x:] and [, y:] are copied as is, and [.##] and [0000.#] are given as a format strings to TryFormat. In this way, by expressing the format string as ‘ReadOnlySpan<char>’, the string allocation is avoided.

As explained in the beginning above, since the String is actually a Utf-16 byte string, it can be expressed by ‘ReadOnlySpan<char>/Span<char>’. Unlike ‘string’, ‘ReadOnlySpan<char>’ enables partial acquisition and can use ‘char[]’. Therefore, it is easy to use from the pool.

And so, providing an API that accepts the string as ‘ReadOnlySpan<char>’ would make it easy to improve performance. However, due to ‘ref struct’, it cannot be retained in the field and ‘.NET Standard 2.0’ does not provide implicit conversion for ‘string -> ReadOnlySpan<char>’. Therefore, the usability is adversely affected. It is necessary to keep this in mind for the design.

Direct writing without allocate String

ZString’s internal implementation is zero allocation. When the string is generated in the end, it is allocated. This is because most APIs request a string. However, if the applicable library has an API that accepts something other than a string, this final string generation is also avoided and a completely zero allocation can be attained. For example, since Unity’s TextMeshPro has an API called ‘SetCharArray(char[] sourceText, int start, int length)’, it can be given directly and the string generation can be avoided.

Even with .NET Core, APIs that can accept ‘ReadOnlySpan<char>’ are increasing. In this way, by having more approaches that avoid the string, the application’s overall performance should improve.

Utf8String and ‘ReadOnlySpan<byte>’

In the network or file I/O, in many cases, the data requested in the end is not String (UTF16), but byte[](UTF8). In such a case, if it is ‘Encoding.UTF8.GetBytes(stringBuilder.ToString())’, char[] writing → string generation → UTF8 encoding will be quite useless. If it is written directly in UTF8, the overhead will be zero. Therefore, ZString provides the ‘CreateUtf8StringBuilder’ method.

We can expect it to be effectively used for generating matrix data to be sent to a network that needs to be frequently written or be effectively used as the template engine’s backend.

To write directly to the numeric type’s Utf8, ‘System.Buffers.Text.Utf8Formatter’ is used. They have the TryFormat method to write to ‘Span<byte>’.

In other words, like string(Utf16) expressing ‘ReadOnlySpan<char>’, Utf8 expresses ‘ReadOnlySpan<byte>’.

Summary

String is a basic type. Since it is a basic type, it looks simple while having many special operations. And just by using it normally, it is impossible to avoid all the pitfalls. ZString offers an API similar to String/StringBuilder. Therefore, instead of thinking of the complicated details given here, it is designed to just simply replace it to give the best performance. Iwould be happy if you all tried it.