I decided to do some benchmarking after Andre's post. The results were very interesting.
As mentioned in my comment to Andre, I thought that
Span
would be faster than Richards's solution. After seeing the results I wanted to find a faster solution. You can see that I used Richard's as a base and managed to find a faster solution - managed to improve it by 18.5% faster with
FastMergeImmutableArraysWithStringBuilder
benchmark.
Here are the results with my test system information:
BenchmarkDotNet v0.13.6, Windows 11 (10.0.22621.1992/22H2/2022Update/SunValley2)
AMD Ryzen 7 3700X, 1 CPU, 16 logical and 8 physical cores
.NET SDK 7.0.304
[Host] : .NET 7.0.9 (7.0.923.32018), X64 RyuJIT AVX2
DefaultJob : .NET 7.0.9 (7.0.923.32018), X64 RyuJIT AVX2
| Method | N | Mean | Error | StdDev | Median |
|-------------------------------------------- |------ |-------------:|-------------:|-------------:|-------------:|
| MergeListsToListJoin | 100 | 2,466.4 ns | 47.69 ns | 56.77 ns | 2,452.9 ns |
| MergeListsToListConcat | 100 | 3,570.6 ns | 30.59 ns | 28.61 ns | 3,570.4 ns |
| MergeListsToSpanJoin | 100 | 2,018.7 ns | 37.14 ns | 32.92 ns | 2,011.2 ns |
| MergeListsToSpanConcat | 100 | 1,483.6 ns | 29.16 ns | 33.58 ns | 1,482.4 ns |
| MergeListsToReadOnlySpanJoin | 100 | 1,910.5 ns | 33.62 ns | 31.45 ns | 1,899.6 ns |
| MergeListsToReadOnlySpanConcat | 100 | 1,498.6 ns | 15.89 ns | 14.87 ns | 1,498.7 ns |
| MergeListsWithStringBuilder | 100 | 1,155.8 ns | 20.46 ns | 19.14 ns | 1,150.9 ns |
| MergeArraysWithStringBuilder | 100 | 1,060.7 ns | 21.14 ns | 40.74 ns | 1,052.7 ns |
| MergeImmutableArraysWithStringBuilder | 100 | 1,123.6 ns | 22.23 ns | 46.40 ns | 1,119.0 ns |
| FastMergeImmutableArraysWithStringBuilder | 100 | 975.4 ns | 16.24 ns | 14.40 ns | 976.2 ns |
| FasterMergeImmutableArraysWithStringBuilder | 100 | 1,007.5 ns | 20.08 ns | 46.55 ns | 1,022.3 ns |
| MergeListsToListJoin | 1000 | 22,157.8 ns | 288.08 ns | 269.47 ns | 22,144.4 ns |
| MergeListsToListConcat | 1000 | 34,887.5 ns | 683.79 ns | 839.76 ns | 35,055.0 ns |
| MergeListsToSpanJoin | 1000 | 20,063.4 ns | 399.40 ns | 373.60 ns | 19,969.5 ns |
| MergeListsToSpanConcat | 1000 | 15,156.6 ns | 229.77 ns | 214.93 ns | 15,171.1 ns |
| MergeListsToReadOnlySpanJoin | 1000 | 20,002.7 ns | 260.86 ns | 244.01 ns | 19,975.9 ns |
| MergeListsToReadOnlySpanConcat | 1000 | 15,598.0 ns | 297.16 ns | 305.16 ns | 15,628.4 ns |
| MergeListsWithStringBuilder | 1000 | 15,263.7 ns | 656.26 ns | 1,934.99 ns | 15,856.0 ns |
| MergeArraysWithStringBuilder | 1000 | 12,504.3 ns | 284.62 ns | 821.20 ns | 12,430.0 ns |
| MergeImmutableArraysWithStringBuilder | 1000 | 12,729.6 ns | 254.12 ns | 495.65 ns | 12,858.3 ns |
| FastMergeImmutableArraysWithStringBuilder | 1000 | 10,811.3 ns | 170.85 ns | 151.46 ns | 10,802.3 ns |
| FasterMergeImmutableArraysWithStringBuilder | 1000 | 11,378.8 ns | 225.61 ns | 582.37 ns | 11,648.3 ns |
| MergeListsToListJoin | 10000 | 522,580.8 ns | 10,395.54 ns | 11,971.52 ns | 525,432.2 ns |
| MergeListsToListConcat | 10000 | 655,883.7 ns | 12,753.81 ns | 13,646.43 ns | 660,786.4 ns |
| MergeListsToSpanJoin | 10000 | 351,701.7 ns | 4,842.52 ns | 4,529.69 ns | 351,433.6 ns |
| MergeListsToSpanConcat | 10000 | 306,222.2 ns | 4,471.00 ns | 4,182.18 ns | 305,097.5 ns |
| MergeListsToReadOnlySpanJoin | 10000 | 344,782.7 ns | 4,458.88 ns | 4,170.83 ns | 344,936.2 ns |
| MergeListsToReadOnlySpanConcat | 10000 | 302,246.1 ns | 3,425.33 ns | 3,036.46 ns | 302,369.6 ns |
| MergeListsWithStringBuilder | 10000 | 266,227.9 ns | 5,220.39 ns | 5,802.45 ns | 267,154.6 ns |
| MergeArraysWithStringBuilder | 10000 | 262,338.4 ns | 5,076.62 ns | 5,431.92 ns | 264,012.8 ns |
| MergeImmutableArraysWithStringBuilder | 10000 | 257,863.5 ns | 4,833.98 ns | 5,172.31 ns | 257,541.0 ns |
| FastMergeImmutableArraysWithStringBuilder | 10000 | 250,550.6 ns | 4,957.86 ns | 5,901.98 ns | 252,194.0 ns |
| FasterMergeImmutableArraysWithStringBuilder | 10000 | 248,685.6 ns | 4,842.27 ns | 5,181.18 ns | 249,153.2 ns |
Side Note: as
Span
is a
Ref Struct
I cannot set as a private field, so I have to get the reference to an array in the method itself - see code below.
Here is the Benchmark code used:
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class Program
{
static void Main()
=> BenchmarkRunner.Run<Benchmark>();
}
public class Benchmark
{
#pragma warning disable CS8618
private List<string> a1List;
private List<string> a2List;
private string[] a1Array;
private string[] a2Array;
private string[] a1ImmutableArray;
private string[] a2ImmutableArray;
#pragma warning restore CS8618
[Params(100, 1000, 10000)]
public int N;
[GlobalSetup]
public void GlobalSetup()
{
a1List = new List<string>(N);
a2List = new List<string>(N);
a1Array = new string[N];
a2Array = new string[N];
a1ImmutableArray = new string[N];
a2ImmutableArray = new string[N];
for (int i = 0; i < N; i++)
{
a1List.Add($"a{i}");
a2List.Add($"{i + 1}");
a1Array[i] = $"a{i}";
a2Array[i] = $"{i + 1}";
a1ImmutableArray[i] = $"a{i}";
a2ImmutableArray[i] = $"{i + 1}";
}
}
[Benchmark]
public string MergeListsToListJoin()
{
List<string> combined = new();
int maxLength = Math.Max(a1List.Count, a2List.Count);
for (int i = 0; i < maxLength; i++)
{
combined.Add(a1List[i]);
if (i < a2List.Count)
combined.Add(a2List[i]);
}
return string.Join("", combined);
}
[Benchmark]
public string MergeListsToListConcat()
{
List<string> combined = new();
int maxLength = Math.Max(a1List.Count, a2List.Count);
for (int i = 0; i < maxLength; i++)
{
combined.Add(a1List[i]);
if (i < a2List.Count)
combined.Add(a2List[i]);
}
return string.Concat(combined);
}
[Benchmark]
public string MergeListsToSpanJoin()
{
Span<string> a1Span = a1Array.AsSpan();
Span<string> a2Span = a2Array.AsSpan();
int maxLength = Math.Max(a1Span.Length, a2Span.Length);
string[] combined = new string[maxLength * 2];
Span<string> combinedSpan = combined.AsSpan();
for (int i = 0, j = 0; i < maxLength; i++, j += 2)
{
combinedSpan[j] = a1Span[i];
if (i < a2Span.Length)
combinedSpan[j + 1] = a2Span[i];
}
return string.Join("", combined);
}
[Benchmark]
public string MergeListsToSpanConcat()
{
Span<string> a1Span = a1Array.AsSpan();
Span<string> a2Span = a2Array.AsSpan();
int maxLength = Math.Max(a1Span.Length, a2Span.Length);
string[] combined = new string[maxLength * 2];
Span<string> combinedSpan = combined.AsSpan();
for (int i = 0, j = 0; i < maxLength; i++, j += 2)
{
combinedSpan[j] = a1Span[i];
if (i < a2Span.Length)
combinedSpan[j + 1] = a2Span[i];
}
return string.Concat(combined);
}
[Benchmark]
public string MergeListsToReadOnlySpanJoin()
{
ReadOnlySpan<string> a1Span = a1Array.AsSpan();
ReadOnlySpan<string> a2Span = a2Array.AsSpan();
int maxLength = Math.Max(a1Span.Length, a2Span.Length);
string[] combined = new string[maxLength * 2];
Span<string> combinedSpan = combined.AsSpan();
for (int i = 0, j = 0; i < maxLength; i++, j += 2)
{
combinedSpan[j] = a1Span[i];
if (i < a2Span.Length)
combinedSpan[j + 1] = a2Span[i];
}
return string.Join("", combined);
}
[Benchmark]
public string MergeListsToReadOnlySpanConcat()
{
ReadOnlySpan<string> a1Span = a1Array.AsSpan();
ReadOnlySpan<string> a2Span = a2Array.AsSpan();
int maxLength = Math.Max(a1Span.Length, a2Span.Length);
string[] combined = new string[maxLength * 2];
Span<string> combinedSpan = combined.AsSpan();
for (int i = 0, j = 0; i < maxLength; i++, j += 2)
{
combinedSpan[j] = a1Span[i];
if (i < a2Span.Length)
combinedSpan[j + 1] = a2Span[i];
}
return string.Concat(combined);
}
[Benchmark]
public string MergeListsWithStringBuilder()
{
StringBuilder output = new();
for (int i = 0; i < a1List.Count; ++i)
{
output.Append(a1List[i]);
if (i < a2List.Count)
output.Append(a2List[i]);
}
return output.ToString();
}
[Benchmark]
public string MergeArraysWithStringBuilder()
{
StringBuilder output = new();
for (int i = 0; i < a1Array.Length; ++i)
{
output.Append(a1Array[i]);
if (i < a2Array.Length)
output.Append(a2Array[i]);
}
return output.ToString();
}
[Benchmark]
public string MergeImmutableArraysWithStringBuilder()
{
StringBuilder output = new();
for (int i = 0; i < a1ImmutableArray.Length; ++i)
{
output.Append(a1ImmutableArray[i]);
if (i < a2ImmutableArray.Length)
output.Append(a2ImmutableArray[i]);
}
return output.ToString();
}
[Benchmark]
public string FastMergeImmutableArraysWithStringBuilder()
{
StringBuilder output = new();
ref string item1 = ref MemoryMarshal.GetArrayDataReference(a1ImmutableArray);
ref string item2 = ref MemoryMarshal.GetArrayDataReference(a2ImmutableArray);
for (int i = 0; i < a1ImmutableArray.Length; ++i)
{
output.Append(Unsafe.Add(ref item1, i));
if (i < a2ImmutableArray.Length)
output.Append(Unsafe.Add(ref item2, i));
}
return output.ToString();
}
[Benchmark]
public string FasterMergeImmutableArraysWithStringBuilder()
{
StringBuilder output = new();
ref string start1 = ref MemoryMarshal.GetArrayDataReference(a1ImmutableArray);
ref string end1 = ref Unsafe.Add(ref start1, a1ImmutableArray.Length);
ref string start2 = ref MemoryMarshal.GetArrayDataReference(a2ImmutableArray);
ref string end2 = ref Unsafe.Add(ref start2, a2ImmutableArray.Length);
while (Unsafe.IsAddressLessThan(ref start1, ref end1))
{
output.Append(start1);
start1 = ref Unsafe.Add(ref start1, 1);
if (Unsafe.IsAddressLessThan(ref start2, ref end2))
{
output.Append(start2);
start2 = ref Unsafe.Add(ref start2, 1);
}
}
return output.ToString();
}
}
NOTE: I did my best to keep each solution as close to identical as possible to ensure a fair comparison.
I'll be interested to see if someone has a faster solution.
UPDATE
Here is one of the fastest benchmarks working with the OP's data:
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
string[] a1ImmutableArray = { "a", "b", "c", "d" };
string[] a2ImmutableArray = { "1", "2", "3" };
StringBuilder output = new();
ref string start1 = ref MemoryMarshal.GetArrayDataReference(a1ImmutableArray);
ref string end1 = ref Unsafe.Add(ref start1, a1ImmutableArray.Length);
ref string start2 = ref MemoryMarshal.GetArrayDataReference(a2ImmutableArray);
ref string end2 = ref Unsafe.Add(ref start2, a2ImmutableArray.Length);
while (Unsafe.IsAddressLessThan(ref start1, ref end1))
{
output.Append(start1);
start1 = ref Unsafe.Add(ref start1, 1);
if (Unsafe.IsAddressLessThan(ref start2, ref end2))
{
output.Append(start2);
start2 = ref Unsafe.Add(ref start2, 1);
}
}
Console.WriteLine(output.ToString());
Enjoy!