25P by GN⁺ 1일전 | ★ favorite | 댓글 3개
  • OpenJDK의 ThreadMXBean.getCurrentThreadUserTime()/proc 파일 파싱 대신 clock_gettime() 호출로 교체되며 최대 400배 성능 향상을 달성
  • 기존 구현은 /proc/self/task/<tid>/stat 파일을 열고 읽고 파싱하는 복잡한 I/O 경로를 거쳤음
  • 새 구현은 Linux 커널의 clockid_t 비트 인코딩을 활용해 pthread_getcpuclockid()로 얻은 ID의 하위 비트를 조정, 유저 타임만 직접 조회
  • 벤치마크 결과 평균 호출 시간이 11μs → 279ns로 감소, 이후 커널 fast-path 적용 시 약 13% 추가 개선
  • POSIX 제약을 넘어 리눅스 내부 ABI 이해를 통한 최적화가 가능함을 보여주는 사례

기존 구현의 문제

  • getCurrentThreadUserTime()/proc/self/task/<tid>/stat 파일을 열어 13번째와 14번째 필드를 파싱해 CPU 유저 타임을 계산
    • 파일 경로 생성, 파일 열기, 버퍼 읽기, 문자열 파싱, sscanf() 호출 등 다단계 처리 필요
    • 명령어 이름에 괄호가 포함될 수 있어 strrchr()로 마지막 )를 찾는 복잡한 로직 포함
  • 반면 getCurrentThreadCpuTime()은 단일 clock_gettime(CLOCK_THREAD_CPUTIME_ID) 호출만 수행
  • 2018년 버그 리포트(JDK-8210452)에 따르면 두 메서드 간 속도 차이는 30~400배에 달함

/proc 접근 경로와 clock_gettime() 경로 비교

  • /proc 방식은 open(), read(), sscanf(), close()여러 시스템 호출과 커널 내부 문자열 생성을 포함
  • clock_gettime() 방식은 단일 시스템 호출sched_entity 구조체에서 직접 시간 값을 읽음
  • 병렬 부하 시 /proc 접근은 커널 락 경합으로 인해 지연이 심화됨

새로운 구현 방식

  • POSIX 표준은 CLOCK_THREAD_CPUTIME_ID유저+시스템 시간을 반환하도록 정의되어 있음
  • Linux 커널은 clockid_t의 하위 비트로 시계 종류를 인코딩
    • 00=PROF, 01=VIRT(유저 전용), 10=SCHED(유저+시스템)
  • pthread_getcpuclockid()로 얻은 clockid의 하위 비트를 01로 바꾸면 유저 타임 전용 시계로 전환 가능
  • 새 코드에서는 파일 I/O와 파싱을 제거하고, clock_gettime() 호출만으로 유저 타임을 반환

성능 측정 결과

  • 수정 전 평균 호출 시간 11.186μs, 수정 후 0.279μs로 약 40배 개선
    • 16스레드 환경에서 측정, 원래 보고된 30~400배 범위와 일치
  • CPU 프로파일에서 파일 열기·닫기 관련 시스템 호출이 사라지고, 단일 clock_gettime() 호출만 남음

커널 fast-path 추가 최적화

  • 커널은 clockidPID=0이 인코딩된 경우 현재 스레드로 바로 접근하는 fast-path를 제공
  • JVM이 pthread_getcpuclockid() 대신 직접 clockid를 구성해 PID=0을 넣으면 radix tree 탐색을 생략 가능
  • 수동 구성한 clockid 사용 시 평균 시간 81.7ns → 70.8ns, 약 13% 추가 개선
  • 다만 clockid_t 크기 등 커널 내부 구현에 의존하므로 가독성과 호환성 손실 우려 존재

결론 및 교훈

  • 40줄 삭제로 400배 성능 격차 제거, 새로운 커널 기능 없이 기존 ABI의 세부 구조 활용만으로 달성
  • 커널 소스 코드 탐독의 가치 강조: POSIX는 이식성을 보장하지만, 커널 코드는 가능성의 한계를 보여줌
  • 기존 가정 재검토의 중요성: /proc 파싱은 과거에는 합리적이었으나, 현재는 비효율적임
  • 이 변경은 JDK 26(2026년 3월 출시 예정)에 포함되어, ThreadMXBean.getCurrentThreadUserTime() 호출 시 자동 성능 향상 제공

대단하네요.

2배 빨라졌다면 똑똑한 짓을 한 것일 수 있고, 100배 빨라졌다면 멍청한 짓을 멈춘 것일 뿐

완전 틀린 말은 아니라고 생각하지만, 커널과 엮인 경우에는 느린 걸 알아차리는 것부터 정말 어려웠을 거라고 생각합니다.

사실 코드 전체 갈아엎어서 2~3배 향상도 어려운 일인데, 단순히 몇줄 바꿔서 최대 400배 향상은 정말 대단하군요.

Hacker News 의견들
  • 작성자인 나임. 지난번 커널 버그 관련 글 이후, JVM이 자체적으로 스레드 활동을 보고하는 방식을 살펴봤음
    “이 스레드의 CPU 사용 시간은 얼마인가?”라는 질문이 생각보다 너무 비싼 연산이라는 걸 알게 되었음
    • 나노초 단위의 측정을 논하려면 시계의 안정성과 정확도를 매우 잘 이해해야 함
      원자시계 수준의 기준이 없다면 절대적인 수치를 주장하기 어렵다고 생각함
    • 분포가 여러 자릿수 단위로 퍼져 있는 이유를 살펴봤는지 궁금함. 그 자체로 흥미로운 현상임
    • 짧은 TL;DR 요약이 정말 고마웠음. 이런 요약이 글의 진입 장벽을 낮춰주고 읽는 동기를 만들어줌
    • “놀랍지 않음(Quelle Surprise)”이라는 반응을 남김
  • clock_gettime()vDSO를 통해 컨텍스트 스위치를 피함. 그래서 flamegraph에서도 그 흔적이 보임
    • 하지만 일부 clock만 해당됨. CLOCK_VIRTCLOCK_SCHED 같은 경우는 여전히 syscall 호출이 필요함
    • vDSO 프레임 아래를 보면 여전히 syscall이 있음. 특정 clock id에 대한 빠른 경로(fast path) 가 구현되어 있지 않은 듯함
    • CLOCK_THREAD_CPUTIME_ID는 결국 커널로 넘어감. task struct를 참조해야 하기 때문임
      관련 커널 소스는 posix-cpu-timers.c,
      cputime.c,
      gettimeofday.c 참고
  • PERF_COUNT_SW_TASK_CLOCK을 사용하면 약 8ns 수준의 측정도 가능함
    perf_event_mmap_page를 통해 공유 페이지에서 읽고, rdtsc 호출로 델타를 계산하는 방식임
    문서화가 잘 안 되어 있고 오픈소스 구현도 거의 없음
    • 정말 멋진 트릭임. 다만 perf_event 설정과 권한 요구가 크기 때문에 긴 수명의 스레드에 적합해 보임
    • seqlock이 필요한 이유를 물음. 페이지 값과 rdtsc 사이에서 컨텍스트 스위치가 일어나지 않도록 하기 위함인지 궁금함
      아마 rdtsc 후 페이지 값을 다시 확인해 바뀌었으면 재시도하는 구조로 보임
      참고로 clock_gettime도 vdso 기반 가상 syscall임
    • clock_gettime은 syscall이 아니라 vdso를 사용함
  • Flamegraph는 정말 훌륭한 도구임
    코드만 봤을 땐 괜찮아 보이지만, flamegraph를 보면 “이게 뭐야?!” 싶을 때가 많음
    정적 초기화가 아닌 초기화, 한 줄짜리 로거 호출이 비싼 직렬화를 유발하는 등 여러 문제를 발견했음
    • 나는 icicle graph도 좋아함. flamegraph의 반대 방향으로 누적되어, 여러 경로가 공통 라이브러리를 호출할 때 병목을 보기 쉬움
    • 이 SVG 예시를 새 탭에서 열면 인터랙티브 줌이 가능함
    • 성능 프로파일링과 최적화 실험은 개발에서 가장 즐거운 부분 중 하나임. “왜 저게 이렇게 느리지?”라는 놀라움이 많음
    • 문자열 파싱과 memoization의 조합이 이상하게 들린다는 의견도 있었음. 실제로는 비싼 정규식 패턴 파싱을 캐싱하지 않아 생긴 문제였음
    • flamegraph를 처음 써보려는 사람에게는 기본 개념과 시작점을 물어봄
  • “이미지 새 탭에서 열기”가 실제로 SVG 인터랙션을 제공한다는 점이 놀라웠음
  • OpenJDK 패치 작성자인 나임. /proc을 읽을 때의 메모리 오버헤드와 eBPF 프로파일링, 그리고 문서화가 부족한 user-space ABI의 역사를 다뤘음
    자세한 내용은 내 블로그 글에 정리했음
    • 왜 원래 구현이 그렇게 되어 있었는지 궁금하다는 질문을 받음. 매 호출마다 파일 IO와 문자열 파싱을 하는 건 비효율적이지만, 당시엔 이유가 있었을 것이라 생각함
    • Jaromir가 내 글을 보고 “나도 같은 시기에 초안을 썼다”며 서로의 글을 링크함. 내 글이 더 엄밀하다고 평가해줘서 기뻤음
  • C나 C++ 같은 시스템 언어라고 해서 항상 빠른 건 아님. 무엇을 하느냐에 따라 속도는 크게 달라짐
  • vDSO를 통한 읽기는 커널 전환, 버퍼 직렬화, 파싱 과정을 피하기 때문에 훨씬 빠름
  • “2배 빨라졌다면 똑똑한 짓을 한 것일 수 있고, 100배 빨라졌다면 멍청한 짓을 멈춘 것일 뿐”이라는 인용을 공유함
    출처 트윗
  • QuestDB 팀은 이 분야에서 최고 수준임. 사람들도, 소프트웨어도 모두 훌륭함
    Jaromir의 블로그도 정말 멋졌음