박종훈 기술블로그
close menu

JDK-8382880 기여기: C2 Final Reshape Counts의 dead code 제거

이번 글에서는 OpenJDK의 JDK-8382880 이슈에 기여하며, C2 컴파일러의 최종 그래프 재구성 단계에서 사용되던 카운트 필드들이 어떤 역할을 했고 왜 제거되었는지를 정리해본다.

이슈의 제목과 내용은 다음과 같다.

C2: Remove unused Final_Reshape_Counts::get_*_count() methods

JDK-8351152 removed the last calls to Final_Reshape_Counts::get_*_count() which are now unused. This can be cleaned up.

이슈 내용

JDK-8351152에서 UseSSE < 2 관련 코드가 제거되면서, Final_Reshape_Counts 구조체의 다음 getter 메서드들이 더 이상 호출되지 않게 되었다.

int get_call_count () const { return _call_count ; }
int get_float_count () const { return _float_count ; }
int get_double_count() const { return _double_count; }

이슈는 이 미사용 getter들을 정리하는 것이 목적이다.

코드를 살펴보니, getter를 제거하면 대응하는 필드(_call_count, _float_count, _double_count)와 increment 메서드(inc_call_count(), inc_float_count(), inc_double_count())도 더 이상 사용처가 없어진다는 것을 알 수 있었다. getter뿐 아니라 이들까지 함께 정리하기로 했다.

Final_Reshape_Counts란

Final_Reshape_Counts는 C2 JIT 컴파일러의 최종 그래프 재구성(final graph reshaping) 단계에서 사용되는 구조체다.

C2는 Java 바이트코드를 Ideal Graph라는 중간 표현(IR)으로 변환한 뒤 다양한 최적화를 수행하고, 마지막에 이 그래프를 기계어 매칭 가능한 형태로 변환한다. 여기서 Ideal Graph란, 연산 하나, 변수 하나, 상수 하나가 각각 노드(Node)로 표현되는 그래프 자료구조다. 예를 들어 a + b * c라는 코드는 Add, Mul, a, b, c 각각이 노드가 된다. Final_Reshape_Counts는 이 마지막 단계에서 그래프를 순회하면서 노드 타입별 통계를 수집하는 역할을 한다.

FPU 모드와 제거된 카운트의 과거 용도

이 카운트들이 왜 존재했는지 이해하려면, x87 FPU의 정밀도 모드를 알아야 한다.

x87 FPU란

x87 FPU는 Intel이 1980년에 출시한 8087 칩에서 시작된 부동소수점 전용 보조 프로세서 계열이다. 초기 CPU(8086 등)는 정수 연산만 할 수 있었기 때문에, 부동소수점 연산을 하려면 별도의 칩(8087)을 꽂아야 했다. 이후 80486부터 FPU가 CPU에 내장되었고, 이 내장 FPU가 여전히 x87 명령어 세트를 사용하기 때문에 “x87 FPU”라는 이름이 남아있다.

x87 FPU 정밀도 모드

x87 FPU는 부동소수점 연산의 정밀도(precision)를 제어하는 모드 비트를 가지고 있다.

  • 24-bit (single precision) — float 연산용
  • 53-bit (double precision) — double 연산용
  • 64-bit (extended precision) — x87 기본값

Java는 float은 32비트, double은 64비트로 엄격하게 정의하는데, x87 FPU는 내부적으로 80비트 확장 정밀도로 계산한다. Java 스펙에 맞추려면 FPU 제어 워드를 전환해야 했다.

float 연산과 double 연산이 섞여 있으면 정밀도 모드를 24-bit와 53-bit 사이에서 반복적으로 전환해야 하는데, 이 전환에는 비용이 발생한다. 현대 x86에서는 SSE/SSE2 명령어를 사용하므로 x87 FPU 모드 전환이 필요 없다. SSE는 float/double을 각각 별도 명령어로 처리하기 때문이다.

JDK-8351152에서 제거된 최적화 코드

JDK-8351152에서 제거된 코드는 다음과 같다.

#ifdef IA32
  // If original bytecodes contained a mixture of floats and doubles
  // check if the optimizer has made it homogeneous, item (3).
  if (UseSSE == 0 &&
      frc.get_float_count() > 32 &&
      frc.get_double_count() == 0 &&
      (10 * frc.get_call_count() < frc.get_float_count()) ) {
    set_24_bit_selection_and_mode(false, true);
  }
#endif // IA32

SSE 없이(UseSSE == 0) x87 FPU만 사용하는 32비트 x86(IA32)에서, float 연산이 32개 이상이고 double 연산이 없으며 함수 호출이 float 연산의 1/10 미만이면, FPU를 24-bit 정밀도 모드로 고정하는 최적화였다. float만 쓰는 메서드라면 모드 전환 비용을 아예 제거할 수 있었던 것이다. 단, 함수 호출이 많으면 callee가 다른 정밀도를 사용할 수 있어 이 최적화를 포기한다.

이 코드가 get_float_count(), get_double_count(), get_call_count()의 유일한 호출부였고, 이것이 제거되면서 세 getter가 미사용 상태가 된 것이다.

수정 내용

파일: src/hotspot/share/opto/compile.cpp

1. 미사용 getter 제거

이슈에서 직접적으로 요청한 부분이다. 더 이상 호출되지 않는 다음 메서드들을 제거했다.

int get_call_count () const { return _call_count ; }
int get_float_count () const { return _float_count ; }
int get_double_count() const { return _double_count; }

2. 필드와 increment 메서드 제거

getter가 사라지면서 대응하는 필드와 increment 메서드도 dead code가 되었다. 함께 제거했다.

  • 필드: _call_count, _float_count, _double_count
  • 메서드: inc_call_count(), inc_float_count(), inc_double_count()

3. 호출부에서 inc_* 호출 제거

final_graph_reshaping_main_switch() 내의 다음 호출들을 제거했다.

  • frc.inc_float_count() (2곳)
  • frc.inc_double_count() (2곳)
  • frc.inc_call_count() (2곳)

4. case label은 유지

float/double 관련 Op 노드들의 case label을 제거하면 default case로 빠지면서 assert(!n->is_Mem()) assertion이 발생한다. 따라서 case label은 유지하되, 카운트 증가 없이 break만 수행하도록 변경했다.

5. if/else 구조 정리

frc.inc_call_count()만 수행하던 if 분기를 제거하고, else 분기(uncommon call의 shared argument clone)의 조건을 반전시켜 정리했다.

추가: 정리 후 남은 필드

참고로, 이번 정리 대상이 아닌 Final_Reshape_Counts의 나머지 필드들은 다음과 같은 역할을 한다.

필드설명
_java_call_count인라인되지 않은 Java 호출 수
_inner_loop_count정렬이 필요한 내부 루프 수
_visited노드 방문 여부 추적용 비트셋
_tests분기 노드 목록 (무결성 검사용)

_java_call_count_inner_loop_count는 코드 생성 직전에 노드 수를 예측하는 데 사용된다. 코드 생성 단계에서 Ideal Node들이 MachNode(기계어 명령 노드)로 변환되면서, 정렬용 NOP이나 호출 관련 보조 명령어가 추가되어 노드 수가 늘어난다. 노드가 너무 많아지면 메모리를 과도하게 사용하게 되므로, 미리 예측해서 한도를 초과하면 C2 컴파일을 포기하고 인터프리터나 C1으로 폴백한다.

  • _java_call_count: Java 호출 하나당 약 3개의 MachNode가 추가로 생긴다고 추정하여 노드 수 예측에 반영한다. 또한 Java 호출이 하나라도 있으면 stack banging(스택 공간 사전 체크) 코드를 삽입하여 스택 오버플로우를 미리 감지한다.
  • _inner_loop_count: 루프는 성능을 위해 메모리 주소 정렬(alignment)을 맞추는데, 이때 NOP 명령어를 패딩으로 삽입한다. 루프 하나당 최대 (OptoLoopAlignment - 1)개의 NOP 노드가 추가될 수 있으므로, 이를 미리 반영한다.

_tests는 그래프 순회 중 만나는 분기 노드들을 모아두는 리스트다. IfNode(if-else 조건 분기, 나가는 경로 2개)와 PCTableNode(switch문이나 예외 처리 분기, 나가는 경로 여러 개)을 수집해두었다가, 나중에 “나가는 경로 수가 맞는지” 검증한다. 예를 들어 IfNode는 true/false 2개의 경로가 있어야 하는데, 최적화 과정에서 한쪽이 도달 불가능(dead code)으로 제거되면 경로가 1개만 남을 수 있다. 이런 불일치를 잡아내는 무결성 검사용이다.

추가: x87 FPU와 SSE의 구조적 차이

이번 이슈를 조사하면서 x87 FPU와 SSE의 차이에 대해서도 알아보았다. 앞서 정밀도 모드 전환 비용을 언급했는데, 사실 x87과 SSE는 그보다 더 근본적인 아키텍처 차이가 있다.

스택 vs 레지스터: x87 FPU는 8개의 레지스터가 스택 구조로 연결되어 있어, 연산마다 값을 PUSH하고 계산 후 POP하는 과정을 거쳐야 한다. 반면 SSE는 독립적인 XMM 레지스터를 직접 지정하여 연산한다(addss xmm0, xmm1처럼). 레지스터 간 직접 계산이 가능하므로 코드가 직관적이고 컴파일러 최적화도 용이하다.

SIMD(Single Instruction, Multiple Data): x87은 한 번에 값 하나만 처리하지만, SSE는 128비트 XMM 레지스터를 활용하여 float 4개 또는 double 2개를 동시에 처리할 수 있다. 최신 AVX 확장은 256/512비트까지 지원하여 한 명령어로 더 많은 데이터를 병렬 처리한다.

이런 이점들 때문에 SSE2 이상을 지원하는 환경에서는 x87 FPU를 사용할 이유가 없어졌고, 앞서 본 정밀도 모드 전환 최적화도 불필요해진 것이다.

마무리

코드 변경 자체는 단순한 dead code 제거지만, 히스토리를 따라가면서 C2 컴파일러의 최종 그래프 재구성 단계가 어떤 일을 하는지, 그리고 현대 x86에서는 더 이상 필요 없어진 x87 FPU 모드 최적화가 과거에 어떤 역할을 했는지를 알 수 있었다. 매번 새로운 것을 배우게 된다.

부동소수점을 이해하는것은 항상 쉽지 않다.

categories: 개발

tags: JDK , OpenJDK , HotSpot , C2 , 오픈소스 기여