디버그에선 잘되는데 릴리즈에선 왜 죽을까? 컴파일러의 루프 최적화 버그 추적기

최근 libde265 라이브러리를 1.0.18에서 1.0.19로 업데이트한 후 기묘한 현상을 겪었다.
디버그 모드에서는 잘 작동하던 프로그램이, 릴리즈 모드로 빌드만 하면 시작하자마자 프로세스가 강제 종료되는 것.

더 황당한 것은 1.0.19의 헤더와 lib로 빌드한 상태에서 1.0.18의 DLL을 슬쩍 끼워넣으면정상 동작한다는 점이었다.
링크 오류도 아니고, 빌드 옵션 문제도 아닌 이 기괴한 현상의 원인을 디스어셈블리 레벨까지 내려가 추적해 보았다.

결론을 스포일링 하자면, 1.0.18VS2026 v18.5.3으로 빌드한 것이고, v18.6.0으로 빌드했을 때 문제가 터졌다.
즉, 라이브러리는 잘못이 없고 컴파일러가 범인이다.

y = 188 이라니, 뭐가 문제지?

RelWithDebInfo 구성을 통해 릴리즈 모드를 유지하면서 디버그 기호(PDB)를 남겨서 예외 지점을 포착했다.
디버거가 가리킨 곳은 영상 디코딩 스캔 순서를 초기화하는 fill_scan_pos 내부의 do-while 루프.

// fill_scan_pos 내부 크래시 지점
position S = ScanOrderSub[lastSubBlock];
xC = (S.x<<2) + ScanOrderPos[lastScanPos].x; // <-- 여기서 Access Violation (0xC0000005) 발생

이때 디버거에 찍힌 인자 값이 좀 이상했다.

  • log2TrafoSize = 2 (4x4 블록이므로 x, y는 0~3 사이여야 함)
  • x = 0, y = 188

4x4 블록을 처리하는 루프에서 y가 188이라니요…
게다가 레지스터 창을 보니 배열 인덱스인 rdx 값이 0xffffffffffef72771라는 거대한 음수를 갖고 있었다.

즉, 무언가 이유로 루프 탈출 조건(xC == x && yC == y)을 찾지 못해 무한 루프가 돌았고…
인덱스가 음수 영역으로 깎여 내려가다가 유효하지 않은 메모리 경계를 건드려 터진 것이다.

그렇다면 왜 루프가 폭주했을까? 호출부의 코드를 살펴보았다.

호출부의 코드

문제가 발생한 상위 호출 함수는 다음과 같이 평범한 4중 루프 구조를 가지고 있다.

void init_scan_orders()
{
  // (앞부분 생략...)

  for (int log2size=2; log2size<=5; log2size++)
    for (int scanIdx=0; scanIdx<3; scanIdx++)
      for (int y=0; y<(1<<log2size); y++)
        for (int x=0; x<(1<<log2size); x++)
          {
            fill_scan_pos(&scanpos[scanIdx][log2size][ y*(1<<log2size) + x ], x, y, scanIdx, log2size);
          }
}

조건을 보면 분명 y < (1<<log2size)이므로 log2size=2일 때 y는 4보다 작아야 한다.
절대로 y = 188라는 값이 나올 수 없다.

결국 범인은 이 코드를 기계어로 번역한 컴파일러의 최적화 엔진이라는 뜻이다.
상황별 컴파일 결과를 비교해 보면 그 요망한(?) 흔적이 고스란히 드러난다.


디스어셈블리 비교 분석

1. 기본 릴리즈 빌드 (최적화 오류 발생)

최적화 엔진이 루프 한계선(\(1 \ll \text{log2size}\))을 캐싱하려고 시도하다가, 초기화(Store)하기도 전에 값부터 꺼내 쓰는(Load) 순서 역전 버그를 유발하는 것이 원인이다.

; 4중 루프 내부 진입 직전 (log2size 루프 내부)
00007FFB97D13A92  mov         eax,dword ptr [rsp+80h]  ; [Error] 아직 계산 결과가 채워지지 않은 스택 공간에서 '쓰레기 값'을 먼저 로드

... (중간 레지스터 할당 로직) ...

; y 루프 (for (int y=0; y<(1<<log2size); y++)) 경계 검사 영역
00007FFB97D13AC0  mov         r13d,eax                 ; r13d(y 루프 한계선)에 방금 읽은 쓰레기 값이 그대로 주입됨
00007FFB97D13AC3  mov         eax,esi                  
00007FFB97D13AC5  mov         dword ptr [rsp+80h],eax  ; [지각] 정작 올바르게 계산된 진짜 한계값(esi)은 이제야 스택에 저장됨
00007FFB97D13ACC  test        r13d,r13d
  • 핵심 포인트: [rsp+80h]에 진짜 루프 한계선이 저장되는 시점은 3AC5 행이다.
    하지만 컴파일러는 이보다 훨씬 앞선 3A92 행에서 값을 먼저 읽어와 r13d를 오염시킨다.
  • 결과: 초기화되지 않은 무작위 데이터가 y 루프의 상한선이 되면서 y가 경계를 넘어 폭주하게 만들고, 하위 함수에서 무한 루프와 크래시를 유발한다.

2. #pragma optimize(“”, off) 적용 (최적화 해제)

최적화가 원인인 것을 알았으니, 최적화 자체를 해제하는 방법이 가장 간단하다.

#if defined(_MSC_VER)
#pragma optimize("", off)
#endif

void init_scan_orders()
{
  // (코드 생략...)
}

#if defined(_MSC_VER)
#pragma optimize("", on)
#endif

최적화 엔진의 개입을 완전히 배제했을 때의 컴파일 결과이다.
레지스터 캐싱 같은 기교를 부리지 않고, 매 단계마다 스택 메모리를 참조하며 정직하게 연산한다.

; y 루프 한계선 검사 및 비교 영역
00007FFB97D13B38  mov         eax,1  
00007FFB97D13B3D  mov         ecx,dword ptr [rsp+34h]  ; 스택에서 현재 log2size 안전하게 로드
00007FFB97D13B41  shlx        eax,eax,ecx              ; eax = 1 << log2size 를 실시간 동적 계산
00007FFB97D13B46  cmp         dword ptr [rsp+3Ch],eax  ; 변수 y([rsp+3Ch])와 방금 계산한 한계선(eax)을 비교
00007FFB97D13B4A  jge         init_scan_orders+1B6h 
  • 핵심 포인트: 매 루프마다 shlx(부호 없는 비트 시프트 가속 명령어)를 사용해 상한선을 실시간으로 엄격하게 다시 계산한다.
  • 결과: 오작동은 완벽하게 사라지며 코드가 안전하게 돌아간다.
    다만 매번 메모리 로드와 비트 연산이 반복되므로, 컴파일러가 제공할 수 있는 성능 최적화 혜택은 포기.

3. const int 명시적 상수 선언 (정상 최적화 우회)

원인은 알았으니, 최적화를 적절히 할 수 있는 범위까지 진도를 나가본다.

void init_scan_orders()
{
  // (앞부분 생략...)

  for (int log2size=2; log2size<=5; log2size++)
  {
    const int one_log2size = 1<<log2size;
    for (int scanIdx=0; scanIdx<3; scanIdx++)
      for (int y=0; y<one_log2size; y++)
        for (int x=0; x<one_log2size; x++)
          {
            fill_scan_pos(&scanpos[scanIdx][log2size][ y*one_log2size + x ], x, y, scanIdx, log2size);
          }
  }
}

복잡한(?) 수식을 지역 변수로 쪼개어 컴파일러에게 명확한 선후 관계를 제공한 결과다.
꼬여있던 데이터 흐름이 풀리면서 컴파일러가 완전히 새롭고 영리한 기계어를 생성한다.

; 1. 루프 진입 전 명확한 정적 초기화
00007FFBB8D73A92  mov         esi,4                    ; log2size=2 일 때의 첫 한계값 '4'를 esi 레지스터에 대입

... (4 루프 제어  fill_scan_pos 호출 로직) ...

; 2. y 루프 경계 조건 비교 (스택 참조 전면 제거)
00007FFBB8D73AB0  test        esi,esi                  ; 레지스터에 고정된 상수(esi)를 기준으로 y 루프 경계를 검사
00007FFBB8D73AB2  jle         init_scan_orders+0D8h 

... 

; 3. 최외각 루프 종결 및 다음 단계를 위한 레지스터 갱신 영역
00007FFBB8D73B11  inc         ebp                      ; log2size++
00007FFBB8D73B13  rol         esi,1                    ; [최적화] esi 레지스터를 왼쪽으로 1비트 회전(2배 곱하기)!!
00007FFBB8D73B15  cmp         ebp,5  
  • 핵심 포인트: 문제의 스택 공간([rsp+80h])을 쓰던 오염된 제어 로직이 완전히 사라졌다.
    log2size가 늘어날 때마다 한계치인 one_log2size가 4, 8, 16, 32로 정확히 2배씩 증가한다는 특성을 컴파일러가 온전히 파악해 냈다.
  • 결과: 바깥 루프가 돌 때마다 rol esi, 1(Bit Rotation) 명령어로 레지스터 자체를 가속하여 상한선을 처리한다.
    버그를 안전하게 우회하면서도, 기계어 아키텍처 관점에서는 원래 최적화 의도보다 훨씬 정교하고 빠른 하드웨어 친화적 바이너리가 완성되었다.

마치며: const 선언이 주는 뜻밖의 효과

지금까지 코드에서 const로 임시 변수를 분리하는 건 그저 ‘사람의 가독성’을 높이기 위한 규칙이라고만 생각했었다.

하지만 이번 디버깅을 통해 복잡한 수식을 명시적 변수로 격리하는 행위가 컴파일러 최적화 엔진의 연산 우선순위 그래프를 단순화하여, 컴파일러의 코드 오독(Miscompilation)을 막아주는 강력한 방파제 역할을 할 수 있다는 교훈을 얻었다.
컴파일러가 가끔은 생각보다 멍청할 수 있으니, 릴리즈 모드에서만 발생하는 의문의 크래시를 마주한다면 반드시 디스어셈블리 창을 열어 컴파일러의 동선을 의심해 봐야 한다.


덧1. 디스어셈블리 결과를 분석하는데는 역시 AI가 킹왕짱.

덧2. 본 포스팅 역시 상당 부분은 AI로 작성했음.

덧3. VS2026 v18.6.1에서도 이 오류는 수정되지 않았음.

덧4. MS에 리포트 완료.

  1. 0xffffffffffef7277은 -1,084,809 

카테고리:

업데이트: