본문

테크
프로그램 성능 향상 팁 #2

작성일 2023년 07월 24일

들어가며

지난 편에서는 Interlocked operation을 이용하여 코드의 성능을 개선하는 방법에 대해 알아보았습니다. 이번에는 그 외에도 성능을 높일 수 있는 여러 가지 방법에 대해 알아보려고 합니다.

메모리를 미리 할당해서 성능 높이기

StringBuilder 클래스는 C#에서 문자열 조작 작업을 최적화하기 위해 자주 사용됩니다. 이 클래스는 문자열을 동적으로 수정할 수 있으며, 많은 양의 문자열 조작이 필요한 경우 효율적으로 작업을 수행할 수 있습니다.

그러나 StringBuilder에 문자열을 추가할 때, 내부 버퍼(메모리 공간)의 크기가 더 이상 충분하지 않다면, StringBuilder는 새로운 메모리 공간을 할당받아야 합니다. 이 과정은 추가적인 배열 작업을 필요로 하며, 이로 인해 성능 저하가 발생할 수 있습니다.

따라서 문자열 조작 작업이 많을 때, 미리 충분한 크기의 메모리 공간을 할당해 주면 이러한 성능 저하를 방지할 수 있습니다. 이 방식의 장점은 StringBuilder가 필요로 하는 메모리 공간을 미리 알고 있다면, 불필요한 메모리 재할당과 배열 작업을 줄일 수 있다는 것입니다.

다음은 이에 대한 간단한 예제 코드입니다. 메모리를 다시 할당받아서 처리하는 코드가 발생시키는 성능 저하의 크기가 매우 크지는 않지만, 반복이 많이 일어나는 경우라면 적용해 볼 필요가 있습니다. 빨간색 배경이 있는 코드 부분이 메모리를 미리 할당받도록 하는 부분입니다.

   using System;using System.Diagnostics;using System.Text;classProgram{staticvoidMain(string[] args){constint iterationCount = 1000000; // 테스트 반복 횟수conststring appendText = "This is a test."; // StringBuilder에 추가할 문자열// 초기 할당 없는 StringBuilder 테스트var sw = Stopwatch.StartNew();var sb1 = new StringBuilder();for (int i = 0; i < iterationCount; i++){sb1.Append(appendText);}sw.Stop();Console.WriteLine($"Without initial capacity: {sw.ElapsedMilliseconds}ms");// 초기 할당 있는 StringBuilder 테스트sw.Restart();var sb2 = new StringBuilder(iterationCount * appendText.Length);for (int i = 0; i < iterationCount; i++){sb2.Append(appendText);}sw.Stop();Console.WriteLine($"With initial capacity: {sw.ElapsedMilliseconds}ms");Console.ReadLine();}}
   

아래 캡쳐 화면은 실행결과입니다.

실행창

Word 단위로 묶어서 성능 높이기

배열과 같은 긴 데이터를 비교하거나 처리할 때는 Word 단위로 묶어서 사용할 때가 가정 성능이 좋습니다. 컴퓨터는 메모리를 여러 비트로 구성된 '워드(word)'라는 단위로 읽고 쓰는 방식을 사용합니다. 이 워드의 크기는 컴퓨터 아키텍처에 따라 다르며, 일반적으로 32비트나 64비트를 많이 사용합니다.

하나의 워드를 읽거나 쓸 때 CPU는 한 번의 연산만으로 워드의 모든 비트를 동시에 처리할 수 있습니다. 즉, 워드 단위로 데이터를 처리하면 하나의 연산으로 더 많은 양의 데이터를 처리할 수 있으므로 더 빠른 성능을 얻을 수 있습니다. 하지만, 워드 단위로 데이터를 처리하려면 데이터가 워드의 크기에 맞게 정렬되어 있어야 합니다. 그래서 바이트 배열을 워드 크기인 'int' 배열로 변환하는 과정이 필요합니다. 이렇게 변환하면 워드 단위로 더 많은 데이터를 한 번에 처리할 수 있습니다. 반면에, 데이터를 바이트 단위로 처리하면 워드 단위로 처리할 때보다 많은 연산이 필요합니다.

   using System;using System.Diagnostics;using System.Numerics;using System.Runtime.InteropServices;using System.Text;classProgram{staticvoidMain(string[] args){constint length = 10000000; // 배열 길이constbyte fillValue = 0x1; // 배열 채우기 값// 두 배열 초기화byte[] arr1 = newbyte[length];byte[] arr2 = newbyte[length];for (int i = 0; i < length; i++){arr1[i] = fillValue;arr2[i] = fillValue;}// byte로 비교var sw = Stopwatch.StartNew();for (int i = 0; i < length; i++){if (arr1[i] != arr2[i]){Console.WriteLine("Arrays are not equal");}}sw.Stop();Console.WriteLine($"Byte comparison: {sw.ElapsedMilliseconds}ms");// int로 비교int[] arr1Int = newint[length / 4];int[] arr2Int = newint[length / 4];Buffer.BlockCopy(arr1, 0, arr1Int, 0, length);Buffer.BlockCopy(arr2, 0, arr2Int, 0, length);sw.Restart();for (int i = 0; i < length / 4; i++){if (arr1Int[i] != arr2Int[i]){Console.WriteLine("Arrays are not equal");}}sw.Stop();Console.WriteLine($"Int comparison: {sw.ElapsedMilliseconds}ms");// SequenceEqualsw.Restart();if (!arr1.SequenceEqual(arr2)){Console.WriteLine("Arrays are not equal");}sw.Stop();Console.WriteLine($"SequenceEqual comparison: {sw.ElapsedMilliseconds}ms");Console.ReadLine();}}
   

아래 캡처 화면은 실행 결과입니다. SequenceEqual()가 Word 단위로 묶어서 실행하는 것보다 성능이 조금 떨어지긴 하지만, 코드가 간결하고 데이터의 타입을 신경쓰지 않아도 되는 이점이 있습니다.

실행창

컴퓨터의 성능을 높이는 또 다른 방법으로는 SIMD (Single Instruction Multiple Data)를 사용하는 것이 있습니다. SIMD는 단일 명령어를 사용하여 여러 데이터에 동시에 연산을 수행하는 방법을 의미합니다. 이 방식은 벡터화 연산이 필요한 과학적 계산이나 그래픽, 디지털 신호 처리 등 다양한 분야에서 활용됩니다.

SIMD는 특정 데이터 타입의 배열이나 리스트 등에 대해 같은 연산을 수행해야 할 때 가장 효율적입니다. 예를 들어, 두 벡터의 모든 요소를 더하거나, 배열의 모든 요소를 특정 상수로 곱하는 경우에 SIMD를 사용하면 성능을 크게 향상시킬 수 있습니다.

메모리는 순차적으로 접근해야 성능이 높아진다

이번에는 2차원 배열에 대한 순차적인 접근과 열 우선 접근의 차이를 비교해보겠습니다.

행 우선 순회 방식은 메모리에 연속적으로 저장된 데이터를 순차적으로 읽습니다. 이는 CPU가 데이터를 효율적으로 가져올 수 있게 해주는데, 이유는 CPU가 메모리에서 데이터를 가져올 때 하나의 값만 가져오는 것이 아니라 "블록" 단위로 가져오기 때문입니다. 이를 "프리페치(prefetch)"라고 하며, CPU는 이후에 필요할 것으로 예상되는 메모리 위치의 데이터를 미리 가져옵니다. 따라서 매번 데이터를 가져오는 동작을 생략하여 성능을 끌어 올릴 수 있게 되는 것입니다.

반면에, 열 우선 순회 방식은 연속적이지 않은 메모리 위치를 접근하므로, CPU는 프리페치를 효율적으로 수행할 수 없습니다. 이는 매번 다른 주소의 데이터를 가져와야 하므로 더 많은 시간이 소요됩니다.

   using System;using System.Diagnostics;classProgram{staticvoidMain(string[] args){int size = 10000;int[][] datas = newint[size][];for (int i = 0; i < size; i++){datas[i] = newint[size];for (int j = 0; j < size; j++){datas[i][j] = i * j;}}var stopwatch = new Stopwatch();// First Loop (Row-First)stopwatch.Start();int count1 = 0;for (int i = 0; i < datas.Length; i++){for (int j = 0; j < datas.Length; j++){if (datas[i][j] > 0){count1++;}}}stopwatch.Stop();Console.WriteLine($"Row-First Loop Time: {stopwatch.ElapsedMilliseconds} ms");// Second Loop (Column-First)stopwatch.Restart();int count2 = 0;for (int i = 0; i < datas.Length; i++){for (int j = 0; j < datas.Length; j++){if (datas[j][i] > 0){count2++;}}}stopwatch.Stop();Console.WriteLine($"Column-First Loop Time: {stopwatch.ElapsedMilliseconds} ms");Console.ReadLine();}}
   

아래 캡처 화면은 실행 결과입니다. 차이가 상당한 것을 확인할 수 있습니다.

시스템 cpu

해시맵을 활용한 성능 개선

이번에는 레거시 코드에서 발견된 이중 반복문을 통해서 자료를 탐색하는 코드의 성능 개선에 대해서 살펴보겠습니다. 실무의 코드가 상당히 길고 다른 작업에 관한 코드로 인해서 이해하기 어려울 수 있기 때문에, 의사 코드 수준에서 설명을 진행하겠습니다.

반복적인 검색 작업이 필요한 경우, 해시맵과 같은 빠른 검색 성능을 제공하는 데이터 구조를 사용하면 큰 성능 향상을 얻을 수 있는데요. 이번 경우가 바로 그 상황에 해당합니다. 아래의 레거시 코드는 함수 이름 목록과 메서드 목록을 반복하면서 일치하는 함수를 찾는 작업을 수행합니다. 이 코드는 이중 반복문을 사용하므로 시간 복잡도는 O(n2)입니다.

   voidSearchFunctionNameInMethods(){List functionNames = new List() { "Function1", "Function2", "Function3" };MethodInfo[] methods = ...;foreach (string functionName in functionNames){foreach (MethodInfo method in methods){if (method.Name == functionName){Console.WriteLine("Method {0} is found.", functionName);}}}}
   

이 코드를 최적화하기 위해, 함수 이름 목록을 HashSet으로 변경하였습니다. HashSet은 내부적으로 해시 테이블을 사용하여 데이터를 저장하므로, 검색 작업의 시간 복잡도는 O(1)입니다. 따라서 아래의 최적화된 코드는 메서드 목록만 반복하면서 함수 이름이 HashSet에 포함되어 있는지 확인하므로, 시간 복잡도는 O(n)으로 크게 개선되었습니다.

추가적으로 반복문이 단순해져서 코드의 가독성도 높아졌다는 이점도 있습니다.

   voidSearchFunctionNameInMethods(){HashSet functionNames = new HashSet() { "Function1", "Function2", "Function3" };MethodInfo[] methods = ...;foreach (MethodInfo method in methods){if (functionNames.Contains(method.Name)){Console.WriteLine("Method {0} is found.", method.Name);}}}
   

스레드 공회전 피하기

스레드의 공회전(busy-waiting) 혹은 루프를 사용하는 것은 꽤 자주 보이는 프로그래밍 패턴입니다. 특정 작업을 수행한 후, 일정 시간동안 대기하며(Sleep() 메소드를 사용) 그 후 다시 작업을 시작하는 형태인데요. 이러한 접근법은 종종 비효율적이며, CPU 자원을 불필요하게 사용하는 경우가 많습니다.

이를 개선하기 위한 한 가지 방법은 Guarded Suspension 패턴을 사용하는 것입니다. 이 패턴에서, 스레드는 작업을 수행해야 할 때만 깨어나며, 그렇지 않을 경우에는 대기 상태에 머무릅니다. 레거시 코드에서는 데이터를 모아두었다가 주기적으로 처리하는 코드가 이렇게 Sleep()을 이용한 경우였는데요. 이 코드를 Guarded suspension pattern을 응용해서 버퍼에 일정 크기의 데이터가 모아지면 스레드를 깨우는 형식으로 교체하면 성능을 개선할 수 있습니다.

   publicclassDataProcessor{publicDataProcessor(){_thread = new Thread(processBuffer);_thread.IsBackground = true;_thread.Start();}publicvoidAddData(string log){lock (_lock){_buffer.Add(log);}}privatevoidProcessBuffer(){while (true){Thread.Sleep(10);lock (_lock){if (_buffer.Count >= BufferSizeLimit){// 데이터 처리_buffer.Clear();}}}}}
   
   publicclassDataProcessor{publicDataProcessor(){thread = new Thread(() => {while (true){lock (_lock){Monitor.Wait(_lock);// 데이터 처리}}});thread.IsBackground = true;thread.Start();}publicvoidAddData(string log){lock (_lock){buffer.Add(log);if (buffer.Length > BUFFER_SIZE_LIMIT) Monitor.Pulse(_lock);}}}
   

하지만 이번 경우에는 어느쪽이 반드시 성능에 유리하다고는 할 수가 없습니다. 상황에 맞춰서 선택해야 하는데요. 각각의 특징을 살펴보겠습니다. 레거시 코드는 Thread.Sleep()을 사용하여 스레드가 일정 시간마다 깨어나 데이터가 충분히 쌓였는지 확인합니다. 이 방식은 데이터가 즉시 처리되므로 반응성이 좋습니다. 그러나 Sleep 시간 동안 다른 스레드가 실행될 수 있으므로 컨텍스트 스위칭이 더 자주 발생할 수 있습니다.

Guarded Suspension 패턴을 사용하는 두 번째 코드는 모니터를 사용하여 데이터를 처리하는 스레드가 필요한 경우에만 깨어나도록 설계되었습니다. 데이터가 충분히 쌓이지 않았다면, 스레드는 Monitor.Wait()를 통해 블로킹 상태에 있게 됩니다. 데이터가 충분히 쌓이면 Monitor.Pulse()에 의해 깨어나 데이터를 처리하게 됩니다. 이 방식은 CPU 사용량을 줄이는데 효과적이며, 불필요한 컨텍스트 스위칭을 최소화합니다. 그러나 반응성 측면에서는 데이터가 충분히 쌓일 때까지 기다려야 하므로 지연이 발생할 수 있습니다.

따라서, 두 방식 사이의 선택은 사용 사례에 따라 달라집니다. 그리고 실제 성능 차이는 사용하는 시스템과 환경, 워크로드 등 다양한 요인에 따라 달라질 수 있습니다.

마른 수건 쥐어짜는 나눗셈 성능 개선

일반적으로 컴퓨터에서는 비트 이동 연산이 나눗셈 연산보다 빠릅니다. 이는 비트 이동 연산이 간단한 레지스터 연산이기 때문이고, 나눗셈 연산은 CPU에서 복잡한 연산을 수행해야 하기 때문입니다.

“value / 12” 연산은 아래와 같은 코드로 변경할 수 있는데요. '>>'는 오른쪽으로 비트를 이동시키는 연산자이며, 'value >> 2'는 value를 4로 나눈 것과 동일한 결과를 제공합니다. 그런 다음 이 결과를 3으로 나누므로 전체 연산은 원래 'value / 12'와 동일한 결과를 제공합니다.

   intresult= (value>>2) /3;
   

그러나 이런 방식에는 몇 가지 단점이 있습니다.

  1. 이러한 최적화는 가독성을 저해하고 코드를 복잡하게 만들 수 있습니다.
  2. 컴파일러는 이미 이러한 종류의 최적화를 수행하는 경우가 많습니다. 따라서 프로그래머가 직접 이러한 최적화를 수행하는 것은 종종 불필요할 수 있습니다.
  3. 이 최적화는 나눗셈 연산을 완전히 제거하지 못하며, 여전히 '(value >> 2) / 3'에서 3으로 나누는 연산이 필요합니다.

따라서, 실제 코딩에서는 직접적인 나눗셈 연산을 사용하는 것이 더 좋습니다. 가독성을 유지하고, 컴파일러가 자동으로 수행할 수 있는 최적화를 수행하도록 둘 수 있습니다.

그러나 이러한 최적화 기법이 유효한 상황도 있습니다. 예를 들어, 특정 플랫폼에서 컴파일러 최적화가 제한적이거나, 특정 연산에서 극단적인 성능 향상이 필요한 경우에는 이러한 최적화 기법을 적용할 수 있습니다. 이런 경우에는, 이런 최적화를 적용하기 전후의 성능을 측정하여 실제 효과를 확인하는 것이 중요합니다.

   using System;using System.Diagnostics;classProgram{staticvoidMain(string[] args){Stopwatch sw = new Stopwatch();// Divisionsw.Start();for (int i = 0; i < 10000000; i++){int result = i / 2;}sw.Stop();Console.WriteLine($"Division Time: {sw.ElapsedMilliseconds} ms");sw.Reset();// Bit Shiftsw.Start();for (int i = 0; i < 10000000; i++){int result = i >> 1; // Same as dividing by 2}sw.Stop();Console.WriteLine($"Bit Shift Time: {sw.ElapsedMilliseconds} ms");Console.ReadLine();}}
   

아래 캡처 화면은 실행 결과입니다.

시스템 cpu

마치며

이번 글에서는 지난 편에 이어서 좀 더 다양한 상황에서의 성능 개선에 대해서 살펴보았습니다. 프로젝트를 진행하는 도중에 메모한 것들이다 보니 다소 부족한 면이 많습니다. 이글의 반응을 봐서 기회가 된다면 다른 상황에 대해서도 다뤄보도록 하겠습니다.

이러한 방법들은 모두 상황에 따라 적용해야 할 방법이 다르며, 때로는 서로 상충하는 경우도 있습니다. 따라서 어떤 방법이 가장 효과적인지 판단하기 위해서는 해당 시스템의 요구사항과 특성을 정확히 이해하고, 다양한 방법을 적용해보며 그 효과를 직접 확인해보는 것이 중요합니다.

제 글이 여러분의 성능 최적화 작업에 도움이 되었기를 바랍니다.

류종택[email protected]
Development TeamAPM Agent Developer

지금 바로
와탭을 경험해 보세요.