루프 언롤링 — JIT가 반복문을 펼치는 이유
루프 언롤링은 반복문의 본문을 여러 번 복제해 종료 검사 횟수를 줄이고 명령어 수준 병렬성과 벡터화의 발판을 마련하는 JIT 최적화입니다. 동작 원리와 효과, 그리고 우리가 직접 손으로 펼치면 안 되는 이유를 정리합니다.
지난 글에서 GC가 자동으로 메모리를 절약하는 모습을 봤습니다. 이번엔 다시 JIT 컴파일러로 돌아와, C2가 핫 루프를 만났을 때 수행하는 고전적 최적화 하나를 살펴봅니다. 바로 루프 언롤링(Loop Unrolling) 입니다. 이름 그대로 반복문을 “펼치는” 것인데, 단순해 보이지만 현대 CPU의 동작 방식과 맞물려 상당한 성능 차이를 만들어 냅니다.
반복마다 숨어 있는 비용
배열을 합산하는 평범한 루프를 생각해 봅시다. 우리는 sum += a[i]라는 본질적인 일에만 주목하지만, 매 반복에는 보이지 않는 비용이 따라붙습니다 — i < n 비교, 조건 분기, i++ 증가입니다. n이 100만이면 이 부수 작업도 100만 번씩 실행됩니다.
루프 언롤링은 본문을 여러 번 복제해 한 반복에서 여러 원소를 처리합니다. 그러면 종료 검사 횟수가 그만큼 줄어듭니다.
4배 언롤링하면 종료 조건 검사가 1/4로 줄어듭니다. 다만 n이 4의 배수가 아닐 수 있으므로, 남는 “꼬리(tail)” 반복은 별도의 작은 루프로 처리합니다. 이 모든 변환을 C2가 자동으로 수행합니다.
왜 빨라지는가 — 세 가지 효과
언롤링의 이점은 단지 비교 연산을 줄이는 데 그치지 않습니다.
첫째, 분기 오버헤드 감소입니다. 반복 횟수에 비례하던 종료 검사·점프가 줄어듭니다.
둘째, 명령어 수준 병렬성(ILP) 향상입니다. 펼쳐진 본문의 a[i], a[i+1], a[i+2], a[i+3] 덧셈은 서로 독립적입니다. 현대 CPU는 의존성 없는 명령을 동시에 여러 개 실행할 수 있는 슈퍼스칼라 구조이므로, 독립적인 연산이 많을수록 파이프라인이 꽉 차게 돌아갑니다. 꼬여 있던 반복이 펼쳐지면서 이 병렬성이 드러납니다.
셋째, 벡터화의 발판입니다. 펼친 연산들을 SIMD 명령(한 번에 여러 데이터를 처리)으로 묶을 수 있게 됩니다. C2의 자동 벡터화(SuperWord)는 언롤링된 루프 위에서 작동해, a[i..i+3]을 한 번의 벡터 덧셈으로 처리하기도 합니다.
손으로 펼치지 마라
여기서 흔한 유혹이 생깁니다. “그럼 내가 미리 펼쳐 쓰면 더 빠르지 않을까?” 거의 항상 그렇지 않습니다.
// 안티패턴: 손으로 언롤링
for (int i = 0; i < n; i += 4) {
sum += a[i] + a[i+1] + a[i+2] + a[i+3];
// n이 4의 배수가 아니면? 경계 버그 위험
// 가독성 저하, JIT의 추가 최적화 방해
}
손으로 펼친 코드에는 여러 문제가 있습니다. 경계 처리 버그가 생기기 쉽고, 가독성이 떨어지며, 무엇보다 JIT가 더 잘 펼칠 수 있는데 우리가 어설프게 펼쳐서 오히려 방해할 수 있습니다. C2는 대상 CPU의 캐시 라인, 레지스터 개수, 벡터 폭까지 고려해 최적의 언롤 횟수를 정합니다. 우리가 손으로 정한 ×4가 그 판단보다 나을 가능성은 희박합니다.
가장 좋은 코드는 JIT가 최적화하기 쉬운 단순하고 규칙적인 루프입니다.
// 권장: 단순하고 명확하게. 최적화는 JIT에 맡긴다
int sum = 0;
for (int i = 0; i < a.length; i++) {
sum += a[i];
}
a.length를 직접 조건으로 쓰면 JIT가 배열 길이를 알고 범위 검사(bounds check)까지 제거할 수 있어, 오히려 더 빠른 코드가 나옵니다.
언제 안 되나
언롤링도 만능은 아닙니다. 본문에 호출이 인라이닝되지 않는 메서드가 있거나, 반복 간에 데이터 의존성이 강하거나(sum처럼 누적 변수가 하나뿐이면 ILP 이점이 줄어듦), 루프가 너무 커서 코드 크기(코드 캐시)를 과하게 차지하면 언롤링이 제한되거나 생략됩니다. 이런 경우까지 JIT가 판단하므로, 우리는 루프를 단순하게 유지하는 데만 집중하면 됩니다.
정리
루프 언롤링은 반복문 본문을 복제해 분기 오버헤드를 줄이고 ILP·벡터화의 발판을 마련하는 JIT 최적화입니다. 핵심 교훈은 “직접 펼치지 말 것” — 단순하고 규칙적인 루프를 짜면 C2가 대상 CPU에 맞춰 알아서 최적화합니다. 그런데 이런 최적화의 효과를 손으로 측정하려 하면 거의 틀린 숫자를 얻게 됩니다. 다음 글에서는 이런 마이크로벤치마크를 올바르게 측정하는 표준 도구, JMH를 다룹니다.
지난 글: 문자열 중복 제거 — G1이 똑같은 문자열을 합치는 법
다음 글: JMH — 신뢰할 수 있는 마이크로벤치마크
읽어주셔서 감사합니다. 😊