cpu-profiling

6 개의 포스트

datadog

JDK 25에서 JFR을 활용한 편향 없는 Java CPU 프로파일링 (새 탭에서 열림)

Java Flight Recorder(JFR)는 저부하·상시 운영에 적합한 강력한 진단 도구지만, CPU 사용량을 정확히 반영해야 하는 프로파일링에서는 한계가 있다. 특히 `ExecutionSample`은 실제 CPU 시간에 비례해 샘플링하지 않아 CPU 집약적이거나 리액티브한 애플리케이션의 핫스팟을 과소·과대평가할 수 있다. 따라서 현대 프로파일러는 JFR의 안정성과 `AsyncGetCallTrace`·JVMTI 기반 샘플링의 정확성을 결합해 사용하며, 장기적으로는 JVM이 공식 지원하는 CPU 프로파일링 이벤트가 필요하다는 것이 글의 결론이다. ## 연속 프로파일링의 기본 원리 - 프로파일러는 일정 시간 동안 스택 트레이스를 반복 수집하고 집계해 애플리케이션의 동작 패턴을 파악한다. - 샘플링 시점은 프로파일러마다 다르다. - CPU 시간 기반: 실제로 스레드가 CPU를 사용하는 동안만 시간이 증가한다. - 벽시계 시간 기반: 실행, 대기, I/O, 락 대기 등 모든 경과 시간을 포함한다. - CPU 시간 프로파일링은 연산 핫스팟을 찾는 데 적합하다. - 예: 무한 루프나 계산 집중 메서드 - 벽시계 시간 프로파일링은 지연 원인을 찾는 데 유용하다. - 예: 느린 데이터베이스 쿼리, 락 대기, I/O - 메모리 할당, 락 경합, 스레드 파킹, GC 등 특정 이벤트를 기준으로 스택을 수집하는 방식도 있다. - 어떤 이벤트를 사용하든 핵심은 “이벤트가 발생했을 때 어떤 코드가 실행 중이었는가”를 스택으로 기록하고 누적하는 것이다. ## JFR `ExecutionSample`의 장점과 한계 - JFR은 JVM에 내장되어 있으며 GC, 클래스 로딩, 스레드 스케줄링, 메모리 할당 등 다양한 런타임 이벤트를 구조화해 제공한다. - 낮은 오버헤드로 상시 실행할 수 있어 운영 환경에 적합하다. - CPU 프로파일링에는 `ExecutionSample` 이벤트가 사용된다. - 그러나 `ExecutionSample`은 운영체제 수준에서 실제 CPU 시간을 직접 기준으로 샘플링하지 않는다. - JVM이 관찰한 실행 가능 스레드 중 일부를 순환하며 샘플링한다. - CPU를 많이 사용하는 스레드가 더 자주 나타날 수는 있지만, 실제 CPU 사용량에 정확히 비례하지는 않는다. - CPU가 포화된 환경이나 리액티브 애플리케이션에서는 스레드 스케줄링 특성 때문에 특정 CPU 핫스팟이 충분히 나타나지 않을 수 있다. - 결과적으로 프로파일이 틀렸다기보다는 데이터가 불완전해져 원인 분석 시간이 길어질 수 있다. ## `AsyncGetCallTrace`를 이용한 CPU 샘플링 - JVMTI 에이전트와 운영체제 신호인 `SIGPROF`를 사용하면 CPU 시간에 기반해 샘플링할 수 있다. - 신호가 발생한 스레드 안에서 HotSpot의 `AsyncGetCallTrace`를 호출해 비동기적으로 Java 스택을 추적한다. - 이 방식은 세이프포인트 편향을 피하고 JFR에 정의된 이벤트가 아닌 임의의 CPU 이벤트에서도 스택을 수집할 수 있다. - `async-profiler` 같은 도구가 이 접근법을 널리 확산시켰다. - 실제 CPU 사용량에 가까운 결과를 제공하므로 CPU-bound 작업 분석에 특히 효과적이다. ## 내부 JVM API 의존성 문제 - `AsyncGetCallTrace`는 공식 공개 API가 아니라 HotSpot 내부 메커니즘이다. - OpenJ9, Zing 등 다른 JVM에서도 지원되지만 안정적인 표준 인터페이스로 설계된 것은 아니다. - 높은 부하나 특수한 상황에서는 오류를 일으킬 가능성이 있어 프로파일러가 JVM 장애를 방지하기 위한 방어 로직을 추가해야 한다. - Datadog 프로파일러는 다음 방식을 조합한다. - JFR: 안정적인 런타임 텔레메트리 수집 - `AsyncGetCallTrace`: 정확한 Java CPU 스택 샘플링 - `vmstructs` 워킹: JVM 내부 메타데이터를 직접 읽어 스택과 런타임 상태 복원 - `vmstructs`는 표준 API가 노출하지 않는 정보를 얻을 수 있지만, 역시 JVM 내부 구조에 의존한다. - 따라서 프로파일러 제작자는 다음과 같은 절충을 해야 한다. - JFR만 사용하면 안전하지만 CPU 샘플링 정확도가 떨어질 수 있다. - 내부 API를 사용하면 정확하지만 JVM 안정성과 유지보수 부담이 커진다. - 실제 상용 프로파일러들은 대체로 두 방식을 함께 사용한다. ## JVM 프로파일링 기반을 개선하려는 움직임 - Datadog, SAP, Amazon, OpenJDK 커뮤니티는 이 문제가 특정 회사만의 문제가 아니라는 데 공감했다. - JFR은 이미 운영 환경용 프로파일링 기반으로 적합하지만, 정확한 CPU 샘플링을 위한 공식 이벤트가 부족했다. - 기존 생태계가 비공개·비공식 HotSpot API에 의존하는 것은 장기적으로 바람직하지 않다. - 글에서는 이러한 한계를 해결하기 위해 JVM에 안전성과 정확성을 모두 갖춘 “일급 CPU 프로파일링 이벤트”를 추가하려는 협력 과정을 소개한다. - 제공된 원문은 OpenJDK 논의와 새 이벤트의 구체적인 설계 설명이 시작되는 부분에서 중단되어 있다. 운영 환경에서는 JFR만으로 CPU 병목을 단정하기보다, CPU 시간 기반 샘플링을 지원하는 프로파일러를 함께 사용하는 것이 좋다. 다만 JVM 내부 API 의존성은 안정성 위험을 동반하므로, 장기적으로는 공식 JFR CPU 프로파일링 이벤트가 제공되는 JVM과 프로파일러를 선택하는 것이 바람직하다.

datadog

.NET 연속 프로파일러: CPU 및 월 타임 프로파일링 (새 탭에서 열림)

Datadog의 .NET 컨티뉴어스 프로파일러는 CPU 사용량과 Wall Time(실행 시간)을 효과적으로 수집하기 위해 저수준 스레드 샘플링 방식을 채택하고 있습니다. 운영 환경의 부하를 최소화하면서도 정확한 데이터를 확보하기 위해 관리되는 스레드(Managed Threads)를 정밀하게 추적하며, 가비지 컬렉션(GC)과 같은 네이티브 스레드의 영향까지 함께 분석합니다. 이를 통해 개발자는 연산 집약적인 코드뿐만 아니라 I/O 대기 등으로 인한 지연 원인까지 심층적으로 파악할 수 있습니다. ### CPU와 Wall Time 프로파일링의 개념적 차이 * **CPU 프로파일링**: 스레드가 CPU 코어에서 실제로 실행되는 동안 소모한 사이클을 측정하여 연산량이 많은 코드 블록을 찾는 데 집중합니다. * **Wall Time 프로파일링**: I/O 대기나 락(Lock) 경합 등 스레드가 중단된 시간까지 포함하여 메서드 실행에 걸린 전체 시간을 측정하며, 요청 지연의 근본 원인을 파악하는 데 유용합니다. * **샘플링 방식 채택**: ETW(Windows)나 perf(Linux) 같은 도구는 높은 권한과 시스템 부하 문제로 운영 환경에 부적합하므로, 특정 주기로 스레드 스택을 관찰하는 샘플링 방식을 사용하여 성능 영향을 최소화합니다. ### 효율적인 스레드 모니터링 구조 * **관리되는 스레드 추적**: `ICorProfilerCallback`의 메서드들을 활용해 .NET 런타임이 관리하는 스레드의 생성 및 파괴를 실시간으로 모니터링하고 `ManagedThreadList`에 보관합니다. * **네이티브 스레드 오탐 방지**: 초기 구현에서는 C#을 사용했으나, 네이티브 스레드가 관리되는 메서드를 호출할 때 발생하는 예외적인 상황을 방지하기 위해 전체 구조를 C++로 작성하여 프로파일러 자체 스레드가 샘플링되는 문제를 해결했습니다. * **공용 익스포터 활용**: 수집된 샘플 데이터는 Rust로 작성된 고성능 익스포터를 통해 Datadog 백엔드로 전송되며, 이 모듈은 PHP, Ruby 등 다른 언어 프로파일러와 공유되어 안정성을 확보했습니다. ### OS 수준의 CPU 프로파일링 최적화 * **상태 확인 메커니즘**: 10ms마다 실행 가능한 스레드를 검사하며, Windows는 `NtQueryInformationThread`를, Linux는 `/proc/self/task/<tid>/stat` 파일을 파싱하여 CPU 소비량을 확인합니다. * **저수준 C 구현을 통한 성능 개선**: Linux 환경에서 `std::ifstream` 등 고수준 C++ 클래스를 사용할 때 발생하는 메모리 할당 오버헤드를 줄이기 위해, 할당이 없는 저수준 C API로 교체하여 전체 메모리 할당량의 8%와 CPU 사용량의 2%를 절감했습니다. * **GC 스레드 가시화**: .NET 5 이상의 환경에서는 프로파일링 API가 감지하지 못하는 서버 GC 및 배경 GC 스레드의 CPU 소비량을 별도로 계산하여 플레임 그래프에 표시함으로써 성능 간섭 현상을 명확히 보여줍니다. ### 분산 추적과 연동된 Wall Time 분석 * **Code Hotspots 기능**: 분산 트레이서와 연동하여 특정 요청(Span)을 처리 중인 스레드를 우선적으로 샘플링하며, 이를 통해 느린 요청의 원인이 되는 코드 경로를 정확히 짚어냅니다. * **P/Invoke 비용 최소화**: 트레이서가 프로파일러를 호출할 때 발생하는 오버헤드를 줄이기 위해, 스팬 ID가 기록되는 메모리 위치를 직접 공유하여 추가적인 API 호출 없이 데이터를 실시간으로 읽어옵니다. * **동적 샘플링**: 실행 중인 스레드가 많아질수록 샘플링 간격을 조절하여 데이터의 정확도와 시스템 부하 사이의 균형을 유지합니다. 이 프로파일러는 고성능 환경에서 안정적으로 동작하기 위해 C++와 Rust를 기반으로 저수준 OS API를 직접 제어하도록 설계되었습니다. 특히 Linux 환경에서의 파일 파싱 최적화나 트레이서와의 메모리 공유 방식은 대규모 트래픽을 처리하는 서비스에서 프로파일러 자체의 오버헤드를 극단적으로 줄여야 하는 개발자들에게 유용한 참고 사례가 됩니다.

datadog

.NET Continuous Profiler: 내부 동작 원리 (새 탭에서 열림)

Datadog의 .NET 프로파일러는 운영 환경에서 24시간 내내 실행될 수 있도록 설계된 상시(continuous) 프로파일링 도구로, 애플리케이션 성능에 미치는 영향을 최소화하면서 CPU, 메모리, 잠금 경합 등 다양한 런타임 지표를 수집합니다. 이를 통해 개발자는 별도의 재현 환경을 구축하지 않고도 실제 운영 상황의 성능 병목 현상을 정밀하게 분석할 수 있으며, 수집된 데이터는 효율적인 집계와 가독성 높은 호출 스택 변환 과정을 거쳐 백엔드로 전달됩니다. ## .NET 프로파일러의 구조와 데이터 수집 * 개별 리소스(CPU, 예외, 잠금 경합 등)를 담당하는 여러 독립적인 프로파일러와 샘플러, 데이터를 노출하는 프로바이더로 구성된 모듈형 아키텍처를 가집니다. * 수집된 샘플은 호출 스택(Call Stack), 컨텍스트 정보(Labels), 수치 데이터 벡터를 포함하며, 동일한 스택과 레이블을 가진 샘플은 하나로 병합하여 데이터 크기를 최적화합니다. * 샘플 집계 및 `.pprof` 형식으로의 직렬화 로직은 성능 향상과 타 언어 프로파일러와의 공유를 위해 Rust 언어로 구현되었습니다. ## 분산 추적 연동 및 백엔드 처리 * 'Runtime ID'를 사용하여 하나의 프로세스 내에서 여러 서비스(예: IIS AppDomain)가 실행되더라도 각 서비스를 정확히 식별하고 분산 추적(Traces/Spans) 데이터와 프로파일을 정교하게 연결합니다. * 트레이서(Tracer)가 런타임 ID와 서비스 이름(DD_SERVICE)의 매핑 정보를 프로파일러에 전달함으로써, 백엔드에서 특정 서비스에 해당하는 프로파일을 정확히 찾아낼 수 있게 합니다. ## 호출 스택(Call Stack) 가독성 개선 * 닷넷 런타임 API가 제공하는 로우(raw) 데이터를 개발자가 소스 코드 수준에서 즉시 이해할 수 있도록 정제(Clean-up)하는 과정을 거칩니다. * 생성자(`.ctor`)는 실제 클래스 이름으로, 익명 메서드나 람다식은 원래 메서드명에 `_AnonymousMethod`나 `_Lambda` 접미사를 붙여 변환합니다. * 로컬 메서드나 컴파일러가 생성한 비동기 상태 머신의 `MoveNext` 메서드 등 복잡한 이름들도 소스 코드 구조를 반영하도록 가공하여 분석의 혼선을 줄입니다. ## 네이티브 구현을 통한 성능 최적화 * 프로파일러 자체의 메모리 할당이 대상 애플리케이션의 가비지 컬렉션(GC)에 압박을 주지 않도록 핵심 로직을 C#(Managed code)이 아닌 네이티브 수준에서 구현하는 방향을 선택했습니다. * 이러한 설계는 운영 환경에서 성능 저하를 무시할 수 있는 수준으로 유지하면서도 상세한 런타임 성능 데이터를 지속적으로 수집할 수 있는 기반이 됩니다. 운영 환경의 부하를 최소화하면서 실제 트래픽 상황의 성능 문제를 정확히 진단하고 싶다면, 네이티브 수준에서 최적화되고 소스 코드 가독성까지 고려된 상시 프로파일링 도구를 도입하는 것이 가장 효과적인 전략입니다.

datadog

Datadog 에이 (새 탭에서 열림)

Datadog은 에이전트가 더 적은 CPU를 사용하면서도 더 많은 데이터를 빠르게 처리할 수 있도록 메트릭 식별 키(Metric Context) 생성 알고리즘을 최적화했습니다. Go 언어의 프로파일링 도구를 활용해 병목 지점인 태그 정렬 과정을 찾아냈으며, 특수화된 알고리즘과 해시 전략 수정을 통해 처리량을 대폭 개선했습니다. 결과적으로 동일한 리소스 내에서 더 많은 DogStatsD 메트릭을 수집하고 처리할 수 있는 성능 효율성을 달성했습니다. ## CPU 프로파일링을 통한 병목 지점 파악 * Go 언어의 런타임 도구와 플레임그래프(Flamegraph)를 사용하여 고부하 상황에서의 CPU 사용량을 분석했습니다. * 분석 결과, DogStatsD 서버가 샘플을 수신할 때 호출되는 `addSample`과 `trackContext` 함수가 가장 많은 CPU를 점유하고 있음을 확인했습니다. * 구체적으로 메트릭의 고유성을 보장하기 위해 수행하는 태그 정렬 알고리즘(`util.SortUniqInPlace`)이 전체 성능의 주요 병목 원인으로 지목되었습니다. ## 기존 메트릭 컨텍스트 생성 방식의 한계 * 메트릭 컨텍스트는 메트릭 이름과 태그 조합을 해시화하여 RAM 내 저장소의 키로 사용하며, 동일한 메트릭은 항상 같은 키를 생성해야 합니다. * 일관된 해시 생성을 위해 모든 태그를 정렬하고 중복을 제거하는 과정을 거치는데, 이 정렬 작업의 비용이 메트릭 양에 비례해 급격히 증가합니다. * 해시 충돌을 방지하면서도 수천 개의 메트릭을 초당 처리할 수 있을 만큼 알고리즘의 원시 성능이 매우 중요한 구조였습니다. ## 성능 향상을 위한 단계적 최적화 전략 * **코드 특수화(Specialization):** 태그의 개수에 따라 서로 다른 정렬 알고리즘을 적용하도록 최적화하여, 가장 빈번하게 발생하는 케이스에 대해 최상의 성능을 내도록 개선했습니다. * **해시 알고리즘 교체:** 마이크로 벤치마크를 통해 속도와 고유성이 뛰어난 **Murmur3** 알고리즘을 채택했습니다. * **Go 런타임 최적화 활용:** 기존 128비트 해시 대신 64비트 메트릭 컨텍스트를 사용하도록 변경했습니다. 이를 통해 Go 런타임의 최적화된 맵 접근 함수(`mapassign_fast64`, `mapaccess2_fast64`)가 작동하게 되어 맵 조작 속도를 높였습니다. * **근본적인 디자인 재설계:** 정렬이 성능의 가장 큰 장애물임을 인지하고, 정렬과 중복 제거에 의존하던 기존 알고리즘을 완전히 대체하는 새로운 설계 방식을 도입했습니다. 성능 최적화를 위해서는 단순히 하드웨어 사양을 높이는 대신, Go의 `pprof`와 같은 도구로 핫 패스(Hot path)를 정확히 진단하는 것이 우선입니다. 특히 대규모 데이터를 처리하는 시스템이라면 언어 런타임이 제공하는 하위 수준의 최적화(예: 특정 비트 수에 따른 맵 최적화)를 적극적으로 활용하고, 당연하게 여겨지던 정렬과 같은 알고리즘을 의심하여 재설계하는 과정이 필요합니다.

datadog

Datadog Agent 메트릭 파이프라인의 성능 개선 (새 탭에서 열림)

Datadog Agent는 동일한 CPU 리소스로 더 많은 메트릭을 빠르게 처리하기 위해 메트릭 고유 키(Context) 생성 로직을 최적화했습니다. Go 언어의 프로파일링 도구를 통해 태그 정렬 및 해싱 과정이 시스템의 주요 병목 지점임을 확인했으며, 이를 해결하기 위해 상황별 특수화 알고리즘과 64비트 해시 최적화 기법을 도입했습니다. 이러한 개선을 통해 에이전트의 데이터 처리 성능을 한 단계 높이고 리소스 효율성을 극대화하는 결과를 얻었습니다. ### 병목 지점 식별 및 분석 * Go 언어(Golang)의 CPU 프로파일링과 플레임그래프(Flamegraph) 도구를 활용하여 메트릭 파이프라인 내 리소스 소모가 큰 지점을 추적했습니다. * 분석 결과, 메트릭을 수신하고 고유 키를 생성하는 `addSample` 및 `trackContext` 함수가 가장 많은 CPU를 점유하고 있음을 확인했습니다. * 특히 태그 중복을 제거하고 동일한 해시 값을 보장하기 위해 수행하는 태그 정렬 로직(`util.SortUniqInPlace`)이 전체 성능의 주요 장애물로 작용하고 있었습니다. ### 메트릭 컨텍스트 생성의 기술적 문제 * 고유 식별을 위해 메트릭 이름, DogStatsD 태그, 컨테이너 태그를 모두 조합하여 해시 키를 생성해야 합니다. * 해시 충돌을 방지하면서도 빠른 생성 속도를 유지해야 하며, 동일한 메트릭에 대해 항상 일관된 키를 생성하기 위해 태그 리스트를 정렬하는 과정이 필수적이었습니다. * 태그 리스트 정렬은 데이터 양이 많아질수록 비용이 급격히 증가하는 특성이 있어, 매번 메트릭이 들어올 때마다 이를 수행하는 것은 비효율적이었습니다. ### 성능 최적화를 위한 다각도 접근 * **코드 특수화(Specialization):** 모든 경우에 일반적인 정렬 알고리즘을 사용하는 대신, 태그의 개수에 따라 가장 빠른 성능을 낼 수 있는 정렬 방식을 선택적으로 적용하도록 로직을 개선했습니다. * **해시 알고리즘 및 구조 개선:** 벤치마크를 통해 속도와 고유성이 검증된 Murmur3 알고리즘을 도입했습니다. * **Go 런타임 최적화 활용:** 기존 128비트 해시를 충돌 방지에 충분한 64비트로 전환하여, Go 런타임의 최적화된 맵 접근 함수(`mapassign_fast64`, `mapaccess2_fast64`)가 동작하도록 유도함으로써 처리 속도를 가속화했습니다. 데이터 집약적인 시스템에서는 런타임 프로파일링을 통해 '핫 패스(Hot path)'를 정확히 찾아내는 것이 중요합니다. 특히 태그 정렬이나 해싱과 같은 빈번한 기본 연산에서 발생하는 미세한 오버헤드를 줄이는 것만으로도 대규모 환경에서의 전체 처리량(Throughput)을 크게 향상시킬 수 있습니다.

datadog

Go 1.18의 프로파일링 개선 사항 (새 탭에서 열림)

Go 1.18은 제네릭과 퍼징(Fuzzing) 외에도 프로파일링 측면에서 비약적인 발전을 이루었으며, 특히 리눅스 환경에서의 CPU 프로파일링 정확도를 획기적으로 개선했습니다. 기존 버전에서 멀티코어 시스템의 CPU 사용량을 실제보다 낮게 측정하던 고질적인 버그를 해결하고, 프로파일러 레이블(pprof labels)의 신뢰성을 높인 것이 핵심입니다. 이러한 변화 덕분에 개발자들은 고부하 분산 시스템에서도 더욱 정밀하게 성능 병목 지점을 파악할 수 있게 되었습니다. ### 리눅스 CPU 프로파일링의 정확도 향상 * **기존 방식의 한계**: Go 1.17까지는 `setitimer(2)` 시스템 콜을 사용하여 10ms마다 `SIGPROF` 신호를 발생시켰으나, POSIX 신호의 특성상 큐에 쌓이지 않아 신호가 처리되기 전 다른 신호가 오면 유실되는 문제가 있었습니다. * **멀티코어에서의 과소측정**: 커널의 시간 측정 단위인 '지피(jiffy)' 해상도 한계로 인해 여러 코어에서 발생한 신호가 특정 시점에 몰리게 되며, 이 과정에서 대량의 신호가 누락되어 실제 CPU 사용량(예: 20코어)보다 훨씬 적은 수치(예: 2.4코어)만 기록되는 현상이 발생했습니다. * **timer_create(2) 도입**: Go 1.18은 스레드별로 신호를 관리할 수 있는 `timer_create(2)`를 도입하여 신호 유실을 방지했습니다. 이를 통해 멀티코어 시스템에서도 모든 CPU 버스트를 정확하게 포착할 수 있습니다. * **cgo 스레드 대응**: Go 런타임이 생성한 스레드뿐만 아니라 cgo 코드에서 생성된 스레드까지 아우르기 위해 `timer_create(2)`와 `setitimer(2)`를 정교하게 조합하여 구현했습니다. ### 프로파일러 레이블(pprof labels) 버그 수정 * **레이블 누락 문제**: 고루틴에 특정 키/값 쌍을 할당하여 프로파일을 분류할 수 있게 해주는 pprof 레이블이 간혹 스택 트레이스에서 누락되는 현상이 발견되었습니다. * **근본 원인 해결**: CPU 프로파일러가 레이블 정보를 수집할 때 엉뚱한 고루틴 객체를 참조하던 로직을 발견했습니다. 이를 현재 스레드에서 실제로 실행 중인 고루틴(`gp.m.curg`)을 정확히 가리키도록 수정하여 데이터의 일관성을 확보했습니다. * **트레이싱 연동 강화**: 이번 수정을 통해 프로파일링 데이터를 분산 트레이싱(Tracing)과 연결하여 분석하는 작업의 신뢰도가 크게 향상되었습니다. Go 1.18은 고성능 멀티코어 서비스를 운영하는 환경에서 필수적인 업데이트입니다. 특히 리눅스 서버에서 Go 애플리케이션의 성능을 분석할 때 이전 버전보다 훨씬 신뢰할 수 있는 데이터를 제공하므로, CPU 프로파일링 기반의 최적화를 진행 중이라면 즉시 업데이트할 것을 권장합니다.