TECHARTNOMAD | TECHARTFLOWIO.COM

GRAPHICS PROGRAMMING

UNROLL

jplee 2025. 3. 9. 23:06

UNROLL은 보통 그래픽스 셰이더 코드에서 사용되는 디렉티브 또는 어노테이션으로, 확장성 있는 루프를 최적화하기 위해 사용됩니다.

디렉티브 컴파일러란 코드에 포함된 특별한 지시문(Directive)을 통해, 컴파일러에게 특정한 동작을 수행하도록 지시하는 메커니즘을 의미합니다. 이러한 지시문은 코드의 처리 방식을 조정하거나, 특정 조건 또는 최적화를 강제하는 데 사용됩니다.
특정 코드 조각을 컴파일에 포함하거나 제외(예: #if, #define, #pragma). 특정한 최적화를 강제(예: unroll, inline). 프로젝트 또는 플랫폼 간 호환성을 보장.

UNROLL은 루프를 컴파일 타임에 전개(unroll)하는 것을 요청하거나 강제하는 역할을 합니다.
컴퓨터 그래픽스, 특히 GPU 셰이더 언어(HLSL, GLSL, 또는 Cg)에서 성능을 높이기 위해 루프를 풀어서(즉, 각 반복을 하나의 명령어 블록으로 변환) GPU 병렬 연산 성능을 극대화하기 위해 사용됩니다.
보통 이렇게 선언됩니다:

// HLSL
[unroll]
for (int i = 0; i < 4; i++)
{
    result += arr[i];
}

위 코드에서 [unroll] 디렉티브는 컴파일러에게 루프를 전개하라고 요청합니다. 컴파일러는 이 루프를 다음처럼 처리할 수 있습니다.

// HLSL
result += arr[0];
result += arr[1];
result += arr[2];
result += arr[3];

 
GLSL에서는 일반적으로 #pragma를 통해 처리가 가능하며 다음과 같이 표현될 수 있습니다

#pragma unroll
for (int i = 0; i < 4; i++) {
    result += arr[i];
}

 
실제 예시를 예로 들면...
매크로

#if defined(UNITY_COMPILER_HLSL)
	#define PSS_LOOP [loop]
	#define PSS_UNROLL(max_iterations) [unroll(max_iterations)]
#else
	#define PSS_LOOP
	#define PSS_UNROLL(max_iterations)
#endif
PSS_UNROLL(6)
for (int profileInd=0; profileInd<profileElementCnt; profileInd++)

PSS_UNROLL(6)은 작성된 코드에서 루프 언롤링(loop unrolling)을 수행하기 위한 매크로 입니다. 루프 언롤링은 컴파일러 또는 프로그램의 성능 최적화를 위해 고전적으로 사용되는 방법 중 하나이며 이 과정에서 루프의 반복 횟수를 줄이고, 반복 실행을 하나씩 직접 코드로 펼쳐서 실행 성능을 높일 수 있습니다.
매크로 PSS_UNROLL()은 6이라는 값을 전달받아 내부적으로 루프 언롤링을 적용하는 로직으로 동작할 가능성이 있습니다. 이 코드는 사용 중인 빌드 도구나 컴파일러에 따라 다릅니다. 보통 루프가 6번 반복될 경우, 해당 루프가 언롤링되어 다음과 같이 컴파일될 수 있습니다.
전개 후 실제 컴파일 된 코드의 예시를 보면

     // 루프 언롤링으로 대체된 모습
     processIteration(0);
     processIteration(1);
     processIteration(2);
     processIteration(3);
     processIteration(4);
     processIteration(5);

성능 최적화: GPU와 같은 병렬 처리 장치에서는 루프 구조와 같은 불필요한 브랜칭이 성능을 저하시킬 수 있습니다. 특히 대량의 픽셀이나 버텍스를 처리할 때 루프로 인한 제어 흐름 오버헤드가 누적되어 전체적인 렌더링 성능에 큰 영향을 미칠 수 있습니다. 루프 전개를 통해 이러한 오버헤드를 최소화하고 GPU의 병렬 처리 능력을 최대한 활용할 수 있습니다.
루프 최적화 제한 완화: GPU 셰이더 컴파일러는 반복 횟수가 가변적이거나 큰 경우 자동 루프 전개(unroll)를 생략할 수 있습니다. 이는 컴파일러가 최적화의 안정성과 효율성을 보장하기 위한 보수적인 접근 방식을 취하기 때문입니다. UNROLL 디렉티브를 사용하면 이러한 제한을 우회하여 강제로 전개할 수 있으며, 특정 상황에서는 이를 통해 상당한 성능 향상을 달성할 수 있습니다.

// 성능 최적화 예시 - 텍스처 샘플링
float4 SampleTextures(Texture2D textures[4], float2 uv) {
    float4 result = 0;
    [unroll]
    for(int i = 0; i < 4; i++) {
        result += textures[i].Sample(samplerState, uv);
    }
    return result / 4.0;
}

// 루프 최적화 제한 완화 예시 - 행렬 곱셈
float3 MultiplyMatrixVector(float3x3 matrix, float3 vector) {
    float3 result = 0;
    [unroll] // 컴파일러에게 루프 전개를 강제
    for(int i = 0; i < 3; i++) {
        float sum = 0;
        [unroll]
        for(int j = 0; j < 3; j++) {
            sum += matrix[i][j] * vector[j];
        }
        result[i] = sum;
    }
    return result;
}

단, 루프의 반복 횟수가 과도하게 크면 코드 크기 증가로 인해 컴파일러나 GPU 성능이 저하될 수 있습니다. 특히 인스트럭션 캐시 미스가 발생하거나 레지스터 압박이 심해질 수 있으므로 신중한 접근이 필요합니다. UNROLL은 선택적 요청이므로 컴파일러가 이를 무시할 수 있으며, 항상 실행이 보장되지는 않습니다. 컴파일러는 하드웨어 제약사항이나 최적화 정책에 따라 UNROLL 요청을 거부할 수 있습니다.
UNROLL은 컴파일 시점에서 루프 전개를 강제하여 성능을 최적화하는 그래픽스 프로그래밍 도구입니다. 이는 루프 반복 횟수가 작고 고정된 경우에 특히 효과적이며, Unity나 언리얼 엔진과 같은 게임 엔진의 셰이더 프로그래밍에서 흔히 사용됩니다. 특히 텍스처 샘플링, 라이팅 계산, 행렬 연산과 같은 고정된 패턴의 반복 작업에서 뛰어난 성능 향상을 보여줍니다. 또한 최신 GPU 아키텍처에서는 컴파일러의 최적화 능력이 향상되어, UNROLL을 통한 성능 개선 효과가 더욱 두드러지게 나타날 수 있습니다.
언리얼 엔진에서 UNROLL을 효과적으로 사용하는 방법은 GPU 셰이더 코드의 성능을 최적화하는 데 중점을 둡니다. UNROLL은 셰이더 코드에서 루프를 컴파일 타임에 전개(Unroll)시켜 연산 오버헤드를 줄이는 데 사용됩니다. 하지만 효과적으로 사용하려면 루프의 성격과 GPU 아키텍처를 고려해야 합니다. 아래는 언리얼 엔진에서의 최적화된 활용 방안입니다.

UNROLL 기본 적용 원리

[unroll]
for (int i = 0; i < 4; i++) {
    result += weights[i] * values[i];
}

컴파일 타임에 전개되는 결과.

result += weights[0] * values[0];
result += weights[1] * values[1];
result += weights[2] * values[2];
result += weights[3] * values[3];

이 방식은 반복문 처리의 조건 분기(branching)를 제거해 GPU 병렬 처리 성능을 극대화할 수 있습니다.

2. 언리얼 엔진에서 효과적인 UNROLL 사용 방법

2.1. 루프 반복 횟수가 작고 고정적인 경우

UNROLL은 루프의 반복 횟수가 작고 고정된 경우에 적합합니다. 언리얼 엔진에서는 루프 횟수를 결정할 때 다음과 같이 적용 가능합니다.

최소 반복 횟수 예제:

[unroll]
for (int i = 0; i < 4; i++) {
    result += ComputeLightContribution(lightData[i]);
}

반복 횟수가 4처럼 적고 고정된 경우, 셰이더 컴파일러가 이를 쉽게 최적화하여 반복문 전개를 수행합니다.
루프 반복 횟수가 큰 경우 반복 횟수가 큰 경우에는 GPU 소스 코드가 과도하게 길어져 컴파일 속도와 성능이 저하될 수 있습니다. 따라서 반복 횟수가 많은 루프에서는 UNROLL을 피해야 합니다.

2.2. 브랜칭 분기를 줄이는 데 사용

브랜칭 조건이 포함된 루프에서는 GPU가 각 스레드마다 조건문을 평가하고 분기 처리를 수행해야 하므로, 병렬 처리 효율성이 크게 저하될 수 있습니다. 특히 워프(warp) 내의 스레드들이 서로 다른 분기를 실행하게 되면, 워프 다이버전스(warp divergence)가 발생하여 성능이 급격히 떨어질 수 있습니다. UNROLL을 사용하면 이러한 조건문들을 컴파일 시점에서 완전히 펼쳐서 제거할 수 있으며, 이를 통해 런타임에서의 분기 처리 오버헤드를 최소화하고 GPU의 병렬 처리 능력을 최대한 활용할 수 있습니다.

// 브랜칭 조건을 포함

for (int i = 0; i < numLights; i++) {
    if (lightData[i].enabled) {
        result += ComputeLightContribution(lightData[i]);
    }
}
// UNROLL 적용

[unroll]
for (int i = 0; i < 8; i++) { // 가정: 최대 조명 개수
    result += lightData[i].enabled ? ComputeLightContribution(lightData[i]) : 0.0;
}

분기를 감소시킴으로써 GPU가 더 효율적으로 루프를 병렬 처리.
다른 예를 들면

// 최적화되지 않은 코드 - 워프 다이버전스 발생
float4 ProcessPixel(float3 normal, float3 lightDir[4]) {
    float4 result = 0;
    for(int i = 0; i < 4; i++) {
        if(dot(normal, lightDir[i]) > 0) {
            result += CalculateLighting(normal, lightDir[i]);
        }
    }
    return result;
}

// UNROLL을 사용한 최적화된 코드
[unroll]
float4 ProcessPixelOptimized(float3 normal, float3 lightDir[4]) {
    float4 result = 0;
    for(int i = 0; i < 4; i++) {
        // 조건문 대신 수학적 연산으로 대체
        float NdotL = max(0, dot(normal, lightDir[i]));
        result += CalculateLighting(normal, lightDir[i]) * NdotL;
    }
    return result;
}

두 번째 버전에서는 UNROLL 지시문을 사용하고 조건문을 제거하여 워프 다이버전스를 방지하고 GPU의 병렬 처리 효율을 높일 수 있습니다.

 

2.3. GPU 텍스처 샘플링에 적용

언리얼 엔진의 셰이더 코드에서 여러 텍스처 샘플링 작업이 필요한 경우, UNROLL 지시문을 활용하면 큰 이점을 얻을 수 있습니다. 일반적으로 텍스처 샘플링은 메모리 접근이 필요한 고비용 연산인데, UNROLL을 적용하면 컴파일러가 이러한 샘플링 작업들을 순차적 실행 대신 병렬로 처리할 수 있도록 최적화합니다. 이는 특히 포스트 프로세싱 효과나 머티리얼 블렌딩과 같이 여러 텍스처를 동시에 처리해야 하는 상황에서 성능 향상에 크게 기여할 수 있습니다.

// 예로 블러효과를 처리할 때 텍스처 샘플링 루프를 전개하여 반복적인 계산 오버헤드 방어.

[unroll]
for (int i = -2; i <= 2; i++) {
    for (int j = -2; j <= 2; j++) {
        result += texture.Sample(textureSampler, uv + float2(i, j) * blurScale);
    }
}

2.4. 고정 크기 배열 처리

언리얼 셰이더 코드에서 고정 크기 배열 데이터를 연산할 때도 UNROLL은 매우 적합한 최적화 방법입니다. 배열의 크기가 컴파일 시점에서 명확하게 정의되어 있기 때문에, 컴파일러는 루프를 효과적으로 전개하여 실행 시간을 단축시킬 수 있습니다. 예를 들어, 여러 종류의 조명 데이터를 계산할 때 반복문 대신 UNROLL을 사용하면 각각의 조명 계산이 독립적으로 처리되어 GPU의 병렬 처리 능력을 최대한 활용할 수 있습니다. 특히 포인트 라이트, 스팟 라이트, 디렉셔널 라이트와 같은 다양한 광원 타입에 대한 계산을 수행할 때 이러한 최적화가 효과적입니다.

// MAX_LIGHT 는 고정값으로 설정
// 컴파일러가 전개하여 각 조명 계산을 분리된 코드로 생성

[unroll]
for (int i = 0; i < MAX_LIGHTS; i++) {
    outputColor += ComputeLight(lights[i], worldPos);
}

3. UNROLL 사용 시 주의사항

3.1. GPU 퍼포먼스와 코드 크기 균형 유지

  1. 루프 전개는 너무 잦은 경우 코드 크기가 급격히 증가할 수 있으며, 이는 셰이더 컴파일 속도를 현저하게 저하시킬 수 있습니다. 또한 전개된 코드는 GPU 명령어 캐시 효율성을 감소시키고, 전반적인 렌더링 파이프라인의 처리 속도에도 부정적인 영향을 미칠 수 있습니다.
  2. 특히 반복 횟수가 큰 루프(>32)인 경우, 불필요한 GPU 리소스 사용이 발생하고 성능이 급격히 저하될 수 있습니다. 이런 상황에서는 UNROLL 대신 LOOP 디렉티브를 사용하는 것이 더 효율적일 수 있으며, 이를 통해 컴파일러가 해당 플랫폼에 최적화된 루프 구조를 자동으로 생성하도록 할 수 있습니다. 특히 대규모 데이터 처리나 복잡한 수학적 연산이 필요한 경우에는 더욱 그러합니다.
// LOOP`는 실제 반복문으로 컴파일하여 GPU 레지스터와 성능 효율성을 유지.


[loop]
for (int i = 0; i < numElements; i++) {
    result += array[i];
}

3.2. 반복 횟수가 동적인 루프에는 사용하지 말 것

UNROLL은 고정된 루프 반복 횟수에 적합합니다. 이는 컴파일 시점에서 루프의 전체 구조를 미리 파악하고 최적화할 수 있기 때문입니다. 하지만 반복 횟수가 동적으로 결정되거나 런타임에 변경되는 경우, 컴파일러가 최적의 코드를 생성하기 어렵습니다. 또한 너무 복잡한 로직이 포함된 경우에는 UNROLL로 인해 생성되는 코드의 크기가 급격히 증가할 수 있으며, 이는 GPU 메모리 관리 효율을 떨어뜨리고 캐시 적중률을 저하시킬 수 있으므로 피해야 합니다.

동적 루프의 예:

int loopCount = GetLoopCount();  // 동적 값
[unroll] // 컴파일러가 실행 가능 여부 판단
for (int i = 0; i < loopCount; i++) {
    result += SomeFunction();
}

반복 횟수가 고정되지 않은면 LOOP 로 대체할 것.

3.3. 플랫폼별 특성을 고려

각각의 GPU 아키텍처(AMD, NVIDIA, Intel, ARM Mali 등)마다 UNROLL과 루프 최적화 방식이 서로 다른 특성을 보입니다. 특히 모바일 GPU와 데스크톱 GPU 사이에는 최적화 방식에서 큰 차이가 있을 수 있습니다. 언리얼 엔진의 멀티플랫폼 특성상, 셰이더 코드는 다양한 하드웨어 환경에 대한 호환성을 고려해야 하며, 각 플랫폼에서 발생할 수 있는 성능 차이와 렌더링 결과물의 일관성도 함께 검토해야 합니다.


4. 언리얼 엔진 워크플로에서 적용 시 고려 사항

  1. 머티리얼 그래프에서 Custom 노드 활용: 언리얼 머티리얼 그래프에서 복잡한 루프 연산이 필요할 때는 Custom 노드를 활용해 HLSL 셰이더 코드를 직접 작성할 수 있습니다.
  2. Shader Complexity 뷰 활용: 셰이더 복잡도를 시각화함으로써 UNROLL이 성능에 미치는 영향을 실시간으로 확인할 수 있습니다.
  3. 플랫폼 테스트: UNROLL 최적화 효과를 검증하기 위해 컴파일된 셰이더 파일을 확인하거나 RenderDoc, FrameView 등의 프로파일링 도구로 GPU 성능을 모니터링합니다.