밀리초 단위로 벤치마크하기
요약
본 글은 소프트웨어 성능 측정을 위한 심층적인 방법론을 제시합니다. 단순 평균 비교를 넘어 신뢰구간(Confidence Interval)과 통계적 검정(t-test 등)을 활용하여 정확한 성능 차이를 분석해야 합니다. 또한, 마이크로 벤치마크의 한계를 인지하고 시스템 전체의 정상 상태 성능 및 부하 조건에서의 백분위수 측정이 중요함을 강조합니다.
핵심 포인트
- 단순 평균 대신 신뢰구간(95% CI)과 통계적 검정을 사용해야 합니다.
- 부하와 실제 경과 시간에 둔감한 지표로 측정하는 것이 중요합니다.
- 마이크로벤치마크는 기초 자료일 뿐, 시스템 전체의 정상 상태 성능을 평가해야 합니다.
- 평균보다 부하가 걸린 상태의 95~99 백분위수(Percentile)를 확인해야 합니다.
신뢰구간을 제시하는 벤치마크, 또는 대조군과 비교하는 벤치마크라고 부르는 편이 낫겠음. 절대적인 수치 하나만으로는 알 수 있는 게 적으므로, CPU 부하·스로틀링·GC 등 수많은 변수의 영향을 받는 대조군을 같은 실행 안에서 측정해야 함. 여러 차례 실행할 때도 구현들을 번갈아 측정해 잡음이 공평하게 분산되도록 해야 함.
측정값이 쌓이면 평균만 비교하지 말고 95% 신뢰구간 등을 계산해야 함. 구간이 겹치면 어느 쪽이 빠른지 확신하기 어려울 수 있고, 겹치지 않으면 대체로 판단할 수 있음. 좋은 벤치마크는 반복 횟수를 늘릴수록 신뢰구간이 좁아져 미세한 개선도 구별할 수 있지만, 좁아지지 않는다면 신호 대 잡음비의 한계에 도달한 셈임.
엄격히 통제된 하드웨어 실험실 밖에서 신뢰할 만하고 실제로 활용할 수 있는 결과를 얻은 방법은 이것뿐이었음. Google의 Tachometer가 이렇게 동작하며, 다른 실행 도구들도 이 방식을 채택하면 좋겠음. https://github.com/google/tachometer.
신뢰구간을 보여주는 대신 사용자가 p값을 지정하게 하고, Welch의 t 검정 같은 것을 실행하면 됨.
내 기준은 부하와 실제 경과 시간에 둔감한 지표로 벤치마크하기임. 그렇지 않으면 현재 환경의 한계를 확인하는 데 그치기 쉬움.
측정 대상과 분야, 필요한 신뢰도에 따라 크게 달라짐. criterion이 측정값을 안정시키느라 오래 걸리기도 하지만, 그 정도 정확도는 필요 없다는 접근 때문에 잡음을 쫓으며 시간을 낭비하거나 실제로는 변화가 없거나 느려진 결과를 성능 개선으로 착각하는 경우를 봤음.
10ms보다 짧으면 인터프리터 시작 같은 고정 비용에 왜곡된다는 대목은 저자의 경험이 Python에 한정된 듯한 인상을 줌. Java라면 JIT가 프로그램을 충분히 최적화했는지부터 확인해야 함.
데이터베이스처럼 대표성 있는 데이터를 만들고 성능을 평가하는 데 오래 걸리는 분야도 많음. 짧은 마이크로벤치마크는 기초 자료로 유용하지만, 결국 전체 시스템의 정상 상태 성능을 평가해야 함. 게임 렌더링도 300ms만 측정해서는 25분 뒤 프레임이 떨어지는지, 메모리가 누수되는지 알 수 없음.
루프 밖으로 연산 이동하기 같은 최적화는 벤치마크 결과가 불분명해도 가독성을 높일 수 있음. 실제 환경과 시험 환경의 동작은 양방향으로 크게 달라질 수 있으며, 특히 공간과 시간의 절충은 동시에 실행되는 다른 코드의 캐시 부담에도 영향을 줌.
예전에 프로파일러가 실행 시간의 5%를 차지한다고 표시한 불필요한 함수 호출을 제거했더니 전체 시간이 20% 줄었음. 그때부터 프로파일러가 틀릴 수 있는 방식을 고민하게 됐으며, 최적화는 여전히 경험과 감에 의존하는 면이 큼.
도구는 어디를 살펴볼지 알려줄 뿐, 수정 사항을 남길지는 별개의 판단임. 매몰비용이나 체면 때문에 효과 없는 변경도 병합하려는 경우가 있고, 한 번 우연히 빠르게 나온 결과에 집착해 이후 열 번의 반대 결과를 무시하기도 함.
대량 처리 시스템에서 10ms는 상당히 긴 시간임. 내가 운영한 Java 시스템은 비즈니스 로직 처리나 캐시를 적극 활용한 데이터 처리의 서버 지연 시간이 1ms 미만이었고, 클라이언트에서는 3~5ms 정도였음.
측정값 집계에도 주의해야 함. 평균은 거의 아무것도 알려주지 못하며, 부하가 걸린 상태의 95·99·99.9백분위수는 평균이나 중앙값과 완전히 다른 모습을 보여줄 수 있음.
처음부터 성능 전문가가 되려던 것은 아니지만, 지루한 과제를 재미있게 만들려고 최적화하다 보니 그렇게 됐음. 졸업 후 먼 곳으로 이사해 첫 직장에 갔더니 UI가 손으로 그림을 그리는 영상을 빨리 감기한 것처럼 느리게 표시됐음. 까다로운 버그 몇 개를 고쳐 실력을 증명한 뒤 성능 개선에 착수함.
사무실에서 가장 느린 컴퓨터를 쓰던 덕분에 처음 열두 번가량의 수정에는 벤치마크조차 필요 없었음. 머릿속으로 초를 세는 것만으로 0.25초 이상 줄었는지 알 수 있었고, 나중에는 휴대 기기의 스톱워치로 100ms 차이를 측정함. 코드에 종료 시각과 시작 시각의 차이를 출력하기까지 거의 두 달이 걸렸음.
이후 데이터가 쌓이면서 필터링의 확장성 문제가 드러남. 서로 다른 조건으로 두 번 훑은 결과를 제곱 시간 비교로 대조하는 교집합 검사가 프로젝트 수십 곳에 조금씩 다른 형태로 복사돼 있었음. 긴 연휴 동안 이를 filter(filter(x))를 수행하는 함수 하나로 통합해 코드 500줄을 줄였고, 여러 해에 걸친 데이터에서도 호출 시간의 증가율을 크게 낮춤.
이런 벤치마크는 CPU 클럭의 지속적인 변동, 시스템 인터럽트, SMI 등으로 결과가 틀어지는 경우가 많음.
AMD Zen 3에서 하이퍼스레딩을 끄고, 클럭 조절 정책과 부스트를 바꾸고, CPU 주파수를 고정해 봤지만 소용없었음. 변동이 너무 커 결과를 재현할 수 없어 결국 포기했으며, 다른 환경에서는 다를 수 있음.
Intel의 정밀 벤치마크 지침은 커널 안에서 코드를 실행하고, 인터럽트를 끄고, cpuid로 비순차 실행을 막고, rdtsc 대신 rdtscp를 쓰는 방법 등을 제시함.
시스템을 최대한 예측 가능하게 만드는 것 외에는 잡음을 인정하고 반복 측정 뒤 통계적으로 분석하는 방식으로 대응해야 함. 원하는 신뢰도에 맞춰 준비 실행, 반복 횟수, 측정 시간 등을 정할 수 있으며, 분포가 여러 봉우리를 가진다면 백분위수를 추적하는 것도 유용함.
2008년쯤 10마이크로초 수준의 고빈도 매매 연산을 다룰 때는 결과가 매우 예측 가능했음. GC가 없는 C++를 사용하고, 시작 시 객체 풀을 미리 할당해 핵심 경로의 힙 잠금 경합을 피했음.
입출력은 뮤텍스로 보호하는 연결 리스트를 통해 별도 스레드로 넘겼고, 처리 스레드는 전용 CPU 코어에 고정함. 당시 가능한 한 결정적으로 동작하도록 만든 구성이었음.
내가 만드는 이기종 컴퓨팅 컴파일러 xc는 GPU와 CPU를 같은 언어로 다루고 둘 사이의 데이터 위험을 컴파일러가 관리함. 벤치마크에서 가장 오래 걸리는 부분은 대체로 1초에서 몇 초 수준이며, arm64나 x86_64에서 C++·Swift·ObjC와 비교할 때도 그 정도 시간 규모를 원함.
다만 2차원 행렬 곱셈의 자동 벡터화가 대부분의 컴파일러가 활용하지 못하는 arm64의 SME/SME2를 이용해 clang/g++보다 150배 빨라지면, 의미 있는 비교값을 얻으려 실행 시간을 조금 늘려야 할 수도 있음. https://compile-xc.org/compiler/performance/
내가 아는 아주 똑똑한 사람이 벤치마크를 오래 돌릴수록 무관한 다른 활동까지 측정하게 될 가능성이 높아진다고 했음. 그래서 짧게 여러 번 실행하고 가장 빠른 결과를 채택하는 방식을 권했음.
Python Need For Speed Sprint에서 벤치마크를 많이 수행했는데, 이 조언이 꽤 잘 통했음.
https://github.com/c-blake/bu/blob/main/doc/tim.md도 기본적으로 같은 철학을 따르지만, 측정 시간의 단순 최솟값 대신 좀 더 정교한 최솟값 추정량을 사용함. 표본 최솟값도 어떤 의미에서는 실제보다 조금 높을 수밖에 없음.
문서에는 원글의 200400ms 같은 목표 시간에 맞추려고 문제 규모를 무심코 늘릴 때 생기는 측정 함정도 다룸.0.2% 차이도 관찰 가능하고, 보고되는 불확실성은 대체로 변동을 잘 포착함. 다만 분포는 정규분포가 아니며, 더 많은 형태 매개변수나 95% 신뢰구간 같은 것이 필요할 수 있음.
이 도구로 사용자 공간에서 CPU 주파수만 고정해도 같은 장비의 CPU 연산 시간이 몇 달에 걸쳐 한 자릿수 마이크로초 수준으로 안정적으로 나오는 것을 자주 봄. 10ms 작업에서 0.01
Java 벤치마크에는 Java Microbenchmark Harness(JMH) 가 있음. https://github.com/openjdk/jmh.
여러 차례 준비 실행을 거쳐 인터프리터 실행이나 컴파일 시간이 아닌 JIT 최적화 코드를 측정하고, JVM을 여러 개 띄워 실행 간 편차를 줄임. 불필요한 코드 제거를 막는 Blackhole, 사전 설정과 상수 접기 방지에 쓰는 State 같은 도구도 제공함.
이 조언은 https://xkcd.com/2400/의 맥락에서 읽어야 함. 한 번도 최적화하지 않은 코드에서 손쉬운 수정으로 3배나 10배 속도 향상을 노린다면 괜찮을 수 있음.
하지만 최적화를 더 밀어붙이고 더 작은 개선을 찾아야 할수록, 이 조언은 의존하기 어려운 경험칙에 가까워짐.
2% 미만의 성능 개선을 자랑하는 프로젝트는 crate와 V8 포인터 압축 작업, 두 곳에서만 봤음. 나는 보통 수정 여섯 개를 합쳐 15% 개선했다고 하거나, TTFB가 600ms에서 590ms로 줄었다는 식으로 보고함.
여러 수정을 묶을 때는 같은 호출 트리나 공통 관심사에 집중해야 함. 그래야 첫 수정에 드는 검증 비용에 비해 추가 수정의 시험 범위가 조금만 늘어나고, 회귀 오류를 놓칠 위험과 출시 지연 부담도 함께 분산할 수 있음. 코드베이스 전체에 작은 수정을 흩뿌리면 시험 비용과 결함 유출 위험이 급증해, 점진적 개선 자체를 막으려는 분위기가 생김.
이 방식을 터득한 프로젝트에서는 하위 시스템을 하나씩 옮겨 다니며 아는 문제를 모두 고쳐, 2년 동안 매 단계마다 30%씩 개선했음. 한 번에 수정이 세 개일 때도 열 개일 때도 있었음. 두 번째 순회에서는 새로 배운 방법을 이전 영역에 적용하기도 했지만, 주로 새 기능과 버그 수정이 가져온 성능 퇴행을 찾아냈음.
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기