dev.to

The Java Compiler That Became Its Own Test Case

자바 컴파일러가 런타임의 테스트 케이스가 되기까지

Java-to-C 번역기 자체를 ParparVM에서 실행하는 셀프 호스팅에 성공했습니다. 약 3만 7천 줄 규모의 프로그램으로 런타임 결함과 성능 병목을 찾았으며, 출력 일치율은 247개 중 245개였습니다. 성능은 JDK 25보다 느리고 메모리 사용량도 큽니다.

AI 요약

Codename One의 Java-to-C 번역기는 Java로 작성됐습니다. 이 번역기를 ParparVM에서 실행해 자체적으로 컴파일하는 셀프 호스팅에 성공했습니다. 생성된 네이티브 실행 파일은 Java 프로그램을 C로 변환합니다. 번역기와 ASM을 합친 약 3만 7천 줄 규모의 프로그램이 컬렉션, 문자열, 파일 처리, 예외, 가비지 컬렉터를 함께 사용하므로 런타임을 실제 작업 부하로 시험하는 수단도 됩니다.

셀프 호스팅 범위와 출력 검증

셀프 호스팅 빌드는 clean, ios, macos 번역 경로를 다룹니다. JavaScript 생성기와 이 소스 범위 밖의 API를 요구하는 일부 아카이브·압축 도우미는 빌드 시 스텁으로 대체합니다. ParparVM에서 번역기와 ASM을 실행해 JVM 번역기와 같은 클래스 파일을 읽고 C 코드를 생성합니다.

검증은 네이티브 빌드를 반복 실행했을 때 출력이 같은지, JVM 번역기와 결과가 일치하는지를 구분합니다. 기록된 JavaAPI 규모의 입력에서는 247개 파일 중 245개가 바이트 단위로 같았습니다. 나머지 두 파일은 HashMap 메서드의 데드 코드 제거 방식이 달랐습니다. 생성된 두 프로그램은 모두 컴파일됐고 실행 결과도 일치했습니다. 따라서 셀프 호스팅이라는 이름만으로 차이를 덮지 않고, 이 두 파일을 구체적인 비교 대상으로 남겼습니다.

런타임에서 드러난 정확성 결함

초기 오류 중 하나는 ASM이 만든 레이블 이름이었습니다. 이름에 객체 식별 해시가 들어갔고, ParparVM에서 해시가 음수면 label_L-180306432001: 같은 C 코드가 나왔습니다. C에서는 마이너스 기호가 연산자로 해석되므로, 유효한 Java 예외 처리기를 포함한 메서드도 잘못된 C가 됐습니다. 생성 식별자는 이제 객체 해시에 기대지 않고 결정적으로 만듭니다. HashSet 순회에 따라 달라지던 지역 변수 선언 순서도 안정화했습니다. 객체가 메모리에 배치된 위치에 따라 컴파일 결과가 달라지지 않도록 한 수정입니다.

또 다른 결함은 래퍼 클래스의 TYPE 필드였습니다. Integer.TYPE, Long.TYPE, Double.TYPE 값이 null이었습니다. 번역기는 원시 타입에 대응하는 C 타입을 고를 때 원시 타입 클래스를 맵의 키로 씁니다. 키가 모두 null이면 서로 다른 타입 항목이 같은 키로 합쳐집니다. 작은 컬렉션 테스트에서는 드러나지 않을 수 있는 런타임 오류가 번역기 전체를 실행하면서 발견됐습니다.

성능 병목과 측정 결과

상수 풀에는 문자열이 약 20만 개 쌓였습니다. 기존 구현은 새 문자열을 넣을 때마다 indexOf()로 목록을 검색해 항목이 늘수록 삽입 비용도 커졌습니다. 수정 후에는 출력 인덱스를 유지하는 목록과 검색용 맵을 함께 씁니다. 목록이 기존처럼 출력 순서를 결정하고, 맵은 문자열이 이미 있는지 빠르게 확인합니다. 따라서 검색을 빠르게 바꿔도 상수 풀 순서는 바뀌지 않으며, 출력 비교로 이 점을 검증할 수 있습니다.

가비지 컬렉터에서도 조정이 필요했습니다. 살아 있는 객체가 많고 할당도 잦은 프로세스가, 가용 메모리를 충분히 고려하지 않은 성장 임계값에 막혔습니다. 프로파일링 결과 프로세스가 할당이나 수집 작업을 하지 않고 잠든 상태가 확인됐습니다. 수정은 무제한 프로세스 경로의 동작을 조정하되, 명시적으로 설정한 프로세스 메모리 예산은 계속 적용합니다.

릴리스 빌드 비교는 ASM과 번역기를 포함한 약 570개 클래스를 16코어, 메모리 64GB Mac에서 변환했습니다. -O3와 ThinLTO를 사용한 네이티브 ParparVM 번역기는 1.84초에 최대 메모리 1,443MB를 썼습니다. JDK 25는 1.56초와 516MB, JDK 8은 2.27초와 502MB였습니다. 네이티브 번역기는 이전보다 약 6배 느린 상태에서 개선됐지만, 이 측정에서는 JDK 25가 더 빨랐고 메모리도 훨씬 적게 썼습니다. 이후 조사에서는 불필요한 컬렉션 객체와 오래 유지되는 문자열이 추가 개선 대상으로 확인됐습니다.

빌드와 검증

저장소의 vm/selfhost에서 작동하는 JDK 8을 JDK_8_HOME으로 지정하고 Maven도 해당 JDK를 사용해야 합니다. 기본 빌드는 다음 명령으로 실행합니다.

./build-selfhost.sh

./verify-selfhost.sh /path/to/application/classes AppName com.example

기본 빌드는 -O1을 사용하며 검증 스크립트가 기대하는 target/parpar를 만듭니다. 성능 비교에 사용한 -O3 빌드는 별도의 target/parpar-O3를 생성하므로 기본 실행 파일을 덮어쓰지 않습니다. 검증에는 완전한 입력 클래스 집합과 번역기가 기대하는 애플리케이션 이름, 패키지를 전달해야 합니다. 벤치마크 스크립트는 같은 입력에 여러 런타임을 번갈아 적용하고, 성능 비율을 출력하기 전에 생성 결과를 확인합니다. 출력이 다른 컴파일러가 더 빨리 끝났다는 이유만으로 성능 비교에서 이긴 것으로 보지 않습니다.

셀프 호스팅 작업은 PR #5766에 포함됐습니다. 결정적 이름 생성과 상수 풀 검색 수정은 일반 빌드에서 사용하는 번역기에도 반영됐습니다.

원문: dev.to / 번역·요약: Trawling