라떼군 이야기


대규모 배열 반복문 처리 시 C#이 Java보다 5배 느린 이유 (루프 언롤링과 벡터화)

Problem

1억 개의 요소를 가진 대규모 정수 배열에서 특정 조건(예: 100보다 큰 값)을 만족하는 요소의 개수를 세는 단순한 반복문을 실행할 때, C# 코드가 Java 코드에 비해 약 5배 이상 느리게 실행되는 현상이 발생할 수 있습니다. 동일한 논리 구조를 가진 for 또는 foreach 루프를 작성했음에도 불구하고, 두 언어 간의 극단적인 성능 차이가 나타납니다. 이러한 문제는 개발자들이 두 언어의 성능을 비교하는 마이크로 벤치마크(Microbenchmark)를 작성할 때 흔히 마주치는 혼란스러운 상황입니다.

Background

이러한 성능 차이는 C#과 Java 언어 자체의 문법적 차이가 아니라, 코드를 실행하는 런타임 환경(C#의 CLR과 Java의 JVM)의 JIT(Just-In-Time) 컴파일러 최적화 수준 차이에서 비롯됩니다. 소스 코드는 컴파일 시 중간 언어(IL 또는 Bytecode)로 변환되고, 실행 시점에 JIT 컴파일러에 의해 실제 CPU가 이해할 수 있는 기계어로 번역됩니다. 이 과정에서 JIT 컴파일러는 코드를 분석하여 실행 속도를 높이기 위한 다양한 최적화 기법을 적용합니다. 단순한 배열 순회 루프의 경우, 컴파일러가 얼마나 공격적으로 ‘루프 언롤링(Loop Unrolling)‘과 ‘벡터화(Vectorization)‘를 적용하느냐에 따라 최종 기계어의 실행 속도가 극적으로 달라집니다.

Solution

이 문제의 핵심 원인은 Java의 JVM(특히 C2 컴파일러)이 C#의 .NET CLR(과거 버전 기준)보다 단순 루프에 대해 더 강력한 자동 최적화를 수행하기 때문입니다. 이를 이해하고 C#에서 성능을 극대화하는 방법을 알아봅시다.

1. JVM의 루프 언롤링 (Loop Unrolling)

JVM은 실행 중 코드를 프로파일링하여 자주 실행되는 ‘핫 루프(Hot Loop)‘를 식별하고, C2 컴파일러를 통해 고도로 최적화된 기계어를 생성합니다. 이때 루프의 반복 횟수를 줄이기 위해 루프 본문을 여러 번 펼치는 루프 언롤링을 적용합니다. 이로 인해 조건 검사(jump)와 인덱스 증가(inc) 명령어가 줄어들어 성능이 대폭 향상됩니다.

2. C#에서 성능 격차를 극복하는 방법: 수동 벡터화 (SIMD)

과거의 .NET JIT 컴파일러는 이러한 자동 벡터화 및 언롤링에 보수적이었습니다. C#에서 Java와 동일하거나 그 이상의 성능을 내려면 System.Numerics 네임스페이스의 Vector<T>를 사용하여 **SIMD(Single Instruction, Multiple Data)**를 수동으로 적용할 수 있습니다. SIMD는 하나의 CPU 명령어로 여러 개의 데이터를 동시에 처리하는 기술입니다.

using System;
using System.Numerics;

public class Program
{
    // 기존의 느린 방식 (단순 foreach 루프)
    public static int TestSpeedStandard(int[] array)
    {
        int count = 0;
        foreach (int element in array)
        {
            if (element > 100)
            {
                count++;
            }
        }
        return count;
    }

    // SIMD를 활용하여 최적화한 방식
    public static int TestSpeedSIMD(int[] array)
    {
        int count = 0;
        int i = 0;
        
        // Vector<int>.Count는 현재 CPU 아키텍처가 한 번에 처리할 수 있는 int의 개수입니다 (예: 256비트 레지스터면 8개)
        int vectorSize = Vector<int>.Count;
        
        // 비교할 기준 값(100)으로 채워진 벡터를 생성합니다.
        Vector<int> threshold = new Vector<int>(100);

        // 배열을 벡터 크기만큼씩 건너뛰며 일괄 처리합니다.
        for (; i <= array.Length - vectorSize; i += vectorSize)
        {
            // 배열에서 벡터 크기만큼 데이터를 로드합니다.
            Vector<int> v = new Vector<int>(array, i);
            
            // 벡터 요소들이 100보다 큰지 한 번에 비교합니다 (크면 -1(모든 비트가 1), 작거나 같으면 0 반환)
            Vector<int> greaterThan = Vector.GreaterThan(v, threshold);
            
            // 조건을 만족하는 요소의 개수를 계산하여 count에 더합니다.
            // (C# 11 이상에서는 Vector.Sum() 등을 활용할 수도 있습니다)
            for (int j = 0; j < vectorSize; j++)
            {
                if (greaterThan[j] != 0) count++;
            }
        }

        // 벡터 크기로 나누어 떨어지지 않고 남은 나머지 요소들을 처리합니다.
        for (; i < array.Length; i++)
        {
            if (array[i] > 100) count++;
        }

        return count;
    }
}

위와 같이 SIMD를 적용하면 CPU의 벡터 레지스터를 활용하여 한 번에 여러 요소를 비교하므로, Java의 자동 최적화된 코드와 비슷하거나 더 빠른 속도를 낼 수 있습니다.

Deep Dive

성능 벤치마크를 수행할 때는 JIT 컴파일러의 계층화된 컴파일(Tiered Compilation) 특성을 이해해야 합니다. JVM은 초기에 빠른 실행을 위해 C1 컴파일러를 사용하고, 코드가 충분히 ‘예열(Warm-up)‘되면 더 강력한 C2 컴파일러로 재컴파일합니다. 따라서 벤치마크 시 예열 단계를 거치지 않으면 정확한 성능을 측정할 수 없습니다. 최근의 .NET(특히 .NET 7 및 .NET 8)은 JIT 컴파일러가 비약적으로 발전하여 OSR(On-Stack Replacement) 및 향상된 자동 루프 벡터화를 지원합니다. 따라서 최신 버전의 .NET을 사용하면 수동으로 SIMD를 작성하지 않아도 Java와 유사한 수준으로 단순 루프 성능이 자동으로 최적화되는 것을 확인할 수 있습니다.

Conclusion

C#이 Java보다 대규모 루프 처리에서 느리게 나타났던 이유는 JVM의 JIT 컴파일러가 제공하는 공격적인 루프 언롤링과 자동 벡터화 최적화 때문이었습니다. C#에서 극단적인 성능이 필요한 경우 Vector<T>를 이용한 수동 SIMD 최적화를 고려할 수 있으며, 최신 버전의 .NET으로 업그레이드하는 것만으로도 JIT 컴파일러의 개선을 통해 상당한 성능 향상을 얻을 수 있습니다.

References

협업 및 후원 연락하기 →