← SEARCHABLE ARCHIVE
Assembly · 2026-08-15

x86-64 성능 최적화는 어떻게 측정할까?

uop, 의존성, 캐시, 분기 예측을 벤치마크와 프로파일로 검증하기

INTERACTIVE READING MODE →

x86-64 성능 최적화는 어떻게 측정할까?

본 블로그 작성 글들은 강의를 목표로 제작되었습니다. CPU 성능은 microarchitecture, 전력·온도 상태, 메모리 계층, 운영체제와 입력에 따라 달라지므로 이 글의 패턴을 고정 cycle 숫자로 외우지 마세요.

어셈블리 명령 수가 적다고 반드시 빠르지 않다. 한 명령이 여러 micro-operation으로 분해될 수 있고, 서로 독립인 여러 명령은 겹쳐 실행될 수 있다. 반대로 명령이 하나뿐이어도 cache miss나 긴 dependency가 전체 시간을 지배할 수 있다.

성능 최적화는 “멋진 명령으로 교체”하는 작업이 아니라 병목을 측정하고, 원인 가설을 만들고, 같은 조건의 실험으로 반증하는 과정이다.

latency와 throughput을 구분한다

  • latency: 한 연산의 입력이 준비된 뒤 결과를 다음 연산이 사용할 수 있을 때까지의 지연
  • throughput: 독립된 같은 연산을 단위 시간에 얼마나 자주 시작할 수 있는가
; dependency chain: 각 add가 이전 결과를 기다림
add rax, rbx
add rax, rcx
add rax, rdx

; 독립 작업: 겹쳐 실행될 여지가 있음
add r8,  rbx
add r9,  rcx
add r10, rdx

같은 add 세 개라도 critical path가 다르다. 벤치마크가 dependency chain을 재는지, independent throughput을 재는지 구분한다.

instruction과 uop은 같은 단위가 아니다

x86 front-end는 명령을 내부 uop으로 디코드한다. 한 instruction이 하나 또는 여러 uop이 될 수 있고, 일부 패턴은 macro-fusion이나 micro-fusion의 영향을 받는다. microcoded instruction도 있다.

따라서 다음은 모두 불완전한 지표다.

  • 소스 줄 수
  • 어셈블리 명령 수
  • binary byte 수
  • loop의 jmp 개수

이 지표들은 front-end 압력 가설에는 도움을 주지만 실행 시간을 직접 증명하지 않는다.

병목은 여러 층에 있다

층대표 병목
Front-endinstruction cache, decode, uop cache, branch fetch
Executionport 사용, latency, dependency, divider
MemoryL1/L2/L3 miss, DRAM, TLB, store forwarding
Controlbranch misprediction, indirect target
Systempage fault, scheduler, lock contention, I/O

함수 안의 10개 명령을 줄여도 page fault 하나가 지배하면 체감 성능은 변하지 않는다. 먼저 wall time과 profiler로 실제 hot path인지 확인한다.

cache locality가 명령 선택보다 클 수 있다

for (size_t i = 0; i < n; ++i)
    sum += values[i];

연속 접근은 hardware prefetcher와 cache line 사용에 유리하다. 같은 원소 수라도 큰 구조체에서 한 필드만 읽으면 cache line 대부분을 버릴 수 있고, pointer chasing은 다음 주소가 현재 load 결과에 의존해 memory-level parallelism을 제한한다.

AoS를 SoA로 바꾸거나 working set을 줄이는 데이터 구조 변경이 handwritten ASM보다 훨씬 큰 효과를 낼 수 있다.

branch prediction은 입력 분포 문제다

if (value > threshold)
    ++count;

값이 거의 항상 한쪽이면 분기는 잘 예측될 수 있다. 무작위에 가깝다면 misprediction이 많을 수 있다. cmov나 mask 변환은 제어 의존성을 데이터 의존성으로 바꾼다.

branchless는 양쪽 작업을 모두 수행하거나 dependency chain을 늘릴 수 있다. 입력을 실제 분포와 다르게 무작위로 만든 microbenchmark는 제품 성능을 잘못 예측할 수 있다.

partial register와 false dependency를 이해한다

32비트 register write가 상위를 0으로 만드는 성질은 dependency 제거에 도움이 된다.

xor eax, eax

현대 CPU는 이런 zeroing idiom을 특별히 처리할 수 있다. 반면 일부 partial-register 조합이나 오래된 microarchitecture는 병합 의존성을 만들 수 있다. 정확한 비용은 CPU 세대별 문서를 확인한다.

“xor eax,eax가 mov eax,0보다 무조건 빠르다”처럼 모든 CPU와 문맥에 적용하는 규칙으로 만들지 않는다. 코드 크기, flags, compiler 선택, dependency가 함께 작용한다.

load/store와 aliasing

store한 주소를 곧바로 load할 때 CPU는 store buffer에서 값을 전달할 수 있다. 크기와 주소가 잘 맞지 않으면 store-forwarding stall이 생길 수 있다.

mov qword ptr [rsp], rax
mov eax, dword ptr [rsp + 1]    ; 겹치지만 경계가 달라 까다로운 패턴

또한 compiler가 두 pointer의 overlap 가능성을 배제하지 못하면 load/store 순서를 자유롭게 바꾸지 못한다. 소스 수준의 alias 계약을 개선하는 것이 더 나은 codegen으로 이어질 수 있다.

benchmark는 질문 하나만 측정한다

좋은 microbenchmark는 다음을 명시한다.

  • 어떤 가설을 검증하는가?
  • 입력 크기와 분포는 무엇인가?
  • 결과가 사용되어 dead-code elimination을 막는가?
  • 준비·할당·I/O 시간을 포함하는가 제외하는가?
  • cold cache와 warm cache 중 무엇을 재는가?
  • 반복 사이 상태가 독립적인가?

한 벤치마크로 latency, throughput, end-to-end 성능을 동시에 결론 내리지 않는다.

측정 환경을 통제한다

가능한 범위에서 다음을 기록한다.

  • CPU 모델과 microcode, core 종류
  • compiler 버전, 정확한 flags, target ISA
  • Debug/Release, LTO/PGO 여부
  • 입력 데이터와 seed
  • thread affinity 또는 migration 여부
  • 전원 모드, turbo, 온도 throttling
  • 반복 수, 중앙값·분산·outlier 처리
  • OS와 background load

한 번의 최고값보다 여러 독립 run의 분포를 본다. 짧은 코드는 timer 자체, frequency 변화, scheduler interrupt의 영향을 크게 받는다.

rdtsc는 곧바로 함수 시간 측정기가 아니다

TSC는 cycle과 관련된 counter를 제공하지만 out-of-order 실행, core migration, invariant TSC, serialization, frequency 해석을 고려해야 한다. rdtscp, fence, OS timer와의 조합은 측정 목표에 따라 다르다.

범용 코드에서는 먼저 신뢰할 수 있는 benchmark framework와 high-resolution monotonic clock을 사용한다. 직접 rdtsc harness를 만들면 empty measurement와 serialization overhead부터 검증한다.

profiler로 위치를 찾고 counter로 원인을 좁힌다

샘플링 profiler는 시간이 어디에 쓰이는지 보여준다. hardware performance counter는 cycles, instructions, branches, cache/TLB miss 같은 사건에 대한 가설을 돕는다.

하지만 counter 이름과 의미는 CPU 모델마다 다르고 multiplexing, skid, speculative event가 있다. IPC 하나만 보고 “좋다/나쁘다”고 단정하지 않는다. 높은 IPC도 쓸모없는 작업을 많이 한 결과일 수 있고, memory-bound workload는 낮은 IPC가 자연스럽다.

compiler output을 기준선으로 둔다

최적화 순서는 다음이 안전하다.

  1. 명확한 C/C++ 기준 구현과 테스트 작성
  2. Release compiler output 확인
  3. 데이터 layout과 algorithm 개선
  4. target option, PGO, intrinsic 검토
  5. 좁은 hot loop에서만 handwritten ASM 실험
  6. 동일 입력·동일 계약으로 정확성과 성능 비교

compiler는 inlining, constant propagation, vectorization, scheduling을 함수 경계 너머로 수행할 수 있다. 별도 ASM 함수는 optimizer에게 opaque해져 오히려 손해가 될 수 있다.

빠른데 틀린 코드를 거부한다

성능 비교 전에 다음을 통과해야 한다.

  • 0, 1, vector 폭 전후, 매우 큰 길이
  • signed/unsigned 경계와 overflow 계약
  • 정렬·비정렬 입력
  • alias/overlap 가능 입력
  • NaN, infinity, signed zero와 허용 오차
  • 지원하지 않는 CPU feature에서 fallback
  • sanitizer, 예외, stack unwind가 필요한 빌드

전제 조건을 좁혀 빠르게 만들었다면 API에 그 전제를 문서화하고 호출자가 증명해야 한다.

결과가 작으면 제거할 준비를 한다

handwritten ASM 채택 기준에는 속도뿐 아니라 다음을 포함한다.

  • 실제 end-to-end 영향
  • 대상 CPU 범위에서 일관된 이득
  • binary dispatch와 fallback 비용
  • compiler 업데이트 후 재검증 비용
  • 디버깅, unwind, sanitizer, 보안 완화 호환성
  • 팀이 유지할 수 있는가

컴파일러 또는 intrinsic이 같은 성능을 내면 더 투명한 구현을 선택하는 것이 장기 최적화일 수 있다.

성능 보고서 템플릿

병목 위치:
가설:
기준 구현/commit:
대안 구현:
정확성 계약과 테스트:
CPU/OS/compiler/options:
입력 크기와 분포:
측정 방법과 반복 수:
결과 분포:
counter 관찰:
기각 가능한 다른 설명:
채택/철회 조건:

정리

x86-64 성능은 명령 수 하나로 설명되지 않는다. latency, throughput, uop, execution port, dependency, cache·TLB, branch prediction과 시스템 잡음이 함께 작동한다.

먼저 profiler로 hot path를 찾고, 정확한 질문을 가진 benchmark로 가설을 검증한다. compiler 출력과 데이터 구조 개선을 기준선으로 두고, handwritten ASM은 이득과 유지 조건을 반복해서 증명할 수 있는 좁은 영역에만 남긴다.

공식 참고 자료