StringBuilder vs String Join

2023. 6. 4. 11:02Language/C#

회사에서 StringBuilder가 쓰여진 부분을 List로 모은 다음, 마지막 Join을 통해 성능 개선을 했다고 발표 한 자료가 있었습니다.

하지만 제가 알기로는 StringBuilder가 가장 빠르다고 알고 있는데, List와 Join을 통해 성능 개선을 했다고 해서 궁금해서 Benchmark를 돌려보기로 하였습니다.

집에서 사용하는 OS가 리눅스라 VS Code와 dotnet CLI를 통해 예제를 실행하도록 하겠습니다.

Spec.

  • CPU : Intel Core i5-4690
  • .NET Version : .NET 7.0.3
  • OS : Linuxmin 21
  • BenchmarkDotnet Version : v.0.13.5

프로젝트 준비

프로젝트 생성

먼저 아래와 같이 프로젝트를 생성합니다.

$ mkdir StringBenchmark
$ cd StringBenchmark
$ dotnet new console
$ code .

code .은 VS Code를 실행 시키는 명령어 입니다.

Nuget Package 생성

VS Code에서 터미널을 띄우고 싶으면 Ctrl + `을 사용하시면 됩니다.

$ dotnet add package BenchmarkDotNet 

Benchmark

원본 소스

정확히 기억은 나지 않지만, 아래와 같이 for문 안에서 StringBuilder를 생성해서 appenLine을 하고 마지막에 toString으로 List에 넣었던거 같습니다.

[Benchmark]
public void StringBuilder()
{
    var list = new List<string>();
    for(int i = 0; i < 1000; i ++)
    {
        var builder = new StringBuilder();
        for(int j = 0; j < 50; j++)
        {
            builder.AppendLine("aa,");
        }

        list.Add(builder.ToString());
    }
}

성능 개선 소스

[Benchmark]
public void StringJoin()
{
    var list = new List<string>();
    for(int i = 0; i < 1000; i++)
    {
        var joins = new List<string>();
        for(int j = 0; j < 50; j++)
        {
            joins.Add("aa");
        }
        list.Add(String.Join(",\n", joins));
    }
}

전체 소스 program.cs는 아래와 같습니다.

using System;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

var summary = BenchmarkRunner.Run<StringBenchmark>();

[MemoryDiagnoser]
public class StringBenchmark
{
    [Benchmark]
    public void StringBuilder()
    {
        var list = new List<string>();
        for(int i = 0; i < 1000; i ++)
        {
            var builder = new StringBuilder();
            for(int j = 0; j < 50; j++)
            {
                builder.AppendLine("aa,");
            }

            list.Add(builder.ToString());
        }
    }

    [Benchmark]
    public void StringJoin()
    {
        var list = new List<string>();
        for(int i = 0; i < 1000; i++)
        {
            var joins = new List<string>();
            for(int j = 0; j < 50; j++)
            {
                joins.Add("aa");
            }
            list.Add(String.Join(",\n", joins));
        }
    }
}

Benchmark를 실행시키기 위해서는 Release로 실행시켜야 합니다.

또한 리눅스의 경우 CPU를 어느 정도 이상 사용 하려면 권한이 필요한데, sudo 권한을 주면 됩니다.

$ sudo dotnet run --configuration Release
// * Summary *

BenchmarkDotNet=v0.13.5, OS=linuxmint 21
Intel Core i5-4690 CPU 3.50GHz (Haswell), 1 CPU, 4 logical and 4 physical cores
.NET SDK=7.0.200
  [Host]     : .NET 7.0.3 (7.0.323.6910), X64 RyuJIT AVX2
  DefaultJob : .NET 7.0.3 (7.0.323.6910), X64 RyuJIT AVX2
Method Mean Error StdDev Gen0 Allocated
StringBuilder 40.00 us 0.712 us 0.595 us 48.5840 149.02 KB
StringJoin 36.39 us 0.288 us 0.255 us 33.3252 102.15 KB
항목 설명
Mean 측정 값의 평균 속도
Error 오차 열은 99.9% 신뢰 구간의 절반
StdDev 표준 편차, 평균 속도에서 벗어난 정도
Gen0 1000개의 작업 당 컬렉션 수
Allocated 메모리

결과

StringJoin이 평균적으로 더 빠른것으로 보입니다.

새로운 밴치마크

의문점

왜 StringJoin이 빨랐던 걸까요??

소스 코드를 보시면 for문 안에서 StringBuilder를 계속 new로 인스턴스를 새로 생성하고 있습니다.

List와 StringBuilder는 Clear를 통해 새로 생성하지 않고 사용 할 수 있습니다.

개선 된 코드

[Benchmark]
public void ImproveStringBuilder()
{
    var list = new List<string>();
    var builder = new StringBuilder();
    for(int i = 0; i < 1000; i ++)
    {
        builder.Clear();
        for(int j = 0; j < 50; j++)
        {
            builder.AppendLine("aa,");
        }
        builder.AppendLine("aa,");
        list.Add(builder.ToString());
    }
}

[Benchmark]
public void ImproveStringJoin()
{
    var list = new List<string>();
    var joins = new List<string>();
    for(int i = 0; i < 1000; i++)
    {
        joins.Clear();
        for(int j = 0; j < 50; j++)
        {
            joins.Add("aa");
        }
        list.Add(String.Join(",\n", joins));
    }
}
// * Summary *

BenchmarkDotNet=v0.13.5, OS=linuxmint 21
Intel Core i5-4690 CPU 3.50GHz (Haswell), 1 CPU, 4 logical and 4 physical cores
.NET SDK=7.0.200
  [Host]     : .NET 7.0.3 (7.0.323.6910), X64 RyuJIT AVX2
  DefaultJob : .NET 7.0.3 (7.0.323.6910), X64 RyuJIT AVX2
Method Mean Error StdDev Gen0 Gen1 Allocated
StringBuilder 619.6 us 12.13 us 22.18 us 275.3906 172.8516 1281.84 KB
StringJoin 912.5 us 12.71 us 11.89 us 302.7344 220.7031 1547.46 KB
ImproveStringBuilder 423.0 us 8.33 us 8.56 us 89.3555 58.5938 439.44 KB
ImproveStringJoin 707.5 us 13.52 us 13.28 us 91.7969 55.6641 431.39 KB

결론

StringBuilder가 StringJoin 보다 빠른것으로 보입니다.

또한 StringBulder를 생성 할 때 크기를 미리 지정해주면 더 빠르다고 합니다.

StringBuilder에 대한 진실 혹은 거짓말

위 블로그를 보면 짧은 문자열의 경우 StringBuilder가 더 느리다고 합니다.

  • StringBuilder는 내부 문자열 버퍼를 유지하며 그 초기 값은 16이다.
  • StringBuilder는 내부 버퍼가 부족하게 되면 새로운 내부 버퍼를 할당하며 기존 버퍼의 내용을 복사하는 오버헤드를 갖는다.
  • StringBuilder.ToString()은 문자열을 새로이 생성하여 반환하므로 또 다른 오버헤드를 유발할 수도 있다.

Append vs. AppendLine

그리고 하는 김에 몇개 더 해봤는데, Append("AA\n")와 AppendLine("AA")을 비교해봤습니다.

[MemoryDiagnoser]
public class StringBenchmark{
    [Benchmark]
    public void StringAppend()
    {
       var builder = new StringBuilder();
        for(int i = 0; i < 1000; i ++)
        {
            builder.Append("aa\n");
        }
    }

    [Benchmark]
    public void StringAppendLine()
    {
       var builder = new StringBuilder();
        for(int i = 0; i < 1000; i ++)
        {
            builder.AppendLine("aa");
        }
    }
}
// * Summary *

BenchmarkDotNet=v0.13.5, OS=linuxmint 21
Intel Core i5-4690 CPU 3.50GHz (Haswell), 1 CPU, 4 logical and 4 physical cores
.NET SDK=7.0.200
  [Host]     : .NET 7.0.3 (7.0.323.6910), X64 RyuJIT AVX2
  DefaultJob : .NET 7.0.3 (7.0.323.6910), X64 RyuJIT AVX2
Method Mean Error StdDev Gen0 Allocated
StringAppend 5.223 us 0.0331 us 0.0293 us 2.8152 8.63 KB
StringAppendLine 6.439 us 0.0917 us 0.0858 us 2.8152 8.63 KB

메모리는 똑같은데 속도는 Append에다가 NewLine을 한게 훨씬 빠른것으로 보입니다.

'Language > C#' 카테고리의 다른 글

dotnet cli nuget 저장소 지정  (0) 2023.06.16
.NET AOP DynamicProxy  (0) 2023.06.05
상속에서 Dispose 패턴  (0) 2023.05.31
EF Core Fluent API Entity Configuration  (0) 2023.05.17
.NET Serilog 사용법  (0) 2023.04.26