글 작성자: Doublsb

string을 매번 선언할 때마다 메모리 할당이 일어나는 것은 당연한 일이지만, 그만큼 경계해야 할 일이기도 하다.

특히나 프레임마다 string을 선언하거나 stringBuilder 없이 문자열을 합치는 것은 성능 상 좋지 않다.

 

이러한 메모리 할당을 줄이는 방법은 굉장히 다양하지만, 이번 글에서는 가장 금방 적용할 수 있는 라이브러리를 소개한다.

 

https://github.com/Cysharp/ZString

 

GitHub - Cysharp/ZString: Zero Allocation StringBuilder for .NET and Unity.

Zero Allocation StringBuilder for .NET and Unity. Contribute to Cysharp/ZString development by creating an account on GitHub.

github.com

 

ZString 라이브러리는 .Net과 Unity 프로젝트에 적용할 수 있는 StringBuilder 라이브러리이다.

이 라이브러리를 사용하면 얼마나 할당이 줄어드는지는 깃허브 도큐먼트에도 서술되어 있다.

약 3~4배의 성능 차이를 보임

실제로 프로젝트에서 메모리 할당량 개선이 있었고, 사용이 간편해서 좋았다.

StringBuilder를 사용하는 방식과 차이는 없기 때문에 굳이 도큐먼트에도 있는 사용법을 나열하지는 않겠다.

 

다만 별개로 어떻게 제로 할당을 주장하는지는 궁금했기 때문에, 각 도큐먼트 항목들을 조금 알아봤다.

 


  • Struct StringBuilder to avoid allocation of builder itself

ZString은 빌더 자체의 할당을 피하기 위해 구조체 형식의 StringBuilder를 사용한다고 한다.

구조체는 힙에 할당되지 않고 스택에 할당되기 때문에, 가비지로부터 자유로워지게 되므로 이렇게 서술한 것 같다.

실제로 Utf16ValueStringBuilder.cs 코드를 보니 struct로 서술되어 있었다.

 

 

  • Rent write buffer from ThreadStatic or ArrayPool

쓰기 버퍼를 ThreadStatic이나 ArrayPool에서 대여한다고 쓰여 있다. 둘 다 처음 보는 자료형이라서 조사해봤다.

 

ThreadStatic은 멀티스레드 환경에서 멤버 변수를 독립적으로 사용할 수 있게 하기 위한 프로퍼티라고 한다.

A 스레드와 B 스레드가 동시에 같은 변수를 참조, 수정하려고 하면 예측할 수 없다. 이 때 스레드별로 고유한 변수를 사용하려면 ThreadStatic 프로퍼티를 달면 된다. 각 스레드 당 하나의 복사본이 만들어지는 방식이라고 한다.

근데 이러면 한 변수를 여러 스레드가 참조하는 게 아니다. 그냥 각자 자기들 변수 가지고 노는 거지 (...)

 

ArrayPool은 배열의 메모리 할당과 해제를 최소화하여 성능을 향상시키는 데 사용되는 클래스다.

인스턴스를 재사용할 수 있는 리소스 풀을 제공한다고 .net 공식문서가 서술하고 있다.

작은 크기의 배열을 매번 할당하고 해제하지 말고, ArrayPool로 관리하면 메모리 상으로 이득을 볼 수 있다.

유니티에서 최적화 기법을 위해 사용하는 오브젝트 풀링과 유사하다. 그냥 배열 풀러라고 보면 되는 듯.

 

으엄... 쓰기 버퍼를 ThreadStatic에서 대여했다는 잘 이해가 안 가는데, ArrayPool을 이용하여 이득을 봤다는 부분은 알겠다.

        [ThreadStatic]
        static char[]? scratchBuffer;

        [ThreadStatic]
        internal static bool scratchBufferUsed;

코드를 봤을 때에는 scratchBuffer라는 배열을 선언해서 사용하던데, 다만 즉시 dispose 되는 경우에만 사용하고 있다 (??)

즉시 dispose를 해야 하는 경우에는 ThreadStatic, 그리고 아닌 경우에는 ArrayPool에서 배열을 빌려 오는 방식이다.

 

일단 스택오버플로우를 찾아봐도 딱히 이 부분에 대한 이점을 모르겠어서 패스하겠다. 아시는 분은 알려주십쇼...

 

 

  • All append methods are generics(Append<T>(T value)) and write to buffer directly instead of concatenate value.ToString

모든 Append 메서드는 제네릭이고 value.ToString을 거치지 않고 버퍼에 바로 작성한다고 한다. 이 부분은 이해했다.

실제로 코드에서는 FormatterCache<T>를 이용해 값을 저장해두고 있었다.

그리고 StringBuilder.ToString() 메서드를 부를 때 이 값들을 한꺼번에 합친다는 것이군... 음청남...

 

 

  • T1~T16 AppendFormat(AppendFormat<T1,...,T16>(string format, T1 arg1, ..., T16 arg16) avoids boxing of struct argument
  • Also T1~T16 Concat(Concat<T1,...,T16>(T1 arg1, ..., T16 arg16)) avoid boxing and value.ToString allocation

보통 Append나 Join 메서드는 arg1, arg2와 같이 매개변수 2개를 합치는 경우가 일반적인데, ZString에서는 16번째 매개변수까지 작성할 수 있게 커버해놨다. 역시 성능 개선을 위해서는 코드 노가다가 짱인 것 같다는 교훈을 다시 느끼기 (...)


결론적으로 이 라이브러리는 구조체 사용, 배열 풀링, 매개변수 노가다(...)를 이용해서 성능 개선을 이뤘다는 것을 이해했다.

반응형