VS2026 v18.6.0 루프 최적화 버그 추적기
디버그에선 잘되는데 릴리즈에선 왜 죽을까? 컴파일러의 루프 최적화 버그 추적기
최근 libde265 라이브러리를 1.0.18에서 1.0.19로 업데이트한 후 기묘한 현상을 겪었다.
디버그 모드에서는 잘 작동하던 프로그램이, 릴리즈 모드로 빌드만 하면 시작하자마자 프로세스가 강제 종료되는 것.
더 황당한 것은 1.0.19의 헤더와 lib로 빌드한 상태에서 1.0.18의 DLL을 슬쩍 끼워넣으면 또 정상 동작한다는 점이었다.
링크 오류도 아니고, 빌드 옵션 문제도 아닌 이 기괴한 현상의 원인을 디스어셈블리 레벨까지 내려가 추적해 보았다.
결론을 스포일링 하자면, 1.0.18은 VS2026 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에 리포트 완료.
-
0xffffffffffef7277은 -1,084,809 ↩
